From ba5c93fae22d6a07910906666c73cf61bd537f63 Mon Sep 17 00:00:00 2001 From: Vicki League Date: Thu, 16 Oct 2025 09:36:16 -0400 Subject: [PATCH] [CL-427] Add skeleton loading components to the CL (#16728) --- .storybook/format-args-for-code-snippet.ts | 5 + .../popup/layout/popup-layout.stories.ts | 34 ++++++ libs/components/src/index.ts | 1 + libs/components/src/skeleton/index.ts | 3 + .../skeleton/skeleton-group.component.html | 7 ++ .../src/skeleton/skeleton-group.component.ts | 18 +++ .../src/skeleton/skeleton-group.stories.ts | 73 ++++++++++++ .../src/skeleton/skeleton-text.component.html | 11 ++ .../src/skeleton/skeleton-text.component.ts | 31 +++++ .../src/skeleton/skeleton-text.stories.ts | 48 ++++++++ .../src/skeleton/skeleton.component.html | 8 ++ .../src/skeleton/skeleton.component.ts | 26 ++++ libs/components/src/skeleton/skeleton.mdx | 112 ++++++++++++++++++ .../src/skeleton/skeleton.stories.ts | 50 ++++++++ 14 files changed, 427 insertions(+) create mode 100644 libs/components/src/skeleton/index.ts create mode 100644 libs/components/src/skeleton/skeleton-group.component.html create mode 100644 libs/components/src/skeleton/skeleton-group.component.ts create mode 100644 libs/components/src/skeleton/skeleton-group.stories.ts create mode 100644 libs/components/src/skeleton/skeleton-text.component.html create mode 100644 libs/components/src/skeleton/skeleton-text.component.ts create mode 100644 libs/components/src/skeleton/skeleton-text.stories.ts create mode 100644 libs/components/src/skeleton/skeleton.component.html create mode 100644 libs/components/src/skeleton/skeleton.component.ts create mode 100644 libs/components/src/skeleton/skeleton.mdx create mode 100644 libs/components/src/skeleton/skeleton.stories.ts diff --git a/.storybook/format-args-for-code-snippet.ts b/.storybook/format-args-for-code-snippet.ts index bf36c153c0..8fc44ebfb0 100644 --- a/.storybook/format-args-for-code-snippet.ts +++ b/.storybook/format-args-for-code-snippet.ts @@ -25,6 +25,11 @@ export const formatArgsForCodeSnippet = `'${v}'`).join(", "); return `[${key}]="[${formattedArray}]"`; } + + if (typeof value === "number") { + return `[${key}]="${value}"`; + } + return `${key}="${value}"`; }) .join(" "); 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 a7103fdfd3..2fa37e3ecd 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 2384696b77..f36a3fdddf 100644 --- a/libs/components/src/index.ts +++ b/libs/components/src/index.ts @@ -37,6 +37,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 0000000000..3872cc2a32 --- /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 0000000000..d6c88dc732 --- /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 0000000000..8895397ae8 --- /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 0000000000..ae13dad127 --- /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 0000000000..716184d0e9 --- /dev/null +++ b/libs/components/src/skeleton/skeleton-text.component.html @@ -0,0 +1,11 @@ +
+ @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 0000000000..1bc88bd320 --- /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(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 0000000000..36446328b1 --- /dev/null +++ b/libs/components/src/skeleton/skeleton-text.stories.ts @@ -0,0 +1,48 @@ +import { Meta, moduleMetadata, StoryObj } from "@storybook/angular"; + +import { SharedModule } from "../shared/shared.module"; + +import { SkeletonTextComponent } from "./skeleton-text.component"; + +import { formatArgsForCodeSnippet } from ".storybook/format-args-for-code-snippet"; + +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*/ ` + (args)}> + `, + }), +}; + +export const TextMultiline: Story = { + render: (args) => ({ + props: args, + template: /*html*/ ` + (args)}> + `, + }), + 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 0000000000..2b0a1f7aba --- /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 0000000000..a9d83dd80a --- /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 0000000000..ab0ba22f43 --- /dev/null +++ b/libs/components/src/skeleton/skeleton.mdx @@ -0,0 +1,112 @@ +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. Use for +non-text shapes. + +#### Customizing + +The basic skeleton component is fully customizable in shape and edge appearance to allow consumers +to more accurately represent the content. + +**Inputs** + +| Input | Description | Accepted options | Default | +| ----------- | --------------------------------------------------- | ---------------- | ------- | +| `edgeShape` | configure whether corners are fully rounded or boxy | `box`, `circle` | `box` | + +**Classes** + +Customize the shape's size by applying tailwind size classes to the `bit-skeleton` element (example +`tw-h-3 tw-w-12`). Please refer to the tailwind docs for all height/width options, and note that +custom values are possible with tailwind as well. + + + + +### Skeleton Text + +Specific skeleton component used to represent lines of text. + +#### Customizing + +The number of lines of text in the skeleton is configurable. + +**Inputs** + +| Input | Description | Accepted options | Default | +| ------- | --------------------------------------------- | ---------------- | ------- | +| `lines` | configure how many lines of text are rendered | any `number` | `1` | + + + + +### Skeleton Group + +Arranges skeleton loaders into a pre-arranged group that mimics the table and item components. + +#### Customizing + +**Slots** + +Use the following slots to render `` and/or `bit-skeleton-text` elements. + +| Slot | Description | +| -------------- | ----------------------------------------------------------------------------------------------- | +| `slot="start"` | content that should appear horizontally before the default content; will not grow to fill space | +| default | main content area; grows to fill the horizontal space | +| `slot="end"` | content that should appear horizontally after the default content; will not grow to fill space | + + + +## 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 0000000000..56a89c6e07 --- /dev/null +++ b/libs/components/src/skeleton/skeleton.stories.ts @@ -0,0 +1,50 @@ +import { Meta, moduleMetadata, StoryObj } from "@storybook/angular"; + +import { formatArgsForCodeSnippet } from "../../../../.storybook/format-args-for-code-snippet"; +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 BoxEdgeShape: Story = { + render: (args) => ({ + props: args, + template: /*html*/ ` +
Examples of different size shapes with edgeShape={{ edgeShape }}
+
+ (args)} class="tw-size-32"> + (args)} class="tw-w-40 tw-h-5"> +
+ `, + }), + args: { + edgeShape: "box", + }, +}; + +export const CircleEdgeShape: Story = { + ...BoxEdgeShape, + args: { + edgeShape: "circle", + }, +};