Search code examples
javascriptreactjsreact-routerstorybook

How can you wrap a story with a react-router layout route?


I have a very simple and plain ComponentX that renders some styled HTML, no data fetching or even routing needed. It has a single, simple story. ComponentX is meant to be used in a dark-themed website, so it assumes that it will inherit color: white; and other such styles. This is crucial to rendering ComponentX correctly. I won't bore you with the code for ComponentX.

Those contextual styles, such as background-color: black; and color: white;, are applied to the <body> by the GlobalStyles component. GlobalStyles uses the css-in-js library Emotion to apply styles to the document.

import { Global } from '@emotion/react';

export const GlobalStyles = () => (
  <>
    <Global styles={{ body: { backgroundColor: 'black' } }} />
    <Outlet />
  </>
);

As you can see, this component does not accept children, but rather is meant to be used as a layout route, so it renders an <Outlet />. I expect the application to render a Route tree like the below, using a layout route indicated by the (1)

  <Router>
    <Routes>
      <Route element={<GlobalStyles/>} >      <== (1)
        <Route path="login">
          <Route index element={<Login />} />
          <Route path="multifactor" element={<Mfa />} />
        </Route>

Not pictured: the <Login> and <Mfa> pages call ComponentX.

And this works!

The problem is with the Stories. If I render a plain story with ComponentX, it will be hard to see because it expects all of those styles on <body> to be present. The obvious solution is to create a decorator that wraps each story with this <Route element={<GlobalStyles/>} >. How can this be accomplished? Here's my working-but-not-as-intended component-x.stories.tsx:

import React from 'react';

import ComponentX from './ComponentX';

export default {
  component: ComponentX,
  title: 'Component X',
};

const Template = args => <ComponentX {...args} />;

export const Default = Template.bind({});
Default.args = {};
Default.decorators = [
  (story) => <div style={{ padding: '3rem' }}>{story()}</div>
];

(I realize that I can make <GlobalStyles> a simple wrapper component around the entire <Router>, but I want to use this pattern to create stories for other components that assume other, intermediate layout routes.)


Solution

  • What I've usually done is to create custom decorator components to handle wrapping the stories that need specific "contexts" provided to them.

    Example usage:

    Create story decorator functions

    import React from 'react';
    import { Story } from '@storybook/react';
    import { ThemeProvider } from '@mui/material/styles';
    import CssBaseline from '@mui/material/CssBaseline';
    import { MemoryRouter as Router, Routes, Route } from 'react-router-dom';
    import theme from '../src/constants/theme';
    import { AppLayout } from '../src/components/layout';
    
    // Provides global theme and resets/normalizes browser CSS
    export const ThemeDecorator = (Story: Story) => (
      <ThemeProvider theme={theme}>
        <CssBaseline />
        <Story />
      </ThemeProvider>
    );
    
    // Render a story into a routing context inside a UI layout
    export const AppScreen = (Story: Story) => (
      <Router>
        <Routes>
          <Route element={<AppLayout />}>
            <Route path="/*" element={<Story />} />
          </Route>
        </Routes>
      </Router>
    );
    

    .storybook/preview.js

    import { INITIAL_VIEWPORTS } from '@storybook/addon-viewport';
    import { ThemeDecorator } from './decorators';
    
    export const parameters = {
      actions: { argTypesRegex: '^on[A-Z].*' },
      controls: {
        matchers: {
          color: /(background|color)$/i,
          date: /Date$/,
        },
      },
      options: {
        storySort: {
          includeName: true,
          method: 'alphabetical',
          order: ['Example', 'Theme', 'Components', 'Pages', '*'],
        },
      },
      viewport: {
        viewports: {
          ...INITIAL_VIEWPORTS,
        },
      },
    };
    
    export const decorators = [ThemeDecorator]; // <-- provide theme/CSS always
    

    Any story that needs the app layout and routing context:

    import React from 'react';
    import { ComponentStory, ComponentMeta } from '@storybook/react';
    import { AppScreen, MarginPageLayout } from '../../.storybook/decorators';
    
    import BaseComponentX from './ComponentX';
    
    export default {
      title: 'Components/Component X',
      component: BaseComponentX,
      decorators: [AppScreen], // <-- apply additional decorators
      parameters: {
        layout: 'fullscreen',
      },
    } as ComponentMeta<typeof BaseComponentX>;
    
    const BaseComponentXTemplate: ComponentStory<typeof BaseComponentX> = () => (
      <BaseComponentX />
    );
    
    export const ComponentX = BaseComponentXTemplate.bind({});
    

    In my example you could conceivably place all your providers and that Global component (w/ props) in what I've implemented as ThemeDecorator and set as a default decorator for all stories.