1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

1人フロントエンドAdvent Calendar 2022

Day 25

Storybookのアドオンを自作する

Last updated at Posted at 2022-12-25

はじめに

この記事はStorybookのアドオンを作成から公開までを紹介します。
この記事ではTypeScriptReactの基礎知識を前提として記述しますので、不慣れな方は随時それぞれのドキュメントを参照ください。

今回作成したnpmパッケージはこちらで、リポジトリはこちらです。

アドオン

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

今回作成するアドオン

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

gif.gif

環境の準備

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

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

リポジトリの作成

まずは作りたいアドオン名でGithubリポジトリを作成してください。私はstorybook-role-visualizationで作りました。ここからはローカルで作業するのでクローンして手元に持ってきてください。まずはnmp initでnodeパッケージを作成します。生成されたpackage.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"
}

namedescriptionauthorはプロジェクトごとに適切なものを入力してください。追加するアドオンの設定ファイルはpreset.jsから読み込むのでmainpreset.jsを指定しています。

利用するパッケージの準備

まずはreactreact-dom@babel/cliをインストールします。

npm i react react-dom @babel/cli

そしてtypescript化のためにtypescriptとreactのtypesパッケージもインストールします。

npm i -D typescript @types/react @types/react-dom

typescriptファイルの設定ファイルも適当に設定しておきます。

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の環境を作成します。

npx storybook init

storiessrc内にある場合は外に出しておくと、後ほど公開するときのファイルに含まれないのでおすすめです。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に登録します。

.babelrc.js
module.exports = {
  presets: ["@babel/preset-env", "@babel/preset-typescript", "@babel/preset-react"],
};

babelの実行コマンドをscriptsを追加します。

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
このようなパネルの設定はsrc/preset/manager.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フォルダのコードを読み取るようにして行わせます。

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に定義します。

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に作成します。

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を用いて構築しています。IconButtonactiveに渡した値で自身の表示を変え、onClickに渡した関数で値を変更します。keyは識別子、titleIconButtonの説明です。Iconsはアイコンを設定しています。たくさんのアイコンがあるので作成するアドオンにあったものを選択してください。アイコン一覧が書かれたStorybookはこちらです。

作成したToolコンポーネントをmanage.tsxに登録します。

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

Storyに状態を反映する

先ほど作成した状態を表示するUIコンポーネントに伝えていきます。通常のStorybookで行うようにUIコンポーネントに影響を及ぼすにはdecoratorsを用いることで可能となります。それと同様でアドオンでもdecoratorsを用いてUIコンポーネントに影響を与えます。アドオンを設定するプロジェクトではデフォルトでDecorator関数を追加することが出来ますのでそれを利用します。
まずは状態が変化するたびにstyleを変更できるようにstyleの追加と削除を行えるコードを用意しておきます。

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);
  }
};

clearStyleidをidとする要素があったときそれ自身を削除する関数です。
addtylesidをidとして、cssが記述された引数のcssinnerHTMLに持つstyle要素を作成してheadに追加します。もし、idをidとする要素がすでに存在していた場合はその中身cssを上書きするもしくは、何も行いません。
これでaddStyleが呼ばれたときにAccessibility roleを持つ要素にボーダが与えられ、clearStyleが呼ばれるとボーダーが消えるような仕組みを作ることが出来ます。

これを利用して、roleVisualizationが変更されるたびに挙動が変わるDecorator関数を作成します。

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を取り出してこのスコープで扱えるようにしています。

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と同じくuseEffectstorybook/adddonsが提供するAPIです(振る舞いは同じです)。この内部ではroleVisualizationの値によって先ほどのhelper.tsの中身を出し分けています。roleVisualizationがTruthyの時はaddStyleでstyleを与えて、Faslsyになった時かクリーンアップ時にclearStyleでstyleを削除しています。

このDecorator関数をdecoratorsに追加することでAccessibility roleを持つ要素にボーダが与えるようなアドオンが完成します。
デフォルトで追加するようにするにはsrc/preset/preview.tsを作成してdecoratorsに登録を行って、preser.jsからそれを読み込むようにします。

src/preset/preview.ts
import { withGlobals } from "./../useWithGlobals";

export const decorators = [withGlobals];
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,
};

ここまで設定が終わると、ボタンを押してroleVisualizationtrueにすることでAccessibility roleを持つ要素にボーダが与えるようなアドオンが完成します。
gif.gif

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.jsaddonsに追加することで利用することが出来ます。

module.exports = {
  "addons": [
    "storybook-role-visualization"
  ],
}

こうすることで作成したアドオンを利用することが出来ます。
gif.gif

おわりに

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

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?