Using Storybook with Vue single file components
Josephus Paye II
Posted on January 8, 2020
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
- Create a new project with the default preset
vue create vue-storybook-tutorial --default
- Change into the newly created project directory and install Storybook
cd vue-storybook-tutorial
npx -p @storybook/cli sb init --type vue
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>
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>',
});
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
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>
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>
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;
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>
stories/ButtonPrimary.story.vue
:
+ <include-source>stories/ButtonPrimary.story.vue</include-source>
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;
};
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
);
};
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,
});
});
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;
}
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);
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 });
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';
Now running yarn storybook
should add a new panel Source which will show the source of the selected story.
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.
Posted on January 8, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.