From e323cb6e59f0e5497fedea1298769d9e44011327 Mon Sep 17 00:00:00 2001 From: Vicki League Date: Fri, 3 Oct 2025 12:32:47 -0400 Subject: [PATCH 1/2] skeleton POC --- .../popup/layout/popup-layout.stories.ts | 33 +++++++++ libs/components/src/index.ts | 1 + libs/components/src/skeleton/index.ts | 1 + .../src/skeleton/skeleton.component.html | 8 ++ .../src/skeleton/skeleton.component.ts | 14 ++++ .../src/skeleton/skeleton.stories.ts | 74 +++++++++++++++++++ 6 files changed, 131 insertions(+) create mode 100644 libs/components/src/skeleton/index.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.stories.ts 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..64a00ebcd4fb 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,7 @@ import { SearchModule, SectionComponent, ScrollLayoutDirective, + SkeletonComponent, } from "@bitwarden/components"; import { PopupRouterCacheService } from "../view-cache/popup-router-cache.service"; @@ -335,6 +336,7 @@ export default { SectionComponent, IconButtonModule, BadgeModule, + SkeletonComponent, ], providers: [ { @@ -594,6 +596,37 @@ 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..c5eb54484796 --- /dev/null +++ b/libs/components/src/skeleton/index.ts @@ -0,0 +1 @@ +export * from "./skeleton.component"; 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..7d08a9f6c612 --- /dev/null +++ b/libs/components/src/skeleton/skeleton.component.ts @@ -0,0 +1,14 @@ +import { CommonModule } from "@angular/common"; +import { Component, input } from "@angular/core"; + +@Component({ + selector: "bit-skeleton", + templateUrl: "./skeleton.component.html", + imports: [CommonModule], + host: { + class: "tw-block", + }, +}) +export class SkeletonComponent { + edgeShape = input<"box" | "circle">("box"); +} diff --git a/libs/components/src/skeleton/skeleton.stories.ts b/libs/components/src/skeleton/skeleton.stories.ts new file mode 100644 index 000000000000..432216a3e5f4 --- /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", + 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", + }, +}; From 3559835c151b7d1a68238188f4a46744493977c2 Mon Sep 17 00:00:00 2001 From: Vicki League Date: Tue, 7 Oct 2025 15:28:29 -0400 Subject: [PATCH 2/2] add helper skeleton components and docs --- .../popup/layout/popup-layout.stories.ts | 17 +++-- libs/components/src/skeleton/index.ts | 2 + .../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 | 12 +++ .../src/skeleton/skeleton-text.component.ts | 31 ++++++++ .../src/skeleton/skeleton-text.stories.ts | 46 ++++++++++++ .../src/skeleton/skeleton.component.ts | 14 +++- libs/components/src/skeleton/skeleton.mdx | 74 +++++++++++++++++++ .../src/skeleton/skeleton.stories.ts | 2 +- 11 files changed, 286 insertions(+), 10 deletions(-) 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.mdx 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 64a00ebcd4fb..2fa37e3ecd9d 100644 --- a/apps/browser/src/platform/popup/layout/popup-layout.stories.ts +++ b/apps/browser/src/platform/popup/layout/popup-layout.stories.ts @@ -30,6 +30,8 @@ import { SectionComponent, ScrollLayoutDirective, SkeletonComponent, + SkeletonTextComponent, + SkeletonGroupComponent, } from "@bitwarden/components"; import { PopupRouterCacheService } from "../view-cache/popup-router-cache.service"; @@ -337,6 +339,8 @@ export default { IconButtonModule, BadgeModule, SkeletonComponent, + SkeletonTextComponent, + SkeletonGroupComponent, ], providers: [ { @@ -607,15 +611,12 @@ export const SkeletonLoading: Story = {
Loading...
- + @for (num of data; track $index) { -
- -
- - -
-
+ + + + }
diff --git a/libs/components/src/skeleton/index.ts b/libs/components/src/skeleton/index.ts index c5eb54484796..3872cc2a32f7 100644 --- a/libs/components/src/skeleton/index.ts +++ b/libs/components/src/skeleton/index.ts @@ -1 +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.ts b/libs/components/src/skeleton/skeleton.component.ts index 7d08a9f6c612..a9d83dd80a50 100644 --- a/libs/components/src/skeleton/skeleton.component.ts +++ b/libs/components/src/skeleton/skeleton.component.ts @@ -1,6 +1,15 @@ 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", @@ -10,5 +19,8 @@ import { Component, input } from "@angular/core"; }, }) export class SkeletonComponent { - edgeShape = input<"box" | "circle">("box"); + /** + * 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 index 432216a3e5f4..0198e95e882c 100644 --- a/libs/components/src/skeleton/skeleton.stories.ts +++ b/libs/components/src/skeleton/skeleton.stories.ts @@ -5,7 +5,7 @@ import { SharedModule } from "../shared/shared.module"; import { SkeletonComponent } from "./skeleton.component"; export default { - title: "Component Library/Skeleton", + title: "Component Library/Skeleton/Skeleton", component: SkeletonComponent, decorators: [ moduleMetadata({