APG Patterns
English GitHub
English GitHub

Checkbox

ユーザーがセットから1つ以上のオプションを選択できるコントロール。

🤖 AI Implementation Guide

デモ

ネイティブ HTML

ネイティブ HTML を優先

このカスタムコンポーネントを使用する前に、ネイティブの <input type="checkbox"> 要素の使用を検討してください。 ネイティブ要素は組み込みのアクセシビリティを提供し、JavaScript なしで動作し、ARIA 属性を必要としません。

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

カスタム実装は、ネイティブ要素では提供できないカスタムスタイリングが必要な場合、またはチェックボックスグループの複雑な不確定状態管理が必要な場合にのみ使用してください。

ユースケース ネイティブ HTML カスタム実装
基本的なフォーム入力 推奨 不要
JavaScript 無効時のサポート ネイティブで動作 フォールバックが必要
不確定(混在)状態 JS プロパティのみ* 完全に制御可能
カスタムスタイリング 制限あり(ブラウザ依存) 完全に制御可能
フォーム送信 組み込み hidden input が必要

*ネイティブの indeterminate は JavaScript プロパティであり、HTML 属性ではありません。宣言的に設定することはできません。

アクセシビリティ

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.astro
---
/**
 * APG Checkbox Pattern - Astro Implementation
 *
 * A control that allows users to select one or more options.
 * Uses native input[type="checkbox"] with Web Components for enhanced interactivity.
 *
 * @see https://www.w3.org/WAI/ARIA/apg/patterns/checkbox/
 */

export interface Props {
  /** Initial checked state */
  initialChecked?: boolean;
  /** Indeterminate (mixed) state */
  indeterminate?: boolean;
  /** Whether the checkbox is disabled */
  disabled?: boolean;
  /** Form field name */
  name?: string;
  /** Form field value */
  value?: string;
  /** Checkbox id for label association */
  id?: string;
  /** Additional CSS class */
  class?: string;
}

const {
  initialChecked = false,
  indeterminate = false,
  disabled = false,
  name,
  value,
  id,
  class: className = '',
} = Astro.props;
---

<apg-checkbox
  class={`apg-checkbox ${className}`.trim()}
  data-indeterminate={indeterminate ? 'true' : undefined}
>
  <input
    type="checkbox"
    id={id}
    class="apg-checkbox-input"
    checked={initialChecked}
    disabled={disabled}
    name={name}
    value={value}
  />
  <span class="apg-checkbox-control" aria-hidden="true">
    <span class="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"
          stroke-width="2"
          stroke-linecap="round"
          stroke-linejoin="round"></path>
      </svg>
    </span>
    <span class="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" stroke-width="2" stroke-linecap="round"></path>
      </svg>
    </span>
  </span>
</apg-checkbox>

<script>
  class ApgCheckbox extends HTMLElement {
    private input: HTMLInputElement | null = null;
    private rafId: number | null = null;

    connectedCallback() {
      this.rafId = requestAnimationFrame(() => this.initialize());
    }

    private initialize() {
      this.rafId = null;
      this.input = this.querySelector('input[type="checkbox"]');

      if (!this.input) {
        console.warn('apg-checkbox: input element not found');
        return;
      }

      // Set initial indeterminate state if specified
      if (this.dataset.indeterminate === 'true') {
        this.input.indeterminate = true;
      }

      this.input.addEventListener('change', this.handleChange);
    }

    disconnectedCallback() {
      if (this.rafId !== null) {
        cancelAnimationFrame(this.rafId);
        this.rafId = null;
      }
      this.input?.removeEventListener('change', this.handleChange);
      this.input = null;
    }

    private handleChange = (event: Event) => {
      const target = event.target as HTMLInputElement;

      // Clear indeterminate on user interaction
      if (target.indeterminate) {
        target.indeterminate = false;
      }

      this.dispatchEvent(
        new CustomEvent('checkedchange', {
          detail: { checked: target.checked },
          bubbles: true,
        })
      );
    };
  }

  if (!customElements.get('apg-checkbox')) {
    customElements.define('apg-checkbox', ApgCheckbox);
  }
</script>

使い方

使用例
---
import Checkbox from './Checkbox.astro';
---

<form>
  <!-- With wrapping label -->
  <label class="inline-flex items-center gap-2">
    <Checkbox name="terms" />
    I agree to the terms and conditions
  </label>

  <!-- With separate label -->
  <label for="newsletter">Subscribe to newsletter</label>
  <Checkbox id="newsletter" name="newsletter" initialChecked={true} />

  <!-- Indeterminate state for "select all" -->
  <label class="inline-flex items-center gap-2">
    <Checkbox indeterminate />
    Select all items
  </label>
</form>

<script>
  // Listen for change events
  document.querySelectorAll('apg-checkbox').forEach((checkbox) => {
    checkbox.addEventListener('checkedchange', (e) => {
      console.log('Checked:', e.detail.checked);
    });
  });
</script>

API

プロパティ デフォルト 説明
initialChecked boolean false 初期のチェック状態
indeterminate boolean false チェックボックスが不確定(混在)状態かどうか
disabled boolean false チェックボックスが無効かどうか
name string - フォームフィールド名
value string - フォームフィールド値
id string - 外部ラベルとの関連付け用ID
class string "" 追加のCSSクラス

カスタムイベント

イベント 詳細 説明
checkedchange { checked: boolean } チェックボックスの状態が変更されたときに発火

テスト

テストは、キーボード操作、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.astro.ts
/**
 * Checkbox Astro Component Tests using Container API
 *
 * These tests verify the actual Checkbox.astro component output using Astro's Container API.
 * This ensures the component renders correct HTML structure and attributes.
 *
 * Note: Web Component behavior tests (click interaction, event dispatching) require
 * E2E testing with Playwright as they need a real browser environment.
 *
 * @see https://docs.astro.build/en/reference/container-reference/
 */
import { experimental_AstroContainer as AstroContainer } from 'astro/container';
import { describe, it, expect, beforeEach } from 'vitest';
import { JSDOM } from 'jsdom';
import Checkbox from './Checkbox.astro';

describe('Checkbox (Astro Container API)', () => {
  let container: AstroContainer;

  beforeEach(async () => {
    container = await AstroContainer.create();
  });

  // Helper to render and parse HTML
  async function renderCheckbox(
    props: {
      initialChecked?: boolean;
      indeterminate?: boolean;
      disabled?: boolean;
      name?: string;
      value?: string;
      id?: string;
      class?: string;
    } = {}
  ): Promise<Document> {
    const html = await container.renderToString(Checkbox, { props });
    const dom = new JSDOM(html);
    return dom.window.document;
  }

  // 🔴 High Priority: HTML Structure
  describe('HTML Structure', () => {
    it('renders apg-checkbox custom element wrapper', async () => {
      const doc = await renderCheckbox();
      const wrapper = doc.querySelector('apg-checkbox');
      expect(wrapper).not.toBeNull();
    });

    it('renders input with type="checkbox"', async () => {
      const doc = await renderCheckbox();
      const input = doc.querySelector('input[type="checkbox"]');
      expect(input).not.toBeNull();
    });

    it('renders checkbox control span with aria-hidden', async () => {
      const doc = await renderCheckbox();
      const control = doc.querySelector('.apg-checkbox-control');
      expect(control).not.toBeNull();
      expect(control?.getAttribute('aria-hidden')).toBe('true');
    });

    it('renders check icon inside control', async () => {
      const doc = await renderCheckbox();
      const checkIcon = doc.querySelector('.apg-checkbox-icon--check');
      expect(checkIcon).not.toBeNull();
      expect(checkIcon?.querySelector('svg')).not.toBeNull();
    });

    it('renders indeterminate icon inside control', async () => {
      const doc = await renderCheckbox();
      const indeterminateIcon = doc.querySelector('.apg-checkbox-icon--indeterminate');
      expect(indeterminateIcon).not.toBeNull();
      expect(indeterminateIcon?.querySelector('svg')).not.toBeNull();
    });
  });

  // 🔴 High Priority: Checked State
  describe('Checked State', () => {
    it('renders unchecked by default', async () => {
      const doc = await renderCheckbox();
      const input = doc.querySelector('input[type="checkbox"]') as HTMLInputElement | null;
      expect(input?.hasAttribute('checked')).toBe(false);
    });

    it('renders checked when initialChecked is true', async () => {
      const doc = await renderCheckbox({ initialChecked: true });
      const input = doc.querySelector('input[type="checkbox"]');
      expect(input?.hasAttribute('checked')).toBe(true);
    });
  });

  // 🔴 High Priority: Disabled State
  describe('Disabled State', () => {
    it('renders without disabled attribute by default', async () => {
      const doc = await renderCheckbox();
      const input = doc.querySelector('input[type="checkbox"]');
      expect(input?.hasAttribute('disabled')).toBe(false);
    });

    it('renders with disabled attribute when disabled is true', async () => {
      const doc = await renderCheckbox({ disabled: true });
      const input = doc.querySelector('input[type="checkbox"]');
      expect(input?.hasAttribute('disabled')).toBe(true);
    });
  });

  // 🟡 Medium Priority: Indeterminate State
  describe('Indeterminate State', () => {
    it('does not have data-indeterminate by default', async () => {
      const doc = await renderCheckbox();
      const wrapper = doc.querySelector('apg-checkbox');
      expect(wrapper?.hasAttribute('data-indeterminate')).toBe(false);
    });

    it('has data-indeterminate="true" when indeterminate is true', async () => {
      const doc = await renderCheckbox({ indeterminate: true });
      const wrapper = doc.querySelector('apg-checkbox');
      expect(wrapper?.getAttribute('data-indeterminate')).toBe('true');
    });
  });

  // 🟡 Medium Priority: Form Integration
  describe('Form Integration', () => {
    it('renders without name attribute by default', async () => {
      const doc = await renderCheckbox();
      const input = doc.querySelector('input[type="checkbox"]');
      expect(input?.hasAttribute('name')).toBe(false);
    });

    it('renders with name attribute when provided', async () => {
      const doc = await renderCheckbox({ name: 'terms' });
      const input = doc.querySelector('input[type="checkbox"]');
      expect(input?.getAttribute('name')).toBe('terms');
    });

    it('renders without value attribute by default', async () => {
      const doc = await renderCheckbox();
      const input = doc.querySelector('input[type="checkbox"]');
      expect(input?.hasAttribute('value')).toBe(false);
    });

    it('renders with value attribute when provided', async () => {
      const doc = await renderCheckbox({ value: 'accepted' });
      const input = doc.querySelector('input[type="checkbox"]');
      expect(input?.getAttribute('value')).toBe('accepted');
    });
  });

  // 🟡 Medium Priority: Label Association
  describe('Label Association', () => {
    it('renders without id attribute by default', async () => {
      const doc = await renderCheckbox();
      const input = doc.querySelector('input[type="checkbox"]');
      expect(input?.hasAttribute('id')).toBe(false);
    });

    it('renders with id attribute when provided for external label association', async () => {
      const doc = await renderCheckbox({ id: 'my-checkbox' });
      const input = doc.querySelector('input[type="checkbox"]');
      expect(input?.getAttribute('id')).toBe('my-checkbox');
    });
  });

  // 🟢 Low Priority: CSS Classes
  describe('CSS Classes', () => {
    it('has default apg-checkbox class on wrapper', async () => {
      const doc = await renderCheckbox();
      const wrapper = doc.querySelector('apg-checkbox');
      expect(wrapper?.classList.contains('apg-checkbox')).toBe(true);
    });

    it('has apg-checkbox-input class on input', async () => {
      const doc = await renderCheckbox();
      const input = doc.querySelector('input');
      expect(input?.classList.contains('apg-checkbox-input')).toBe(true);
    });

    it('appends custom class to wrapper', async () => {
      const doc = await renderCheckbox({ class: 'custom-class' });
      const wrapper = doc.querySelector('apg-checkbox');
      expect(wrapper?.classList.contains('apg-checkbox')).toBe(true);
      expect(wrapper?.classList.contains('custom-class')).toBe(true);
    });
  });

  // 🟢 Low Priority: Combined States
  describe('Combined States', () => {
    it('renders checked and disabled together', async () => {
      const doc = await renderCheckbox({ initialChecked: true, disabled: true });
      const input = doc.querySelector('input[type="checkbox"]');
      expect(input?.hasAttribute('checked')).toBe(true);
      expect(input?.hasAttribute('disabled')).toBe(true);
    });

    it('renders with all form attributes', async () => {
      const doc = await renderCheckbox({
        id: 'terms-checkbox',
        name: 'terms',
        value: 'accepted',
        initialChecked: true,
      });
      const input = doc.querySelector('input[type="checkbox"]');
      expect(input?.getAttribute('id')).toBe('terms-checkbox');
      expect(input?.getAttribute('name')).toBe('terms');
      expect(input?.getAttribute('value')).toBe('accepted');
      expect(input?.hasAttribute('checked')).toBe(true);
    });
  });
});

リソース