diff --git a/apps/browser/src/platform/popup/layout/popup-layout.stories.ts b/apps/browser/src/platform/popup/layout/popup-layout.stories.ts index a7103fdfd3cb..2fa37e3ecd9d 100644 --- a/apps/browser/src/platform/popup/layout/popup-layout.stories.ts +++ b/apps/browser/src/platform/popup/layout/popup-layout.stories.ts @@ -29,6 +29,9 @@ import { SearchModule, SectionComponent, ScrollLayoutDirective, + SkeletonComponent, + SkeletonTextComponent, + SkeletonGroupComponent, } from "@bitwarden/components"; import { PopupRouterCacheService } from "../view-cache/popup-router-cache.service"; @@ -335,6 +338,9 @@ export default { SectionComponent, IconButtonModule, BadgeModule, + SkeletonComponent, + SkeletonTextComponent, + SkeletonGroupComponent, ], providers: [ { @@ -594,6 +600,34 @@ export const Loading: Story = { }), }; +export const SkeletonLoading: Story = { + render: (args) => ({ + props: { ...args, data: Array(8) }, + template: /* HTML */ ` + + + + +
+
Loading...
+
+ + @for (num of data; track $index) { + + + + + + } +
+
+
+
+
+ `, + }), +}; + export const TransparentHeader: Story = { render: (args) => ({ props: args, diff --git a/libs/components/src/index.ts b/libs/components/src/index.ts index d231048563c6..babd5fbfbf70 100644 --- a/libs/components/src/index.ts +++ b/libs/components/src/index.ts @@ -36,6 +36,7 @@ export * from "./search"; export * from "./section"; export * from "./select"; export * from "./shared/compact-mode.service"; +export * from "./skeleton"; export * from "./table"; export * from "./tabs"; export * from "./toast"; diff --git a/libs/components/src/skeleton/index.ts b/libs/components/src/skeleton/index.ts new file mode 100644 index 000000000000..3872cc2a32f7 --- /dev/null +++ b/libs/components/src/skeleton/index.ts @@ -0,0 +1,3 @@ +export * from "./skeleton.component"; +export * from "./skeleton-text.component"; +export * from "./skeleton-group.component"; diff --git a/libs/components/src/skeleton/skeleton-group.component.html b/libs/components/src/skeleton/skeleton-group.component.html new file mode 100644 index 000000000000..d6c88dc73234 --- /dev/null +++ b/libs/components/src/skeleton/skeleton-group.component.html @@ -0,0 +1,7 @@ +
+
+ + +
+ +
diff --git a/libs/components/src/skeleton/skeleton-group.component.ts b/libs/components/src/skeleton/skeleton-group.component.ts new file mode 100644 index 000000000000..8895397ae897 --- /dev/null +++ b/libs/components/src/skeleton/skeleton-group.component.ts @@ -0,0 +1,18 @@ +import { CommonModule } from "@angular/common"; +import { Component } from "@angular/core"; + +/** + * Arranges skeleton loaders into a pre-arranged group that mimics the table and item components. + * + * Pass skeleton loaders into the start, default, and end content slots. The content within each slot + * is fully customizable. + */ +@Component({ + selector: "bit-skeleton-group", + templateUrl: "./skeleton-group.component.html", + imports: [CommonModule], + host: { + class: "tw-block", + }, +}) +export class SkeletonGroupComponent {} diff --git a/libs/components/src/skeleton/skeleton-group.stories.ts b/libs/components/src/skeleton/skeleton-group.stories.ts new file mode 100644 index 000000000000..ae13dad1274a --- /dev/null +++ b/libs/components/src/skeleton/skeleton-group.stories.ts @@ -0,0 +1,73 @@ +import { Meta, moduleMetadata, StoryObj } from "@storybook/angular"; + +import { SharedModule } from "../shared/shared.module"; + +import { SkeletonGroupComponent } from "./skeleton-group.component"; +import { SkeletonTextComponent } from "./skeleton-text.component"; +import { SkeletonComponent } from "./skeleton.component"; + +export default { + title: "Component Library/Skeleton/Skeleton Group", + component: SkeletonGroupComponent, + decorators: [ + moduleMetadata({ + imports: [SharedModule, SkeletonTextComponent, SkeletonComponent], + }), + ], +} as Meta; + +type Story = StoryObj; + +export const Default: Story = { + render: (args) => ({ + props: args, + template: /*html*/ ` + + + + + + `, + }), +}; + +export const NoEndSlot: Story = { + render: (args) => ({ + props: args, + template: /*html*/ ` + + + + + `, + }), +}; + +export const NoStartSlot: Story = { + render: (args) => ({ + props: args, + template: /*html*/ ` + + + + + `, + }), +}; + +export const CustomContent: Story = { + render: (args) => ({ + props: args, + template: /*html*/ ` + + + +
+ + + +
+
+ `, + }), +}; diff --git a/libs/components/src/skeleton/skeleton-text.component.html b/libs/components/src/skeleton/skeleton-text.component.html new file mode 100644 index 000000000000..f29eaca07efb --- /dev/null +++ b/libs/components/src/skeleton/skeleton-text.component.html @@ -0,0 +1,12 @@ +
+ @for (line of this.linesArray(); track $index; let last = $last, first = $first) { + + } +
diff --git a/libs/components/src/skeleton/skeleton-text.component.ts b/libs/components/src/skeleton/skeleton-text.component.ts new file mode 100644 index 000000000000..04c61d3e5be7 --- /dev/null +++ b/libs/components/src/skeleton/skeleton-text.component.ts @@ -0,0 +1,31 @@ +import { CommonModule } from "@angular/common"; +import { Component, computed, input } from "@angular/core"; + +import { SkeletonComponent } from "./skeleton.component"; + +/** + * Specific skeleton component used to represent lines of text. It uses the `bit-skeleton` + * under the hood. + * + * Customize the number of lines represented with the `lines` input. Customize the width + * by applying a class to the `bit-skeleton-text` element (i.e. `tw-w-1/2`). + */ +@Component({ + selector: "bit-skeleton-text", + templateUrl: "./skeleton-text.component.html", + imports: [CommonModule, SkeletonComponent], + host: { + class: "tw-block", + }, +}) +export class SkeletonTextComponent { + /** + * The number of text lines to display + */ + readonly lines = input(1); + + /** + * Array-transformed version of the `lines` to loop over + */ + protected linesArray = computed(() => Array.from(Array(this.lines()).keys())); +} diff --git a/libs/components/src/skeleton/skeleton-text.stories.ts b/libs/components/src/skeleton/skeleton-text.stories.ts new file mode 100644 index 000000000000..b74f8bef4443 --- /dev/null +++ b/libs/components/src/skeleton/skeleton-text.stories.ts @@ -0,0 +1,46 @@ +import { Meta, moduleMetadata, StoryObj } from "@storybook/angular"; + +import { SharedModule } from "../shared/shared.module"; + +import { SkeletonTextComponent } from "./skeleton-text.component"; + +export default { + title: "Component Library/Skeleton/Skeleton Text", + component: SkeletonTextComponent, + decorators: [ + moduleMetadata({ + imports: [SharedModule], + }), + ], + args: { + lines: 1, + }, + argTypes: { + lines: { + control: { type: "number", min: 1 }, + }, + }, +} as Meta; + +type Story = StoryObj; + +export const Text: Story = { + render: (args) => ({ + props: args, + template: /*html*/ ` + + `, + }), +}; + +export const TextMultiline: Story = { + render: (args) => ({ + props: args, + template: /*html*/ ` + + `, + }), + args: { + lines: 5, + }, +}; diff --git a/libs/components/src/skeleton/skeleton.component.html b/libs/components/src/skeleton/skeleton.component.html new file mode 100644 index 000000000000..2b0a1f7aba14 --- /dev/null +++ b/libs/components/src/skeleton/skeleton.component.html @@ -0,0 +1,8 @@ + diff --git a/libs/components/src/skeleton/skeleton.component.ts b/libs/components/src/skeleton/skeleton.component.ts new file mode 100644 index 000000000000..a9d83dd80a50 --- /dev/null +++ b/libs/components/src/skeleton/skeleton.component.ts @@ -0,0 +1,26 @@ +import { CommonModule } from "@angular/common"; +import { Component, input } from "@angular/core"; + +/** + * Basic skeleton loading component that can be used to represent content that is loading. + * Use for layout-level elements and text, not for interactive elements. + * + * Customize the shape's edges with the `edgeShape` input. Customize the shape's size by + * applying classes to the `bit-skeleton` element (i.e. `tw-w-40 tw-h-8`). + * + * If you're looking to represent lines of text, use the `bit-skeleton-text` helper component. + */ +@Component({ + selector: "bit-skeleton", + templateUrl: "./skeleton.component.html", + imports: [CommonModule], + host: { + class: "tw-block", + }, +}) +export class SkeletonComponent { + /** + * The shape of the corners of the skeleton element + */ + readonly edgeShape = input<"box" | "circle">("box"); +} diff --git a/libs/components/src/skeleton/skeleton.mdx b/libs/components/src/skeleton/skeleton.mdx new file mode 100644 index 000000000000..ea41020664fb --- /dev/null +++ b/libs/components/src/skeleton/skeleton.mdx @@ -0,0 +1,74 @@ +import { Meta, Canvas, Source } from "@storybook/addon-docs"; + +import * as skeletonStories from "./skeleton.stories"; +import * as skeletonTextStories from "./skeleton-text.stories"; +import * as skeletonGroupStories from "./skeleton-group.stories"; + + + +# Skeleton Loading + +The skeleton component can be used as an alternative loading indicator to the spinner by mimicking +the content that will be loaded such as text, images, or video. It can be used to represent layout +components as well, but should not be used for interactive elements like form controls or buttons. + +## Skeleton Loading Components + +There are three components that can be used to create a skeleton loading page. + +### Skeleton + +Basic skeleton loading component that can be used to represent content that is loading. The shape of +the edges is configurable to be either squared or round. Use for non-text shapes. + + + + +### Skeleton Text + +Specific skeleton component used to represent lines of text. The number of lines is configurable. + + + + +### Skeleton Group + +Arranges skeleton loaders into a pre-arranged group that mimics the table and item components. It +uses start, default, and end slots to allow any skeleton content to be rendered. + + + +## Display Considerations + +For pages that load quickly, we want to avoid the skeleton flashing in and out. To avoid this, we +recommend the following display guidelines: + +- After the loading is initiated (by page load or by user action), wait 1 second to display the + skeleton loader. +- After waiting 1s, render the loading skeleton. +- Ideally the skeleton disappears after 10 seconds, but we do not enforce a max duration. Add a max + duration at your discretion. + +## Accessibility + +Because there are typically multiple skeleton loaders present on a page that is using skeleton +loading, the individual skeleton loaders should not announce themselves or be present to +screenreaders, as this would overwhelm the user with multiple identical announcements. Thus, the +skeleton components are hidden from screenreaders. + +Instead, the recommended strategy is to use a page-level announcement for screenreaders: + +- We recommend using the + [Angular CDK LiveAnnouncer](https://material.angular.dev/cdk/a11y/overview#liveannouncer) to first + announce that content is loading when the skeleton loader is displayed, and then to announce that + content has loaded. The announcements should be localized, and the politeness level should be set + to `polite`. + +- Alternatively, you may wish to render your own `role="status"` element or a custom `aria-live` + region in the template to accomplish the announcements detailed above. + +## Example with Browser Extension + +To see a full-page example of what skeleton loading might look like using all three skeleton +components, check the +[Popup Layout Skeleton Loading story](?path=/docs/browser-popup-layout--skeleton-loading). diff --git a/libs/components/src/skeleton/skeleton.stories.ts b/libs/components/src/skeleton/skeleton.stories.ts new file mode 100644 index 000000000000..0198e95e882c --- /dev/null +++ b/libs/components/src/skeleton/skeleton.stories.ts @@ -0,0 +1,74 @@ +import { Meta, moduleMetadata, StoryObj } from "@storybook/angular"; + +import { SharedModule } from "../shared/shared.module"; + +import { SkeletonComponent } from "./skeleton.component"; + +export default { + title: "Component Library/Skeleton/Skeleton", + component: SkeletonComponent, + decorators: [ + moduleMetadata({ + imports: [SharedModule], + }), + ], + args: { + edgeShape: "box", + }, + argTypes: { + edgeShape: { + control: { type: "radio" }, + options: ["box", "circle"], + }, + }, +} as Meta; + +type Story = StoryObj; + +export const Square: Story = { + render: (args) => ({ + props: args, + template: /*html*/ ` + + `, + }), + args: { + edgeShape: "box", + }, +}; + +export const Rectangle: Story = { + render: (args) => ({ + props: args, + template: /*html*/ ` + + `, + }), + args: { + edgeShape: "box", + }, +}; + +export const Circle: Story = { + render: (args) => ({ + props: args, + template: /*html*/ ` + + `, + }), + args: { + edgeShape: "circle", + }, +}; + +export const Oval: Story = { + render: (args) => ({ + props: args, + template: /*html*/ ` + + `, + }), + args: { + edgeShape: "circle", + }, +};