APG Patterns
English GitHub
English GitHub

Checkbox

2状態(チェック済み/未チェック)および3状態(チェック済み/未チェック/部分的にチェック)をサポートします。

🤖 AI Implementation Guide

デモ

Native HTML

Use Native HTML First

Before using this custom component, consider using native <input type="checkbox"> elements. They provide built-in accessibility, work without JavaScript, and require no ARIA attributes.

<label>
  <input type="checkbox" name="agree" />
  I agree to the terms
</label>

Use custom implementations only when you need custom styling that native elements cannot provide, or complex indeterminate state management for checkbox groups.

Use Case Native HTML Custom Implementation
Basic form input Recommended Not needed
JavaScript disabled support Works natively Requires fallback
Indeterminate (mixed) state JS property only* Full control
Custom styling Limited (browser-dependent) Full control
Form submission Built-in Requires hidden input

*Native indeterminate is a JavaScript property, not an HTML attribute. It cannot be set declaratively.

アクセシビリティ

WAI-ARIA ロール

ロール 要素 説明
checkbox <input type="checkbox"> または role="checkbox" を持つ要素 要素をチェックボックスとして識別します。ネイティブの <input type="checkbox"> は このロールを暗黙的に持ちます。

この実装ではネイティブの <input type="checkbox"> を使用しており、checkbox ロールを暗黙的に提供します。<div><button> を使用したカスタム実装では、明示的に role="checkbox" が必要です。

WAI-ARIA ステート

aria-checked / checked

チェックボックスの現在のチェック状態を示します。すべてのチェックボックス実装で必須です。

true | false | mixed (不確定状態の場合)
必須 はい
ネイティブ HTML checked プロパティ(暗黙的な aria-checked
カスタム ARIA aria-checked="true|false|mixed"
変更トリガー クリック、Space

indeterminate(ネイティブプロパティ)

混合状態を示します。通常、一部のアイテムが選択されている場合の「すべて選択」チェックボックスで使用されます。

true | false
必須 いいえ(混合状態の場合のみ)
注意 JavaScriptプロパティのみ、HTML属性ではありません
動作 ユーザー操作時に自動的にクリアされます

disabled(ネイティブ属性)

チェックボックスがインタラクティブでなく、変更できないことを示します。

存在 | 不在
必須 いいえ(無効化時のみ)
効果 タブ順序から除外され、入力を無視します

キーボードサポート

キー アクション
Space チェックボックスの状態を切り替える(チェック/未チェック)
Tab 次のフォーカス可能な要素にフォーカスを移動
Shift + Tab 前のフォーカス可能な要素にフォーカスを移動

注意: Switchパターンとは異なり、Enterキーではチェックボックスが切り替わりません。

アクセシブルな名前

チェックボックスにはアクセシブルな名前が必要です。これは以下の方法で提供できます:

  • label要素(推奨) - <label>for 属性で使用するか、inputをラップします
  • aria-label - チェックボックスに非表示のラベルを提供します
  • aria-labelledby - 外部要素をラベルとして参照します

ビジュアルデザイン

この実装は、色のみに依存せずに状態を示すことで、WCAG 1.4.1(色の使用)に準拠しています:

  • チェックマークアイコン - チェック時に表示
  • ダッシュ/マイナスアイコン - 不確定状態時に表示
  • 空のボックス - 未チェック時に表示
  • 強制カラーモード - Windowsハイコントラストモードでのアクセシビリティのためにシステムカラーを使用

参考資料

ソースコード

Checkbox.tsx
import { cn } from '@/lib/utils';
import { useCallback, useEffect, useRef, useState } from 'react';

export interface CheckboxProps extends Omit<
  React.InputHTMLAttributes<HTMLInputElement>,
  'type' | 'onChange'
> {
  /** Initial checked state */
  initialChecked?: boolean;
  /** Indeterminate (mixed) state */
  indeterminate?: boolean;
  /** Callback when checked state changes */
  onCheckedChange?: (checked: boolean) => void;
  /** Test ID for wrapper element */
  'data-testid'?: string;
}

export const Checkbox: React.FC<CheckboxProps> = ({
  initialChecked = false,
  indeterminate = false,
  onCheckedChange,
  className,
  disabled,
  'data-testid': dataTestId,
  ...inputProps
}) => {
  const inputRef = useRef<HTMLInputElement>(null);
  const [checked, setChecked] = useState(initialChecked);
  const [isIndeterminate, setIsIndeterminate] = useState(indeterminate);

  // Update indeterminate property on the input element
  useEffect(() => {
    if (inputRef.current) {
      inputRef.current.indeterminate = isIndeterminate;
    }
  }, [isIndeterminate]);

  // Sync with prop changes
  useEffect(() => {
    setIsIndeterminate(indeterminate);
  }, [indeterminate]);

  const handleChange = useCallback(
    (event: React.ChangeEvent<HTMLInputElement>) => {
      const newChecked = event.target.checked;
      setChecked(newChecked);
      setIsIndeterminate(false);
      onCheckedChange?.(newChecked);
    },
    [onCheckedChange]
  );

  return (
    <span className={cn('apg-checkbox', className)} data-testid={dataTestId}>
      <input
        ref={inputRef}
        type="checkbox"
        className="apg-checkbox-input"
        checked={checked}
        disabled={disabled}
        onChange={handleChange}
        {...inputProps}
      />
      <span className="apg-checkbox-control" aria-hidden="true">
        <span className="apg-checkbox-icon apg-checkbox-icon--check">
          <svg viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
            <path
              d="M10 3L4.5 8.5L2 6"
              stroke="currentColor"
              strokeWidth="2"
              strokeLinecap="round"
              strokeLinejoin="round"
            />
          </svg>
        </span>
        <span className="apg-checkbox-icon apg-checkbox-icon--indeterminate">
          <svg viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
            <path d="M2.5 6H9.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
          </svg>
        </span>
      </span>
    </span>
  );
};

export default Checkbox;

使い方

Example
import { Checkbox } from './Checkbox';

function App() {
  return (
    <form>
      {/* With wrapping label */}
      <label className="inline-flex items-center gap-2">
        <Checkbox
          name="terms"
          onCheckedChange={(checked) => console.log('Checked:', checked)}
        />
        I agree to the terms and conditions
      </label>

      {/* With separate label */}
      <label htmlFor="newsletter">Subscribe to newsletter</label>
      <Checkbox id="newsletter" name="newsletter" initialChecked={true} />

      {/* Indeterminate state for "select all" */}
      <label className="inline-flex items-center gap-2">
        <Checkbox indeterminate aria-label="Select all items" />
        Select all items
      </label>
    </form>
  );
}

API

プロパティ デフォルト 説明
initialChecked boolean false 初期チェック状態
indeterminate boolean false 不確定(mixed)状態かどうか
onCheckedChange (checked: boolean) => void - 状態変更時のコールバック
disabled boolean false 無効化するかどうか
name string - フォームフィールド名
value string - フォームフィールド値
id string - 外部ラベルとの関連付け用 ID

その他すべてのプロパティは、基礎となる <input> 要素に渡されます。

テスト

テストは、キーボード操作、ARIA属性、アクセシビリティ要件全般にわたってAPG準拠を検証します。Checkboxコンポーネントは2層テスト戦略を採用しています。

テスト戦略

ユニットテスト (Container API)

Astro Container APIを使用してコンポーネントのHTML出力を検証します。ブラウザを必要とせずに正しいテンプレートレンダリングを確認できます。

  • HTML構造と要素の階層
  • 初期属性値(checked、disabled、indeterminate)
  • フォーム連携属性(name、value、id)
  • CSSクラスの適用

E2Eテスト (Playwright)

実際のブラウザ環境でWeb Componentの動作を検証します。JavaScript実行が必要なインタラクションをカバーします。

  • クリック・キーボード操作
  • カスタムイベントのディスパッチ(checkedchange)
  • ユーザー操作によるindeterminate状態のクリア
  • ラベル関連付けとクリック動作
  • フォーカス管理とタブナビゲーション

テストカテゴリ

高優先度: HTML構造 (Unit)

テスト 説明
input type type="checkbox"のinputをレンダリング
checked attribute checked属性がinitialChecked propを反映
disabled attribute disabled propがtrueのときdisabled属性が設定される
data-indeterminate indeterminate状態用のdata属性が設定される
control aria-hidden 視覚的コントロール要素にaria-hidden="true"が設定される

高優先度: キーボード操作 (E2E)

テスト 説明
Space key チェックボックスの状態を切り替える
Tab navigation Tabでチェックボックス間のフォーカスを移動
Disabled Tab skip 無効なチェックボックスはTab順序でスキップされる
Disabled key ignore 無効なチェックボックスはキー入力を無視する

注意: Switchパターンとは異なり、Enterキーではチェックボックスが切り替わりません。

高優先度: クリック操作 (E2E)

テスト 説明
checked toggle クリックでチェック状態を切り替える
disabled click 無効なチェックボックスはクリック操作を防ぐ
indeterminate clear ユーザー操作でindeterminate状態がクリアされる
checkedchange event 正しいdetailでカスタムイベントがディスパッチされる

中優先度: フォーム連携 (Unit)

テスト 説明
name attribute フォームのname属性がレンダリングされる
value attribute フォームのvalue属性がレンダリングされる
id attribute ラベル関連付けのためにID属性が正しく設定される

中優先度: ラベル関連付け (E2E)

テスト 説明
Label click 外部ラベルをクリックするとチェックボックスが切り替わる
Wrapping label ラップするラベルをクリックするとチェックボックスが切り替わる

低優先度: CSSクラス (Unit)

テスト 説明
default class apg-checkboxクラスがラッパーに適用される
custom class カスタムクラスがコンポーネントクラスとマージされる

テストツール

完全なドキュメントについては、 testing-strategy.md (opens in new tab) を参照してください。

Checkbox.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import { Checkbox } from './Checkbox';

describe('Checkbox', () => {
  // 🔴 High Priority: DOM State
  describe('DOM State', () => {
    it('has role="checkbox"', () => {
      render(<Checkbox aria-label="Accept terms" />);
      expect(screen.getByRole('checkbox')).toBeInTheDocument();
    });

    it('is unchecked by default', () => {
      render(<Checkbox aria-label="Accept terms" />);
      const checkbox = screen.getByRole('checkbox');
      expect(checkbox).not.toBeChecked();
    });

    it('is checked when initialChecked=true', () => {
      render(<Checkbox aria-label="Accept terms" initialChecked />);
      const checkbox = screen.getByRole('checkbox');
      expect(checkbox).toBeChecked();
    });

    it('toggles checked state on click', async () => {
      const user = userEvent.setup();
      render(<Checkbox aria-label="Accept terms" />);
      const checkbox = screen.getByRole('checkbox');

      expect(checkbox).not.toBeChecked();
      await user.click(checkbox);
      expect(checkbox).toBeChecked();
      await user.click(checkbox);
      expect(checkbox).not.toBeChecked();
    });

    it('supports indeterminate property', () => {
      render(<Checkbox aria-label="Select all" indeterminate />);
      const checkbox = screen.getByRole('checkbox') as HTMLInputElement;
      expect(checkbox.indeterminate).toBe(true);
    });

    it('clears indeterminate on user interaction', async () => {
      const user = userEvent.setup();
      render(<Checkbox aria-label="Select all" indeterminate />);
      const checkbox = screen.getByRole('checkbox') as HTMLInputElement;

      expect(checkbox.indeterminate).toBe(true);
      await user.click(checkbox);
      expect(checkbox.indeterminate).toBe(false);
    });

    it('is disabled when disabled prop is set', () => {
      render(<Checkbox aria-label="Accept terms" disabled />);
      const checkbox = screen.getByRole('checkbox');
      expect(checkbox).toBeDisabled();
    });

    it('does not change state when clicked while disabled', async () => {
      const user = userEvent.setup();
      render(<Checkbox aria-label="Accept terms" disabled />);
      const checkbox = screen.getByRole('checkbox');

      expect(checkbox).not.toBeChecked();
      await user.click(checkbox);
      expect(checkbox).not.toBeChecked();
    });
  });

  // 🔴 High Priority: Label & Form
  describe('Label & Form', () => {
    it('sets accessible name via aria-label', () => {
      render(<Checkbox aria-label="Accept terms and conditions" />);
      expect(
        screen.getByRole('checkbox', { name: 'Accept terms and conditions' })
      ).toBeInTheDocument();
    });

    it('sets accessible name via external <label>', () => {
      render(
        <>
          <label htmlFor="terms-checkbox">Accept terms and conditions</label>
          <Checkbox id="terms-checkbox" />
        </>
      );
      expect(
        screen.getByRole('checkbox', { name: 'Accept terms and conditions' })
      ).toBeInTheDocument();
    });

    it('toggles checkbox when clicking external label', async () => {
      const user = userEvent.setup();
      render(
        <>
          <label htmlFor="terms-checkbox">Accept terms</label>
          <Checkbox id="terms-checkbox" />
        </>
      );
      const checkbox = screen.getByRole('checkbox');

      expect(checkbox).not.toBeChecked();
      await user.click(screen.getByText('Accept terms'));
      expect(checkbox).toBeChecked();
    });

    it('supports name attribute for form submission', () => {
      render(<Checkbox aria-label="Accept terms" name="terms" />);
      const checkbox = screen.getByRole('checkbox');
      expect(checkbox).toHaveAttribute('name', 'terms');
    });

    it('sets value attribute correctly', () => {
      render(<Checkbox aria-label="Red" name="color" value="red" />);
      const checkbox = screen.getByRole('checkbox');
      expect(checkbox).toHaveAttribute('value', 'red');
    });

    it('supports aria-describedby for description', () => {
      render(
        <>
          <Checkbox aria-label="Accept terms" aria-describedby="terms-desc" />
          <p id="terms-desc">Please read our terms carefully</p>
        </>
      );
      const checkbox = screen.getByRole('checkbox');
      expect(checkbox).toHaveAttribute('aria-describedby', 'terms-desc');
    });

    it('supports aria-labelledby for external label reference', () => {
      render(
        <>
          <span id="label-text">Accept terms</span>
          <Checkbox aria-labelledby="label-text" />
        </>
      );
      expect(screen.getByRole('checkbox', { name: 'Accept terms' })).toBeInTheDocument();
    });
  });

  // 🔴 High Priority: Keyboard
  describe('Keyboard', () => {
    it('toggles on Space key', async () => {
      const user = userEvent.setup();
      render(<Checkbox aria-label="Accept terms" />);
      const checkbox = screen.getByRole('checkbox');

      checkbox.focus();
      expect(checkbox).not.toBeChecked();
      await user.keyboard(' ');
      expect(checkbox).toBeChecked();
    });

    it('moves focus with Tab key', async () => {
      const user = userEvent.setup();
      render(
        <>
          <Checkbox aria-label="Checkbox 1" />
          <Checkbox aria-label="Checkbox 2" />
        </>
      );

      await user.tab();
      expect(screen.getByRole('checkbox', { name: 'Checkbox 1' })).toHaveFocus();
      await user.tab();
      expect(screen.getByRole('checkbox', { name: 'Checkbox 2' })).toHaveFocus();
    });

    it('skips disabled checkbox with Tab', async () => {
      const user = userEvent.setup();
      render(
        <>
          <Checkbox aria-label="Checkbox 1" />
          <Checkbox aria-label="Checkbox 2 (disabled)" disabled />
          <Checkbox aria-label="Checkbox 3" />
        </>
      );

      await user.tab();
      expect(screen.getByRole('checkbox', { name: 'Checkbox 1' })).toHaveFocus();
      await user.tab();
      expect(screen.getByRole('checkbox', { name: 'Checkbox 3' })).toHaveFocus();
    });

    it('ignores Space key when disabled', async () => {
      const user = userEvent.setup();
      render(<Checkbox aria-label="Accept terms" disabled />);
      const checkbox = screen.getByRole('checkbox');

      checkbox.focus();
      await user.keyboard(' ');
      expect(checkbox).not.toBeChecked();
    });
  });

  // 🟡 Medium Priority: Accessibility
  describe('Accessibility', () => {
    it('has no axe violations', async () => {
      const { container } = render(<Checkbox aria-label="Accept terms" />);
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has no axe violations when checked', async () => {
      const { container } = render(<Checkbox aria-label="Accept terms" initialChecked />);
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has no axe violations when indeterminate', async () => {
      const { container } = render(<Checkbox aria-label="Select all" indeterminate />);
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has no axe violations when disabled', async () => {
      const { container } = render(<Checkbox aria-label="Accept terms" disabled />);
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has no axe violations with external label', async () => {
      const { container } = render(
        <>
          <label htmlFor="terms">Accept terms</label>
          <Checkbox id="terms" />
        </>
      );
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });
  });

  // 🟡 Medium Priority: Callbacks
  describe('Callbacks', () => {
    it('calls onCheckedChange when state changes', async () => {
      const handleCheckedChange = vi.fn();
      const user = userEvent.setup();
      render(<Checkbox aria-label="Accept terms" onCheckedChange={handleCheckedChange} />);

      await user.click(screen.getByRole('checkbox'));
      expect(handleCheckedChange).toHaveBeenCalledWith(true);

      await user.click(screen.getByRole('checkbox'));
      expect(handleCheckedChange).toHaveBeenCalledWith(false);
    });

    it('calls onCheckedChange when indeterminate is cleared', async () => {
      const handleCheckedChange = vi.fn();
      const user = userEvent.setup();
      render(
        <Checkbox aria-label="Select all" indeterminate onCheckedChange={handleCheckedChange} />
      );

      await user.click(screen.getByRole('checkbox'));
      expect(handleCheckedChange).toHaveBeenCalledWith(true);
    });
  });

  // 🟢 Low Priority: HTML Attribute Inheritance
  describe('HTML Attribute Inheritance', () => {
    it('merges className correctly', () => {
      render(<Checkbox aria-label="Accept terms" className="custom-class" data-testid="wrapper" />);
      const wrapper = screen.getByTestId('wrapper');
      expect(wrapper).toHaveClass('custom-class');
      expect(wrapper).toHaveClass('apg-checkbox');
    });

    it('passes through data-* attributes', () => {
      render(<Checkbox aria-label="Accept terms" data-testid="custom-checkbox" />);
      expect(screen.getByTestId('custom-checkbox')).toBeInTheDocument();
    });

    it('sets id attribute', () => {
      render(<Checkbox aria-label="Accept terms" id="my-checkbox" />);
      const checkbox = screen.getByRole('checkbox');
      expect(checkbox).toHaveAttribute('id', 'my-checkbox');
    });

    it('sets required attribute', () => {
      render(<Checkbox aria-label="Accept terms" required />);
      const checkbox = screen.getByRole('checkbox');
      expect(checkbox).toBeRequired();
    });
  });
});

リソース