Build Your Team an Accessible, Shareable Component Library
Robert Pearce
Posted on April 27, 2020
Orginally posted on https://robertwpearce.com/build-your-team-an-accessible-shareable-component-library.html
Today we're going to dive into building a frontend component library from start to finish that you can share privately with your team or publicly with everyone!
By the end of this post, you will be able to use TypeScript, React, Storybook, and more to provide a simple way to create accessible components that can be included in all of your projects.
If you'd like to skip to the code, here is the example component library we're going to make: https://github.com/rpearce/example-component-library.
Overview
This is a big post that covers a lot of ground, so buckle up.
- When Should I Make a Component Library and Why?
- Project API (Usage)
- Main Project Tools
- Project Structure
- Component Structure
- Creating the Project
- TypeScript Setup
- Linting Setup
- Testing Setup
- Storybook Setup
- An Example Component
- Building Our TypeScript
- Building Our CSS
- Building Our Stories
- Continuous Integration Notes
- Publishing Notes
When Should I Make a Component Library and Why?
Scenario 1: Component Entropy
Components make up large parts of our applications. As projects age, components can become increasingly coupled with other components, business logic, and application state management tools like redux.
These components usually start out small, focused, and pure. As time passes and the imperative of timely code delivery takes its toll, these components become harder to compose, harder to reason about, and cause us to yearn for simpler, less-involved times.
Instead of rewriting those components in place and repeating the same process, consider extracting and developing each one in isolation in a library. This will allow you to keep each one's surface area small and keep your business logic, state management, routing logic, etc., where it belongs: in your application.
With this scenario, a good intermediary step, before pulling components into their own project, would be to create a folder in your application for these components and set up a tool like storybook to house the individual examples and compositions of them.
Scenario 2: Multiple Projects (or The Possibility of Multiple Projects)
Consider this exchange:
Them: You know that spinner/widget/dropdown/search thing we have over here? It looks and works great! We want the same thing over here and over here. How difficult is that?
Me: Those are different projects, and that is really more like 4 different components working together, so a) hard to do cleanly but good for the long-term or b) easy (for now) if I copy and paste.
Them: We need to ship.
Me: Okay, so copy and paste it is...
What's special about this exchange is that both sets of concerns and perspectives are valid. Software stakeholders typically want and need to ship features and fixes quickly, and they usually want to maintain brand consistency across their ecosystems. Software developers at those companies want to be able to ship features and fixes and maintain brand consistency, but they are also aware of the cost of short-term decision making (this is a way of accruing technical debt).
We know that even the best code is useless to a business if there are no customers around paying to use it, but we also know that suboptimal tech decision making can grind projects to a halt over time, averting the stakeholder's directive of shipping features and fixes quickly.
So what can we do to not only amend the scenario above but also make this undesired state unrepresentable in the future? We can start our projects with an accompanying component library! For existing projects, we can begin moving them in that direction.
Project API (Usage)
Let's first define how we are going to include our components in our project.
JS Imports
Component JavaScript can be imported in a few different ways:
// import from the main (or module) specification in
// package.json, depending on your bundler and its version
import { Circle } from 'mylib'
// straight from the ESModule build
import Circle from 'mylib/dist/esm/Circle'
// straight from the CommonJS build
import Circle from 'mylib/dist/cjs/Circle'
// straight from the Universal Module Definition build
import Circle from 'mylib/dist/umd/Circle'
CSS Imports
Component CSS can be imported like this:
import 'mylib/dist/css/Circle/styles.css'
If you know you will use all of the components and wish to import all of their CSS at once:
import 'mylib/dist/css/styles.css'
The JS import is simple enough, but you might be wondering, "What's the deal with importing CSS like this? I thought we were on to things like styled-components, emotion, CSS modules, etc?"
These tools are great if the consuming application can bundle up and inject the styles using the same instance of the tool, but can you guarantee each app will use these same styling tools? If so, by all means go that direction. However, if your library is injecting its own styles into the document at runtime, you will not only potentially run into style specificity / collision issues if you don't have the application styles load last, but strict content security policies will potentially disallow the dynamically added styles from even being applied!
The solution? Go with the lowest common denominator: regular, vanilla CSS (or something that outputs regular, vanilla CSS). We'll come back to this in the example component section.
Main Project Tools
It's time to build the project! Here are the main tools we will use:
-
NodeJS (version
13.13.0
) - TypeScript
- React
- Storybook (UI examples)
- jest (testing)
- axe-core (testing accessibility)
- linting
- eslint with prettier
-
husky with lint-staged; only on
pre-push
Project Structure
.
├── .storybook (1)
│ └── ...
├── dist (2)
│ └── ...
├── docs (3)
│ └── ...
├── examples (4)
│ └── ...
├── scripts
│ └── buildCSS (5)
├── source (6)
│ └── ...
├── .eslintignore
├── .eslintrc.js
├── .gitignore
├── .prettierrc.js
├── CHANGELOG.md (7)
├── LICENSE (8)
├── README.md
├── husky.config.js
├── jest.config.js
├── lint-staged.config.js
├── package.json
├── testSetup.ts
├── tsconfig.base.json (9)
├── tsconfig.cjs.json
├── tsconfig.esm.json
├── tsconfig.json
└── tsconfig.umd.json
-
.storybook/
– storybook examples configuration -
dist/
– compiled project output -
docs/
– compiled storybook examples output -
examples/
– addcreate-react-app
,gatsby
, and other example projects here -
scripts/buildCSS
– store build scripts here like this CSS-related one -
source/
– where your project lives; we'll dive into this in the next section -
CHANGELOG.md
– be a good teammate and document your library's changes; very useful for your teams and useful if you decide to open source the project -
LICENSE
– a good idea if you plan to open source; otherwise, putUNLICENSED
in yourpackage.json
license field -
tsconfig.json
, et al – typescript build configs; we'll dive into this in the project setup section
Component Structure
.
└── source
└── ComponentA
├── __snapshots__
│ └── test.tsx.snap
├── index.tsx
├── stories.tsx
├── styles.css
└── test.tsx
└── ComponentB
└── ...
└── ComponentC
└── ...
├── index.ts
└── test.tsx
The component and everything to do with it are co-located in the source/ComponentA/
folder:
-
index.tsx
component file (and any additional component files) - storybook stories
- CSS
- tests
This grouping of everything having to do with a component makes it very easy to find everything you need. If you would prefer a different setup, you can adjust the tool configurations however you like.
Each component is then exported from the main index.ts
file.
It's now time to start the project from scratch and make this outline a reality!
Creating the Project
To begin, let's create the project and a package.json
file with some project-related information:
$ mkdir example-component-library && cd $_
$ touch package.json
And in package.json
:
{
"name": "@yournpm/example-component-library",
"version": "0.1.0",
"description": "Example repository for a shared React components library",
"main": "dist/cjs/index.js",
"module": "dist/esm/index.js",
"repository": {
"type": "git",
"url": "git@github.com:yourgithub/example-component-library.git"
},
"homepage": "https://github.com/yourgithub/example-component-library",
"bugs": "https://github.com/yourgithub/example-component-library",
"author": "Your Name <you@youremail.com>",
"license": "BSD-3",
"keywords": [],
"tags": [],
"sideEffects": ["dist/**/*.css"],
"files": ["LICENSE", "dist/"],
"scripts": {},
"devDependencies": {},
"peerDependencies": {
"react": "*",
"react-dom": "*"
},
"dependencies": {}
}
Once you save that, run your build tool to make sure everything is ok:
$ npm install
Notably, we've set our main
field to dist/cjs/index.js
, the CommonJS build, for compatibility with NodeJS environments because they don't yet work well with ESModules. We've set our module
field to look at dist/esm/index.js
, the ESModule build. If you want to make use of the Universal Module Definition build we'll create later on, you can use the browser
field: "browser": "dist/umd/index.js"
. Personally, if I build with webpack, I want webpack to select the module
field over the browser
one because it will always be of a smaller size, for the UMD builds are meant to be run in any of a few different environments.
Also of importance is the sideEffects
field. If our library code was pure and didn't have side effects, we would set the value to false
, and build tools like webpack would prune away all of the unused code. However, since we also are exporting CSS, we need to make sure that it doesn't get dropped by the build tool, so we do that with "sideEffects": ["dist/**/*.css"]
.
Lastly, we know we're going to be using React, so we can go ahead and set that as a peerDependency
(it's up to you to decide what versions of React you'll support).
TypeScript Setup
We can now add TypeScript to our project with some compiler and project-related options. We'll also add some type definition libraries that we'll use later, as well as a dependency on tslib
to make compiling our code to ES5 seamless.
$ npm install --save-dev --save-exact \
@types/node \
@types/react \
@types/react-dom \
typescript
$ npm install --save --save-exact tslib
$ touch tsconfig.base.json tsconfig.json
We will place our compilerOptions
in tsconfig.base.json
so that they can be extended in all our different builds in the future:
{
"compilerOptions": {
"allowJs": false,
"allowSyntheticDefaultImports": true,
"declaration": true,
"esModuleInterop": true,
"importHelpers": true,
"jsx": "react",
"lib": ["es2020", "dom"],
"moduleResolution": "node",
"noImplicitAny": true,
"outDir": "dist/",
"sourceMap": false,
"strict": true,
"target": "es5"
}
}
Note that the importHelpers
flag tells tslib
whether it should be enabled or not.
The tsconfig.json
will be used as a default to include our future source
directory:
{
"extends": "./tsconfig.base.json",
"include": ["source/**/*"]
}
We'll add some more TypeScript-related packages when we get to the tools that need them, and we'll add more TypeScript build configurations in the section on building our typescript.
Linting Setup
Linting is a great way to have everyone adhere to the same set of rules for code style. For our project, we're going to install a few tools to help us out.
$ npm install --save-dev --save-exact \
@typescript-eslint/eslint-plugin \
@typescript-eslint/parser \
eslint \
eslint-config-prettier \
eslint-plugin-jest \
eslint-plugin-jsx-a11y \
eslint-plugin-prettier \
eslint-plugin-react \
eslint-plugin-react-hooks \
husky \
lint-staged \
prettier
$ touch \
.eslintignore \
.eslintrc.js \
.prettierrc.js \
husky.config.js \
lint-staged.config.js
The .eslintignore
file will make sure we include files and folders that are ignored by default (using the !
) and exclude files and folders that we don't care about linting.
!.eslintrc.js
!.prettierrc.js
!.storybook/
dist/
docs/
examples/
The .eslintrc.js
file is something you and your team will need to figure out for yourselves, but here's where I stand on the issues:
module.exports = {
env: {
browser: true,
es6: true,
jest: true,
node: true,
},
extends: [
'plugin:react/recommended',
'plugin:@typescript-eslint/recommended',
'prettier/@typescript-eslint',
'plugin:prettier/recommended',
'plugin:jsx-a11y/recommended',
],
parserOptions: {
ecmaVersion: 2020,
sourceType: 'module',
},
parser: '@typescript-eslint/parser',
plugins: ['jsx-a11y', 'react', 'react-hooks', '@typescript-eslint'],
rules: {
'@typescript-eslint/no-unused-vars': 'error',
'jsx-quotes': ['error', 'prefer-double'],
'jsx-a11y/no-onchange': 'off', // https://github.com/evcohen/eslint-plugin-jsx-a11y/issues/398
'no-trailing-spaces': 'error',
'object-curly-spacing': ['error', 'always'],
quotes: ['error', 'single', { allowTemplateLiterals: true }],
'react-hooks/exhaustive-deps': 'error',
'react-hooks/rules-of-hooks': 'error',
'react/prop-types': 'off',
semi: ['error', 'never'],
},
settings: {
react: {
version: 'detect',
},
},
overrides: [
{
files: ['*.js', '*.jsx'],
rules: {
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/no-var-requires': 'off',
},
},
],
}
The .prettierrc.js
file defines your prettier configuration:
module.exports = {
semi: false,
singleQuote: true,
}
We're almost done with the linting! There are two files left.
For our husky.config.js
file, we'll set it up to run lint-staged
before we push our code to our repository:
module.exports = {
hooks: {
'pre-push': 'lint-staged',
},
}
And for lint-staged.config.js
, we'll specify that we want to run eslint --fix
on our staged files:
module.exports = {
'*': ['eslint --fix'],
}
Now that we've got this all in place, we can update our package.json
's script
object to include a lint
command:
"scripts": {
"lint": "eslint ."
},
You can test this by running:
$ npm run lint
Testing Setup
We're going to use Jest and @testing-library/react
to handle running our tests and testing our component code, so let's install those tools and their companion TypeScript libraries. We'll also install axe-core to handle some automated accessibility testing.
$ npm install --save-dev --save-exact \
@testing-library/jest-dom \
@testing-library/react \
@types/jest \
axe-core \
jest \
ts-jest
$ touch jest.config.js testSetup.ts
Our jest.config.js
collects coverage from the right places, ignores distribution and example directories, requires the testSetup.ts
file, and sets us up to use TypeScript in our tests.
module.exports = {
clearMocks: true,
collectCoverage: true,
collectCoverageFrom: ['<rootDir>/source/**/*.{ts,tsx}'],
coveragePathIgnorePatterns: [
'/node_modules/',
'<rootDir>/source/@types',
'stories',
],
moduleNameMapper: {},
preset: 'ts-jest',
setupFilesAfterEnv: ['<rootDir>/testSetup.ts'],
testPathIgnorePatterns: ['dist/', 'examples/'],
verbose: true,
}
And here is our testSetup.ts
file that you can use to provide global testing tools, patch JSDOM, and more:
import '@testing-library/jest-dom/extend-expect'
All we do in testSetup.ts
is add a lot of custom matchers to the expect
function from jest via @testing-library/jest-dom
.
While we're on the testing subject, we should also update our package.json
's scripts
object to include a test
command:
"scripts": {
// ...
"test": "jest"
},
We don't have any test files yet, but you can confirm everything is set up correctly by running
$ npm run test
Storybook Setup
Storybook is a great way to not only share examples of your components but also get instant feedback while developing them, as well. It also comes with a great set of official addons.
Let's install Storybook for React with TypeScript, and let's also add the addons for accessibility and knobs:
$ npm install --save-dev --save-exact \
@storybook/addon-a11y \
@storybook/addon-knobs \
@storybook/preset-typescript \
@storybook/react \
babel-loader \
ts-loader
$ mkdir .storybook
$ touch .storybook/main.js
The .storybook/main.js
file is where we can specify our Storybook options:
module.exports = {
addons: [
'@storybook/addon-a11y',
'@storybook/addon-knobs',
'@storybook/preset-typescript',
],
stories: ['../source/**/*/stories.tsx'],
}
An Example Component
For our example component, we are going to make a circle with SVG. With only this simple component, we will cover the following aspects of component development:
- TypeScript interfaces for required and optional React props
- Component CSS
- Testing (regular, snapshot, and accessibility)
- Storybook examples
Let's create the files we know we're going to need:
$ mkdir source/Circle
$ touch source/Circle/index.tsx \
source/Circle/stories.tsx \
source/Circle/styles.css \
source/Circle/test.tsx
Component File
import React, { FC } from 'react'
// className, desc, and fill are optional,
// whereas title and size are required
interface Props {
className?: string
desc?: string
fill?: string
size: number
title: string
}
// we provide our Props interface to the
// function component type
const Circle: FC<Props> = ({
className = 'rl-circle',
desc,
fill,
size,
title,
}) => (
<svg
className={className}
height={size}
fill={fill}
role="img"
viewBox="0 0 100 100"
width={size}
xmlns="http://www.w3.org/2000/svg"
>
<title>{title}</title>
{desc && <desc>{desc}</desc>}
<circle cx="50" cy="50" r="50" />
</svg>
)
export default Circle
In this component file, we define the parameters that we're willing to work with, provide a fallback in the case of className
, and make a regular old component.
This file should be pretty straightforward, so let's move on to the CSS!
Component CSS
This is a real easy one.
.rl-circle { margin: 1em; }
The rl
is short for "react library", and I made it up. The CSS that we are creating needs to be made unique, and prefixing your classes is the simplest way of doing that.
Component Tests
It's time to write some tests! We're going to make explicit expectations and do some snapshot tests so that everybody is happy.
import React from 'react'
import { render } from '@testing-library/react'
import Circle from './index'
test('with all props', () => {
const { asFragment, container, getByText } = render(
<Circle
className="class-override"
desc="A blue circle"
fill="#30336b"
size={200}
title="Water planet"
/>
)
const svgEl = container.querySelector('svg')
const titleEl = getByText('Water planet')
const descEl = getByText('A blue circle')
expect(svgEl).toHaveAttribute('height', '200')
expect(svgEl).toHaveAttribute('width', '200')
expect(titleEl).toBeInTheDocument()
expect(descEl).toBeInTheDocument()
expect(asFragment()).toMatchSnapshot()
})
test('with only title & size', () => {
const { asFragment, container, getByText } = render(
<Circle title="Water planet" size={200} />
)
const svgEl = container.querySelector('svg')
const titleEl = getByText('Water planet')
const descEl = container.querySelector('desc')
expect(svgEl).toHaveAttribute('height', '200')
expect(svgEl).toHaveAttribute('width', '200')
expect(titleEl).toBeInTheDocument()
expect(descEl).not.toBeInTheDocument()
expect(asFragment()).toMatchSnapshot()
})
These first tests provide different sets of props and test various aspects of our component based on given props' inclusion.
Next, we can use the axe-core
tool to try our hand at accessibility testing:
import axe from 'axe-core'
// ...
test('is accessible with title, desc, size', (done) => {
const { container } = render(
<Circle desc="A blue circle" size={200} title="Water planet" />
)
axe.run(container, {}, (err, result) => {
expect(err).toEqual(null)
expect(result.violations.length).toEqual(0)
done()
})
})
test('is inaccessible without title', (done) => {
const { container } = render(
<Circle desc="A blue circle" title="Water circle" size={200} />
)
// do something very wrong to prove a11y testing works
container.querySelector('title')?.remove()
axe.run(container, {}, (err, result) => {
expect(err).toEqual(null)
expect(result.violations[0].id).toEqual('svg-img-alt')
done()
})
})
While the first test should be clear, the second test almost seems pointless (hint: it is). I am including it here to demonstrate what a failing accessibility scenario might look like. In reality, the first test in this group pointed out the error in the second test, for I was originally not requiring title
, but I was giving the SVG role="img"
. This is a no-no if there is no aria-label
, aria-labelledby
, nor <title>
to supply the SVG with any textual meaning.
Testing is easy if you keep things simple, and automated accessibility testing is even easier than that, for all you need to do is provide DOM elements.
Component Stories
I find it very difficult to do test driven development when developing components, for it is an exploratory, creative experience for me. Instant feedback makes it easy to run through all my bad ideas (there are many!) and eventually land on some good ones. Storybook stories can help us do that, so let's make our first story in source/Circle/stories.tsx
.
import React from 'react'
import { storiesOf } from '@storybook/react'
import { withA11y } from '@storybook/addon-a11y'
import { color, number, text, withKnobs } from '@storybook/addon-knobs'
// import our component and styles from
// the distribution (build) output
import { Circle } from '../../dist/esm'
import '../../dist/css/Circle/styles.css'
// group our stories under "Circle"
const stories = storiesOf('Circle', module)
// enable the accessibility & knobs addons
stories.addDecorator(withA11y)
stories.addDecorator(withKnobs)
// add a new story and use the
// knobs tools to provide named
// defaults that you can alter
// in the Storybook interface
stories.add('default', () => (
<Circle
desc={text('desc', 'A blue circle')}
fill={color('fill', '#7ed6df')}
size={number('size', 200)}
title={text('title', 'Abstract water planet')}
/>
))
stories.add('another scenario...', () => (
<Circle {/* other example props here */} />
))
Each component gets its own stories.tsx
file, so there's no need to worry about them getting out of hand with all the different components in your library. Add as many different stories for your components as you like! Our Storybook config will collect them all for you into a single place.
Building Our TypeScript
We've already created a tsconfig.base.json
and tsconfig.json
file, and now it's time to add ones for CommonJS (CJS), ESModules (ESM), and Universal Module Definitions (UMD). We will then add some NPM scripts to build out TypeScript for us.
$ touch tsconfig.cjs.json tsconfig.esm.json tsconfig.umd.json
// tsconfig.cjs.json
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"module": "commonjs",
"outDir": "dist/cjs/"
},
"include": ["source/index.ts"]
}
// tsconfig.esm.json
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"module": "esNext",
"outDir": "dist/esm/"
},
"include": ["source/index.ts"]
}
// tsconfig.umd.json
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"module": "umd",
"outDir": "dist/umd/"
},
"include": ["source/index.ts"]
}
Each of these specify where to find the source, what type of module to output, and where to put the resulting compiled code. If you want your code to be compiled to the output, make sure it is either included in the include
field or is require
d by something that is.
In our package.json
, let's add some scripts that make use of these configs:
"scripts": {
"build:js:cjs": "tsc -p tsconfig.cjs.json",
"build:js:esm": "tsc -p tsconfig.esm.json",
"build:js:umd": "tsc -p tsconfig.umd.json",
// ...
},
Easy! If you are guessing that we might want to run these all together in a build:js
command, there are two ways to do that (one verbose and one less so).
Our first attempt:
"scripts": {
"build:js": "npm run build:js:cjs && npm run build:js:esm && npm run build:js:umd",
// ...
},
Not bad, but we can use the npm-run-all
tool to not only write a more succinct script but also run these in parallel!
$ npm install --save-dev --save-exact npm-run-all
"scripts": {
"build:js": "run-p build:js:cjs build:js:esm build:js:umd",
// ...
},
The npm-run-all
tool gives us run-p
for running scripts in parallel and run-s
for running them synchronously.
Watching for changes is also very simple:
"scripts": {
// ...
"build:js:esm:watch": "tsc -p tsconfig.esm.json -w",
// ...
},
While we're here, let's go ahead and add a clean
ing script for our dist/
directory:
"scripts": {
// ...
"clean": "clean:dist", // we'll add more here shortly
"clean:dist": "rm -rf dist",
// ...
},
Now that we can do some clean
ing and build
ing, let's create a single build
script that we can continue adding build steps to as we go:
"scripts": {
"build": "run-s clean build:js", // we'll add more here shortly
// ...
}
Give it all whirl, if you like:
$ npm run build
You should see the following tree structure for your dist/
folder:
.
└── dist
└── cjs
└── Circle
├── index.d.js
└── index.js
├── index.d.js
└── index.js
└── esm
└── Circle
├── index.d.js
└── index.js
├── index.d.js
└── index.js
└── umd
└── Circle
├── index.d.js
└── index.js
├── index.d.js
└── index.js
We're getting places! We have JS, and now we need our CSS.
Building Our CSS
For our styles, we have two goals:
- output each component's styles in a component CSS folder like
dist/css/Circle/styles.css
- output a combination of each component's styles in a single file in
dist/css/styles.css
To achieve this, we're going to write a short bash script, and we're going to place it in scripts/buildCSS
.
$ mkdir scripts
$ touch scripts/buildCSS
$ chmod +x scripts/buildCSS
And in scripts/buildCSS
:
#!/bin/bash
set -euo pipefail
function copy_css {
local dir=$(dirname $0)
local component=$(basename $dir)
local dist_css=$PWD/dist/css
# concatenate component CSS to main CSS file
mkdir -p $dist_css
cat $0 >> $dist_css/styles.css
# copy component CSS to component folder
mkdir -p $dist_css/$component/
cp $0 $dist_css/$component/
}
export -f copy_css
function build {
find $PWD/source \
-name '*.css' \
-exec /bin/bash -c 'copy_css $0' {} \;
}
build
We lean on some coreutils
here to solve our problems for us. The last line of our script, build
, calls the function of the same name that looks inside the source
directory for all CSS files and tells the bash
program to run copy_css
with the path to the CSS file. There's a catch, though: bash
is going to run in a subshell, so we need to make sure our copy_css
function is exported and available by export -f copy_css
.
For the copy_css
function, it's much simpler than it looks! Here are the
steps:
-
mkdir -p $dist_css
creates our output directory,dist/css
. -
cat $0 >> $dist_css/styles.css
concatenates all the lines of our source CSS file and appends them todist/css/styles.css
. -
mkdir -p $dist_css/$component/
creates a component CSS folder likedist/css/Circle/
. We derive the$component
variable by getting thebasename
of thedirname
of our full CSS file path. For example,/Users/myuser/projects/example-component-library/source/Circle/styles.css
has adirname
of/Users/rpearce/projects/example-component-library/source/Circle
, and that has abasename
ofCircle
! Using that deduction, we can derive what component we're working with and create that output directory simply by finding a CSS file. -
cp $0 $dist_css/$component/
copies the source component CSS file to the output component directory; that's it!
If you have a different CSS setup, you'll need to adjust this build script accordingly.
Now that we have our buildCSS
script, we can add an NPM script
to handle building this for us and add that to our build
script:
"scripts": {
"build": "run-s clean build:js build:css",
"build:css": "./scripts/buildCSS",
// ...
},
Similarly to our build:js:esm:watch
command, how might we watch for CSS changes and run our script in a build:css:watch
command? Luckily, there's a tool that can help us with that: chokidar
.
$ npm install --save-dev --save-exact chokidar
"scripts": {
// ...
"build:css:watch": "chokidar \"source/**/*.css\" -c \"./scripts/buildCSS\"",
// ...
},
Building Our Stories
To develop our components and get instant feedback in our Storybook examples, we're going to need to run a few things at once to get it all to work together.
First, let's add a line to our package.json
's scripts
object called
storybook
:
"scripts": {
// ...
"storybook": "start-storybook -p 6006"
},
Next, let's add a start
command that, in this sequence,
- cleans the
dist/
directory - builds only the ESModule JS output
- builds the CSS
and then, in parallel,
- watches the JS for changes and rebuilds the ESModule output
- watches the CSS for changes and rebuilds the CSS
- runs storybook, which watches for changes to the prior two items, for it will detect changes to its
import
s from thedist/
folder
"scripts": {
// ...
"start": "run-s clean:dist build:js:esm build:css && run-p build:js:esm:watch build:css:watch storybook",
// ...
},
If you want to break those up into different scripts to make it more legible, here's a way to do that:
"scripts": {
// ...
"start": "run-s start:init start:run",
"start:init": "run-s clean:dist build:js:esm build:css",
"start:run": "run-p build:js:esm:watch build:css:watch storybook",
// ...
},
You can then run this from the command line, and it should automatically open your web browser and take you to http://localhost:6006.
$ npm run start
Your Storybook library should have your component, and you can adjust the component knobs in one of the sidebars, and you can also see the accessibility audit located in the tab next to the knobs. Note: no amount of automated testing can guarantee accessibility, but it can help you catch silly mistakes.
With all these pieces in place, you can now develop your components and get instante feedback in the browser using the same code that you would provide to a consumer of your package!
Did you know that you can also build static HTML, CSS, and JavaScript files and serve that up through something like GitHub Pages? We can update our package.json
scripts
to include scripts for building our Storybook output to the docs/
folder and for cleaning the docs/
folder, as well.
"scripts": {
// ...
"build:docs": "build-storybook -o docs",
"clean:docs": "rm -rf docs"
"storybook": "start-storybook -p 6006"
},
The clean:docs
script, if ran first, will guarantee that we have fresh output in our docs/
folder. Let's give it a go:
$ npm run clean:docs && npm run build:docs
Since we can now clean and build our Storybook folder, we can update our build
and clean
scripts accordingly:
"scripts": {
"build": "run-s clean build:js build:css build:docs",
// ...
"clean": "run-p clean:dist clean:docs",
// ...
},
Continuous Integration Notes
When you set up a continuous integration (CI) tool for this project, it will be tempting to tell it to simply run $ npm run build
; however, this will not include your linting and testing scripts, and you could potentially have a green light from CI when really you have problems!
While you could always run your linting and testing scripts inside of build
( this can get tedious) or multiple scripts from your CI configuration, let's instead add another script named ci
to handle this for us:
"scripts": {
// ...
"ci": "run-p lint build test",
// ...
},
No worries! Now we can use $ npm run ci
in our CI configuration.
Publishing Notes
I recommend adding a prepublishOnly
script that ensures your linter and tests pass before trying to build your component output:
"scripts": {
// ...
"prepublishOnly": "run-p lint test && run-p build:js build:css",
// ...
},
Also, if you want this to be a private repository, make sure you add "private": true
to your package.json
before publishing.
Wrapping Up
Thank you for reading this, and I hope this helps you create an awesome, accessible component library.
Robert
Posted on April 27, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.