APG Patterns
English
English

Checkbox

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

デモ

Native 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
必須 はい
変更トリガー クリック、Space キー
リファレンス aria-checked / checked (opens in new tab)

indeterminate

true | false
必須 いいえ
変更トリガー 親子同期、ユーザー操作時に自動的にクリア

disabled

present | absent
必須 いいえ
変更トリガー プログラムによる変更

キーボードサポート

キー アクション
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(() => {
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
  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();
    });
  });
});

リソース