Storybookの addon 作って ツールバーにボタンを表示させて Canvas の表示を切り替えられるようにする
はじめに
以下の続き。
コード的にもこの内容からスタートする。
ツールバーにボタンを足す
タブパネルやパネルと同じく addon
の API を使って追加できる。
import React from 'react';
import { addons, types } from '@storybook/addons';
- import { AddonPanel, SyntaxHighlighter, TabWrapper } from '@storybook/components';
+ import { AddonPanel, SyntaxHighlighter, TabWrapper, Icons, IconButton } from '@storybook/components';
import { useParameter, useStorybookApi } from '@storybook/api';
const ADDON_ID = 'myaddon';
const PANEL_ID = `${ADDON_ID}/panel`;
const TAB_ID = `${ADDON_ID}/tab`;
+ const TOOL_ID = `${ADDON_ID}/tool`;
const PARAM_KEY = 'myAddon';
const MyPanel = () => {
const value = useParameter<{ data: string } | null>(PARAM_KEY, null);
const item = value ? value.data : 'No story parameter defined';
return <div>{item}</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.add(TAB_ID, {
type: types.TAB,
title: 'My Addon',
route: ({ storyId }) => `/myaddon/${storyId}`,
match: ({ viewMode }) => {
return viewMode === "myaddon"
},
render: ({ active, key }) => {
const api = useStorybookApi()
return (
<TabWrapper active={!!active} key={key}>
<SyntaxHighlighter language='json'>{JSON.stringify(api.getCurrentStoryData(), undefined, 2)}</SyntaxHighlighter>
</TabWrapper>
)
}
});
+ addons.add(TOOL_ID, {
+ type: types.TOOL,
+ title: "My addon",
+ match: ({ viewMode }) => !!(viewMode && viewMode.match(/^(story|docs)$/)),
+ render: ()=> {
+ const [active, setActive] = React.useState(false)
+ return <IconButton
+ key={TOOL_ID}
+ active={active}
+ title="Enable my addon"
+ onClick={()=> setActive(!active)}
+ >
+ <Icons icon="star" />
+ </IconButton>
+ }
+ });
});
クリックするたびにボタンのアクティブ状態が変化するボタンができた。
動かしてみる。
$ npm run build
$ npm run dev
ボタンが表示された (星マーク)
クリックするとちゃんと状態が変わる。
また、match
で viewMode
が story か docs のときだけ表示するようにしているので、
前の記事で作った myaddon
の状態だと表示されない。
ここで、active
状態はコンポーネントがローカルで保持している状態となる。
そのため、以下の手順を踏んだ場合最終的な表示は非アクティブ状態となる。
- 初期状態 (非アクティブ)
- 星ボタンをクリック
- アクティブになる
-
My Addon
タブをクリックしてviewMode
がmyaddon
に遷移 - 星ボタンは表示されなくなる
-
Canvas
タブをクリックする - 星ボタンは再度表示される
これは、このボタンの初期状態が非アクティブであり、コンポーネントが再度作り直されたときに以前のローカル状態を持ち越せないため。
グローバル状態を参照する
Storybook はグローバル状態を設定・参照するための API を提供している。
これは useGlobals
という hook で利用できる。
これを利用することで先程はできなかった状態の維持が可能になる。
import React from 'react';
import { addons, types } from '@storybook/addons';
import { AddonPanel, SyntaxHighlighter, TabWrapper, Icons, IconButton } from '@storybook/components';
- import { useParameter, useStorybookApi } from '@storybook/api';
+ import { useParameter, useStorybookApi, useGlobals } from '@storybook/api';
const ADDON_ID = 'myaddon';
const PANEL_ID = `${ADDON_ID}/panel`;
const TAB_ID = `${ADDON_ID}/tab`;
const TOOL_ID = `${ADDON_ID}/tool`;
const PARAM_KEY = 'myAddon';
const MyPanel = () => {
const value = useParameter<{ data: string } | null>(PARAM_KEY, null);
const item = value ? value.data : 'No story parameter defined';
return <div>{item}</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.add(TAB_ID, {
type: types.TAB,
title: 'My Addon',
route: ({ storyId }) => `/myaddon/${storyId}`,
match: ({ viewMode }) => {
return viewMode === "myaddon"
},
render: ({ active, key }) => {
const api = useStorybookApi()
return (
<TabWrapper active={!!active} key={key}>
<SyntaxHighlighter language='json'>{JSON.stringify(api.getCurrentStoryData(), undefined, 2)}</SyntaxHighlighter>
</TabWrapper>
)
}
});
addons.add(TOOL_ID, {
type: types.TOOL,
title: "My addon",
match: ({ viewMode }) => !!(viewMode && viewMode.match(/^(story|docs)$/)),
render: ()=> {
- const [active, setActive] = React.useState(false)
+ const [{ activeMyAddon }, updateGlobals] = useGlobals();
+ const toggle = () => updateGlobals({
+ activeMyAddon: !activeMyAddon
+ })
return <IconButton
key={TOOL_ID}
- active={active}
+ active={activeMyAddon}
title="Enable my addon"
- onClick={()=> setActive(!active)}
+ onClick={toggle}
>
<Icons icon="star" />
</IconButton>
}
});
});
これで状態が維持されるようになった。
また、状態が維持されるだけではなく、他のパネルなどからもこの状態が参照できるようになったということも注目すべきだ。
Story のキャンバスの表示を変える
Story のキャンバスの表示を変更するには Storybook ではデコレータというものを実装し、登録することになる。
新しくデコレータを作って登録しよう。
preview 用設定の登録
config
という関数で公開する必要がある。
export const managerEntries = (entry = []) => {
return [...entry, require.resolve("./addon.js")];
}
+ export function config(entry = []) {
+ return [...entry, require.resolve('./preview.js')];
+ }
preview.js
という名前にトランスパイルされるような名前で実装を書く。
decorator の実装
デコレータを実装していく。
今回は背景の色を変えることにしよう。
デコレータは decorators
という名前で公開する。これは複数のデコレータを含む配列だ。
import type { DecoratorFunction } from "@storybook/addons";
import { useEffect } from "@storybook/addons";
const decorator: DecoratorFunction = (Story, context) => {
useEffect(()=> {
const selector = "my-addon-style-id"
const style = document.createElement("style")
style.setAttribute("id", selector)
style.innerHTML = "body { background: red }"
document.head.appendChild(style)
return ()=> {
const ele = document.getElementById(selector)
if(!ele || !ele.parentElement) return
ele.parentElement.removeChild(ele)
}
})
return Story(context);
}
export const decorators = [
decorator
]
デコレータは HOC だ。Story コンポーネントをうけとって、必要な処理をしたり変更しつつ、新しいコンポーネント関数をコールすればいい。
今回は背景色を変更するためのグローバルなサイドエフェクトを発生させる。
サイドエフェクトは useEffect
hook で実行する。
これは React.useEffect
と同じインターフェースだが、react
からはインポートしていない。
この点について以下の文書中に記載されている。
useMemo and useEffect here come from @storybook/addons and not React. This is because the decorator code is running in the preview part of Storybook. That's where the user's code is loaded which may not contain React. Therefore, to be framework agnostic, Storybook implements a React-like hook library which we can use!
Storybook のアプリケーションは、Storybook のアプリケーション部分と、Story のプレビュー部分に別れており、プレビュー部分は iframe で隔離されているということは多くの人が知っていると思う。
今書いている preview
要の設定というのは、この iframe 内に対する設定となる。
したがって、この部分の環境は利用しているフレームワークによってことなり、例えば @storybook/vue3
を使っていれば、iframe 内には react は存在せず、vue が存在するということになる。
そのため、react に依存した処理を書いてはいけない。そこを埋めるために Storybook がそれに相当するロジックを提供しているのだ。
さて、この説明で上記のデコレータは Story をそのまま返しつつ、特定のスタイルをドキュメントに埋め込むというサイドエフェクトを発生させているということはわかったと思う。
動かしてみよう。
$ npm run build
$ npm run dev
真っ赤!
キャンバスの表示変更を切り替えできるようにする
ずっと真っ赤だと見にくいので、必要なときだけ真っ赤にしたい。
ツールバーで使ったグローバル状態を参照するようにして、必要に応じて切り替えられるようにしよう。
import type { DecoratorFunction } from "@storybook/addons";
- import { useEffect } from "@storybook/addons";
+ import { useEffect, useGlobals } from "@storybook/addons";
const decorator: DecoratorFunction = (Story, context) => {
const [{ activeMyAddon }] = useGlobals()
useEffect(()=> {
- const selector = "my-addon-style-id"
- const style = document.createElement("style")
- style.setAttribute("id", selector)
- style.innerHTML = "body { background: red }"
- document.head.appendChild(style)
- return ()=> {
- const ele = document.getElementById(selector)
- if(!ele || !ele.parentElement) return
- ele.parentElement.removeChild(ele)
- }
+ if(activeMyAddon) {
+ const style = document.createElement("style")
+ style.setAttribute("id", selector)
+ style.innerHTML = "body { background: red }"
+ document.head.appendChild(style)
+ } else {
+ const ele = document.getElementById(selector)
+ if(!ele || !ele.parentElement) return
+ ele.parentElement.removeChild(ele)
+ }
})
return Story(context);
}
export const decorators = [
decorator
]
@storybook/addons
から useGlobals
をインポートして、ツールバーで使っている activeMyAddon
を取得し、その値に応じて発生させるサイドエフェクトを変える。
アクティブなら、スタイルを挿入し、そうでなければすでにスタイルが存在するかを確認しあれば削除するようにした。
動かしてみよう。
$ npm run build
$ npm run dev
ツールバーの星ボタンが非アクティブなら背景色は変わらない。
アクティブなら真っ赤になった。
おわりに
ここまでは以下。
とりあえず、Storybook の UI をゴニョゴニョする系は前回、前々回の記事 (以下) を合わせてこれで網羅できた気がする。
Storybook のドキュメントには書いてあるといえば書いてあるが、各ドキュメントにアクセスしにくかったり網羅性が乏しかったり、整理されていないという印象が強くなった。
とはいえ開発を優先してくれたほうが1ユーザとしては嬉しいので、引き続き開発を見ながら利用していきたい。
ここまでを踏まえてちょっと複雑なものを書いてみたいと思った。
Discussion