モチベーション
前略、ヤプリ #1 Advent Calendar 2024の18日目の記事です。shadcn/ui(Radix UI)などコンポジションパターンでの利用が前提のライブラリは次の理由からStorybookのドキュメントの自動生成に向きません。
- Propsが
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root>
のように導出されていて、パーサーが型定義から情報を得られない。 - 利用側で組み立てていくので、単一のコンポーネントを期待するStorybookのインターフェイスとずれがある。
それでもコンポジションする複数のパーツのPropsをArgsTable上に表示できたら嬉しいですよね。手書きするしかないですね(勧めていません)。
前置き
基本方針
shadcn/uiのコンポーネントを例としてStorybookを順に構築していきます。 あくまで書き方の一例なので無理矢理感はその通りです。
- 対象はshadcn/uiのToggleGroup
- Storybookファイル(toggle-group.stories.tsx)のみ編集
- 操作可能かつDocsに主要なPropsが記載されている状態を目指す
想定読者
Storybookと溺死したい人
書くこと
Storybookのドキュメントのニッチな書き方
書かないこと
- 詳細な環境構築・ツールの設定
- 背景・選定理由
- Storybook・TypeScript・Reactの基本
完成形
最終コード
import { Meta } from "@storybook/react"; import { Bold, Italic, Underline } from "lucide-react"; import { ToggleGroup, ToggleGroupItem } from "./toggle-group"; type ToggleGroupProps = React.ComponentProps<typeof ToggleGroup>; type Size = ToggleGroupProps["size"]; type Variant = ToggleGroupProps["variant"]; type Type = ToggleGroupProps["type"]; const SIZES = ["default", "sm", "lg"] as const satisfies Size[]; const VARIANTS = ["default", "outline"] as const satisfies Variant[]; const TYPES = ["single", "multiple"] as const satisfies Type[]; /** * A set of two-state buttons that can be toggled on or off. * * ## リンク * * - [shadcn/ui](https://ui.shadcn.com/docs/components/toggle-group) * - [Radix UI](https://www.radix-ui.com/primitives/docs/components/toggle-group) * */ const meta: Meta = { title: "Components/ToggleGroup", tags: ["autodocs"], args: { type: "multiple", size: "default", variant: "default", _rootAriaLabel: "Text format", }, argTypes: { type: { description: "タイプ", type: { name: "string", required: true, }, table: { type: { summary: TYPES.join("|"), }, category: "Root", }, control: { type: "radio" }, options: TYPES, }, size: { description: "サイズ", table: { type: { summary: SIZES.join("|"), }, defaultValue: { summary: "default" }, category: "Root", }, control: { type: "radio" }, options: SIZES, }, variant: { description: "バリアント", table: { type: { summary: VARIANTS.join("|"), }, defaultValue: { summary: "default" }, category: "Root", }, control: { type: "radio" }, options: VARIANTS, }, _rootDisabled: { name: "disabled", description: "非活性フラグ", table: { type: { summary: "boolean", }, category: "Root", }, control: { type: "boolean" }, }, _singleDefaultValue: { name: "defaultValue", description: "デフォルト値(`single`)", table: { type: { summary: "string", }, category: "Root", }, options: ["bold", "italic", "strikethrough"], control: { type: "radio" }, }, _multipleDefaultValue: { name: "defaultValue", description: "デフォルト値(`multiple`)", table: { type: { summary: "string[]", }, category: "Root", }, options: ["bold", "italic", "strikethrough"], control: { type: "check" }, }, _rootAriaLabel: { name: "aria-label", description: "アクセシビリティラベル", type: { name: "string", required: false, }, table: { category: "Root", }, }, value: { description: "値", type: { name: "string", required: true, }, table: { category: "Item", }, }, _itemDisabled: { name: "disabled", description: "非活性フラグ", table: { type: { summary: "boolean", }, category: "Item", }, control: { type: "boolean" }, }, _itemAriaLabel: { name: "aria-label", description: "アクセシビリティラベル", type: { name: "string", required: true, }, table: { category: "Item", }, }, }, }; export default meta; type RootArgs = { size?: Size; variant?: Variant; _rootDisabled?: boolean; _rootAriaLabel?: string; } & ( | { type: "multiple"; _multipleDefaultValue?: string[]; _singleDefaultValue?: never; } | { type: "single"; _multipleDefaultValue?: never; _singleDefaultValue?: string; } ); type ItemArgs = { _itemDisabled?: boolean; _itemAriaLabel: string; }; type Args = RootArgs & ItemArgs; export const Default = { render: (args: Args) => { const { _itemDisabled, _itemAriaLabel, ...rest } = args; const { type, _multipleDefaultValue, _singleDefaultValue, _rootAriaLabel, _rootDisabled, ...rootRest } = rest; const multipleProps = { type: "multiple", defaultValue: _multipleDefaultValue } as const; const singleProps = { type: "single", defaultValue: _singleDefaultValue } as const; const rootProps = { ...rootRest, disabled: _rootDisabled, "aria-label": _rootAriaLabel, }; const itemProps = { disabled: _itemDisabled, "aria-label": _itemAriaLabel }; return ( <ToggleGroup {...rootProps} {...(type === "multiple" ? multipleProps : singleProps)}> <ToggleGroupItem value="bold" aria-label="Toggle bold"> <Bold className="h-4 w-4" /> </ToggleGroupItem> <ToggleGroupItem value="italic" aria-label="Toggle italic"> <Italic className="h-4 w-4" /> </ToggleGroupItem> <ToggleGroupItem value="strikethrough" {...itemProps}> <Underline className="h-4 w-4" /> </ToggleGroupItem> </ToggleGroup> ); }, };
参考リンク
- How to write stories | Storybook docs
- Args | Storybook docs
- ArgTypes | Storybook docs
- Toggle Group - shadcn/ui
作り方
環境構築
環境
2024/12/18時点
Package | Version |
---|---|
node | 22.11.0 |
next | 15.0.4 |
react | ^19.0.0 |
react-dom | ^19.0.0 |
storybook | ^8.4.7 |
tailwindcss | ^3.4.1 |
typescript | ^5 |
手順メモ
初期化
Next.js
pnpm create next-app@latest storybook-hand-writing
- Tailwind CSS: Y
src/
directory: Y- import alias: N
shadcn/ui
pnpm dlx shadcn@latest init -d
Storybook
pnpm dlx storybook init
app/storybook
を消去。
.storybook/preview
に追記。
import "@/app/globals.css";
パッケージ追加とアップデート
pnpm add -D eslint-plugin-tailwindcss @types/node@latest prettier@latest
npm scriptsやリンター設定を整理して最終的にこうなりました。
package.json
{ "scripts": { "dev": "next dev --turbopack", "build": "next build", "start": "next start", "lint": "next lint && prettier --check .", "format": "next lint --fix && prettier --write .", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build" }, "dependencies": { "class-variance-authority": "0.7.1", "clsx": "2.1.1", "lucide-react": "0.468.0", "next": "15.0.4", "react": "^19.0.0", "react-dom": "^19.0.0", "tailwind-merge": "2.5.5", "tailwindcss-animate": "1.0.7" }, "devDependencies": { "@chromatic-com/storybook": "^3.2.2", "@storybook/addon-essentials": "^8.4.7", "@storybook/addon-interactions": "^8.4.7", "@storybook/addon-onboarding": "^8.4.7", "@storybook/blocks": "^8.4.7", "@storybook/nextjs": "^8.4.7", "@storybook/react": "^8.4.7", "@storybook/test": "^8.4.7", "@types/node": "^22.10.1", "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^8", "eslint-config-next": "15.0.4", "eslint-config-prettier": "9.1.0", "eslint-plugin-storybook": "0.11.1", "eslint-plugin-tailwindcss": "3.17.5", "postcss": "^8", "prettier": "3.4.2", "storybook": "^8.4.7", "tailwindcss": "^3.4.1", "typescript": "^5" } }
.eslintrc.json
{ "extends": [ "next/core-web-vitals", "next/typescript", "plugin:storybook/recommended", "plugin:tailwindcss/recommended", "prettier" ] }
.prettierignore
pnpm-lock.yaml
フォーマットをかけます。prettierの設定はお好みで。
pnpm format
Storybookに表示するまで
pnpm dlx shadcn@latest add toggle-group
コンポーネントと同階層にtoggle-group.stories.tsx
を作ります。サンプルコードはshadcn/uiから借用します。
toggle-group.stories.tsx
import { Meta, StoryObj } from "@storybook/react"; import { Bold, Italic, Underline } from "lucide-react"; import { ToggleGroup, ToggleGroupItem } from "./toggle-group"; const meta: Meta<typeof ToggleGroup> = { title: "Components/ToggleGroup", component: ToggleGroup, tags: ["autodocs"], }; export default meta; type Story = StoryObj<typeof ToggleGroup>; export const Default: Story = { args: { type: "multiple", defaultValue: [], size: "default", variant: "default", }, render: (args) => { return ( <ToggleGroup type="multiple"> <ToggleGroupItem value="bold" aria-label="Toggle bold"> <Bold className="h-4 w-4" /> </ToggleGroupItem> <ToggleGroupItem value="italic" aria-label="Toggle italic"> <Italic className="h-4 w-4" /> </ToggleGroupItem> <ToggleGroupItem value="strikethrough" aria-label="Toggle strikethrough"> <Underline className="h-4 w-4" /> </ToggleGroupItem> </ToggleGroup> ); }, };
render
関数はレンダリングするコンポーネントを実装者が柔軟に指定できるのでこれを使います。デフォルトでは、args
にはmeta
で指定したコンポーネントのPropsが詰まっています。fyi: Component Story Format (CSF) | Storybook docs
この状態でStorybookを起動します。ToggleGroup
のインストール前からStorybookのサーバーを起動していた場合は再起動してください。表示されていればOKです。
pnpm storybook
すごくプレーンなものが表示されました。
Storyを操作可能にする
次にDefault
のStoryを操作可能にします。
render: (args) => { return ( <ToggleGroup {...args}>
type
,defaultValue
, size
, variant
がToggleGroup
のPropsとして渡り、
Storyを操作できるようになりました。Toggle
の実装を参考にvariant
やsize
を変えればレンダー中のコンポーネントに反映されていることを確認できます。
とはいえ、variant
がdefault
とoutline
を持つことはStorybookを見ただけではわかりませんし、変更するにもテキストとして入力せざるを得ないのは怠いです。説明不足を補うために次はargTypes
を書きます。本題です。
本題
ArgsTableの基本的な書き方
Storybookに手書きで理解させます。
const SIZES = ["default", "sm", "lg"] as const satisfies React.ComponentProps<typeof ToggleGroup>["size"][]; const VARIANTS = ["default", "outline"] as const satisfies React.ComponentProps<typeof ToggleGroup>["variant"][]; const TYPES = ["single", "multiple"] as const satisfies React.ComponentProps<typeof ToggleGroup>["type"][]; export const Default: Story = { // ...args argTypes: { type: { description: "タイプ", table: { type: { summary: TYPES.join("|"), }, defaultValue: { summary: "md" }, }, control: { type: "radio" }, options: TYPES, }, size: { description: "サイズ", table: { type: { summary: SIZES.join("|"), }, defaultValue: { summary: "md" }, }, control: { type: "radio" }, options: SIZES, }, variant: { description: "バリアント", table: { type: { summary: VARIANTS.join("|"), }, defaultValue: { summary: "default" }, }, control: { type: "radio" }, options: VARIANTS, }, }, // ...render
このように書けば、画像のようになっていないかもしれません。デフォルト値がmd
なのはコピペしたものを修正し忘れました。適宜default
に読み替えてください。
必須のマークを追加
type
は必須指定なのでArgsTable上で表現しましょう。
type: { description: "タイプ", type: { name: "string", required: true, },
type
ばっかりで紛らわしいですね。コンポーネントのPropsではなくArgsTableの設定値のtype
はtable
の方にも同じプロパティがあるので挿入位置に注意しましょう。
必須であることがわかるようになりました。disabled
も同じ要領で追加します。
カテゴリー分け
ToggleGroup
のPropsについてしか記述していないのでToggleGroupItem
のPropsも表示させたいです。さて、ToggleGroup
とToggleGroupItem
のPropsをどう分けましょう。table.category
に対してカテゴリーを指定すれば実現できます。
defaultValue: { description: "デフォルト値", table: { type: { summary: "string[]", }, category: "Root", }, },
カテゴリーが表示できましたね。
共通の情報をmetaに移動
ここまで個別のStory Objectの中に書いてきましたが、デフォルトのargs
やargTypes
はStory全体に適用する指定の方が個別のStoryの見通しが良くなるでしょう。meta
の中にargs
,argTypes
を移動させます。
const meta: Meta<typeof ToggleGroup> = { ..., args: {...}, argTypes: {...} }
型定義の変更
ToggleGroupItem
の方のPropsも追加したいですが、問題があって、disabled
がToggleGroup
のargTypes
のキーとバッティングしてしまいます。回避策としてキーとは別に表示名を指定できます。ここではToggleGroup
の方を_rootDisabled
、ToggleGroupItem
の方を_itemDisabled
というキーにします。ただ、このまま指定しようとすると型エラーが出ると思います。Storyの型定義が単一コンポーネント前提なので仕方ないところがあります。思い切ってStorybook型定義を省略してしまいましょう。 (苦しくなってきました。)
const meta: Meta = { export const Default = {
今度はrender
関数のargs
がany
になっています。CSF記法で普通に書くと、コンポーネントのprops
とStorybookのargs
の型定義は一致します。普通の書き方をしてないので、堅牢性は劣るのですがStorybookのargs
とコンポーネントのprops
は別個のものと考えましょう。args
に自由に型定義を注釈することで矛盾を消し去ります。関連箇所もろとも修正します。
また、<ToggleGroup {...args}>
のところでもエラーが出るのでargs
をToggleGroup
に渡すやつとToggleGroupItem
に渡すやつに分割して整理します。
type ToggleGroupProps = React.ComponentProps<typeof ToggleGroup>; type Size = ToggleGroupProps["size"]; type Variant = ToggleGroupProps["variant"]; type Type = ToggleGroupProps["type"]; const SIZES = ["default", "sm", "lg"] as const satisfies Size[]; const VARIANTS = ["default", "outline"] as const satisfies Variant[]; const TYPES = ["single", "multiple"] as const satisfies Type[]; type RootArgs = { size?: Size; variant?: Variant; _rootDisabled?: boolean; } & ( | { type: "multiple"; defaultValue?: string[]; } | { type: "single"; defaultValue?: string; } ); type ItemArgs = { _itemDisabled?: boolean; }; type Args = RootArgs & ItemArgs; export const Default = { render: (args: Args) => { const { _itemDisabled, ...rest } = args; const { _rootDisabled,...rootRest } = rest; const rootProps = { ...rootRest, disabled: _rootDisabled }; const itemProps = { disabled: _itemDisabled }; return ( <ToggleGroup {...rootProps}> <ToggleGroupItem value="bold" aria-label="Toggle bold"> <Bold className="h-4 w-4" /> </ToggleGroupItem> <ToggleGroupItem value="italic" aria-label="Toggle italic"> <Italic className="h-4 w-4" /> </ToggleGroupItem> <ToggleGroupItem value="strikethrough" aria-label="Toggle strikethrough" {...itemProps}> <Underline className="h-4 w-4" /> </ToggleGroupItem> </ToggleGroup> ); }, };
パーツのPropsを追加
続けてmeta.argTypes
にToggleGroupItem
のカテゴリとProps(value
, disabled
, aria-label
)を追加します。
argTypes: { ... _rootAriaLabel: { name: "aria-label", description: "アクセシビリティラベル", type: { name: "string", required: false, }, table: { category: "Root", }, }, value: { description: "値", type: { name: "string", required: true, }, table: { category: "Item", }, }, _itemDisabled: { name: "disabled", description: "非活性フラグ", table: { type: { summary: "boolean", }, category: "Item", }, control: { type: "boolean" }, }, _itemAriaLabel: { name: "aria-label", description: "アクセシビリティラベル", type: { name: "string", required: true, }, table: { category: "Item", }, }, }, type RootArgs = { size?: Size; variant?: Variant; _rootDisabled?: boolean; _rootAriaLabel?: string; } & ( ... type ItemArgs = { _itemDisabled?: boolean; _itemAriaLabel: string; }; export const Default = { render: (args: Args) => { const { _itemDisabled, _itemAriaLabel, ...rest } = args; const { _rootDisabled, _rootAriaLabel, ...rootRest } = rest; const rootProps = { ...rootRest, disabled: _rootDisabled, "aria-label": _rootAriaLabel }; const itemProps = { disabled: _itemDisabled, "aria-label": _itemAriaLabel }; ...
画像のようにToggleGroup
とToggleGroupItem
に渡せるPropsがわかりやすくなりました。
依存関係のあるPropsとコントロールの修正
defaultValue
も修正します。type="single"
の場合とtype="multiple"
の場合でdefaultValue
がstring
を取るかstring[]
を取るか決まります。type
の値を見て流し込むPropsを絞り込む感じです。また、配列の要素を直接指定するコントロールからチェックボックスのコントロールに変更します。
const meta: Meta = { ... _singleDefaultValue: { name: "defaultValue", description: "デフォルト値(`single`)", table: { type: { summary: "string", }, category: "Root", }, options: ["bold", "italic", "strikethrough"], control: { type: "radio" }, }, _multipleDefaultValue: { name: "defaultValue", description: "デフォルト値(`multiple`)", table: { type: { summary: "string[]", }, category: "Root", }, options: ["bold", "italic", "strikethrough"], control: { type: "check" }, }, ... }; type RootArgs = { size?: Size; variant?: Variant; _rootDisabled?: boolean; _rootAriaLabel?: string; } & ( | { type: "multiple"; _multipleDefaultValue?: string[]; _singleDefaultValue?: never; } | { type: "single"; _multipleDefaultValue?: never; _singleDefaultValue?: string; } ); export const Default = { render: (args: Args) => { const { _itemDisabled, _itemAriaLabel, ...rest } = args; const { type, _multipleDefaultValue, _singleDefaultValue, _rootAriaLabel, _rootDisabled, ...rootRest } = rest; const multipleProps = { type: "multiple", defaultValue: _multipleDefaultValue } as const; const singleProps = { type: "single", defaultValue: _singleDefaultValue } as const; const rootProps = { ...rootRest, disabled: _rootDisabled, "aria-label": _rootAriaLabel, }; const itemProps = { disabled: _itemDisabled, "aria-label": _itemAriaLabel }; return ( <ToggleGroup {...rootProps} {...(type === "multiple" ? multipleProps : singleProps)}> ...
結果は画像のとおりです。この状態からDocs
ではなくDefault
のページで任意のdefaultValue
を選択したあとリロードすると初期値として選択されているはずです。
概要の追記
タイトル直下にリンクと説明を追加します。meta
にJSDocで注釈すると表示できます。もちろんマークダウンが使えます。
/** * A set of two-state buttons that can be toggled on or off. * * ## リンク * * - [shadcn/ui](https://ui.shadcn.com/docs/components/toggle-group) * - [Radix UI](https://www.radix-ui.com/primitives/docs/components/toggle-group) * */ const meta: Meta = {
これで終わりです。完成形へ。
Tips
マークダウンの書けるところ
meta
への注釈やargTypes
の各args
のdescription
に書くことができます。
関数Propの書き方
ArgsTable内でもリンクを設定できます。description
にマークダウンで書きます。table.type
はsummary
に加えてdetail
を書くことでdetails/summary
要素のような折りたたみにできます。関数の詳しい説明やシグネイチャーを書いたりできるでしょう。
argTypes: { ... onValueChange: { description: "値変更時のハンドラ([API Reference](https://www.radix-ui.com/primitives/docs/components/toggle-group#root))", table: { category: "Root", type: { summary: "function", detail: "(value: string[]) => void", }, }, },
テンプレート化して他のStoryで使い回すには
Default
の中身を関数に切り出して、Story Objectのrender
関数にところで呼び出します。
const renderComponent = (args: Args) => { const { _itemDisabled, _itemAriaLabel, ...rest } = args; const { type, _multipleDefaultValue, _singleDefaultValue, _rootAriaLabel, _rootDisabled, ...rootRest } = rest; const multipleProps = { type: "multiple", defaultValue: _multipleDefaultValue } as const; const singleProps = { type: "single", defaultValue: _singleDefaultValue } as const; const rootProps = { ...rootRest, disabled: _rootDisabled, "aria-label": _rootAriaLabel, }; const itemProps = { disabled: _itemDisabled, "aria-label": _itemAriaLabel }; return ( <ToggleGroup {...rootProps} {...(type === "multiple" ? multipleProps : singleProps)}> <ToggleGroupItem value="bold" aria-label="Toggle bold"> <Bold className="h-4 w-4" /> </ToggleGroupItem> <ToggleGroupItem value="italic" aria-label="Toggle italic"> <Italic className="h-4 w-4" /> </ToggleGroupItem> <ToggleGroupItem value="strikethrough" {...itemProps}> <Underline className="h-4 w-4" /> </ToggleGroupItem> </ToggleGroup> ); }; export const Outline = { render: (args: Args) => renderComponent({ ...args, variant: "outline" }), };
お約束
アプリプラットフォーム「Yappli」を開発・提供している株式会社ヤプリは、Mobile Tech for Allをミッションに、モバイルテクノロジーで世の中をもっと便利に、もっと楽しくすることを目指しています。デザインシステムの構築や技術的な負債の解消、より価値のあるCX/UXを届ける機能開発など、チャレンジングな課題が山積しています。少しでも興味があれば、お声掛けください。
🎄🎍Happy Hacking 🎍🎄