Written in December 2022. The configuration below records the Angular, Storybook and Chromatic setup I used then. Check the versions in your project before following it step by step.
Storybook is an amazing tool, and you should use it if you’re thinking about starting a reusable component library for your project(s).
The Problem
All apps grow(if they’re successful) and quite often this means that it gets easier and easier for the code to become unmanageable. One of the parts that usually becomes a big ball of mud is the reusable component part.
While adding a new feature, you realize that you’re re-creating the same button for the 5th time. Same CSS, same HTML.
I’m not affiliated with StoryBook in any shape or form. This is my experience so far.
The Solution
Start a component library. Our library should fulfil the following requirements 👇
Easy to maintain
Easy to add new components to it
Easy to visualize
Easy to play with component inputs
Bonus: Easy to test accessibility
Bonus: Easy to test interactivity
Bonus: Easy to do visual regression testing
Wait! Before you jump in and starting building all of this yourself( like any developer would do), have a look at Storybook. It seems to be a tool that ticks ✅ all of the boxes above. Let’s see how it performs and how easy it is to integrate in our Angular monorepo-ish.
Part 1 - Setup Storybook
This is where our journey starts. Our project structure looks like this right now:
In our monorepo, we have 2 folders: one for apps and one for projects. (environments is purely for environment based configuration)
The 📁web folder is where our web client lives. The 📁business-logic folder is where our reusable logic lives(unimportant for this article). The 📁ui folder is where our reusable components live and the 📁ui-showcase is our old, hand-made, pain in the 🍑, difficult to maintain component library visualizer.
I tried to run npx storybook init (the easy way 😅). But the command failed...partially...and created some files here and there. Spent a bit of time to figure out what happened, but in the end I decided to go the manual way, to also understand better what is actually happening under the hood.
So, with this in mind, I started looking at some example repos, and search a bit through Github on how other people are doing this.
The .storybook folder
Here’s the first required part. You need a 📁.storybook folder where your Storybook configuration will live. Here’s the main.js file 👇️
Here we’re telling the Typescript compiler which folders to scan(📁ui and 📁business-logic in our case). Ideally all models would be defined in the 📁ui folder, but right now, that's not the case. This is why we have to reference stuff from the 📁business-logic folder.
We also setup aliases for the 📁environments folder and for the 📁business-logic folder. This cleans-up our imports.
Last but not least, we exclude files that we don’t care about(.spec files in this case).
And here’s the typings.d.ts 👇️
TYPESCRIPT
declare module '*.md' {
const content: string;
export default content;
}
TYPESCRIPT
declare module '*.md' {
const content: string;
export default content;
}
This adds typings to .md imports. Not sure why this is needed.
The angular.json file
We also need to change our angular configuration to add a new application. This is what I had to add 👇️
JSON
"storybook": {
"projectType": "application",
"root": "projects/ui",
"sourceRoot": "projects/ui",
"architect": {
"build": {
"options": {
"tsConfig": ".storybook/tsconfig.json",
"styles": ["projects/ui/src/lib/styles.scss"],
"scripts": []
}
}
}
},
JSON
"storybook": {
"projectType": "application",
"root": "projects/ui",
"sourceRoot": "projects/ui",
"architect": {
"build": {
"options": {
"tsConfig": ".storybook/tsconfig.json",
"styles": ["projects/ui/src/lib/styles.scss"],
"scripts": []
}
}
}
},
This adds a new project to the angular-cli, tells which .tsconfig to use and loads the root styles needed throughout the 📁ui folder.
The package.json file
There are a couple of dev dependencies that need installing 👇️
JSON
{
"@babel/core": "7.20.5",
"@compodoc/compodoc": "1.1.19",
"@storybook/addon-actions": "6.5.14",
"@storybook/addon-essentials": "6.5.14",
"@storybook/addon-interactions": "6.5.14",
"@storybook/addon-links": "6.5.14",
"@storybook/angular": "6.5.14",
"@storybook/builder-webpack5": "6.5.14",
"@storybook/manager-webpack5": "6.5.14",
"@storybook/testing-library": "0.0.13",
"babel-loader": "8.3.0",
"eslint-plugin-storybook": "0.6.8"
}
JSON
{
"@babel/core": "7.20.5",
"@compodoc/compodoc": "1.1.19",
"@storybook/addon-actions": "6.5.14",
"@storybook/addon-essentials": "6.5.14",
"@storybook/addon-interactions": "6.5.14",
"@storybook/addon-links": "6.5.14",
"@storybook/angular": "6.5.14",
"@storybook/builder-webpack5": "6.5.14",
"@storybook/manager-webpack5": "6.5.14",
"@storybook/testing-library": "0.0.13",
"babel-loader": "8.3.0",
"eslint-plugin-storybook": "0.6.8"
}
On top of this, there are a few new commands we’ll need 👇️
"storybook": "npm run docs:json && start-storybook -p 6006",
"build-storybook": "npm run docs:json && build-storybook"
}
These commands will help you run Storybook locally with npm run storybook and build for releasing with npm run build-storybook .
The tsconfig.app.json file
This is the configuration for out client(our 📁web folder in our case). In here, we have to tell the typescript compiler to ignore *.stories.ts files. This can be done by adding this: "exclude": ["**/*.stories.ts”] . That's it.
The .eslintrc.json file
As a tip, it’s worth adding the "plugin:storybook/recommended” to the extends part of your eslint configuration for some smarter linting. This will only care about linting Storybook stories(*.stories.ts files)
Part 2 - Our first reusable component
Just to see it working, here’s my ButtonComponent . It's a standalone component and the contents of it are not super important. We just need a component that we want to see in action.
button.component.ts 👇️
TYPESCRIPT
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { ButtonSize, PossibleButtonSizes, ButtonType } from '../models/buttons';
transition: color 0.2s ease, background-color 0.2s ease;
&:focus {
outline-offset: 4px;
outline-color: $primary-blue;
}
&.primary:not(:disabled) {
background-color: $primary-orange;
&:focus {
outline-color: $primary-orange;
}
&:hover {
background-color: $orange-accent-3;
}
&:active {
background-color: $orange-accent-2;
}
}
&.secondary:not(:disabled) {
background-color: $primary-blue;
&:hover {
background-color: $blue-accent-2;
}
&:active {
background-color: $blue-accent-1;
}
}
&.tertiary:not(:disabled) {
background-color: transparent;
color: $primary-blue;
border: 1px solid currentColor;
&:hover {
background-color: $blue-accent-8;
}
&:active {
background-color: $blue-accent-7;
}
}
&.danger:not(:disabled) {
background-color: $primary-red;
&:focus {
outline-color: $primary-red;
}
&:hover {
background-color: $red-accent-2;
}
&:active {
background-color: $red-accent-1;
}
}
&:disabled {
color: $gray-4;
background-color: $gray-6;
cursor: not-allowed;
}
}
SCSS
@import '../theming/settings/variables/load';
.snappcar-button {
padding: var(--padding);
border-radius: 8px;
border: none;
font-size: var(--fontSize);
font-weight: 600;
color: white;
border: 1px solid transparent;
transition: color 0.2s ease, background-color 0.2s ease;
&:focus {
outline-offset: 4px;
outline-color: $primary-blue;
}
&.primary:not(:disabled) {
background-color: $primary-orange;
&:focus {
outline-color: $primary-orange;
}
&:hover {
background-color: $orange-accent-3;
}
&:active {
background-color: $orange-accent-2;
}
}
&.secondary:not(:disabled) {
background-color: $primary-blue;
&:hover {
background-color: $blue-accent-2;
}
&:active {
background-color: $blue-accent-1;
}
}
&.tertiary:not(:disabled) {
background-color: transparent;
color: $primary-blue;
border: 1px solid currentColor;
&:hover {
background-color: $blue-accent-8;
}
&:active {
background-color: $blue-accent-7;
}
}
&.danger:not(:disabled) {
background-color: $primary-red;
&:focus {
outline-color: $primary-red;
}
&:hover {
background-color: $red-accent-2;
}
&:active {
background-color: $red-accent-1;
}
}
&:disabled {
color: $gray-4;
background-color: $gray-6;
cursor: not-allowed;
}
}
Again, this is not super important. You can use whatever component you want here. A component that you want to reuse.
Important though is to have a .stories.ts file next to it. This is where your stories will be defined, so Storybook knows what to do with it.
button.stories.ts 👇️
TYPESCRIPT
import { Meta, Story } from '@storybook/angular';
import { ButtonComponent } from './button.component';
export default {
component: ButtonComponent,
excludeStories: /.*Data$/
} as Meta;
const Template: Story = (args) => ({
props: {
size: 'default',
...args
}
});
export const Primary = Template.bind({});
Primary.args = {
title: 'Primary button',
type: 'primary',
disabled: false
};
export const Secondary = Template.bind({});
Secondary.args = {
title: 'Secondary button',
type: 'secondary',
disabled: false
};
export const Tertiary = Template.bind({});
Tertiary.args = {
title: 'Tertiary button',
type: 'tertiary',
disabled: false
};
export const Danger = Template.bind({});
Danger.args = {
title: 'Danger button',
type: 'danger',
disabled: false
};
TYPESCRIPT
import { Meta, Story } from '@storybook/angular';
import { ButtonComponent } from './button.component';
export default {
component: ButtonComponent,
excludeStories: /.*Data$/
} as Meta;
const Template: Story = (args) => ({
props: {
size: 'default',
...args
}
});
export const Primary = Template.bind({});
Primary.args = {
title: 'Primary button',
type: 'primary',
disabled: false
};
export const Secondary = Template.bind({});
Secondary.args = {
title: 'Secondary button',
type: 'secondary',
disabled: false
};
export const Tertiary = Template.bind({});
Tertiary.args = {
title: 'Tertiary button',
type: 'tertiary',
disabled: false
};
export const Danger = Template.bind({});
Danger.args = {
title: 'Danger button',
type: 'danger',
disabled: false
};
Here we created 4 stories, to show our button with 4 different configurations. Again, for more info on how to create stories, the Storybook Documentation is quite good.
Now, if you run npm run storybook this is what you'll get 👇️
You can see our stories, all their inputs and you can play with all of that, change the text and see how our button will look/behave in different situations.
This is awesome! Basically, we are done fulfilling the first 4 points of our list.
Part 3 - Bonus stuff
Generic wrapper for all our components
We notice that the component is really up there, on the top left corner of our canvas. That’s not very pretty. We can easily setup a wrapper that will be used on all component stories and that wrapper will add some spacing. Here the new and improved preview.js on the 📁.storybook folder 👇️
JAVASCRIPT
import { setCompodocJson } from '@storybook/addon-docs/angular';
import { componentWrapperDecorator } from '@storybook/angular';
We can easily add an add-on that will flag if a component has accessibility issues. We can do this by installing @storybook/addon-a11y and adding that to the array of add-ons in the main.js file in the 📁.storybook folder. Now we can see that one of our buttons has an a11y issue 👇️
Add mock action handlers
We can also add a mock handler for events, to see when they happened. This is done by slightly changing our Template variable in the button.stories.ts file 👇️
TYPESCRIPT
import { action } from '@storybook/addon-actions';
const actionsData = {
buttonClicked: action('buttonClicked')
};
const Template: Story = (args) => ({
props: {
...args,
buttonClicked: actionsData.buttonClicked
}
});
TYPESCRIPT
import { action } from '@storybook/addon-actions';
const actionsData = {
buttonClicked: action('buttonClicked')
};
const Template: Story = (args) => ({
props: {
...args,
buttonClicked: actionsData.buttonClicked
}
});
After this, every time we click the button, we can see a log 👇️
Interaction tests
Adding an interaction test is also very simple. First, we need to install @storybook/test-runner .
Afterwards, we need a new command in package.json . Mine looks like this: "test-storybook": "test-storybook” .
Last but not least, we need to add the interaction in our stories. I added a simple interaction that tries to click the PrimaryButton in the buttons.stories.ts file 👇️
This is a cool one! Storybook works great with Chromatic. By integrating these two, you can publish your Storybook library, you can visualize your builds and... 🥁🥁🥁 You get alerted when there's a visual regression 🎉.
In order to add it you need a few things:
An account(has a very generous free tier)
Install the chromatic package
Add a new command in your package.json. Mine looks like this: "chromatic": "npx chromatic --project-token={my-project-id}”
Add a new Github Action. My /.github/workflows/chromatic.yml file looks like this 👇️
YAML
# Workflow name
name: 'Chromatic Deployment'
# Event for the workflow
on:
pull_request:
branches: [develop]
# List of jobs
jobs:
test:
# Operating System
runs-on: ubuntu-latest
# Job steps
steps:
- name: Checkout repository
uses: actions/checkout@v3
with:
fetch-depth: 0 # 👈 Required to retrieve git history