Using Storybook with Vue single file components

josephuspaye

Josephus Paye II

Posted on January 8, 2020

Using Storybook with Vue single file components

Storybook is a great tool for designing, writing and testing components in isolation. With it, we can create and test components without needing to set up all the code and business logic that surround them in a real app. The Storybook workflow of focusing on developing one component at a time also helps with encapsulation and modularity — it is less likely we will develop a component that is coupled to other parts of the app if we develop each component in isolation.

Storybook started life as a tool for developing React components, but it now has great support for many other UI frameworks, including Vue. Setting up Storybook with Vue is easy, and with Vue's single-file components (SFCs), we can write stories that keep associated template, logic, and styles co-located in the same file, while being able to view the source in the Storybook UI.

Setting up a Vue CLI project with Storybook

Let's use Vue CLI to setup a simple project with Storybook.

  • Install the latest version of Vue CLI
  npm install -g @vue/cli
Enter fullscreen mode Exit fullscreen mode
  • Create a new project with the default preset
  vue create vue-storybook-tutorial --default
Enter fullscreen mode Exit fullscreen mode
  • Change into the newly created project directory and install Storybook
  cd vue-storybook-tutorial
  npx -p @storybook/cli sb init --type vue
Enter fullscreen mode Exit fullscreen mode

Writing a simple Button component

Let's now create a simple button component to write stories for. The component has one prop, color which takes the value of either normal (the default), or primary.

<template>
    <button class="button" :class="`button-color--${color}`">
        <slot></slot>
    </button>
</template>

<script>
export default {
    name: 'Button',
    props: {
        color: {
            type: String,
            default: 'normal', // 'normal' or 'primary'
        },
    },
};
</script>

<style scoped>
.button {
    appearance: none;
    border: none;
    font-family: sans-serif;
    padding: 8px 16px;
    border-radius: 2px;
}

.button-color--normal {
    background-color: #eee;
    color: #222;
}

.button-color--normal:hover,
.button-color--normal:focus {
    background-color: #e0e0e0;
}

.button-color--normal:active {
    background-color: #bdbdbd;
}

.button-color--primary {
    background-color: #2196f3;
    color: #fff;
}

.button-color--primary:hover,
.button-color--primary:focus {
    background-color: #1e88e5;
}

.button-color--primary:active {
    background-color: #1976D2;
}
</style>
Enter fullscreen mode Exit fullscreen mode

Writing stories

When installed, Storybook creates the stories/ directory with a few sample stories in it. Let's delete those samples and add our own stories in stories/Button.stories.js for the button component.

import Button from '../src/components/Button';

export default {
    title: 'Button',
};

export const normalButton = () => ({
    components: { Button },
    template: '<Button>Normal Button</Button>',
});

export const primaryButton = () => ({
    components: { Button },
    template: '<Button color="primary">Normal Button</Button>',
});
Enter fullscreen mode Exit fullscreen mode

The code above uses the new Component Story Format which has some nice benefits, including the ability to use our stories outside of Storybook - for example, in our automated tests.

We can now run Storybook and visit the provided URL to see the stories:

yarn storybook
Enter fullscreen mode Exit fullscreen mode

Screenshot of the two stories for the Button component in Storybook

The two stories for the Button component in Storybook

With that, we have a working Storybook setup, one that is suggested in the Storybook docs. But I don't like the idea of writing stories in string templates, since there's no syntax highlighting and other useful editing controls. An alternative is to write JSX, but that comes with trade-offs, and I don't think the full power of JavaScript is necessary in this case.

What if we could use Vue's single file components (.vue files) to write stories? Turns out we can!

Writing stories in single file components

Let's move each story to its own file. The .story suffix in the filename is not necessary, but serves as a quick indicator that the component is a story.

stories/ButtonNormal.story.vue:

<template>
    <Button>Normal Button</Button>
</template>

<script>
import Button from '../src/components/Button.vue';

export default {
    name: 'ButtonNormal',
    components: { Button },
};
</script>
Enter fullscreen mode Exit fullscreen mode

stories/ButtonPrimary.story.vue:

<template>
    <Button color="primary">Primary Button</Button>
</template>

<script>
import Button from '../src/components/Button.vue';

export default {
    name: 'ButtonPrimary',
    components: { Button },
};
</script>
Enter fullscreen mode Exit fullscreen mode

We now update stories/Button.stories.js to use the new components:

import ButtonNormal from './ButtonNormal.story.vue';
import ButtonPrimary from './ButtonPrimary.story.vue';

export default {
    title: 'Button',
};

export const normalButton = () => ButtonNormal;

export const primaryButton = () => ButtonPrimary;
Enter fullscreen mode Exit fullscreen mode

Now running yarn storybook should produce the same stories as before, except this time they're written in single file components.

What have we gained?

As is usual when there are different approaches to doing the same thing, every approach comes with a trade-off. The main downside to this approach in this case is the extra file and associated boilerplate of the SFC format that's now needed for each story.

But I think it's worth it for what we gain:

  • Idiomatic Vue templates with syntax highlighting and full editor support
  • Scoped CSS styles for stories when we need it
  • A tidier way to organize code for bigger stories

We could stop here, but there's one important improvement we can make: adding the ability to view the source of the story in the Storybook UI.

Viewing the source of stories in Storybook

There is the official Storysource addon which adds support for viewing the source of stories in Storybook. Unfortunately we can't use it as it won't work with our setup here: it assumes we've written our story inline, but we haven't - they are imported from separate files.

To view the source of our stories, we need to extend the Storybook UI with our own source panel that will work with this setup. To do that, we will:

  • Add an <include-source> custom block to our story component files, and write a custom webpack loader to load the story source
  • Write an addon to display the source in Storybook UI

The <include-source> custom SFC block

The first step is reading the story source and attaching it to the story object at build time so it's available to show in the UI at runtime. To do this, we need two things:

  • The path to the .story.vue file, so we can read it
  • A webpack loader to read the source and attach it to the component

Unfortunately, webpack loaders don't have direct access to the path of the file with the current piece of code (or "entry" in webpack terms) that they're processing. What they do have access to, however, is source of said entry. So we can embed the path of the file in the entry, and use that instead.

A good way to do this is using Vue Loader's custom blocks feature, which allows us to define our own blocks next to the default <template>, <script>, and <style> blocks. Vue Loader will parse the block and pass its content to a custom webpack loader, which will also receive the parsed component to annotate.

Adding the <include-source> custom block

At the end of each .story.vue file, let's add the <include-source> block with the path of the file.

stories/ButtonNormal.story.vue:

+ <include-source>stories/ButtonNormal.story.vue</include-source>
Enter fullscreen mode Exit fullscreen mode

stories/ButtonPrimary.story.vue:

+ <include-source>stories/ButtonPrimary.story.vue</include-source>
Enter fullscreen mode Exit fullscreen mode

Now let's extend the Storybook webpack config to add a loader that handles the custom block. Create a file at .storybook/webpack.config.js with the following content:

const path = require('path');

module.exports = ({ config }) => {
    // Add a custom loader to load and attach the source of the file
    // specified in a <include-source> custom block of a Vue file
    config.module.rules.push({
        // The block type: <include-source>
        resourceQuery: /blockType=include-source/,
        // The custom loader: source-loader.js file in the current directory
        loader: path.resolve(__dirname, 'source-loader.js'),
        // Pass the repo's root path in the loader options to resolve the
        // relative source file paths
        options: {
            rootPath: path.resolve(__dirname, '..'),
        },
    });

    return config;
};
Enter fullscreen mode Exit fullscreen mode

Then create a file at .storybook/source-loader.js with the custom loader:

const fs = require('fs');
const path = require('path');

module.exports = function(source, sourceMap) {
    // `source` (the string in the custom <include-source> block) contains the file path
    const filePath = path.join(this.query.rootPath, source.trim());

    // Read the referenced file and remove the <include-source> block, so it doesn't
    // show up in the source code that will be shown in the UI
    const fileContent = fs
        .readFileSync(filePath, 'utf8')
        .replace(/<include-source>.*<\/include-source>\n/, '');

    // Generate a function that'll receive the Vue component and attach the source
    this.callback(
        null,
        `export default function (Component) {
            Component.options.__source = ${JSON.stringify(fileContent)};
        }`,
        sourceMap
    );
};
Enter fullscreen mode Exit fullscreen mode

Adding the source panel in Storybook

With the source of each story attached to the corresponding component during build time, we can write a Storybook addon that adds a new panel to display the source code.

Create a file at .storybook/source-addon.js with the following content:

import React from 'react';
import { addons, types } from '@storybook/addons';
import { useParameter } from '@storybook/api';
import { AddonPanel } from '@storybook/components';
import { SyntaxHighlighter } from '@storybook/components';

const ADDON_ID = 'vueStorySource';
const PARAM_KEY = 'source';
const PANEL_ID = `${ADDON_ID}/panel`;

// The SourcePanel component (React)
const SourcePanel = ({ active }) => {
    const source = useParameter(PARAM_KEY, null);
    return active && source
        ? React.createElement(
                SyntaxHighlighter,
                {
                    language: 'html',
                    showLineNumbers: false,
                    copyable: true,
                    padded: true,
                    format: false,
                },
                source
            )
        : null;
};

// Register the addon
addons.register(ADDON_ID, () => {
    const render = ({ active, key }) =>
        React.createElement(
            AddonPanel,
            { active, key },
            React.createElement(SourcePanel, { active })
        );

    addons.add(PANEL_ID, {
        type: types.PANEL,
        title: 'Source',
        render,
        paramKey: PARAM_KEY,
    });
});
Enter fullscreen mode Exit fullscreen mode

The code above defines a SourcePanel React component that uses the useParameter Storybook hook to get the story's source and render it using the SyntaxHighlighter component included with Storybook. The source parameter will be read from the story's parameters object, which is set as shown below.

The story() helper function

To add the source parameter, we need to get the .story.vue component source and attach it to the story object when it is defined. Since we'll be doing this for every story, let's write a story helper function that wraps that logic.

Create a new file at stories/story.js with the following content:

export function story(StoryComponent, options = {}) {
    // Get the `withSource` option, default to true. Making this an option
    // allows us to opt-out of displaying the source of a story.
    const { withSource } = Object.assign({ withSource: true }, options);

    // The story export that Storybook will use
    const storyExport = () => StoryComponent;

    // Attach the source as a story paramter
    if (withSource) {
        storyExport.story = {
            parameters: {
                // `.__source` is from our custom <include-source> SFC block
                // and webpack loader
                source: StoryComponent.__source,
            },
        };
    }

    return storyExport;
}
Enter fullscreen mode Exit fullscreen mode

Now we update the definition of each story in stories/Button.stories.js to use this helper which will attach the source as a story parameter:

import { story } from './story';
import ButtonNormal from './ButtonNormal.story.vue';
import ButtonPrimary from './ButtonPrimary.story.vue';

export default {
    title: 'Button',
};

export const normalButton = story(ButtonNormal);

export const primaryButton = story(ButtonPrimary);
Enter fullscreen mode Exit fullscreen mode

If we don't want the source on a certain story, we can disable the source attachment by passing { withSource: false } as the second parameter to the story() function:

export const storyWithDisabledSource = story(MyStory, { withSource: false });
Enter fullscreen mode Exit fullscreen mode

Registering the source addon with Storybook

The final thing to do to see our new panel in Storybook is to register the addon. Update .storybook/addons.js to import and register the new addon:

import '@storybook/addon-actions/register';
import '@storybook/addon-links/register';

import './source-addon';
Enter fullscreen mode Exit fullscreen mode

Now running yarn storybook should add a new panel Source which will show the source of the selected story.

Screenshot of the two stories of the Button component with their source in Storybook

The two stories of the Button component with their source in Storybook

Conclusion

In this post I've shown a decent setup for writing stories in single file components with the ability to view the source of the stories in Storybook. I'm happy to answer questions about this in the comments below.

The complete source code for this post is available on GitHub for reference at https://github.com/JosephusPaye/vue-storybook-tutorial.

The idea for this post came from my work on Keen UI, a lightweight Material-inspired UI component library for Vue. Check out the storybook branch for a real-world example of this setup in use.

Addendum

This post is a part of my #CreateWeekly project, an attempt to create something new publicly every week in 2020.

💖 💪 🙅 🚩
josephuspaye
Josephus Paye II

Posted on January 8, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related