既に、Storybook の特徴的な機能である、堅牢なアドオンのエコシステムを紹介しました。アドオンを使用することで、開発効率とワークフローが向上します。
このおまけの章では自分でアドオンを作る方法について見ていきます。アドオンを作るのは困難だと思われるかもしれませんが、そうでもありません。少し手順を踏めば、アドオンを書き始めることができます。
しかし、まずは今から作るアドオンがどういうものか俯瞰してみましょう。
今回の例では、すでに存在する UI コンポーネントに関連したデザインアセットがあると仮定します。しかし、Storybook の UI を見てもデザインアセットとコンポーネントの関連が見えません。どうすればよいでしょうか。
これから作るアドオンにどういった機能があればよいのか定義してみましょう:
ストーリーとアセットの紐づけには Storybook の機能である parameters を使用します。parameters はストーリーに追加のメタデータを設定することができます。
// YourComponent.js
export default {
title: 'Your component',
decorators: [
/*...*/
],
parameters: {
assets: ['path/to/your/asset.png'],
},
//
};
これから作るアドオンがどういうものかを説明したので、作業を始めていきましょう。
.storybook
フォルダーに design-addon
フォルダーを作成し、さらにその中に register.js
というファイルを作ります。
これだけです。これでアドオンの作成準備が整いました。
.storybook
フォルダーをアドオンの配置場所として使用します。その理由は直接的なアプローチをとることで複雑になりすぎないようにするためです。実際のアドオンを作成するならば、別のパッケージに移動させ、独自のファイルとフォルダーの構成にするべきでしょう。
今追加したファイルに以下のコードを記述します:
//.storybook/design-addon/register.js
import React from 'react';
import { AddonPanel } from '@storybook/components';
import { addons, types } from '@storybook/addons';
addons.register('my/design-addon', () => {
addons.add('design-addon/panel', {
title: 'Assets',
type: types.PANEL,
render: ({ active, key }) => (
<AddonPanel active={active} key={key}>
implement
</AddonPanel>
),
});
});
これが作業を始める際の、典型的なボイラープレートコードです。このコードが何をしているのかというと:
この時点では Storybook を起動しても、アドオンは見えません。アドオンを登録するためには、.storybook/main.js
ファイルにコードを追加する必要があります。以下の内容を現在の addons
リストに追加しましょう:
// .storybook/main.js
module.exports = {
stories: ['../src/components/**/*.stories.js'],
addons: [
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/preset-create-react-app',
'./design-addon/register.js', // our addon
],
};
動きました!Storybook の UI に作成したアドオンが追加されています。
最初の目標は完了しました。次の目標に取り掛かりましょう。
次の目標を完了させるため、インポートを変更し、アセットの情報を表示する新しいコンポーネントを導入する必要があります。
アドオンファイルを以下のように変更します:
//.storybook/design-addon/register.js
import React, { Fragment } from 'react';
/* same as before */
import { useParameter } from '@storybook/api';
const Content = () => {
const results = useParameter('assets', []); // story's parameter being retrieved here
return (
<Fragment>
{results.length ? (
<ol>
{results.map(i => (
<li>{i}</li>
))}
</ol>
) : null}
</Fragment>
);
};
これで、コンポーネントを作り、インポートを変更しました。あとはパネルにコンポーネントを接続すれば、ストーリーに関連のある情報を表示できるアドオンとなることでしょう。
最終的なコードは以下のようになるでしょう:
//.storybook/design-addon/register.js
import React, { Fragment } from 'react';
import { AddonPanel } from '@storybook/components';
import { useParameter } from '@storybook/api';
import { addons, types } from '@storybook/addons';
const Content = () => {
const results = useParameter('assets', []); // story's parameter being retrieved here
return (
<Fragment>
{results.length ? (
<ol>
{results.map(i => (
<li>{i}</li>
))}
</ol>
) : null}
</Fragment>
);
};
addons.register('my/design-addon', () => {
addons.add('design-addon/panel', {
title: 'Assets',
type: types.PANEL,
render: ({ active, key }) => (
<AddonPanel active={active} key={key}>
<Content />
</AddonPanel>
),
});
});
useParameter を使用していることに注目してください。この便利なフックは各ストーリーに設定された parameters
オプションを読み取ることが可能です。このアドオンでは単一または複数のアセットへのパスが指定されます。実際の効果は後ほど確認しましょう。
必要なピースはすべて組み立てました。しかし、実際に動かして何かを表示させるにはどうすればよいでしょうか。
使うためには Task.stories.js
ファイルにちょっとした変更を加え、parameters オプションを追加します。
// src/components/Task.stories.js
export default {
component: Task,
title: 'Task',
parameters: {
assets: [
'path/to/your/asset.png',
'path/to/another/asset.png',
'path/to/yet/another/asset.png',
],
},
};
/* same as before */
それでは Storybook を再起動してタスクのストーリーを選択してみましょう。すると以下のようになります:
ここまで来るとアドオンが動いているのがわかります。さらに Content
コンポーネントを変更し、実際にイメージを表示してみましょう:
//.storybook/design-addon/register.js
import React, { Fragment } from 'react';
import { AddonPanel } from '@storybook/components';
import { useParameter, useStorybookState } from '@storybook/api';
import { addons, types } from '@storybook/addons';
import { styled } from '@storybook/theming';
const getUrl = input => {
return typeof input === 'string' ? input : input.url;
};
const Iframe = styled.iframe({
width: '100%',
height: '100%',
border: '0 none',
});
const Img = styled.img({
width: '100%',
height: '100%',
border: '0 none',
objectFit: 'contain',
});
const Asset = ({ url }) => {
if (!url) {
return null;
}
if (url.match(/\.(png|gif|jpeg|tiff|svg|anpg|webp)/)) {
// do image viewer
return <Img alt="" src={url} />;
}
return <Iframe title={url} src={url} />;
};
const Content = () => {
// story's parameter being retrieved here
const results = useParameter('assets', []);
// the id of story retrieved from Storybook global state
const { storyId } = useStorybookState();
if (results.length === 0) {
return null;
}
const url = getUrl(results[0]).replace('{id}', storyId);
return (
<Fragment>
<Asset url={url} />
</Fragment>
);
};
コードを見てみましょう。@storybook/theming
パッケージの styled
タグを使用しています。これを使用することで Storybook のテーマとアドオンの UI をカスタマイズすることができます。useStorybookState
は Storybook の内部状態にアクセスするためのフックで、どんな些細な情報でも取得することができます。この例では、各ストーリーに付けられた ID を取得するのに使用しています。
実際にアセットをアドオンに表示させるには、アセットを public
フォルダーにコピーし、ここまでの変更を反映し、ストーリーの parameters
オプションを変更しなければなりません。
Storybook が変更を検知し、アセットをロードします。今のところ最初のアセットしか表示出来ません。
最初に挙げた目標を確認してみましょう:
もうすぐ完了ですね。残り一つです。
最後の目標を完了するには、状態を保持することが必要です。React の useState
フックを使用してもいいですし、コンポーネントを class コンポーネントに変更し this.setState()
を使用してもいいでしょう。しかし今回は Storybook の useAddonState
を使用します。これはアドオンの状態を永続化する手段を提供してくれるので、状態を永続化するコードを書かなくて済みます。さらに Storybook の他の UI 要素である ActionBar
を使用しアイテムの切り替えを可能にします。
それではインポートを変更しましょう:
//.storybook/design-addon/register.js
import { useParameter, useStorybookState, useAddonState } from '@storybook/api';
import { AddonPanel, ActionBar } from '@storybook/components';
/* same as before */
そして Content
コンポーネントを変更し、アセットを切り替えられるようにしましょう:
//.storybook/design-addon/register.js
const Content = () => {
// story's parameter being retrieved here
const results = useParameter('assets', []);
// addon state being persisted here
const [selected, setSelected] = useAddonState('my/design-addon', 0);
// the id of the story retrieved from Storybook global state
const { storyId } = useStorybookState();
if (results.length === 0) {
return null;
}
if (results.length && !results[selected]) {
setSelected(0);
return null;
}
const url = getUrl(results[selected]).replace('{id}', storyId);
return (
<Fragment>
<Asset url={url} />
{results.length > 1 ? (
<ActionBar
actionItems={results.map((i, index) => ({
title: typeof i === 'string' ? `asset #${index + 1}` : i.name,
onClick: () => setSelected(index),
}))}
/>
) : null}
</Fragment>
);
};
UI コンポーネントに関連するデザインアセットを表示するという、実際に使える Storybook アドオンを作るために、やるべきことを全て達成しました。
// .storybook/design-addon/register.js
import React, { Fragment } from 'react';
import { useParameter, useStorybookState, useAddonState } from '@storybook/api';
import { addons, types } from '@storybook/addons';
import { AddonPanel, ActionBar } from '@storybook/components';
import { styled } from '@storybook/theming';
const getUrl = input => {
return typeof input === 'string' ? input : input.url;
};
const Iframe = styled.iframe({
width: '100%',
height: '100%',
border: '0 none',
});
const Img = styled.img({
width: '100%',
height: '100%',
border: '0 none',
objectFit: 'contain',
});
const Asset = ({ url }) => {
if (!url) {
return null;
}
if (url.match(/\.(png|gif|jpeg|tiff|svg|anpg|webp)/)) {
return <Img alt="" src={url} />;
}
return <Iframe title={url} src={url} />;
};
const Content = () => {
const results = useParameter('assets', []); // story's parameter being retrieved here
const [selected, setSelected] = useAddonState('my/design-addon', 0); // addon state being persisted here
const { storyId } = useStorybookState(); // the story«s unique identifier being retrieved from Storybook global state
if (results.length === 0) {
return null;
}
if (results.length && !results[selected]) {
setSelected(0);
return null;
}
const url = getUrl(results[selected]).replace('{id}', storyId);
return (
<Fragment>
<Asset url={url} />
{results.length > 1 ? (
<ActionBar
actionItems={results.map((i, index) => ({
title: typeof i === 'string' ? `asset #${index + 1}` : i.name,
onClick: () => setSelected(index),
}))}
/>
) : null}
</Fragment>
);
};
addons.register('my/design-addon', () => {
addons.add('design-addon/panel', {
title: 'Assets',
type: types.PANEL,
render: ({ active, key }) => (
<AddonPanel active={active} key={key}>
<Content />
</AddonPanel>
),
});
});
アドオンの作成で次にやることは、チームや、コミュニティが使えるようにパッケージ化して使えるようにすることでしょう。
しかし、それはこのチュートリアルの範囲を超えてしまいます。このチュートリアルでは、Stoybook API を使用して、カスタムアドオンを作成することで、開発のワークフローを向上できることを示しました。
さらにアドオンをカスタマイズしたい場合は以下を参照してください:
などなど!
アドオンの開発を加速させるために、Storybook チームでは複数の開発キットを用意しています。
これらのパッケージはアドオンの開発を始めるための、スターターキットになっています。
今回作成したアドオンはそのうちの一つである addon-parameters
開発キットを元に作成しました。
開発キットは以下のリンクを参照してください: https://github.com/storybookjs/storybook/tree/next/dev-kits
今後も開発キットを増やしていく予定です。
ワークフローにアドオンを使用することで、時間を節約することができますが、技術者ではないチームメートやレビュアーがその恩恵を受けるのが難しい場合もあります。Storybook を動かせない環境の人もいるのです。それが Storybook をみんなが見られるようにオンラインで公開する理由です。