レビュー済み·難易度: 初級·更新: 2026-04-18

Storybook でフィルタチップコンポーネントのストーリーを書く

フィルタチップ(アクティブ状態・削除ボタン付き・複数選択)の各状態を Storybook CSF 形式でストーリー化し、args と play 関数でインタラクションを検証する例。

nextjstestingui-componentstorybook

対応バージョン

nextjs 15react 19storybook 8

前提環境

Storybook の基本的なストーリー記述(CSF 形式)と React コンポーネントの実装を理解していること

概要

フィルタチップコンポーネントの各状態(デフォルト・アクティブ・削除ボタン付き・複数表示)を 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<...> のキャストより型安全

注意点

tailwind-filter-chip-ui はフィルタチップの UI 実装。jest-component-test は Jest によるコンポーネントテスト。これは Storybook の args・play 関数・autodocs を使いフィルタチップの全状態を文書化・テストするパターンに特化。

関連サンプル