docs/react-v9/contributing/rfcs/react-components/convergence/authoring-stories.md
List contributors to the proposal: @hotell, @miroslavstastny, @ling1726, @andrefcdias, @PeterDraex
<!-- toc -->argTypesCurrently we have no style-guide/functionality requirements on how to be consistent when writing stories for converged components. This RFC should describe (as we progress) the style we want going forward.
For convergence (vNext) we agreed on a collocated stories approach that uses Component Story Format (CSF) for implementation.
What are we missing:
argTypes💡 NOTE: This RFC will address the first 4 points for now (should be updated with others later).
Proposal:
We should use controls.
Why:
argTypesA pattern that is used in some of the converged stories is to define everything by hand:
AccordionExample.argTypes = {
inline: {
defaultValue: false,
control: 'boolean',
},
navigable: {
defaultValue: false,
control: 'boolean',
},
circular: {
defaultValue: false,
control: 'boolean',
},
multiple: {
defaultValue: false,
control: 'boolean',
},
// ... other definitions of controls
};
Proposal:
NOTE: we still need to manually provide
argTypesuntil all vNext packages including react-components will be migrated to new DX. See #18514
this is not necessary as storybook generates all those controls automatically from TS metadata (Props interface)
argTypes can be used for use cases when:
const StoryName = (props: {defaultOpen?:boolean}) => { /* ... */ }
// HIDE actionable Control
StoryName.argTypes = {
defaultOpen: {
control: false,
},
},
Proposals (2)
1. ✅ provide controls with control pane only for default/playground story
This is preferred approach - Based on feedback from @teams-prg/@cxe-prg team
With that approach we would probably need to completely get rid of controls addon pane from all stories except Docs view and our Default/Playground story.
This can be achieved by following config:
// @filename .storybook/preview.js
export const parameters = {
// disable control pane/addon for all stories except `Docs` view
controls: {
disable: true,
},
};
// @filename FooBar.stories.tsx
export const Playground = (props:FooBarProps) => {.....}
Default.parameters = {
controls: {
// Enable Controls Pane only for our default/playground
disable: false,
},
};
Results in:
2. Make controls work for all stories besides default/playground
<details> Storybook will generate controls table with API descriptions (Extracted from JSDoc) based on default export per story file.this would require additional effort from our side (documented below) and we might run into issues when dealing with complex controls although storybook provides decent amount of customization.
// @filename FooBar.stories.tsx
export default {
title: 'Components/FooBar',
component: FooBar,
};
By default the controls pane will be used for every story in the story file. This creates confusing DX to the consumer as those controls will generate only a warning message with the inability to use them.
To make the control pane work for every story, we need to provide props argument with proper type so SB generates those stories accordingly. Also they need to be passed to the underlying component.
Before:
import {FooBar} from './index'
export const Example = () => {
return <FooBar onClick={handleClick} value={value}><div></div></Foobar>
}
After:
import {FooBar,FooBarProps} from './index';
export const Example = (props: FooBarProps) => {
return <FooBar {...props}><div></div></Foobar>
}
For more focused stories that showcase more focused behaviors (lets say a controlled version of component), we need to handle this by hand and also turn off controls for props that are being implemented by us.
Before:
import {FooBar,FooBarProps} from './index';
export const Example = (props: FooBarProps) => {
const handleClick = () => {};
const value = 'hello';
return (
<FooBar onClick={handleClick} value={value}>
<div></div>
</Foobar>
)
}
After:
value is not interactive in controls pane as that's handled by our custom logicimport {FooBar,FooBarProps} from './index'
export const Example = (props: FooBarProps) => {
const handleClick = () => {};
const value = 'hello';
const resolvedProps = {...props,value,handleClick};
return <FooBar {...props}><div></div></Foobar>
}
// define all props (except callbacks - those are omitted by default) that are handled by our custom logic
Example.argTypes = {
// this is needed to remove interactive control from controls pane, otherwise the DX would be confusing (sometimes it works sometimes it doesn't)
value: {
control: false,
},
} as ArgTypes;
Proposal
// @filename ./storybook/preview.js
export const parameters = { controls: { expanded: true } };
Before:
After:
TBA
TBA
Components with complex API require many long stories, both for documentation and for e2e testing. This creates big story files, which are hard to maintain. How can these files be dissected into smaller ones?
.stories.tsx file with default export which configures metadata about the component. This file must be called Component.stories.tsx, for example Button.stories.tsx..stories.tsx files as a named export, and then re-exported from Component.stories.tsx file like this: export * from ‘./IndividualStoryFile.stories’;Component.stories.tsx file must not contain any stories besides the default export.Good Example 1 - single file
// @filename Button.stories.tsx
import { Button, ButtonProps } from './Button'; // the component
import { Meta } from '@storybook/react-webpack5';
export const Default = (props: ButtonProps) => <Button {...props}>Button</Button>;
export const ButtonWithIcon = () => <Button icon={<CalendarIcon />}>Text</Button>;
export default {
title: 'Components/Button',
component: Button,
} as Meta;
Good Example 2 - multiple files
// @filename Button.stories.tsx
import { Button } from './Button'; // the component
import { Meta } from '@storybook/react-webpack5';
// 💡 `Default` re-export needs to be always first !
export * from 'ButtonDefault.stories';
export * from 'ButtonWithIcon.stories';
export default {
title: 'Components/Button',
component: Button,
} as Meta;
// @filename ButtonDefault.stories.tsx
export const ButtonDefault = (props: ButtonProps) => <Button {...props}>Button</Button>;
ButtonDefault.storyName = 'Default';
// @filename ButtonWithIcon.stories.tsx
export const ButtonWithIcon = () => <Button icon={<CalendarIcon />}>Text</Button>;
Bad example 1 - only Component.stories.tsx can have a default export
// @filename ButtonDefault.stories.tsx
export const ButtonDefault = (props: ButtonProps) => <Button {...props}>Button</Button>;
ButtonDefault.storyName = 'Default';
// don’t do this
export default {
title: 'Components/Button',
component: Button,
} as Meta;
// @filename ButtonWithIcon.stories.tsx
export const ButtonWithIcon = () => <Button icon={<CalendarIcon />}>Text</Button>;
// don’t do this
export default {
title: 'Components/Button',
component: Button,
} as Meta;
Bad example 2 - don’t mix re-exports with inline definition within the main story
// @filename Button.stories.tsx
export const ButtonDefault = (props: ButtonProps) => <Button {...props}>Button</Button>;
ButtonDefault.storyName = 'Default';
export * from 'ButtonWithIcon.stories';
export default {
title: 'Components/Button',
component: Button,
} as Meta;
// @filename ButtonWithIcon.stories.tsx
export const ButtonWithIcon = () => <Button icon={<CalendarIcon />}>Text</Button>;
export const ButtonPrimary = (props: ButtonProps) => <Button {...props}>Text</Button>;
ButtonPrimary.args = {
primary: true,
};
ButtonPrimary.storyName = 'Better story name';
Internal keyword and a .internal.stories suffix for easier IDE search.Every component must have a story called Default, which:
Storybook will render a Controls table under this story.
Public stories should follow Fluent Design Language to give developers better feel for patterns they should utilize. For example, when a button is necessary to demonstrate usage of a component, Fluent UI Button should be used instead of a pure HTML button.
Do:
import { Button } from '@fluentui/react-button';
export const Default = (props: PopoverProps) => (
<Popover {...props}>
<PopoverTrigger>
<Button>Popover trigger</Button>
</PopoverTrigger>
<PopoverSurface>Content</PopoverSurface>
</Popover>
);
Don’t:
export const Default = (props: PopoverProps) => (
<Popover {...props}>
<PopoverTrigger>
<button>Popover trigger</button>
</PopoverTrigger>
<PopoverSurface>Content</PopoverSurface>
</Popover>
);
Public stories should only contain code, which is useful for users to see after clicking on “Show code” in documentation. Extra markup (e.g., container with CSS styles) can be added via Decorators.
As mentioned in for E2E testing, we should ensure maximum coverage for all publicly viewable stories by our consumers. For more complex scenarios that need to be tested we should make sure that stories exist for E2E tests but should not be easily accessible publicly.
Storybook has proposed a feature for this in storybookjs/storybook#9209
which will configure stories to exist in deeplink URL format, but do not appear in the nav tree or the docs page. As stated in the issue,
we can workaround before the release of this feature by modifying manager-head.html and set display:nonefor all
stories with a specific DOM id attribute. Storybook uses the id attribute for each link in the nav tree, and sets
the value to the story id.
We propose to use an extra filename extension and naming convention for internal stories:
// MenuTabstopsInternal.internal.stories.tsx
// Deep link /story/components-menu--tabstops-internal
// Does not appear in the sidebar or docs page
export const TabstopsInternal = () => {
// story
};
The naming convention of the story simply adds the Internal keyword to the Pascal case story name. This will match the
filename. More importantly the generated id will contain menu-tabstops-internal.
We can simply use a css wildcard query selector:
[id*='internal'] {
display: none;
}
This means that Internal will be a reserved keyword in our stories which will determine visibility. This does not cause
any conflicts with current stories, since this word is never used in any story name.
This solution will only need to be applied within react-components storybook configuration since that is the storybook currently targeted for
public use. Individual component storybooks are only used for local development, so there is no need to hide internal stories from their nav trees.
N/A for now
N/A