Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
5 changes: 5 additions & 0 deletions .storybook/format-args-for-code-snippet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(" ");
Expand Down
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>
`,
}),
};
11 changes: 11 additions & 0 deletions libs/components/src/skeleton/skeleton-text.component.html
Original file line number Diff line number Diff line change
@@ -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 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(this.lines()).keys()]);
}
48 changes: 48 additions & 0 deletions libs/components/src/skeleton/skeleton-text.stories.ts
Original file line number Diff line number Diff line change
@@ -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 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");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it make more sense for this to just be 'shape'?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe! I felt like shape="circle" would imply it's always a circle versus just the corners, but I could be overthinking it

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I guess the consumer could pass circle and then give it differing width and heights. Might be confusing. Unless if we added width and height inputs. Then, if `shape='circle' we only take one of those values... ๐Ÿค” Do we think consumers will want full control to set styles with tailwind? Or are the width/height inputs a less complex DX?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought about doing width/height inputs but it felt kind of silly when it's super simple with tailwind already, like I'd just be re-implementing classes. The designs have non-circle shapes with rounded corners so that is why I didn't want to imply the object itself being a circle

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, got ya. In that case, I suppose we can't assume everything will be circular.

re width/height inputs: I'm not sure the assumption that it's easy to do with tailwind is necessarily true for everyone. We find it easy because we use tailwind every day but, some folks may not be as familiar. Explicit inputs might feel easier for them. IDK which is 'better' per se though

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Imo since tailwind is the standard throughout the apps, as long as it's clearly documented in Storybook how to use tailwind to apply width/height, it shouldn't be too confusing. (I think I need to work on the docs more.)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@BryanCunningham I added more to the docs, lmk if you think it's more clear

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good!

}
Loading
Loading