mirror of
https://github.com/bitwarden/browser
synced 2025-12-06 00:13:28 +00:00
[CL-427] Add skeleton loading components to the CL (#16728)
This commit is contained in:
@@ -25,6 +25,11 @@ export const formatArgsForCodeSnippet = <ComponentType extends Record<string, an
|
||||
const formattedArray = value.map((v) => `'${v}'`).join(", ");
|
||||
return `[${key}]="[${formattedArray}]"`;
|
||||
}
|
||||
|
||||
if (typeof value === "number") {
|
||||
return `[${key}]="${value}"`;
|
||||
}
|
||||
|
||||
return `${key}="${value}"`;
|
||||
})
|
||||
.join(" ");
|
||||
|
||||
@@ -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 */ `
|
||||
<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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
3
libs/components/src/skeleton/index.ts
Normal file
3
libs/components/src/skeleton/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./skeleton.component";
|
||||
export * from "./skeleton-text.component";
|
||||
export * from "./skeleton-group.component";
|
||||
@@ -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
libs/components/src/skeleton/skeleton-group.component.ts
Normal file
18
libs/components/src/skeleton/skeleton-group.component.ts
Normal file
@@ -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
libs/components/src/skeleton/skeleton-group.stories.ts
Normal file
73
libs/components/src/skeleton/skeleton-group.stories.ts
Normal file
@@ -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>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
11
libs/components/src/skeleton/skeleton-text.component.html
Normal file
11
libs/components/src/skeleton/skeleton-text.component.html
Normal file
@@ -0,0 +1,11 @@
|
||||
<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
|
||||
class="tw-h-3"
|
||||
[ngClass]="{
|
||||
'tw-w-full': first || !last,
|
||||
'tw-w-1/3': !first && last,
|
||||
}"
|
||||
></bit-skeleton>
|
||||
}
|
||||
</div>
|
||||
31
libs/components/src/skeleton/skeleton-text.component.ts
Normal file
31
libs/components/src/skeleton/skeleton-text.component.ts
Normal file
@@ -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(this.lines()).keys()]);
|
||||
}
|
||||
48
libs/components/src/skeleton/skeleton-text.stories.ts
Normal file
48
libs/components/src/skeleton/skeleton-text.stories.ts
Normal file
@@ -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<SkeletonTextComponent>;
|
||||
|
||||
type Story = StoryObj<SkeletonTextComponent>;
|
||||
|
||||
export const Text: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: /*html*/ `
|
||||
<bit-skeleton-text ${formatArgsForCodeSnippet<SkeletonTextComponent>(args)}></bit-skeleton-text>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
|
||||
export const TextMultiline: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: /*html*/ `
|
||||
<bit-skeleton-text ${formatArgsForCodeSnippet<SkeletonTextComponent>(args)}></bit-skeleton-text>
|
||||
`,
|
||||
}),
|
||||
args: {
|
||||
lines: 5,
|
||||
},
|
||||
};
|
||||
8
libs/components/src/skeleton/skeleton.component.html
Normal file
8
libs/components/src/skeleton/skeleton.component.html
Normal file
@@ -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
libs/components/src/skeleton/skeleton.component.ts
Normal file
26
libs/components/src/skeleton/skeleton.component.ts
Normal file
@@ -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");
|
||||
}
|
||||
112
libs/components/src/skeleton/skeleton.mdx
Normal file
112
libs/components/src/skeleton/skeleton.mdx
Normal file
@@ -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";
|
||||
|
||||
<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. 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.
|
||||
|
||||
<Canvas of={skeletonStories.BoxEdgeShape} />
|
||||
<Canvas of={skeletonStories.CircleEdgeShape} />
|
||||
|
||||
### 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` |
|
||||
|
||||
<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.
|
||||
|
||||
#### Customizing
|
||||
|
||||
**Slots**
|
||||
|
||||
Use the following slots to render `<bit-skeleton>` 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 |
|
||||
|
||||
<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).
|
||||
50
libs/components/src/skeleton/skeleton.stories.ts
Normal file
50
libs/components/src/skeleton/skeleton.stories.ts
Normal file
@@ -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<SkeletonComponent>;
|
||||
|
||||
type Story = StoryObj<SkeletonComponent>;
|
||||
|
||||
export const BoxEdgeShape: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: /*html*/ `
|
||||
<div class="tw-mb-4">Examples of different size shapes with edgeShape={{ edgeShape }}</div>
|
||||
<div class="tw-flex tw-flex-row tw-gap-8 tw-items-center">
|
||||
<bit-skeleton ${formatArgsForCodeSnippet<SkeletonComponent>(args)} class="tw-size-32"></bit-skeleton>
|
||||
<bit-skeleton ${formatArgsForCodeSnippet<SkeletonComponent>(args)} class="tw-w-40 tw-h-5"></bit-skeleton>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
args: {
|
||||
edgeShape: "box",
|
||||
},
|
||||
};
|
||||
|
||||
export const CircleEdgeShape: Story = {
|
||||
...BoxEdgeShape,
|
||||
args: {
|
||||
edgeShape: "circle",
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user