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ハイコントラストモードでのアクセシビリティのためにシステムカラーを使用
参考資料
ソースコード
<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> 使い方
<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 | カスタムクラスがコンポーネントクラスとマージされる |
テストツール
- Vitest (opens in new tab) - ユニットテスト用テストランナー
- Astro Container API (opens in new tab) - ユニットテスト用サーバーサイドコンポーネントレンダリング
- Playwright (opens in new tab) - E2Eテスト用ブラウザ自動化
- Testing Library (opens in new tab) - フレームワーク固有のテストユーティリティ(React、Vue、Svelte)
完全なドキュメントについては、 testing-strategy.md (opens in new tab) を参照してください。
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();
});
});
}); リソース
- WAI-ARIA APG: Checkbox パターン (opens in new tab)
- MDN: <input type="checkbox"> (opens in new tab)
- AI Implementation Guide (llm.md) (opens in new tab) - ARIA specs, keyboard support, test checklist