1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-10 21:33:27 +00:00

[CL-95] loading spinner (#16363)

* add spiner from previous branch

* add loading spinner to button

* Add spinner to dialog

* Add spinner to icon button

* add spinner to multi select component

* fix spinner positioning

* Add mock i18n in stories where needed

* round stroke caps. Update classes

* fix ts error

* fix broken tests

* add missing translation keys to stories

* Add mising key for layout

* Add mising key for nav group

* Add mising key for spotlight

* Add mising key for product switcher

* Add mising key for dialog service

* add translation to copy click story
This commit is contained in:
Bryan Cunningham
2025-09-23 15:36:18 -04:00
committed by GitHub
parent 7865bb5e12
commit 9d82fc7dfc
40 changed files with 283 additions and 19 deletions

View File

@@ -22,6 +22,7 @@ const meta: Meta<SpotlightComponent> = {
useFactory: () => {
return new I18nMockService({
close: "Close",
loading: "Loading",
});
},
},

View File

@@ -148,6 +148,7 @@ export default {
required: "required",
inputRequired: "Input is required.",
inputEmail: "Input is not an email-address.",
loading: "Loading",
});
},
},

View File

@@ -3,11 +3,13 @@ import { action } from "@storybook/addon-actions";
import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
import { delay, of } from "rxjs";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { ButtonModule } from "../button";
import { IconButtonModule } from "../icon-button";
import { I18nMockService } from "../utils";
import { AsyncActionsModule } from "./async-actions.module";
import { BitActionDirective } from "./bit-action.directive";
@@ -103,6 +105,14 @@ export default {
error: action("LogService.error"),
} as Partial<LogService>,
},
{
provide: I18nService,
useFactory: () => {
return new I18nMockService({
loading: "Loading",
});
},
},
],
}),
],

View File

@@ -19,6 +19,7 @@ describe("BannerComponent", () => {
useFactory: () =>
new I18nMockService({
close: "Close",
loading: "Loading",
}),
},
],

View File

@@ -22,6 +22,7 @@ export default {
useFactory: () => {
return new I18nMockService({
close: "Close",
loading: "Loading",
});
},
},

View File

@@ -35,6 +35,7 @@ export default {
useFactory: () => {
return new I18nMockService({
moreBreadcrumbs: "More breadcrumbs",
loading: "Loading",
});
},
},

View File

@@ -2,10 +2,9 @@
<span [ngClass]="{ 'tw-invisible': showLoadingStyle() }">
<ng-content></ng-content>
</span>
<span
class="tw-absolute tw-inset-0 tw-flex tw-items-center tw-justify-center"
[ngClass]="{ 'tw-invisible': !showLoadingStyle() }"
>
<i class="bwi bwi-spinner bwi-lg bwi-spin" aria-hidden="true"></i>
</span>
@if (showLoadingStyle()) {
<span class="tw-absolute tw-inset-0 tw-flex tw-items-center tw-justify-center">
<bit-spinner size="fill" noColor></bit-spinner>
</span>
}
</span>

View File

@@ -14,6 +14,7 @@ import { debounce, interval } from "rxjs";
import { AriaDisableDirective } from "../a11y";
import { ButtonLikeAbstraction, ButtonType, ButtonSize } from "../shared/button-like.abstraction";
import { SpinnerComponent } from "../spinner";
import { ariaDisableElement } from "../utils";
const focusRing = [
@@ -60,7 +61,7 @@ const buttonStyles: Record<ButtonType, string[]> = {
selector: "button[bitButton], a[bitButton]",
templateUrl: "button.component.html",
providers: [{ provide: ButtonLikeAbstraction, useExisting: ButtonComponent }],
imports: [NgClass],
imports: [NgClass, SpinnerComponent],
hostDirectives: [AriaDisableDirective],
})
export class ButtonComponent implements ButtonLikeAbstraction {

View File

@@ -1,12 +1,28 @@
import { Meta, StoryObj } from "@storybook/angular";
import { Meta, moduleMetadata, StoryObj } from "@storybook/angular";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { formatArgsForCodeSnippet } from "../../../../.storybook/format-args-for-code-snippet";
import { I18nMockService } from "../utils/i18n-mock.service";
import { ButtonComponent } from "./button.component";
export default {
title: "Component Library/Button",
component: ButtonComponent,
decorators: [
moduleMetadata({
providers: [
{
provide: I18nService,
useFactory: () =>
new I18nMockService({
loading: "Loading",
}),
},
],
}),
],
args: {
disabled: false,
loading: false,

View File

@@ -38,6 +38,7 @@ export default {
success: "Success",
close: "Close",
info: "Info",
loading: "Loading",
});
},
},

View File

@@ -155,6 +155,7 @@ export default {
toggleSideNavigation: "Toggle side navigation",
yes: "Yes",
no: "No",
loading: "Loading",
});
},
},

View File

@@ -52,7 +52,7 @@
>
@if (loading()) {
<div class="tw-absolute tw-flex tw-size-full tw-items-center tw-justify-center">
<i class="bwi bwi-spinner bwi-spin bwi-lg" [attr.aria-label]="'loading' | i18n"></i>
<bit-spinner></bit-spinner>
</div>
}
<div

View File

@@ -17,6 +17,7 @@ import { combineLatest, switchMap } from "rxjs";
import { I18nPipe } from "@bitwarden/ui-common";
import { BitIconButtonComponent } from "../../icon-button/icon-button.component";
import { SpinnerComponent } from "../../spinner";
import { TypographyDirective } from "../../typography/typography.directive";
import { hasScrollableContent$ } from "../../utils/";
import { hasScrolledFrom } from "../../utils/has-scrolled-from";
@@ -41,6 +42,7 @@ import { DialogTitleContainerDirective } from "../directives/dialog-title-contai
I18nPipe,
CdkTrapFocus,
CdkScrollable,
SpinnerComponent,
],
})
export class DialogComponent {

View File

@@ -1,7 +1,10 @@
import { NoopAnimationsModule } from "@angular/platform-browser/animations";
import { Meta, moduleMetadata, StoryObj } from "@storybook/angular";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ButtonModule } from "../../button";
import { I18nMockService } from "../../utils";
import { DialogModule } from "../dialog.module";
import { SimpleDialogComponent } from "./simple-dialog.component";
@@ -12,6 +15,16 @@ export default {
decorators: [
moduleMetadata({
imports: [ButtonModule, NoopAnimationsModule, DialogModule],
providers: [
{
provide: I18nService,
useFactory: () => {
return new I18nMockService({
loading: "Loading",
});
},
},
],
}),
],
parameters: {

View File

@@ -1,6 +1,9 @@
import { Meta, moduleMetadata, StoryObj } from "@storybook/angular";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { IconButtonModule } from "../icon-button";
import { I18nMockService } from "../utils";
import { DisclosureTriggerForDirective } from "./disclosure-trigger-for.directive";
import { DisclosureComponent } from "./disclosure.component";
@@ -11,6 +14,16 @@ export default {
decorators: [
moduleMetadata({
imports: [DisclosureTriggerForDirective, DisclosureComponent, IconButtonModule],
providers: [
{
provide: I18nService,
useFactory: () => {
return new I18nMockService({
loading: "Loading",
});
},
},
],
}),
],
parameters: {

View File

@@ -41,6 +41,7 @@ export default {
return new I18nMockService({
...mockLayoutI18n,
close: "Close",
loading: "Loading",
});
},
},

View File

@@ -63,6 +63,7 @@ export default {
inputRequired: "Input is required.",
inputEmail: "Input is not an email-address.",
toggleVisibility: "Toggle visibility",
loading: "Loading",
});
},
},

View File

@@ -48,6 +48,7 @@ describe("PasswordInputToggle", () => {
provide: I18nService,
useValue: new I18nMockService({
toggleVisibility: "Toggle visibility",
loading: "Loading",
}),
},
],

View File

@@ -19,7 +19,10 @@ export default {
providers: [
{
provide: I18nService,
useValue: new I18nMockService({ toggleVisibility: "Toggle visibility" }),
useValue: new I18nMockService({
toggleVisibility: "Toggle visibility",
loading: "Loading",
}),
},
],
}),

View File

@@ -6,10 +6,6 @@
class="tw-absolute tw-inset-0 tw-flex tw-items-center tw-justify-center"
[ngClass]="{ 'tw-invisible': !showLoadingStyle() }"
>
<i
class="bwi bwi-spinner bwi-spin"
aria-hidden="true"
[ngClass]="{ 'bwi-lg': size() === 'default' }"
></i>
<bit-spinner size="fill" noColor></bit-spinner>
</span>
</span>

View File

@@ -16,6 +16,7 @@ import { AriaDisableDirective } from "../a11y";
import { setA11yTitleAndAriaLabel } from "../a11y/set-a11y-title-and-aria-label";
import { ButtonLikeAbstraction } from "../shared/button-like.abstraction";
import { FocusableElement } from "../shared/focusable-element";
import { SpinnerComponent } from "../spinner";
import { ariaDisableElement } from "../utils";
export type IconButtonType = "primary" | "danger" | "contrast" | "main" | "muted" | "nav-contrast";
@@ -87,7 +88,7 @@ const sizes: Record<IconButtonSize, string[]> = {
{ provide: ButtonLikeAbstraction, useExisting: BitIconButtonComponent },
{ provide: FocusableElement, useExisting: BitIconButtonComponent },
],
imports: [NgClass],
imports: [NgClass, SpinnerComponent],
host: {
/**
* When the `bitIconButton` input is dynamic from a consumer, Angular doesn't put the

View File

@@ -1,12 +1,29 @@
import { Meta, StoryObj } from "@storybook/angular";
import { Meta, moduleMetadata, StoryObj } from "@storybook/angular";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { formatArgsForCodeSnippet } from "../../../../.storybook/format-args-for-code-snippet";
import { I18nMockService } from "../utils";
import { BitIconButtonComponent } from "./icon-button.component";
export default {
title: "Component Library/Icon Button",
component: BitIconButtonComponent,
decorators: [
moduleMetadata({
providers: [
{
provide: I18nService,
useFactory: () => {
return new I18nMockService({
loading: "Loading",
});
},
},
],
}),
],
args: {
bitIconButton: "bwi-plus",
label: "Your button label here",

View File

@@ -44,6 +44,7 @@ export default {
skipToContent: "Skip to content",
submenu: "submenu",
toggleCollapse: "toggle collapse",
loading: "Loading",
});
},
},

View File

@@ -4,4 +4,5 @@ export const mockLayoutI18n = {
skipToContent: "Skip to content",
submenu: "submenu",
toggleCollapse: "toggle collapse",
loading: "Loading",
};

View File

@@ -1,7 +1,10 @@
import { OverlayModule } from "@angular/cdk/overlay";
import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ButtonModule } from "../button/button.module";
import { I18nMockService } from "../utils";
import { MenuTriggerForDirective } from "./menu-trigger-for.directive";
import { MenuModule } from "./menu.module";
@@ -12,6 +15,12 @@ export default {
decorators: [
moduleMetadata({
imports: [MenuModule, OverlayModule, ButtonModule],
providers: [
{
provide: I18nService,
useValue: new I18nMockService({ loading: "Loading" }),
},
],
}),
],
parameters: {

View File

@@ -20,7 +20,7 @@
appendTo="body"
>
<ng-template ng-loadingspinner-tmp>
<i class="bwi bwi-spinner bwi-spin tw-me-1" [title]="loadingText" aria-hidden="true"></i>
<bit-spinner></bit-spinner>
</ng-template>
<ng-template ng-label-tmp let-item="item" let-clear="clear">
<button

View File

@@ -27,6 +27,7 @@ import { I18nPipe } from "@bitwarden/ui-common";
import { BadgeModule } from "../badge";
import { BitFormFieldControl } from "../form-field/form-field-control";
import { SpinnerComponent } from "../spinner";
import { SelectItemView } from "./models/select-item-view";
@@ -37,7 +38,14 @@ let nextId = 0;
selector: "bit-multi-select",
templateUrl: "./multi-select.component.html",
providers: [{ provide: BitFormFieldControl, useExisting: MultiSelectComponent }],
imports: [NgSelectModule, ReactiveFormsModule, FormsModule, BadgeModule, I18nPipe],
imports: [
NgSelectModule,
ReactiveFormsModule,
FormsModule,
BadgeModule,
I18nPipe,
SpinnerComponent,
],
host: {
"[id]": "this.id()",
},

View File

@@ -39,6 +39,7 @@ export default {
toggleCollapse: "toggle collapse",
toggleSideNavigation: "Toggle side navigation",
skipToContent: "Skip to content",
loading: "Loading",
});
},
},

View File

@@ -30,6 +30,7 @@ export default {
toggleCollapse: "toggle collapse",
toggleSideNavigation: "Toggle side navigation",
skipToContent: "Skip to content",
loading: "Loading",
});
},
},

View File

@@ -15,8 +15,10 @@ import {
Security,
VaultOpen,
} from "@bitwarden/assets/svg";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ButtonModule } from "../button";
import { I18nMockService } from "../utils";
import { NoItemsComponent } from "./no-items.component";
import { NoItemsModule } from "./no-items.module";
@@ -27,6 +29,12 @@ export default {
decorators: [
moduleMetadata({
imports: [ButtonModule, NoItemsModule],
providers: [
{
provide: I18nService,
useValue: new I18nMockService({ loading: "Loading" }),
},
],
}),
],
parameters: {

View File

@@ -1,9 +1,12 @@
import { Meta, StoryObj, componentWrapperDecorator, moduleMetadata } from "@storybook/angular";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CardComponent } from "../card";
import { IconButtonModule } from "../icon-button";
import { ItemModule } from "../item";
import { TypographyModule } from "../typography";
import { I18nMockService } from "../utils";
import { SectionComponent, SectionHeaderComponent } from "./";
@@ -19,6 +22,12 @@ export default {
IconButtonModule,
ItemModule,
],
providers: [
{
provide: I18nService,
useValue: new I18nMockService({ loading: "Loading" }),
},
],
}),
componentWrapperDecorator((story) => `<div class="tw-text-main">${story}</div>`),
],

View File

@@ -0,0 +1 @@
export * from "./spinner.component";

View File

@@ -0,0 +1,27 @@
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 56 56"
fill="none"
class="tw-size-full tw-animate-spin"
aria-hidden="true"
>
<circle
cx="28"
cy="28"
r="23"
class="tw-stroke-primary-600"
pathLength="4"
stroke-width="5"
stroke-dasharray="1 3"
stroke-linecap="round"
></circle>
<circle
cx="28"
cy="28"
r="23"
class="tw-stroke-primary-600"
stroke-width="5"
opacity="0.4"
></circle>
</svg>
<span class="tw-sr-only" *ngIf="sr">{{ title }}</span>

After

Width:  |  Height:  |  Size: 519 B

View File

@@ -0,0 +1,51 @@
import { CommonModule } from "@angular/common";
import { Component, HostBinding, Input, booleanAttribute } from "@angular/core";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@Component({
selector: "bit-spinner",
templateUrl: "spinner.component.html",
standalone: true,
imports: [CommonModule],
})
export class SpinnerComponent {
/**
* The size of the spinner. Defaults to `large`.
*/
@Input() size: "fill" | "small" | "large" = "large";
/**
* Disable the default color of the spinner, inherits the text color.
*/
@Input({ transform: booleanAttribute }) noColor = false;
/**
* Accessibility title. Defaults to `Loading`.
*/
@Input() title = this.i18nService.t("loading");
/**
* Display text for screen readers.
*/
@Input({ transform: booleanAttribute }) sr = true;
@HostBinding("class") get classList() {
return ["tw-inline-block", "tw-overflow-hidden", "tw-flex", "tw-items-center"]
.concat(this.sizeClass)
.concat([this.noColor ? "" : "tw-text-primary-600"]);
}
constructor(private i18nService: I18nService) {}
get sizeClass() {
switch (this.size) {
case "small":
return ["tw-h-4"];
case "large":
return ["tw-h-16"];
default:
return ["tw-h-full", "tw-w-full"];
}
}
}

View File

@@ -0,0 +1,53 @@
import { CommonModule } from "@angular/common";
import { Meta, moduleMetadata, StoryObj } from "@storybook/angular";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { I18nMockService } from "../utils";
import { SpinnerComponent } from "./spinner.component";
export default {
title: "Component Library/Spinner",
component: SpinnerComponent,
decorators: [
moduleMetadata({
imports: [CommonModule],
providers: [
{
provide: I18nService,
useFactory: () => {
return new I18nMockService({
loading: "Loading",
});
},
},
],
declarations: [],
}),
],
parameters: {
design: {
type: "figma",
url: "https://www.figma.com/file/Zt3YSeb6E6lebAffrNLa0h/Tailwind-Component-Library?node-id=1881%3A16956",
},
},
} as Meta;
type Story = StoryObj<SpinnerComponent>;
export const Default: Story = {
args: {},
};
export const Fill: Story = {
args: {
size: "fill",
},
};
export const Small: Story = {
args: {
size: "small",
},
};

View File

@@ -23,6 +23,7 @@ import { KitchenSinkSharedModule } from "../kitchen-sink-shared.module";
inputMaxValue: (max) => `Input value must not exceed ${max}.`,
inputMinValue: (min) => `Input value must be at least ${min}.`,
inputRequired: "Input is required.",
loading: "Loading",
multiSelectClearAll: "Clear all",
multiSelectLoading: "Retrieving options...",
multiSelectNotFound: "No items found",

View File

@@ -64,6 +64,7 @@ export default {
toggleSideNavigation: "Toggle side navigation",
yes: "Yes",
no: "No",
loading: "Loading",
});
},
},

View File

@@ -3,8 +3,11 @@ import { Component, importProvidersFrom } from "@angular/core";
import { RouterModule } from "@angular/router";
import { applicationConfig, Meta, moduleMetadata, StoryObj } from "@storybook/angular";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ButtonModule } from "../button";
import { FormFieldModule } from "../form-field";
import { I18nMockService } from "../utils";
import { TabGroupComponent } from "./tab-group/tab-group.component";
import { TabsModule } from "./tabs.module";
@@ -56,6 +59,12 @@ export default {
ItemWithChildCounterDummyComponent,
DisabledDummyComponent,
],
providers: [
{
provide: I18nService,
useValue: new I18nMockService({ loading: "Loading" }),
},
],
}),
applicationConfig({
providers: [

View File

@@ -55,6 +55,7 @@ export default {
error: "Error",
warning: "Warning",
info: "Info",
loading: "Loading",
});
},
},