APG Patterns
English GitHub
English GitHub

Checkbox

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

🤖 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.vue
<template>
  <span class="apg-checkbox" v-bind="wrapperAttrs">
    <input
      ref="inputRef"
      type="checkbox"
      class="apg-checkbox-input"
      :id="props.id"
      :checked="checked"
      :disabled="props.disabled"
      :name="props.name"
      :value="props.value"
      v-bind="inputAttrs"
      @change="handleChange"
    />
    <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"
          />
        </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" />
        </svg>
      </span>
    </span>
  </span>
</template>

<script setup lang="ts">
import { computed, onMounted, ref, useAttrs, watch } from 'vue';

defineOptions({
  inheritAttrs: false,
});

export interface CheckboxProps {
  /** 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;
  /** ID for external label association */
  id?: string;
  /** Callback fired when checked state changes */
  onCheckedChange?: (checked: boolean) => void;
}

const props = withDefaults(defineProps<CheckboxProps>(), {
  initialChecked: false,
  indeterminate: false,
  disabled: false,
  name: undefined,
  value: undefined,
  id: undefined,
  onCheckedChange: undefined,
});

const attrs = useAttrs() as {
  class?: string;
  'data-testid'?: string;
  'aria-describedby'?: string;
  'aria-label'?: string;
  'aria-labelledby'?: string;
  id?: string;
  [key: string]: unknown;
};

const emit = defineEmits<{
  change: [checked: boolean];
}>();

const inputRef = ref<HTMLInputElement | null>(null);
const checked = ref(props.initialChecked);
const isIndeterminate = ref(props.indeterminate);

// Separate attrs for wrapper and input
const wrapperAttrs = computed(() => {
  return {
    class: attrs.class,
    'data-testid': attrs['data-testid'],
  };
});

const inputAttrs = computed(() => {
  const { class: _className, 'data-testid': _testId, ...rest } = attrs;
  return rest;
});

// Update indeterminate property on the input element
const updateIndeterminate = () => {
  if (inputRef.value) {
    inputRef.value.indeterminate = isIndeterminate.value;
  }
};

onMounted(() => {
  updateIndeterminate();
});

watch(
  () => props.indeterminate,
  (newValue) => {
    isIndeterminate.value = newValue;
  }
);

watch(isIndeterminate, () => {
  updateIndeterminate();
});

const handleChange = (event: Event) => {
  const target = event.target as HTMLInputElement;
  const newChecked = target.checked;
  checked.value = newChecked;
  isIndeterminate.value = false;
  props.onCheckedChange?.(newChecked);
  emit('change', newChecked);
};
</script>

使い方

Example
<script setup>
import Checkbox from './Checkbox.vue';

function handleChange(checked) {
  console.log('Checked:', checked);
}
</script>

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

    <!-- With separate label -->
    <label for="newsletter">Subscribe to newsletter</label>
    <Checkbox id="newsletter" name="newsletter" :initial-checked="true" />

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

API

プロパティ デフォルト 説明
initialChecked boolean false 初期のチェック状態
indeterminate boolean false チェックボックスが不確定(混合)状態かどうか
disabled boolean false チェックボックスが無効かどうか
name string - フォームフィールド名
value string - フォームフィールド値

Events

イベント ペイロード 説明
@change boolean 状態が変更されたときに発火

その他のプロパティは、基盤となる <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.vue.ts
import { render, screen } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import Checkbox from './Checkbox.vue';

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

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

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

    it('toggles checked state on click', async () => {
      const user = userEvent.setup();
      render(Checkbox, {
        attrs: { '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, {
        props: { indeterminate: true },
        attrs: { 'aria-label': 'Select all' },
      });
      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, {
        props: { indeterminate: true },
        attrs: { 'aria-label': 'Select all' },
      });
      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, {
        props: { disabled: true },
        attrs: { 'aria-label': 'Accept terms' },
      });
      const checkbox = screen.getByRole('checkbox');
      expect(checkbox).toBeDisabled();
    });

    it('does not change state when clicked while disabled', async () => {
      const user = userEvent.setup();
      render(Checkbox, {
        props: { disabled: true },
        attrs: { 'aria-label': 'Accept terms' },
      });
      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, {
        attrs: { 'aria-label': 'Accept terms and conditions' },
      });
      expect(
        screen.getByRole('checkbox', { name: 'Accept terms and conditions' })
      ).toBeInTheDocument();
    });

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

    it('toggles checkbox when clicking external label', async () => {
      const user = userEvent.setup();
      render({
        components: { Checkbox },
        template: `
          <div>
            <label for="terms-checkbox">Accept terms</label>
            <Checkbox id="terms-checkbox" />
          </div>
        `,
      });
      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, {
        props: { name: 'terms' },
        attrs: { 'aria-label': 'Accept terms' },
      });
      const checkbox = screen.getByRole('checkbox');
      expect(checkbox).toHaveAttribute('name', 'terms');
    });

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

  // 🔴 High Priority: Keyboard
  describe('Keyboard', () => {
    it('toggles on Space key', async () => {
      const user = userEvent.setup();
      render(Checkbox, {
        attrs: { '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({
        components: { Checkbox },
        template: `
          <div>
            <Checkbox aria-label="Checkbox 1" />
            <Checkbox aria-label="Checkbox 2" />
          </div>
        `,
      });

      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({
        components: { Checkbox },
        template: `
          <div>
            <Checkbox aria-label="Checkbox 1" />
            <Checkbox aria-label="Checkbox 2 (disabled)" disabled />
            <Checkbox aria-label="Checkbox 3" />
          </div>
        `,
      });

      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, {
        props: { disabled: true },
        attrs: { 'aria-label': 'Accept terms' },
      });
      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, {
        attrs: { 'aria-label': 'Accept terms' },
      });
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

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

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

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

    it('has no axe violations with external label', async () => {
      const { container } = render({
        components: { Checkbox },
        template: `
          <div>
            <label for="terms">Accept terms</label>
            <Checkbox id="terms" />
          </div>
        `,
      });
      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, {
        props: { onCheckedChange: handleCheckedChange },
        attrs: { 'aria-label': 'Accept terms' },
      });

      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, {
        props: { indeterminate: true, onCheckedChange: handleCheckedChange },
        attrs: { 'aria-label': 'Select all' },
      });

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

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

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

リソース