Storybookのアドオンを自作する

# はじめに
この記事は[Storybook](https://storybook.js.org/)のアドオンを作成から公開までを紹介します。
この記事では[TypeScript](https://www.typescriptlang.org/)と[React](https://ja.reactjs.org/)の基礎知識を前提として記述しますので、不慣れな方は随時それぞれのドキュメントを参照ください。

今回作成したnpmパッケージは[こちら](https://www.npmjs.com/package/storybook-role-visualization)で、リポジトリは[こちら](https://github.com/KokiSakano/storybook-role-visualization)です。


# アドオン
アドオンはStorybookの機能を拡張するためのものです。
アドオンはStorybookに表示するUIに対して行うものとStorybookを構成するためのものがあります。前者はインターラクションテストやアクセシビリティテストなどがあります。後者は`preset-create-react-app`などがあります。
この記事では前者のStorybookに表示するUIに対して行うアドオンを作成していきます。

# 今回作成するアドオン
ツールバーを押すことで、UIコンポーネントが持つAccessibility roleに異なる色のボーダを与えるアドオンを作成します。

![gif.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/591069/fd5695b4-f05b-dcb6-1317-dce2581fa944.gif)


# 環境の準備
Storybookではアドオンの作成を手助けするためのツールが用意されています。

https://github.com/storybookjs/addon-kit

こちらのリポジトリを開いて、`Use this template`から`Create a new repository`でこれから自分が作るアドオンのリポジトリを作成してアドオンの作成を簡単に開始することが出来ます。
しかし、この記事ではアドオンの環境を一から作る方法を紹介したいと思います(peerDependenciesのreactバージョンが低いことなどが気になるので)。

## リポジトリの作成
まずは作りたいアドオン名でGithubリポジトリを作成してください。私は`storybook-role-visualization`で作りました。ここからはローカルで作業するのでクローンして手元に持ってきてください。まずは`nmp init`でnodeパッケージを作成します。生成されたpackage.jsonは以下のように変更してください。
```json: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`をインストールします。
```shell
npm i react react-dom @babel/cli
```
そしてtypescript化のために`typescript`とreactのtypesパッケージもインストールします。
```shell
npm i -D typescript @types/react @types/react-dom
```
typescriptファイルの設定ファイルも適当に設定しておきます。
```json:tsconfig.ts
{
  "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しときます。
```:.gitignore
node_modules
```
次に、アドオンのテストを行うためにStorybookの環境を作成します。
```shell
npx storybook init
```
`stories`が`src`内にある場合は外に出しておくと、後ほど公開するときのファイルに含まれないのでおすすめです。`npm run storybok`でStorybookが問題なく起動することを確認してください。バージョンが17以上のNode環境を利用の方は`ERR_OSSL_EVP_UNSUPPORTED`のエラーが出ると思います。その場合は
```shell
NODE_OPTIONS=--openssl-legacy-provider npm run storybook
```
のように`NODE_OPTIONS`を`--openssl-legacy-provider`に設定すると問題なく動作します(Storybook7.0以降ではこのオプションが不要になるので期待して待ちましょう)。

最後にES6とJSXを使用するためにbabelの準備をします。
利用するパッケージをインストールします。
```shell
npm i -D @babel/preset-env @babel/preset-typescript @babel/preset-react
```
インストールできたらbabelの設定ファイルを作成して、`presets`に登録します。
```js:.babelrc.js
module.exports = {
  presets: ["@babel/preset-env", "@babel/preset-typescript", "@babel/preset-react"],
};
```
babelの実行コマンドを`scripts`を追加します。
```json:package.json
{
  "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をコントロールパネルに追加していきます。
![スクリーンショット 2022-12-25 13.49.15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/591069/f2a64e75-7172-0288-4299-e57ec7031ac7.png)
このようなパネルの設定は`src/preset/manager.tsx`に記述します。
```tsx: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`フォルダのコードを読み取るようにして行わせます。
```js:preset.js
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`に定義します。
```ts:src/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`に作成します。
```tsx:src/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は[こちら](https://5a375b97f4b14f0020b0cda3-wbeulgbetj.chromatic.com/?path=/story/basics-icon--labels)です。

作成したToolコンポーネントを`manage.tsx`に登録します。
```ts:manage.ts
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の部分だけで表示させるための関数を渡しました。

ここまでの記述で自身以外に何の変更も加えないアイコンボタンをツールバーに追加することが出来ました。クリックすることによってボタンの状態が変わることを確認してください。
![gif2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/591069/34717b51-3600-1d37-23c8-593b58eff9c6.gif)


# Storyに状態を反映する
先ほど作成した状態を表示するUIコンポーネントに伝えていきます。通常のStorybookで行うようにUIコンポーネントに影響を及ぼすには`decorators`を用いることで可能となります。それと同様でアドオンでも`decorators`を用いてUIコンポーネントに影響を与えます。アドオンを設定するプロジェクトではデフォルトでDecorator関数を追加することが出来ますのでそれを利用します。
まずは状態が変化するたびにstyleを変更できるようにstyleの追加と削除を行えるコードを用意しておきます。
```ts:src/helper.ts
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関数を作成します。
```ts:useWithGlobals.ts
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`を取り出してこのスコープで扱えるようにしています。
```ts
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です。ここに記述すると長くなるのでこのアドオンを作成したリポジトリで確認してください。
```ts
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`からそれを読み込むようにします。
```ts:src/preset/preview.ts
import { withGlobals } from "./../useWithGlobals";

export const decorators = [withGlobals];
```
```js:preset.js
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を持つ要素にボーダが与えるようなアドオンが完成します。
![gif.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/591069/fd5695b4-f05b-dcb6-1317-dce2581fa944.gif)

# npmパッケージとして公開する
Storybookの[Integrations](https://storybook.js.org/addons)への追加と、npmパッケージとして公開を行います。
まずはStorybookのメタデータを`package.json`に追加します(supportedFrameworksも与えられます)。これをすることでStorybookのIntegrationsに表示されます。
```ts
"storybook": {
  "displayName": "Visualize a11y role addon",
  "unsupportedFrameworks": ["react-native"],
  "icon": ""
},
```
そして、作成したパッケージをnpmで公開します。パッケージの公開方法は[こちら](https://docs.npmjs.com/creating-and-publishing-scoped-public-packages)を参照してください。
公開したパッケージが[こちら](https://www.npmjs.com/package/storybook-role-visualization)になります。StorybookのIntegrationsには公開してからしばらくすると追加されます。

npmパッケージとして公開した後は
```shell
npm i -D storybook-role-visualization
```
こののようにインストールを行なって`.storybook/main.js`の`addons`に追加することで利用することが出来ます。
```ts
module.exports = {
  "addons": [
    "storybook-role-visualization"
  ],
}
```
こうすることで作成したアドオンを利用することが出来ます。
![gif.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/591069/fd5695b4-f05b-dcb6-1317-dce2581fa944.gif)

# おわりに
この記事ではStorybookのアドオンを作成し公開するまでの手順を記しました。意外と簡単にできるのでアドオンを作成して公開してみてはいかがでしょうか。