Pipeline overview
index.js (JSDoc + LitElement)
|
V
storybook/custom-elements.json <- the custom element manifest (source of truth)
|
|- @wc-toolkit/jsx-types -> storybook/custom-elements.types.d.ts (typed *Props)
|- setCustomElementsManifest() (storybook/preview.js)
|
@wc-toolkit/storybook-helpers -> getStorybookHelpers() { args, argTypes, template }
|
*.stories.ts
custom-elements-manifest.config.js drives cem analyze:
export default {
globs: ['packages/cfpb-design-system/src/elements/**/*.js'],
exclude: ['**/*.spec.js', '**/utilities/**'],
outdir: 'storybook',
litelement: true,
plugins: [
sortModulesPlugin(),
jsxTypesPlugin({
outdir: 'storybook',
fileName: 'custom-elements-types.d.ts',
// Generate imports relative to the storybook/ output dir
componentTypePath: (_name, _tag, modulePath) => `../${modulePath}`,
}),
],
};litelement: truetells the analyzer to understand Lit'sstatic properties = {...}shorthand (attribute name, reflect, type) instead of requiring@property()decorators.exclude: ['**/utilities/**']keeps non-component helper modules (likeshared-config.js) out of the manifestsortModulesPluginis a local plugin added to make output diff-stable. Without this regenerating the manifest could produce reorder only diffs.
Run it manually any time with:
yarn analyze
@wc-toolkit/jsx-types (analyzer plugin at build time): reads the manifest and emits storybook/custom-elements.types.d.ts - one Props type per component. For every property it emits two keys: the kebab-case attribute name and the camelCase property name, both typed off the class:
//storybook/custom-elements.types.d.ts snippet
export type CfpbButtonProps = {
'icon-left'?: CfpbButton['iconLeft'];
/** */
iconLeft?: CfpbButton['iconLeft'];
/** */
'icon-right'?: CfpbButton['iconRight'];
/** */
iconRight?: CfpbButton['iconRight'];
...
}@wc-toolkit/storybook-helpers (runtime, used in every .stories.ts) reads the manifest (registered once via setCustomElementsManifest in .storybook/preview.js) and turns it into ready-made Storybook args/argTypes/template for a given tag:
const { args, argTypes, template } = getStorybookHelpers<CfpbButtonProps>(
'cfpb-button',
{ excludeCategories: ['methods] },
);excludeCategories: ['methods'] is used in every story file. It drops public methods from the args/controls table since methods aren't bindable Storybook controls.
setStorybookHlpersConfig({ hideArgsRef: true }) in preview.js suppresses the "ref" column the helper would otherwise add to the args table in the UI.
Class level tags on the component's leading doc comment work correctly and are the only reliable source of documentation right now:
/**
*
* @element cfpb-expandable
* @slot header - The header content for the expandable.
* @slot content - The content within the expandable.
* @fires expandbegin - The expandable started expanding.
* @fires expandend - The expandable finished expanding.
* @fires collapsebegin - The expandables started collapsing.
* @fires collapseend - The expandables finished collapsing.
*/Verified in the generated manifest, these come through with the real descriptions:
// EG from the CEM at storybook/custom-elements.json
...
"slots": [
{
"description": "The header content for the expandable.",
"name": "header"
},
{
"description": "The content within the expandable.",
"name": "content"
}
],
...
"events": [
{
"name": "expandbegin",
"type": {
"text": "CustomEvent"
},
"description": "The expandable started expanding."
},
{
"name": "expandend",
"type": {
"text": "CustomEvent"
},
"description": "The expandable finished expanding."
},
{
"name": "collapsebegin",
"type": {
"text": "CustomEvent"
},
"description": "The expandables started collapsing."
},
{
"name": "collapseend",
"type": {
"text": "CustomEvent"
},
"description": "The expandables finished collapsing."
}
],
...Gotcha: every component in this codebase also writes a @property block directly above static properties
In cfpb-button/index.js for example:
/**
* @property {string} type - The button type: button, submit, or reset.
* @property {boolean} disabled - Whether the button is disabled or not.
...
* @returns {object} The map of properties.
*/
static properties = {
type: { ... },
href: { ... },
...That looks like it should document each attribute, but it doesn't because the CEM lit-plugin doesn't attach @property tag text to individual manifest attributes when they are declared this way.
The net effect of this currently is that the Storybook Controls/Docs tables do not show attribute descriptions for any component. Do not expect @property {type} name - description to do anything visible in Storybook for now. We can open a spike in the future to investigate the correct comment formatting for populating the CEM. I did not want to rewrite all the Web Components comments for this right now. I opted to keep it for in editor documentation, but just be aware of the implication for Storybook.
Since custom elements only receive strings/booleans through markup, stories should drive components through their attributes (kebab-case), not JS properties. This is why every args object in the 4 examples use attribute-cased keys: icon-left, icon-right, icon-left-spin, icon-right-spin, style-as-link, full-on-mobile which is matching the attribute: value declaration in each static properties entry.
getStorybookHelpers's argTypes are keyed the same way, so when you override a control you also use the attribute name:
argTypes {
...argTypes,
'icon-left': { control: 'select', options: ['', ...iconNames] },
'icon-right': { control: 'select', options: ['', ...iconNames] },
}If a component reflects a boolean attribute (reflect: true), prefer asserting against the DOM attribute rather than the JS property so you are checking real rendered state. There is an example of this in packages/cfpb-design-system/elements/cfpb-expandable/index.spec.js under collapsing programatically which checks button.getAttribute('aria-expanded') and elm.hasAttribute('open')
This isn't stylistic. Prior to fixing this, cfpb-button.stories.ts used camelCase keys here (iconLeft, iconRight) which silently failed to attach the icon-select controls to the real args since getStorybookHelpers generates attribute-cased keys.
-
cfpb-button.stories.tsis the fullest example. It uses the helper generatedtemplate(args)directly asrender(no custom markup needed since the button has a default slot). Adds adefault-slotpseudo-arg for the slotted button labelgetStorybookHelpersderives this arg automatically from the manifest's unnamed slot entry (slots: [{ name: '', description: '...'}]) and the story just seeds a default value for it:args: { ...args, variant: 'primary', 'deafault-slot': 'Button label' },It also dynamically builds a select control foricon-left/icon-rightoff the actual icon SVG filenames. It has noplayfunctions. The button has no interaction of its own to demonstrate so its whole contract lives incfpb-button/index.spec.js: variant and type fallbacks, the link form, and disabled state. -
cfpb-expandable.stories.tscan't use the autotemplate()because it needs two named slots. This is the pattern to copy for any component with named slots. It also demonstratesplayfunctions exercising the component's 4 custom events usingfn()spies fromstorybook/testanduserEvent.click, plus a synthetic-event trick for the CSS transition drivecollapsed/expandedevents because the component's interanal BaseTransition listens for the Chromium-prefix name first. Those 4 stay in the story because each is deive by a real click. Programatic property writes moved tocfpb-expandable/index.spec.jsIt writes a custom
render:like this:render: ({ open, 'header-slot': header, 'content-slot': content }) => html`<cfpb-expandable ?open="${open}"> <span slot="header">${header}</span> <p slot="content">${content}</p> </cfpb-expandable>`,
-
cfpb-tag-filter.stories.ts- back to autotemplate(args)since it only has a default slot. It shows the split most clearly. TheDefaultstory'splayfunction clickc the button and assertsitem-clickfires in Chromium, whilecfpb-tag-filter/index.spec.jscovers the event's shape (detail.target,bubbles,composed), the asyncfocus()method, theforlabel form, and value derivation from slotted text. Same component, no overlapping assertions. -
cfpb-tagline.stories.ts- the minimal case. Single boolean property (isLarge, no explicitattribute:override so it defaults to the camelCase name), no play functions. Good starting template for the simplest components.
- Confirm the component's
index.jshas a class-level@element,@slotand@firesJSDoc block since these are what renders in the Docs page - Create
<name>.stories.tsnext toindex.js. Copy thecfpb-tagline.stories.tsas the minimal template, orcfpb-button.stories.tsif you are needing more than one variant. - Import
Meta/StoryObjfrom@storybook/web-components - Call
<Component>.init()at module scope before anything else - Call
getStorybookHelpers<xProps>('tag-name', { excludeCategories: ['methods'] }), importing theXPropstype from thestorybook/custom-elements-types - Decide between
tempalate(args)and a customrender:. Use the autotemplateif the component only has a default slot. Write a customhtmlrender (like in the expandables story) if it has named slots or needs conditional markup. - Set
meta.args/meta.argTypesusing attribute-cased keys, not camelCased properties. If you do this wrong it won't error, it just silently no-ops the control or arg - Set
meta.component: 'tag-name'andtags: ['autodocs]. This isn't optional. Withoutcomponent:set the auto-generatedOverviewdocs page fails to render its canvas and Attributes/Slots/Events table. - Add a
playfunction only for a real user interaction (like a click or keypress) following thecfpb-expandable/cfpb-tag-filterpattern withfn()+userEvent+expectfromstorybook/test. Everything else goes in a spec. See section 8. - Write
<element>/index.spec.jsalongside the story for the component's behavior contract. See section 8. - Run
yarn storybook(regenerates the manifest viayarn analyzefirst) and confirm that new story renders and the controls bind correctly.
Auto-generated CEM and storybook/custom-elements-types.d.ts get linted as part of yarn analyze and linting for TS files was added to the project.
Each element folder holds three files with seperate jobs:
| File | Holds | Runs in |
|---|---|---|
index.js |
the component | n/a |
index.spec.js |
its behavior contract | jsdom |
*.stories.ts |
what it looks like, plus real user interactions | Chromium |
If a test proves what the component looks like, it belongs in a story. If it proves what a user can do, it belongs in a play functions on a story. Everything else goes in index.spec.js: attributes, properties, emitted event shape, slots, fallbacks, error paths, matricies of values. Never assert the same thing in both places.
The reason to bother with the split it portability. index.spec.js files import the component itself, so if Storybook is ever replaced they keep running untouched. Anything proven only by a play functions goes when Storybook goes. Keep that set small.
| What you are proving | Where |
|---|---|
| Supported visual states, variants, sizes | *.stories.ts |
| On user interaction (click, keypress) | story play function |
| Accessibility (axe) | free with every story |
A matrix of cases (it.each) |
index.spec.js |
Event detail, bubbles, composed |
index.spec.js |
| Focus management, slots, form behavior | index.spec.js |
| Invalid input, fallbacks, console warnings | index.spec.js |
| Pure utilities and services | utilities/*.spec.js |
| Multi-component journeys, full pages | Playwright, rarely |
cfpb-tag-filter is the clearest example. The Default story's play function clicks the button with the userEvent and asserts item-click fires in Chromium. index.spec.js triggers the same button with a direct button.click() and asserts the event's detail.target, bubbles and composed. Two files, two environments, no assertion in both.
Do no write test only stories tagged ['!dev', '!autodoc']. We used that pattern here but removed it. Those assertions live in the specs now, where it.each covers a matrix in one block and nothing depends on Storybook.
Copy cfpb-file-upload/index.spec.js for the minimal case, or cfpb-button/index.spec.js if you want a mount() helper. Mount the element, wait for it to be defined and rendered, assert agains the shadow root and then clean up.
describe('<cfpb-alert', () => {
let elm;
beforeEach(async () => {
CfpbAlert.init();
elm = document.createElement('cfpb-alert');
elm.setAttribute('status', 'info');
elm.setAttribute('message', 'Information alert');
elm.innerHTML =
'<span>You can also add an explanation to the alert.</span>';
document.body.appendChild(elm);
await customElements.whenDefined('cfpb-alert');
await elm.updateComplete;
});
afterEach(() => {
document.body.removeChild(elm);
});
...
it('applies the alert role', () => {
const container = elm.shadowRoot.querySelector('.container');
expect(container.getAttribute('role')).toBe('alert');
});
...Prefer createElement and setAttribute over assigning innerHTML template strings. Reach for it.each on a matrix. On named test per case beats a for loop inside a single test, because a failure tells you which case broke.
globals: true is set, so describe, it, expect and vi need no imports.
vitest.config.js defines two:
unitrunspackages/**/*.spec.jsin jsdomstorybook- usesstorybookTest()from@storybook/addon-vitest/vitest-plugin, pointed at.storybook/running in a real headless Chromium via@vitest/browser-playwright. It turns every story into a Vitest test and runs the story'splayfunction as the test body.
yarn test #everything
vitest run --project-unit # just the fast jsdom tests
vitest run packages/**/elements/cfpb-alert # one element
vitest # watch modeSome things can only be proven with several components on a real page, like the doc site's search or and expandable inside a form. Those are Playwright tests, not Vitest ones. They live in test/playwright/, split into docs/ and packages/. playwright.config.ts starts yarn start and points at http://127.0.0.1:4000/designsystem
yarn playwright # run the e2e suite
yarn playwright open # run it in Playwright's UI modeWatch out for the collision. @playwright/test is the e2e runner described here. The seperate playwright package is only a browser driver for the @vitest/browser-playwright, which launches Chromium for the storybook Vitest project. The two have nothing to do with each other.
Keep this suite small. A journey you could prove at the component level belongs in index.spec.js, where it runs faster and fails more legibly.
Registered in .storybook/main.js and configured in .storybook/preview.js
a11y: {
// 'todo' - show a11y violations in the test UI only
// 'error' - fail CI on a11y violations
// 'off' - skip a11y checks entirely
test: 'todo',
},Set as 'todo means that axe core accessibility checks run against every story automatically and violations surface in the Storybook a11y panel/Vitest addon UI, but DO NOT FAIL yarn test or CI. Bumping this to 'error' repo-wide would turn any existing violations across all stories into a build failure if we want that in the future. Importantly, new components get a11y checking for free just by having a story! No extra config per component needed.