概要
フィルタチップコンポーネントの各状態(デフォルト・アクティブ・削除ボタン付き・複数表示)を Storybook 8 の CSF 形式でストーリー化する。args で props を制御し、play 関数でクリックインタラクションを検証する。tags: ["autodocs"] による自動ドキュメント生成も示す。
インストール
npx storybook@latest init
実装
テスト対象のフィルタチップコンポーネント
// components/FilterChip.tsx
type Props = {
label: string;
isActive?: boolean;
onRemove?: () => void;
};
export function FilterChip({ label, isActive = false, onRemove }: Props) {
return (
<span
className={`inline-flex items-center gap-1 rounded-full px-3 py-1 text-sm font-medium ${
isActive
? "bg-blue-100 text-blue-800"
: "bg-gray-100 text-gray-700"
}`}
>
{label}
{onRemove && (
<button
type="button"
onClick={onRemove}
aria-label={`${label} を削除`}
className="ml-0.5 rounded-full p-0.5 hover:bg-blue-200"
>
<svg className="h-3 w-3" viewBox="0 0 12 12" fill="currentColor" aria-hidden="true">
<path d="M6 4.586L9.293 1.293a1 1 0 111.414 1.414L7.414 6l3.293 3.293a1 1 0 01-1.414 1.414L6 7.414 2.707 10.707a1 1 0 01-1.414-1.414L4.586 6 1.293 2.707A1 1 0 012.707 1.293L6 4.586z"/>
</svg>
</button>
)}
</span>
);
}
メタデータとストーリー
// components/FilterChip.stories.tsx
import type { Meta, StoryObj } from "@storybook/react";
import { expect, fn, userEvent, within } from "@storybook/test";
import { FilterChip } from "./FilterChip";
const meta = {
title: "Components/FilterChip",
component: FilterChip,
tags: ["autodocs"],
args: {
onRemove: fn(),
},
} satisfies Meta<typeof FilterChip>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
label: "Next.js",
},
};
export const Active: Story = {
args: {
label: "Next.js",
isActive: true,
},
};
export const WithRemoveButton: Story = {
args: {
label: "routing",
isActive: true,
},
};
export const RemoveInteraction: Story = {
args: {
label: "testing",
isActive: true,
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
// 削除ボタンをクリック
const removeBtn = canvas.getByRole("button", { name: "testing を削除" });
await userEvent.click(removeBtn);
// onRemove が 1 回呼ばれたことを確認
expect(args.onRemove).toHaveBeenCalledTimes(1);
},
};
複数チップを並べるストーリー
// components/FilterChipList.stories.tsx
import type { Meta, StoryObj } from "@storybook/react";
import { fn } from "@storybook/test";
import { FilterChip } from "./FilterChip";
const chips = [
{ label: "Next.js", isActive: true },
{ label: "routing", isActive: true },
{ label: "beginner", isActive: false },
];
export const ChipList: StoryObj = {
render: () => (
<div className="flex flex-wrap gap-2">
{chips.map((chip) => (
<FilterChip
key={chip.label}
label={chip.label}
isActive={chip.isActive}
onRemove={fn()}
/>
))}
</div>
),
};
ポイント
tags: ["autodocs"]をメタデータに追加すると、props テーブルと全ストーリーを自動でドキュメントページに集約できる。手動で Docs ページを書く手間が省けるargs: { onRemove: fn() }をメタデータのトップレベルに書くと、全ストーリーに共通の mock 関数が適用される。ストーリーごとに個別に書く必要がないplay関数内でuserEvent.click()を使うと、実際のユーザー操作に近いイベントシーケンスが発生する。fireEventより DOM イベントが自然に伝播するwithin(canvasElement)でストーリーのレンダリング領域に絞ってクエリする。グローバルなdocument.bodyを使うと、ストーリーが複数ある場合に誤検索する可能性があるisActiveの有無・onRemoveの有無の組み合わせをそれぞれストーリーとして書くことで、コンポーネントが取りうる状態をカタログ化できるsatisfies Meta<typeof FilterChip>は TypeScript の型チェックを strict にしつつ、meta変数をその型として使える。as Meta<...>のキャストより型安全