devex: Add Storybook for component development (#2556)

Adds Storybook in preparation for UI component refactoring.
This commit is contained in:
sua yoo 2025-04-21 13:06:31 -07:00 committed by GitHub
parent c2a11ccf10
commit 78e2dadf0a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 1970 additions and 63 deletions

View File

@ -7,6 +7,7 @@
"redhat.vscode-yaml",
"streetsidesoftware.code-spell-checker",
"ms-python.black-formatter",
"ms-python.pylint"
"ms-python.pylint",
"unifiedjs.vscode-mdx"
]
}

View File

@ -1 +1,2 @@
node_modules/
src/stories

View File

@ -12,6 +12,7 @@ module.exports = {
"plugin:import-x/recommended",
"plugin:wc/recommended",
"plugin:lit/recommended",
"plugin:storybook/recommended",
"prettier",
],
plugins: ["@typescript-eslint", "lit"],

2
frontend/.gitignore vendored
View File

@ -27,3 +27,5 @@ custom-elements.json
/test-results/
/playwright-report/
/playwright/.cache/
*storybook.log

107
frontend/.storybook/main.ts Normal file
View File

@ -0,0 +1,107 @@
import path from "path";
import type { StorybookConfig } from "@storybook/web-components-webpack5";
import type { WebpackConfiguration } from "webpack-dev-server";
const config: StorybookConfig = {
stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
addons: [
"@storybook/addon-webpack5-compiler-swc",
"@storybook/addon-essentials",
{
name: "@storybook/addon-styling-webpack",
options: {
// TODO Consolidate with webpack.config.js
rules: [
{
// Global styles and assets, like fonts and Shoelace,
// that get added to document styles
test: /\.css$/,
sideEffects: true,
include: [
path.resolve(__dirname, "../src"),
path.resolve(
__dirname,
"../node_modules/@shoelace-style/shoelace",
),
],
exclude: /\.stylesheet\.css$/,
use: [
require.resolve("style-loader"),
{
loader: require.resolve("css-loader"),
options: {
importLoaders: 1,
},
},
{
loader: require.resolve("postcss-loader"),
options: {
implementation: require.resolve("postcss"),
},
},
],
},
{
// CSS loaded as raw string and used as a CSSStyleSheet
test: /\.stylesheet\.css$/,
sideEffects: true,
type: "asset/source",
use: [
{
loader: require.resolve("postcss-loader"),
options: {
implementation: require.resolve("postcss"),
},
},
],
},
],
},
},
],
framework: {
name: "@storybook/web-components-webpack5",
options: {},
},
webpackFinal: async (config) => {
// Show eslint errors from Storybook files in Webpack overlay
const ESLintPlugin = require("eslint-webpack-plugin");
config.plugins?.push(
new ESLintPlugin({
files: ["**/stories/*.ts", "**/.storybook/*.ts"],
}),
);
// Watch for changes to custom-elements.json to re-render element
// attributes whenever the custom elements manifest is generated
(config as WebpackConfiguration).devServer = {
watchFiles: ["**/custom-elements.json"],
};
return config;
},
swc: {
jsc: {
parser: {
syntax: "typescript",
decorators: true,
},
// TODO Consolidate with tsconfig.json
transform: {
useDefineForClassFields: false,
},
baseUrl: path.resolve(__dirname, ".."),
// TODO Consolidate with tsconfig.json
paths: {
"@/*": ["./src/*"],
"~assets/*": ["./assets/src/*"],
},
},
},
core: {
disableTelemetry: true,
},
};
export default config;

View File

@ -0,0 +1,25 @@
import "../src/global";
import {
setCustomElementsManifest,
type Preview,
} from "@storybook/web-components";
import customElements from "../src/__generated__/custom-elements.json";
// Automatically document component properties
setCustomElementsManifest(customElements);
const preview: Preview = {
parameters: {
actions: { argTypesRegex: "^on[A-Z].*" },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
},
};
export default preview;

View File

@ -0,0 +1,18 @@
export default {
/** Globs to analyze */
globs: ["src/**/*.ts"],
/** Globs to exclude */
exclude: ["__generated__", "__mocks__"],
/** Directory to output CEM to */
outdir: "src/__generated__",
/** Run in dev mode, provides extra logging */
// dev: true,
/** Run in watch mode, runs on file changes */
// watch: true,
/** Include third party custom elements manifests */
// dependencies: true,
/** Output CEM path to `package.json`, defaults to true */
packagejson: false,
/** Enable special handling for litelement */
litelement: true,
};

View File

@ -56,11 +56,11 @@ class MyCustomComponent extends BtrixElement {
}
```
### VS Code Snippet
## VS Code Snippet
If developing with [Visual Studio Code](https://code.visualstudio.com/), you can generate boilerplate for a `BtrixElement` Browsertrix component by typing in `component` to any TypeScript file and selecting "Btrix Component". Hit ++tab++ to move your cursor between fillable fields in the boilerplate code.
### Unit Testing
## Unit Testing
Unit test files live next to the component file and are suffixed with `.test` (ex: `my-custom-component.test.ts`).

View File

@ -0,0 +1,13 @@
# Using Storybook
[Storybook](https://storybook.js.org/) is a tool for documenting and building UI components in isolation. Component documentation is organized into ["stories"](https://storybook.js.org/docs/writing-stories) that show a variety of possible rendered states of a UI component.
Browsertrix component stories live in `frontend/src/stories`. Component attributes that are public properties (i.e. defined with Lit `@property({ type: Type })`) or documented in a TSDoc comment will automatically appear in stories through the [Custom Elements Manifest](https://custom-elements-manifest.open-wc.org/analyzer/getting-started/) file.
To develop using Storybook, run:
```sh
yarn storybook:watch
```
This will open Storybook in your default browser. Changes to Browsertrix components and stories wil automatically refresh the page.

View File

@ -94,6 +94,7 @@ nav:
- UI Development:
- develop/frontend-dev.md
- develop/ui/components.md
- develop/ui/storybook.md
- develop/localization.md
- Design:
- develop/ui/design-action-menus.md

View File

@ -47,6 +47,7 @@
"eslint-import-resolver-typescript": "^3.6.1",
"eslint-plugin-import-x": "4.5.1",
"eslint-plugin-lit": "^1.11.0",
"eslint-plugin-storybook": "^0.12.0",
"eslint-plugin-wc": "^2.0.4",
"eslint-webpack-plugin": "^4.1.0",
"fork-ts-checker-webpack-plugin": "^6.2.6",
@ -110,19 +111,35 @@
"format": "prettier --write .",
"format:check": "prettier --check .",
"localize:extract": "lit-localize extract && prettier --write xliff/*.xlf",
"localize:build": "lit-localize build"
"localize:build": "lit-localize build",
"cem": "custom-elements-manifest analyze",
"prestorybook": "yarn cem",
"prestorybook:build": "yarn cem",
"storybook": "storybook dev -p 6006",
"storybook:watch": "concurrently 'yarn cem --watch' 'yarn storybook'",
"storybook:build": "storybook build"
},
"devDependencies": {
"@custom-elements-manifest/analyzer": "^0.10.4",
"@lit/localize-tools": "^0.8.0",
"@storybook/addon-essentials": "^8.6.12",
"@storybook/addon-styling-webpack": "^1.0.1",
"@storybook/addon-webpack5-compiler-swc": "^3.0.0",
"@storybook/blocks": "^8.6.12",
"@storybook/test": "^8.6.12",
"@storybook/web-components": "^8.6.12",
"@storybook/web-components-webpack5": "^8.6.12",
"@types/webpack-bundle-analyzer": "^4.7.0",
"@web/dev-server-esbuild": "^0.3.3",
"@web/dev-server-import-maps": "^0.2.0",
"@web/dev-server-rollup": "^0.6.1",
"concurrently": "^9.1.2",
"husky": "^8.0.3",
"lint-staged": "^13.1.0",
"prettier-plugin-tailwindcss": "^0.5.12",
"rollup-plugin-typescript-paths": "^1.4.0",
"sinon": "^12.0.1",
"storybook": "^8.6.12",
"ts-lit-plugin": "^2.0.1",
"webpack-bundle-analyzer": "^4.10.1",
"webpack-dev-server": "^5.2.0"

View File

@ -37,5 +37,11 @@ module.exports = {
xmlSelfClosingSpace: false,
},
},
{
files: "**/*.mdx",
options: {
proseWrap: "always",
},
},
],
};

View File

@ -12,12 +12,7 @@ import { tw } from "@/utils/tailwind";
type Variant = "neutral" | "danger";
/**
* Custom styled button
*
* Usage example:
* ```ts
* <btrix-button>Click me</btrix-button>
* ```
* Custom styled button.
*/
@customElement("btrix-button")
export class Button extends TailwindElement {

View File

@ -22,26 +22,6 @@ tableCSS.split("}").forEach((rule: string) => {
* Low-level component for displaying content as a table.
* To style tables, use TailwindCSS utility classes.
*
* @example Usage:
* ```ts
* <btrix-table>
* <btrix-table-head class="border-b">
* <btrix-table-header-cell class="border-r">col 1 </btrix-table-header-cell>
* <btrix-table-header-cell>col 2</btrix-table-header-cell>
* </btrix-table-head>
* <btrix-table-body>
* <btrix-table-row class="border-b">
* <btrix-table-cell class="border-r">row 1 col 1</btrix-table-cell>
* <btrix-table-cell>row 1 col 2</btrix-table-cell>
* </btrix-table-row>
* <btrix-table-row>
* <btrix-table-cellclass="border-r">row 2 col 1</btrix-table-cell>
* <btrix-table-cell>row 2 col 2</btrix-table-cell>
* </btrix-table-row>
* </btrix-table-body>
* </btrix-table>
* ```
*
* Table columns will be automatically sized according to its content.
* To specify column size, use `grid-template-columns`.
*

11
frontend/src/global.ts Normal file
View File

@ -0,0 +1,11 @@
import "broadcastchannel-polyfill";
import "construct-style-sheets-polyfill";
import "./shoelace";
import "./assets/fonts/Inter/inter.css";
import "./assets/fonts/Recursive/recursive.css";
import "./styles.css";
import { theme } from "@/theme";
// Make theme CSS available in document
document.adoptedStyleSheets = [theme];

View File

@ -1,4 +1,5 @@
import "./utils/polyfills";
import "./global";
import { provide } from "@lit/context";
import { localized, msg, str } from "@lit/localize";
@ -14,15 +15,9 @@ import { until } from "lit/directives/until.js";
import { when } from "lit/directives/when.js";
import isEqual from "lodash/fp/isEqual";
import "broadcastchannel-polyfill";
import "construct-style-sheets-polyfill";
import "./shoelace";
import "./components";
import "./features";
import "./pages";
import "./assets/fonts/Inter/inter.css";
import "./assets/fonts/Recursive/recursive.css";
import "./styles.css";
import { viewStateContext } from "./context/view-state";
import { OrgTab, RouteNamespace } from "./routes";
@ -38,7 +33,6 @@ import AuthService, {
import { BtrixElement } from "@/classes/BtrixElement";
import type { NavigateEventDetail } from "@/controllers/navigate";
import type { NotifyEventDetail } from "@/controllers/notify";
import { theme } from "@/theme";
import { type Auth } from "@/types/auth";
import {
translatedLocales,
@ -53,9 +47,6 @@ import { AppStateService } from "@/utils/state";
import { formatAPIUser } from "@/utils/user";
import brandLockupColor from "~assets/brand/browsertrix-lockup-color.svg";
// Make theme CSS available in document
document.adoptedStyleSheets = [theme];
type DialogContent = {
label?: TemplateResult | string;
body?: TemplateResult | string;

View File

@ -0,0 +1,86 @@
import type { Meta, StoryObj } from "@storybook/web-components";
import { html } from "lit";
import { renderButton, type RenderProps } from "./Button";
const meta = {
component: "btrix-button",
tags: ["autodocs"],
render: renderButton,
argTypes: {
type: {
control: { type: "select" },
options: ["button", "submit"] satisfies RenderProps["type"][],
},
variant: {
control: { type: "select" },
options: ["neutral", "danger"] satisfies RenderProps["variant"][],
},
size: {
control: { type: "select" },
options: ["x-small", "small", "medium"] satisfies RenderProps["size"][],
},
},
args: {
label: "Button",
filled: true,
},
parameters: {
options: {
showPanel: false,
},
},
} satisfies Meta<RenderProps>;
export default meta;
type Story = StoryObj<RenderProps>;
export const Raised: Story = {
args: {
raised: true,
},
};
export const Loading: Story = {
args: {
loading: true,
},
};
export const Variants: Story = {
render: () => html`
${renderButton({
variant: "neutral",
label: "Neutral (Default)",
filled: true,
})}
${renderButton({ variant: "danger", label: "Danger", filled: true })}
`,
};
export const Sizes: Story = {
render: () => html`
${renderButton({
size: "x-small",
label: "X-Small",
filled: true,
})}
${renderButton({
size: "small",
label: "Small",
filled: true,
})}
${renderButton({
size: "medium",
label: "Medium (Default",
filled: true,
})}
`,
};
export const Link: Story = {
args: {
href: "https://webrecorder.net",
label: "Button Link",
},
};

View File

@ -0,0 +1,30 @@
import { html } from "lit";
import { ifDefined } from "lit/directives/if-defined.js";
import type { Button } from "@/components/ui/button";
import "@/components/ui/button";
export type RenderProps = Button;
export const renderButton = ({
variant,
filled,
label,
raised,
loading,
href,
}: Partial<RenderProps>) => {
return html`
<btrix-button
variant=${ifDefined(variant)}
label=${ifDefined(label)}
href=${ifDefined(href)}
?filled=${filled}
?raised=${raised}
?loading=${loading}
>
${label}
</btrix-button>
`;
};

View File

@ -0,0 +1,28 @@
import { Meta } from "@storybook/blocks";
<Meta title="Introduction" />
# Introduction
{/* TODO Consolidate with storybook.md in frontend/docs */}
Browsertrix component stories live in `frontend/src/stories`. Component
attributes that are public properties (i.e. defined with Lit
`@property({ type: Type })`) or documented in a TSDoc comment will automatically
appear in stories through the
[Custom Elements Manifest](https://custom-elements-manifest.open-wc.org/analyzer/getting-started/)
file.
## Troubleshooting
**Component attributes aren't updating in Storybook**
Ensure you're running Storybook with `yarn storybook:watch` instead of
`yarn storybook`. The "watch" script automatically regenerates
`custom-elements.json`, which is the source of element attributes documentation.
## Storybook Docs
- [How to write stories](https://storybook.js.org/docs/writing-stories/?renderer=web-components)
- [About autodocs](https://storybook.js.org/docs/writing-docs/autodocs/?renderer=web-components)
- [Configure Storybook](https://storybook.js.org/docs/configure/?renderer=web-components)

View File

@ -0,0 +1,31 @@
import type { Meta, StoryObj } from "@storybook/web-components";
import { defaultArgs, renderTable, type RenderProps } from "./Table";
import type { Table as TableComponent } from "@/components/ui/table/table";
const meta = {
component: "btrix-table",
subcomponents: {
TableRow: "btrix-table-row",
},
render: renderTable,
tags: ["autodocs"],
argTypes: {
columns: { table: { disable: true } },
rows: { table: { disable: true } },
},
args: defaultArgs,
parameters: {
options: {
showPanel: false,
},
},
} satisfies Meta<RenderProps>;
export default meta;
type Story = StoryObj<TableComponent>;
export const BasicTable: Story = {
args: {},
};

View File

@ -0,0 +1,81 @@
import { html, type TemplateResult } from "lit";
import { ifDefined } from "lit/directives/if-defined.js";
import "@/components/ui/table";
const columns = {
name: {
title: "Name",
},
email: {
title: "Email",
},
role: {
title: "Role",
},
remove: {
title: html`<span class="sr-only">Remove</span>`,
renderItem: () => html`<sl-icon name="trash3"></sl-icon>`,
},
} satisfies RenderProps["columns"];
const rows: { data: Omit<Record<keyof typeof columns, unknown>, "remove"> }[] =
[
{
data: {
name: "Alice",
email: "alice@example.com",
role: 40,
},
},
{
data: { name: "Bob", email: "bob@example.com", role: 20 },
},
] satisfies RenderProps["rows"];
export interface RenderProps {
columns: Record<
string,
{
title: string | TemplateResult;
classes?: string;
renderItem?: (data: Record<string, unknown>) => TemplateResult;
}
>;
rows: {
data: Record<string, unknown>;
classes?: string;
}[];
}
export const defaultArgs = { columns, rows } satisfies RenderProps;
export const renderTable = ({ columns: headers, rows: items }: RenderProps) => {
return html`
<btrix-table>
<btrix-table-head>
${Object.values(headers).map(
({ title, classes }) => html`
<btrix-table-header-cell class=${ifDefined(classes)}>
${title}
</btrix-table-header-cell>
`,
)}
</btrix-table-head>
<btrix-table-body>
${items.map(
({ classes, data }) => html`
<btrix-table-row class=${ifDefined(classes)}>
${Object.entries(headers).map(
([key, { renderItem }]) => html`
<btrix-table-cell class=${ifDefined(classes)}>
${renderItem ? renderItem(data) : data[key]}
</btrix-table-cell>
`,
)}
</btrix-table-row>
`,
)}
</btrix-table-body>
</btrix-table>
`;
};

View File

@ -14,6 +14,7 @@
"inlineSources": true,
"skipLibCheck": true,
"esModuleInterop": true,
"useDefineForClassFields": false,
"plugins": [
{
"name": "ts-lit-plugin",

1527
frontend/yarn.lock generated

File diff suppressed because it is too large Load Diff