import React from 'react';
import Component from 'somewhere';
export default Component;
export const variant1 = () => <Component variant="1" />;
export const variant2 = () => <Component variant="2" />;
export const variant3 = () => <Component variant="3" />;
config.examplesGlob = `**/*.examples.js`; // <- this is the default
Note this isn't a real thing yet, but we'll figure some way to avoid using require.context
soon.
The above file will be added to your storybook as Component:variant1
, Component:variant2
and Component:variant3
. (You can have more complex names, see below).
A key benefit of a non-storybook specific file format is it becomes simple to reuse your examples in other tools, such as unit tests. For example:
import React from 'react';
import { mount } from 'enzyme';
import renderer from 'react-test-renderer';
import { variant1 } from './component.examples';
describe('variant1', () => {
it('example renders correctly', () => {
// Now you can do low-level unit tests on the example
expect(mount(variant1()).find('div').className).toMatch(/component/);
// Or you can do snapshot testing (although storyshots will still make this better)
const component = renderer.create(variant1());
let tree = component.toJSON();
expect(tree).toMatchSnapshot()
});
});
To add metadata at the example level, add extra properties to the exported function. To do it to the component, export an object rather than the component class.
import React from 'react';
import Component from 'somewhere';
// Parameters or decorators for the component (i.e. all stories):
export default {
component: Component,
parameters: { viewports: [320, 1200] },
decorators: [...],
});
// Parameters for a single story
export const variant1 = () => <Component variant="1" />;
variant1.parameters = { viewports: [320, 1200] };
We'll also make a super simple library to ensure you don't have to think about it:
import React from 'react';
import Component from 'somewhere';
import example from '@storybook/example';
export default example(Component, {
parameters: { viewports: [320, 1200] },
})
export const variant1 = example(() => <Component variant="1" />, {
parameters: { viewports: [320, 1200] },
});
- Why not return an object for examples?
We want examples to be consumed as simply as possible. Consider that developers will often be using them directly in tests etc. It is best if it can always be assume an example is a function returning something renderable.
OTOH, the component export (the default
export) is more typically used by tooling (e.g. props tables for documentation) and so can be more flexible. Assigning properties on a component class (or equiv in other frameworks) seems dangerous and ugly.
- Why mutate the function object?
You don't have to. This works too:
export variant1 = Object.assign(() => <Component />, { parameters: { viewports: ... } });
Our feeling is the mutation syntax is simpler and easier to understand.
By default, the story "chapter" (or "kind") will take the title from the component's displayName
(or equiv.), and the story's title will be the name of the example's export.
So the examples above will be called Component:variant1
, Component:variant2
and Component:variant3
.
We'll add a config flag that automatically prefixes the component's title with the pathname on disk.
Suppose you have:
config.prefixComponentTitlesByPathFrom = 'src/'
Then src/components/Button.examples.js
will name its examples like components/Button:variant1
, etc.
Alternatively, we'll support title
and titlePrefix
props on the component, and title
on the example:
export default {
component: Component,
titlePrefix: 'components',
// This would be equivalent to
// title: 'components/Component',
});
// Parameters for a single story
export const variant1 = () => <Component variant="1" />;
variant1.title = 'Initial variant, I like to use wordy story names';
Addons that are more dynamic like actions
and knobs
have an opportunity to be refactored to better reflect the new format.
Actions are enabled by default (you can use the actions: { disabled: true }
parameter to disable them, although there is no real benefit). To use:
export variant1 = ({ action }) => <Component onSomething={action('onSomething')} />;
We'll provide simple utilities that can be used for other tools:
import React from 'react';
import { mount } from 'enzyme';
import { jestAction } from '@storybook/addon-actions';
import { variant1 } from './component.examples';
it('calls the callback when you click it', () => {
const action = jestAction();
const wrapper = mount(variant1({ action }));
wrapper.find('p').simulate('click')
expect(action.onSomething).toHaveBeenCalled();
});
Knobs are similar, conceptually to actions:
export variant1 = Object.extend(
({ knobs: { name } }) => <Component name={name.get} onSetName={name.set} />, {
knobs: {
name: 'Default Name',
}
});
We'll provide simple utilities that can be used for other tools:
import React from 'react';
import { mount } from 'enzyme';
import { jestKnobs } from '@storybook/addon-knobs';
import { variant1 } from './component.examples';
it('calls the callback when you click it', () => {
const knobs = jestKnobs(variant1);
const wrapper = mount(variant1({ knobs }));
wrapper.find('p').simulate('click')
expect(knobs.name.set).toHaveBeenCalled();
const value = 'A different value'
knobs.name.set(value);
expect(wrapper.find('p').text).toBe(value)
});
@tmeasday Excellent breakdown & looking forward to chatting more about this proposal synchronously since I think a lot of context is lost within discord + GH comments. With that being said, I wanted to state my initial feedback & thoughts here to get the conversation started.
First, for things I like about this proposal:
The goal. Encapsulating the data layer of Storybook to make it agnostic to the surrounding environment will ensure that Storybook remains a driving force within the wider community of Component development outside of just traditional Storybook users. Kudos 💯
Making the Component <-> Stories relationship and exports explicit. One constraint with SB at the moment that @sarahgp is currently investigating for our Docz compatibility layer is automated interop with Docz's
PropsTable
andPlayground
components. Much like their SB equivalents, they depend on docgen info attached to a target Component instance itself. Making the Story => Component and Component => Stories mappings explicit will make these types of use cases much easier to achieve and is what the user wants 95% of the time anyhow.On the other hand, there are some things that I think warrant further discussion with the current version of the proposal:
I don't like that the default export is the
Component
and not the stories. Since this is an example file format, semantically it makes sense for the main export to be the examples themselves, with theComponent
being a part of that mapping. Having the Component as the default export inverts this expectation which may work functionally but doesn't make sense to me semantically for what we're trying to achieve with this file convention.We should be very clear with the terminology / naming. What is a
Story
, and what is anExample
in this parlance? imho we should just stick withStory
since it's already the bread & butter of what SB provides, and this initiative is really all about encapsulating the bread from the butter.Using one named export per story results in two issues. A) the naming of stories is clunky, where the 95% use case requires you to add
variant1.title = "my story title"
and more importantly B) aggregating the list of stories in anexamples.js
file is now really awkward, since there's no way to automatically extract the list of stories for a given component / file. This is a very common and fundamental use case for this file format, and I don't think that arbitrarily named exports serves this purpose well at all.My original thinking was that if I import a Story from one of these example files, it would contain all the framework-specific knowledge necessary to render it in isolation, whether that be from an MDX file or from within Storybook itself. Having the story exports be simple "render" functions is nice & simple, but imho it fails to achieve this goal (which may or may not be a problem depending on your intended use case). This is one area where I think it would really help to talk things through over a call. Things like supporting Storybook decorators, addon functionality, and iframe or shadow-dom wrappers around Stories would not be possible to support with this proposal, whereas if we added a small, framework-specific wrapper around each Story export (see my WIP proposal below), then all of these considerations would be much easier to support. I know this is definitely an area with very clear tradeoffs, but I'm looking forward to talking them through with you 😄
Here's a super rough alternative that solves some of these issues as I've been thinking about this space over the past couple of days:
Example mdx file:
A few things to note:
This proposal addresses each of my issues 1-4 above although it is slightly more verbose. The fact that this
examples.js
file imports from@storybook/react
is not a problem imho because it's still making the format 100% encapsulated from the rest of Storybook. It's just adding a framework-specific compatibility layer where these exported stories could optionally be hooked into globally defined decorators, addon options, and/or custom story isolation strategies such as the current iframe isolation or a lighter-weight isolation such as shadow-dom (which is something we've experimented on over at https://github.com/hydrateio/docz-plugin-storybook).cc @ndelangen @mergebandit @sarahgp