はじめに
この記事はStorybookのアドオンを作成から公開までを紹介します。
この記事ではTypeScriptとReactの基礎知識を前提として記述しますので、不慣れな方は随時それぞれのドキュメントを参照ください。
今回作成したnpmパッケージはこちらで、リポジトリはこちらです。
アドオン
アドオンはStorybookの機能を拡張するためのものです。
アドオンはStorybookに表示するUIに対して行うものとStorybookを構成するためのものがあります。前者はインターラクションテストやアクセシビリティテストなどがあります。後者はpreset-create-react-app
などがあります。
この記事では前者のStorybookに表示するUIに対して行うアドオンを作成していきます。
今回作成するアドオン
ツールバーを押すことで、UIコンポーネントが持つAccessibility roleに異なる色のボーダを与えるアドオンを作成します。
環境の準備
Storybookではアドオンの作成を手助けするためのツールが用意されています。
こちらのリポジトリを開いて、Use this template
からCreate a new repository
でこれから自分が作るアドオンのリポジトリを作成してアドオンの作成を簡単に開始することが出来ます。
しかし、この記事ではアドオンの環境を一から作る方法を紹介したいと思います(peerDependenciesのreactバージョンが低いことなどが気になるので)。
リポジトリの作成
まずは作りたいアドオン名でGithubリポジトリを作成してください。私はstorybook-role-visualization
で作りました。ここからはローカルで作業するのでクローンして手元に持ってきてください。まずはnmp init
でnodeパッケージを作成します。生成されたpackage.jsonは以下のように変更してください。
{
"name": "storybook-role-visualization",
"version": "1.0.0",
"description": "visualize aria-role",
"main": "preset.js",
"files": ["dist/**/*", "README.md", "*.js", "*.d.ts"],
"keywords": ["storybook", "addons"],
"author": "Koki Sakano",
"license": "MIT"
}
name
とdescription
、author
はプロジェクトごとに適切なものを入力してください。追加するアドオンの設定ファイルはpreset.js
から読み込むのでmain
はpreset.js
を指定しています。
利用するパッケージの準備
まずはreact
とreact-dom
、@babel/cli
をインストールします。
npm i react react-dom @babel/cli
そしてtypescript化のためにtypescript
とreactのtypesパッケージもインストールします。
npm i -D typescript @types/react @types/react-dom
typescriptファイルの設定ファイルも適当に設定しておきます。
{
"compilerOptions": {
"allowSyntheticDefaultImports": true,
"baseUrl": ".",
"emitDecoratorMetadata": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"incremental": false,
"isolatedModules": true,
"jsx": "react",
"lib": ["ESNext", "dom"],
"module": "commonjs",
"noImplicitAny": true,
"rootDir": "./src",
"skipLibCheck": true,
"target": "es5"
},
"include": [
"src/**/*"
],
}
node_modules
はgitで管理しないのでignoreしときます。
node_modules
次に、アドオンのテストを行うためにStorybookの環境を作成します。
npx storybook init
stories
がsrc
内にある場合は外に出しておくと、後ほど公開するときのファイルに含まれないのでおすすめです。npm run storybok
でStorybookが問題なく起動することを確認してください。バージョンが17以上のNode環境を利用の方はERR_OSSL_EVP_UNSUPPORTED
のエラーが出ると思います。その場合は
NODE_OPTIONS=--openssl-legacy-provider npm run storybook
のようにNODE_OPTIONS
を--openssl-legacy-provider
に設定すると問題なく動作します(Storybook7.0以降ではこのオプションが不要になるので期待して待ちましょう)。
最後にES6とJSXを使用するためにbabelの準備をします。
利用するパッケージをインストールします。
npm i -D @babel/preset-env @babel/preset-typescript @babel/preset-react
インストールできたらbabelの設定ファイルを作成して、presets
に登録します。
module.exports = {
presets: ["@babel/preset-env", "@babel/preset-typescript", "@babel/preset-react"],
};
babelの実行コマンドをscripts
を追加します。
{
"scripts": {
"build": "babel ./src -d ./dist --extensions \".js,.jsx,.ts,.tsx\"",
"build:watch": "babel ./src -d ./dist --extensions \".js,.jsx,.ts,.tsx\" --watch",
}
}
アドオンの設定はsrc
内に記述するのでそれらを読み込んで、dist
フォルダに出力するように設定しました。
アドオン設定の準備
ここまでの設定を活かして下のようにMy Addonをコントロールパネルに追加していきます。
このようなパネルの設定はsrc/preset/manager.tsx
に記述します。
import React from 'react';
import { addons, types } from '@storybook/addons';
import { AddonPanel } from '@storybook/components';
const ADDON_ID = 'myaddon';
const PANEL_ID = `${ADDON_ID}/panel`;
const MyPanel = () => <div>MyAddon</div>;
addons.register(ADDON_ID, () => {
addons.add(PANEL_ID, {
type: types.PANEL,
title: 'My Addon',
render: ({ active, key }) => (
<AddonPanel active={active} key={key}>
<MyPanel />
</AddonPanel>
),
});
});
addons.register
で外部から触るときのidを登録して、addons.add
のidを指定してtype
でパネルやタブ、ツールを設定します。パネルは今回追加したものです。タブはCanvas、Docsの部分を指します。ツールはタブの横にあるアイコンなどの追加です。title
にはそれらの名前を設定します。render
では表示の設定を行います。
パネルの設定は完了しましたが、tsx
のままでは登録することが出来ないので、npm run build
でdistにjavascriptを出力して扱えるようにします。そして.storybook/main.js
にアドオンの登録をします。アドオンの設定はpreset.js
を作成してそこからdist
フォルダのコードを読み取るようにして行わせます。
function managerEntries(entry = []) {
return [...entry, require.resolve("./dist/preset/manager")];
}
module.exports = {
managerEntries,
};
これ以降の開発を行うときはbabelのwatchモードでdist
を更新しつつStorybookも起動させるようにしてコードの変更がすぐ伝わるような環境にしておくと便利です。storybook
のキャッシュ設定で変更がうまく反映されない場合はnpm run storybook -- --no-manager-cache
と設定することでキャッシュなしで実行させることが出来ます。
ツールバーのアイコンボタン作成
今回作るアドオンはツールでオンオフを切り替えるような仕組みにします。まずはアドオンidとツールidを定数に登録していきます。定数は以下のようにconstants.ts
に定義します。
import { types } from "@storybook/addons";
export const ADDON_ID = 'role-visualization-addon-id' as const satisfies string;
export const TYPE_ID = {
[types.TOOL]: `${ADDON_ID}_tool`,
} as const satisfies { [types.TOOL]: string };
被らないように${パッケージ名}+addon-id
をアドオンidに、TYPE_ID
には機能にアクセスする種類を一覧で持つようにしました。今回はツールだけ使用するので${アドオンのid}_tool
のようにひとつだけ設定しました。
これを利用してツールバーに表示するアイコンボタンを作成していきます。components
フォルダを作成してTool.tsx
に作成します。
import React, { useCallback } from 'react';
import { useGlobals } from '@storybook/api';
import { Icons, IconButton } from '@storybook/components';
import { TYPE_ID } from './../constants';
export const Tool = () => {
const [{ roleVisualization }, updateGlobals] = useGlobals();
const toggleMyTool = useCallback(
() =>
updateGlobals({
roleVisualization: !roleVisualization,
}),
[roleVisualization]
);
return (
<IconButton
key={TYPE_ID.tool}
active={roleVisualization}
title="Visualize role to the preview"
onClick={toggleMyTool}
>
<Icons icon="switchalt" />
</IconButton>
);
};
useGlobals
はStorybookが提供するhooksで、Storybook全体で扱うグローバルな状態の登録、変更を行うことが出来ます。
roleVisualization
の型はanyとなってわかりにくいですが、どこからも登録されていなければundefined
を持ちます。その後、updateGlobals
によって初期値の登録と値の更新を行うことが出来ます。この例ではトグル関数として登録されているのでboolean | undefined
の型を持ちます(他でも使われていなければ)。
renderの部分はstorybook/components
を用いて構築しています。IconButton
はactive
に渡した値で自身の表示を変え、onClick
に渡した関数で値を変更します。key
は識別子、title
はIconButton
の説明です。Icons
はアイコンを設定しています。たくさんのアイコンがあるので作成するアドオンにあったものを選択してください。アイコン一覧が書かれたStorybookはこちらです。
作成したToolコンポーネントをmanage.tsx
に登録します。
import { addons, types } from '@storybook/addons';
import { Tool } from './../components/Tool';
import { ADDON_ID, TYPE_ID } from './../constants';
addons.register(ADDON_ID, () => {
addons.add(TYPE_ID.tool, {
type: types.TOOL,
title: 'Visualize role to the preview',
match: ({ viewMode }) => !!(viewMode && viewMode.match(/^(story)$/)),
render: Tool,
});
});
コンポーネントの記述を分けたことによりJSXを使用しないので、拡張子をtsにしました。
このファイルでは作成した定数やコンポーネントをはめ込んでいるだけです。match
では表示する条件を設定しています。ここではCanvasの部分だけで表示させるための関数を渡しました。
ここまでの記述で自身以外に何の変更も加えないアイコンボタンをツールバーに追加することが出来ました。クリックすることによってボタンの状態が変わることを確認してください。
Storyに状態を反映する
先ほど作成した状態を表示するUIコンポーネントに伝えていきます。通常のStorybookで行うようにUIコンポーネントに影響を及ぼすにはdecorators
を用いることで可能となります。それと同様でアドオンでもdecorators
を用いてUIコンポーネントに影響を与えます。アドオンを設定するプロジェクトではデフォルトでDecorator関数を追加することが出来ますのでそれを利用します。
まずは状態が変化するたびにstyleを変更できるようにstyleの追加と削除を行えるコードを用意しておきます。
export const clearStyle = (id: string) => {
const element = document.getElementById(id);
if (element?.parentElement) {
element.parentElement.removeChild(element);
}
};
export const addStyle = (id: string, css: string) => {
const existingStyle = document.getElementById(id);
if (existingStyle) {
if (existingStyle.innerHTML !== css) {
existingStyle.innerHTML = css;
}
} else {
const style = document.createElement('style');
style.setAttribute('id', id);
style.innerHTML = css;
document.head.appendChild(style);
}
};
clearStyle
はid
をidとする要素があったときそれ自身を削除する関数です。
addtyles
はid
をidとして、cssが記述された引数のcss
をinnerHTML
に持つstyle要素を作成してheadに追加します。もし、id
をidとする要素がすでに存在していた場合はその中身cssを上書きするもしくは、何も行いません。
これでaddStyle
が呼ばれたときにAccessibility roleを持つ要素にボーダが与えられ、clearStyle
が呼ばれるとボーダーが消えるような仕組みを作ることが出来ます。
これを利用して、roleVisualization
が変更されるたびに挙動が変わるDecorator関数を作成します。
import { useEffect, useMemo, useGlobals, DecoratorFunction } from '@storybook/addons';
import { clearStyle, addStyle } from './helpers';
import roleBorderCSS from './roleBorderCSS';
export const withGlobals: DecoratorFunction = (StoryFn, context) => {
const [{ roleVisualization }] = useGlobals();
const roleBorderStyle = useMemo(() => {
const selector = '.sb-show-main';
return roleBoarderCSS(selector);
}, [context.id]);
useEffect(() => {
const selectorId = 'role-visualization-style';
if (!roleVisualization) {
clearStyle(selectorId);
return;
}
addStyle(selectorId, roleBoarderStyle);
return () => {
clearStyle(selectorId);
};
}, [roleVisualization, roleBoarderStyle, context.id]);
return StoryFn();
};
const [{ roleVisualization }] = useGlobals();
でroleVisualization
を取り出してこのスコープで扱えるようにしています。
const roleBoarderStyle = useMemo(() => {
const selector = '.sb-show-main';
return roleBoarderCSS(selector);
}, [context.id]);
ここで使われているuseMemo
はreactのものではなく、storybook/adddons
が提供しているAPIであることに注意してください。使い方は同じですが、Storybookのグローバルな状態に対しても機能が有効化されます。context.id
はStoryごとに持つ固有のidですので異なるコンポーネントに変換された時のみこの関数は再計算されます。機能としてはiframe内のbodyのidに与えられる'.sb-show-main'
をroleBorderCSS
に渡したときの結果を返します。roleBorderCSS
はUIコンポーネントにAccessibility roleを持つ要素にボーダが与えるためのcssです。ここに記述すると長くなるのでこのアドオンを作成したリポジトリで確認してください。
useEffect(() => {
const selectorId = 'role-visualization-style';
if (!roleVisualization) {
clearStyle(selectorId);
return;
}
addStyle(selectorId, roleBoarderStyle);
return () => {
clearStyle(selectorId);
};
}, [roleVisualization, roleBoarderStyle, context.id]);
次にこの部分です。先ほどのuseMemo
と同じくuseEffect
はstorybook/adddons
が提供するAPIです(振る舞いは同じです)。この内部ではroleVisualization
の値によって先ほどのhelper.ts
の中身を出し分けています。roleVisualization
がTruthyの時はaddStyle
でstyleを与えて、Faslsyになった時かクリーンアップ時にclearStyle
でstyleを削除しています。
このDecorator関数をdecorators
に追加することでAccessibility roleを持つ要素にボーダが与えるようなアドオンが完成します。
デフォルトで追加するようにするにはsrc/preset/preview.ts
を作成してdecorators
に登録を行って、preser.js
からそれを読み込むようにします。
import { withGlobals } from "./../useWithGlobals";
export const decorators = [withGlobals];
function config(entry = []) {
return [...entry, require.resolve("./dist/preset/preview")];
}
function managerEntries(entry = []) {
return [...entry, require.resolve("./dist/preset/manager")];
}
module.exports = {
managerEntries,
config,
};
ここまで設定が終わると、ボタンを押してroleVisualization
をtrue
にすることでAccessibility roleを持つ要素にボーダが与えるようなアドオンが完成します。
npmパッケージとして公開する
StorybookのIntegrationsへの追加と、npmパッケージとして公開を行います。
まずはStorybookのメタデータをpackage.json
に追加します(supportedFrameworksも与えられます)。これをすることでStorybookのIntegrationsに表示されます。
"storybook": {
"displayName": "Visualize a11y role addon",
"unsupportedFrameworks": ["react-native"],
"icon": ""
},
そして、作成したパッケージをnpmで公開します。パッケージの公開方法はこちらを参照してください。
公開したパッケージがこちらになります。StorybookのIntegrationsには公開してからしばらくすると追加されます。
npmパッケージとして公開した後は
npm i -D storybook-role-visualization
こののようにインストールを行なって.storybook/main.js
のaddons
に追加することで利用することが出来ます。
module.exports = {
"addons": [
"storybook-role-visualization"
],
}
おわりに
この記事ではStorybookのアドオンを作成し公開するまでの手順を記しました。意外と簡単にできるのでアドオンを作成して公開してみてはいかがでしょうか。