🛠️

Storybookの addon 作って ツールバーにボタンを表示させて Canvas の表示を切り替えられるようにする

2022/09/04に公開

はじめに

以下の続き。
https://zenn.dev/sterashima78/articles/473e1764962cbe

コード的にもこの内容からスタートする。

ツールバーにボタンを足す

タブパネルやパネルと同じく addon の API を使って追加できる。

src/addon.tsx
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

ボタンが表示された (星マーク)

クリックするとちゃんと状態が変わる。

また、matchviewMode が story か docs のときだけ表示するようにしているので、
前の記事で作った myaddon の状態だと表示されない。

ここで、active 状態はコンポーネントがローカルで保持している状態となる。
そのため、以下の手順を踏んだ場合最終的な表示は非アクティブ状態となる。

  1. 初期状態 (非アクティブ)
  2. 星ボタンをクリック
  3. アクティブになる
  4. My Addon タブをクリックして viewModemyaddon に遷移
  5. 星ボタンは表示されなくなる
  6. Canvasタブをクリックする
  7. 星ボタンは再度表示される

これは、このボタンの初期状態が非アクティブであり、コンポーネントが再度作り直されたときに以前のローカル状態を持ち越せないため。

グローバル状態を参照する

Storybook はグローバル状態を設定・参照するための API を提供している。
これは useGlobals という hook で利用できる。
これを利用することで先程はできなかった状態の維持が可能になる。

src/addon.tsx
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 という関数で公開する必要がある。

src/index.ts
export const managerEntries = (entry = []) => {
    return [...entry, require.resolve("./addon.js")];
}

+ export function config(entry = []) {
+     return [...entry, require.resolve('./preview.js')];
+ }

preview.js という名前にトランスパイルされるような名前で実装を書く。

decorator の実装

デコレータを実装していく。
今回は背景の色を変えることにしよう。

デコレータは decorators という名前で公開する。これは複数のデコレータを含む配列だ。

addon/preview.tsx
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 からはインポートしていない。

この点について以下の文書中に記載されている。

https://storybook.js.org/tutorials/create-an-addon/react/en/decorators/

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

真っ赤!

キャンバスの表示変更を切り替えできるようにする

ずっと真っ赤だと見にくいので、必要なときだけ真っ赤にしたい。
ツールバーで使ったグローバル状態を参照するようにして、必要に応じて切り替えられるようにしよう。

addon/preview.tsx
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

ツールバーの星ボタンが非アクティブなら背景色は変わらない。

アクティブなら真っ赤になった。

おわりに

ここまでは以下。
https://github.com/sterashima78/test-storybook-addon/tree/tool

とりあえず、Storybook の UI をゴニョゴニョする系は前回、前々回の記事 (以下) を合わせてこれで網羅できた気がする。

https://zenn.dev/sterashima78/articles/0bb466a6036199
https://zenn.dev/sterashima78/articles/473e1764962cbe

Storybook のドキュメントには書いてあるといえば書いてあるが、各ドキュメントにアクセスしにくかったり網羅性が乏しかったり、整理されていないという印象が強くなった。
とはいえ開発を優先してくれたほうが1ユーザとしては嬉しいので、引き続き開発を見ながら利用していきたい。

ここまでを踏まえてちょっと複雑なものを書いてみたいと思った。

Discussion