Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions apps/browser/src/platform/popup/layout/popup-layout.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ import {
SearchModule,
SectionComponent,
ScrollLayoutDirective,
SkeletonComponent,
SkeletonTextComponent,
SkeletonGroupComponent,
} from "@bitwarden/components";

import { PopupRouterCacheService } from "../view-cache/popup-router-cache.service";
Expand Down Expand Up @@ -335,6 +338,9 @@ export default {
SectionComponent,
IconButtonModule,
BadgeModule,
SkeletonComponent,
SkeletonTextComponent,
SkeletonGroupComponent,
],
providers: [
{
Expand Down Expand Up @@ -594,6 +600,34 @@ export const Loading: Story = {
}),
};

export const SkeletonLoading: Story = {
render: (args) => ({
props: { ...args, data: Array(8) },
template: /* HTML */ `
<extension-container>
<popup-tab-navigation>
<popup-page>
<popup-header slot="header" pageTitle="Page Header"></popup-header>
<div>
<div class="tw-sr-only" role="status">Loading...</div>
<div class="tw-flex tw-flex-col tw-gap-4">
<bit-skeleton-text class="tw-w-1/3"></bit-skeleton-text>
@for (num of data; track $index) {
<bit-skeleton-group>
<bit-skeleton class="tw-size-8" slot="start"></bit-skeleton>
<bit-skeleton-text [lines]="2" class="tw-w-1/2"></bit-skeleton-text>
</bit-skeleton-group>
<bit-skeleton class="tw-w-full tw-h-[1px]"></bit-skeleton>
}
</div>
</div>
</popup-page>
</popup-tab-navigation>
</extension-container>
`,
}),
};

export const TransparentHeader: Story = {
render: (args) => ({
props: args,
Expand Down
1 change: 1 addition & 0 deletions libs/components/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
3 changes: 3 additions & 0 deletions libs/components/src/skeleton/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from "./skeleton.component";
export * from "./skeleton-text.component";
export * from "./skeleton-group.component";
7 changes: 7 additions & 0 deletions libs/components/src/skeleton/skeleton-group.component.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<div class="tw-flex tw-flex-row tw-justify-between tw-gap-2">
<div class="tw-flex tw-gap-2 tw-w-full">
<ng-content select="[slot=start]"></ng-content>
<ng-content></ng-content>
</div>
<ng-content select="[slot=end]"></ng-content>
</div>
18 changes: 18 additions & 0 deletions libs/components/src/skeleton/skeleton-group.component.ts
Original file line number Diff line number Diff line change
@@ -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 {}
73 changes: 73 additions & 0 deletions libs/components/src/skeleton/skeleton-group.stories.ts
Original file line number Diff line number Diff line change
@@ -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<SkeletonGroupComponent>;

type Story = StoryObj<SkeletonGroupComponent>;

export const Default: Story = {
render: (args) => ({
props: args,
template: /*html*/ `
<bit-skeleton-group>
<bit-skeleton class="tw-size-8" slot="start"></bit-skeleton>
<bit-skeleton-text [lines]="2" class="tw-w-1/2"></bit-skeleton-text>
<bit-skeleton-text [lines]="1" slot="end" class="tw-w-1/4"></bit-skeleton-text>
</bit-skeleton-group>
`,
}),
};

export const NoEndSlot: Story = {
render: (args) => ({
props: args,
template: /*html*/ `
<bit-skeleton-group>
<bit-skeleton class="tw-size-8" slot="start"></bit-skeleton>
<bit-skeleton-text [lines]="2" class="tw-w-1/2"></bit-skeleton-text>
</bit-skeleton-group>
`,
}),
};

export const NoStartSlot: Story = {
render: (args) => ({
props: args,
template: /*html*/ `
<bit-skeleton-group>
<bit-skeleton-text [lines]="2" class="tw-w-1/2"></bit-skeleton-text>
<bit-skeleton-text [lines]="1" slot="end" class="tw-w-1/4"></bit-skeleton-text>
</bit-skeleton-group>
`,
}),
};

export const CustomContent: Story = {
render: (args) => ({
props: args,
template: /*html*/ `
<bit-skeleton-group>
<bit-skeleton class="tw-size-12" slot="start" edgeShape="circle"></bit-skeleton>
<bit-skeleton-text [lines]="3" class="tw-w-full"></bit-skeleton-text>
<div slot="end" class="tw-flex tw-flex-row tw-gap-1">
<bit-skeleton class="tw-size-4" slot="start"></bit-skeleton>
<bit-skeleton class="tw-size-4" slot="start"></bit-skeleton>
<bit-skeleton class="tw-size-4" slot="start"></bit-skeleton>
</div>
</bit-skeleton-group>
`,
}),
};
12 changes: 12 additions & 0 deletions libs/components/src/skeleton/skeleton-text.component.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<div class="tw-w-full tw-flex tw-flex-col tw-gap-2">
@for (line of this.linesArray(); track $index; let last = $last, first = $first) {
<bit-skeleton
edgeShape="circle"
class="tw-h-3"
[ngClass]="{
'tw-w-full': first || !last,
'tw-w-1/3': !first && last,
}"
></bit-skeleton>
}
</div>
31 changes: 31 additions & 0 deletions libs/components/src/skeleton/skeleton-text.component.ts
Original file line number Diff line number Diff line change
@@ -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<number>(1);

/**
* Array-transformed version of the `lines` to loop over
*/
protected linesArray = computed(() => Array.from(Array(this.lines()).keys()));
}
46 changes: 46 additions & 0 deletions libs/components/src/skeleton/skeleton-text.stories.ts
Original file line number Diff line number Diff line change
@@ -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<SkeletonTextComponent>;

type Story = StoryObj<SkeletonTextComponent>;

export const Text: Story = {
render: (args) => ({
props: args,
template: /*html*/ `
<bit-skeleton-text [lines]="lines"></bit-skeleton-text>
`,
}),
};

export const TextMultiline: Story = {
render: (args) => ({
props: args,
template: /*html*/ `
<bit-skeleton-text [lines]="lines"></bit-skeleton-text>
`,
}),
args: {
lines: 5,
},
};
8 changes: 8 additions & 0 deletions libs/components/src/skeleton/skeleton.component.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<div
class="tw-size-full tw-bg-secondary-100 tw-animate-pulse"
[ngClass]="{
'tw-rounded': edgeShape() === 'box',
'tw-rounded-full': edgeShape() === 'circle',
}"
aria-hidden="true"
></div>
26 changes: 26 additions & 0 deletions libs/components/src/skeleton/skeleton.component.ts
Original file line number Diff line number Diff line change
@@ -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");
}
74 changes: 74 additions & 0 deletions libs/components/src/skeleton/skeleton.mdx
Original file line number Diff line number Diff line change
@@ -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";

<Meta title="Component Library/Skeleton" />

# 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.

<Canvas of={skeletonStories.Square} />
<Canvas of={skeletonStories.Circle} />

### Skeleton Text

Specific skeleton component used to represent lines of text. The number of lines is configurable.

<Canvas of={skeletonTextStories.Text} />
<Canvas of={skeletonTextStories.TextMultiline} />

### 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.

<Canvas of={skeletonGroupStories.Default} />

## 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).
Loading
Loading