Button
role="button" を使用してアクションやイベントをトリガーする要素。
デモ
ネイティブ HTML
ネイティブ HTML を優先
このカスタムコンポーネントを使用する前に、ネイティブの <button> 要素の使用を検討してください。 ネイティブ要素は組み込みのアクセシビリティ、キーボードサポート、フォーム連携を提供し、JavaScript なしで動作します。
<button type="button" onclick="handleClick()">Click me</button>
<!-- For form submission -->
<button type="submit">Submit</button>
<!-- Disabled state -->
<button type="button" disabled>Disabled</button> カスタムの role="button" 実装は、教育目的のみ、またはレガシーの制約により非ボタン要素(<div>、<span> など)をボタンとして動作させる必要がある場合にのみ使用してください。
| 機能 | ネイティブ | カスタム role="button" |
|---|---|---|
| キーボード操作(Space/Enter) | 組み込み | JavaScript が必要 |
| フォーカス管理 | 自動 | tabindex が必要 |
disabled 属性 | 組み込み | aria-disabled + JS が必要 |
| フォーム送信 | 組み込み | サポートなし |
type 属性 | submit/button/reset | サポートなし |
| JavaScript なしでの動作 | 動作する | 動作しない |
| スクリーンリーダーの読み上げ | 自動 | ARIA が必要 |
| Space キーでのスクロール防止 | 自動 | preventDefault() が必要 |
このカスタム実装は、APG パターンを実証するための教育目的で提供されています。本番環境では、常にネイティブの <button> 要素を優先してください。
アクセシビリティ
WAI-ARIA ロール
| ロール | 対象要素 | 説明 |
|---|---|---|
button | <button> または role="button" を持つ要素 | 要素をボタンウィジェットとして識別します。ネイティブの <button> は暗黙的にこのロールを持ちます。 |
この実装は教育目的で <code><span role="button"></code> を使用しています。本番環境では、ネイティブの <code><button></code> 要素を優先してください。
WAI-ARIA プロパティ
tabindex (カスタムボタン要素をキーボードナビゲーションでフォーカス可能にします。ネイティブの <code><button></code> はデフォルトでフォーカス可能です。無効時は -1 に設定します。)
カスタムボタン要素をキーボードナビゲーションでフォーカス可能にします。ネイティブの <button> はデフォルトでフォーカス可能です。無効時は -1 に設定します。
| 値 | "0" | "-1" |
| 必須 | はい(カスタム実装の場合) |
aria-disabled (ボタンがインタラクティブでなく、アクティブ化できないことを示します。ネイティブの <code><button disabled></code> はこれを自動的に処理します。)
ボタンがインタラクティブでなく、アクティブ化できないことを示します。ネイティブの <button disabled> はこれを自動的に処理します。
| 値 | "true" | "false" |
| 必須 | いいえ(無効時のみ) |
aria-label (アイコンのみのボタンや、表示テキストが不十分な場合にアクセシブルな名前を提供します。)
アイコンのみのボタンや、表示テキストが不十分な場合にアクセシブルな名前を提供します。
| 値 | アクションを説明するテキスト文字列 |
| 必須 | いいえ(アイコンのみのボタンの場合のみ) |
キーボードサポート
| キー | アクション |
|---|---|
| Space | ボタンをアクティブ化 |
| Enter | ボタンをアクティブ化 |
| Tab | 次のフォーカス可能な要素にフォーカスを移動 |
| Shift + Tab | 前のフォーカス可能な要素にフォーカスを移動 |
重要: SpaceキーとEnterキーの両方がボタンをアクティブ化します。これはEnterキーのみに応答するリンクとは異なります。カスタム実装では、ページスクロールを防止するためにSpaceキーで event.preventDefault() を呼び出す必要があります。
アクセシブルな名前
ボタンにはアクセシブルな名前が必要です。次の方法で提供できます:
- テキストコンテンツ(推奨) - ボタン内の表示テキスト
- aria-label - アイコンのみのボタンに対する非表示のラベルを提供
- aria-labelledby - 外部要素をラベルとして参照
フォーカススタイル
この実装は明確なフォーカスインジケーターを提供します:
- フォーカスリング - キーボードでフォーカスされた際に表示されるアウトライン
- カーソルスタイル - インタラクティブであることを示すポインターカーソル
- 無効時の外観 - 無効時は不透明度を下げ、not-allowedカーソルを表示
Button と Toggle Button
このパターンは単純なアクションボタン用です。押された状態と押されていない状態を切り替えるボタンについては、
aria-pressed を使用する
Toggle Button パターン
を参照してください。
参考資料
ソースコード
import { cn } from '@/lib/utils';
import { useCallback, useRef } from 'react';
export interface ButtonProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, 'onClick'> {
/** Click handler */
onClick?: (event: React.MouseEvent | React.KeyboardEvent) => void;
/** Disabled state */
disabled?: boolean;
/** Button content */
children: React.ReactNode;
}
/**
* Custom Button using role="button"
*
* This component demonstrates how to implement a custom button using ARIA.
* For production use, prefer the native <button> element which provides
* all accessibility features automatically.
*
* @see https://www.w3.org/WAI/ARIA/apg/patterns/button/
*/
export const Button: React.FC<ButtonProps> = ({
onClick,
disabled = false,
className,
children,
...spanProps
}) => {
// Track if Space was pressed on this element (for keyup activation)
const spacePressed = useRef(false);
const handleClick = useCallback(
(event: React.MouseEvent<HTMLSpanElement>) => {
if (disabled) {
event.preventDefault();
event.stopPropagation();
return;
}
onClick?.(event);
},
[disabled, onClick]
);
const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLSpanElement>) => {
// Ignore if composing (IME input) or already handled
if (event.nativeEvent.isComposing || event.defaultPrevented) {
return;
}
if (disabled) {
return;
}
// Space: prevent scroll on keydown, activate on keyup (native button behavior)
if (event.key === ' ') {
event.preventDefault();
spacePressed.current = true;
return;
}
// Enter: activate on keydown (native button behavior)
if (event.key === 'Enter') {
event.preventDefault();
event.currentTarget.click();
}
},
[disabled]
);
const handleKeyUp = useCallback(
(event: React.KeyboardEvent<HTMLSpanElement>) => {
// Space: activate on keyup if Space was pressed on this element
if (event.key === ' ' && spacePressed.current) {
spacePressed.current = false;
if (disabled) {
return;
}
event.preventDefault();
event.currentTarget.click();
}
},
[disabled]
);
return (
<span
{...spanProps}
role="button"
tabIndex={disabled ? -1 : 0}
aria-disabled={disabled ? 'true' : undefined}
className={cn('apg-button', className)}
onClick={handleClick}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
>
{children}
</span>
);
};
export default Button; 使い方
import { Button } from './Button';
function App() {
return (
<div>
{/* 基本的なボタン */}
<Button onClick={() => console.log('Clicked!')}>
Click me
</Button>
{/* 無効なボタン */}
<Button disabled onClick={() => alert('Should not fire')}>
Disabled
</Button>
{/* アイコンボタン用のaria-label */}
<Button onClick={handleSettings} aria-label="Settings">
<SettingsIcon />
</Button>
</div>
);
} API
| プロパティ | 型 | デフォルト | 説明 |
|---|---|---|---|
onClick | (event) => void | - | クリック/Space/Enterイベントハンドラ |
disabled | boolean | false | ボタンが無効かどうか |
children | ReactNode | - | ボタンのコンテンツ |
その他のプロパティは、内部の <span> 要素に渡されます。
テスト
テストは、キーボード操作、ARIA属性、アクセシビリティ要件全体にわたってAPG準拠を検証します。Buttonコンポーネントは2層のテスト戦略を採用しています。
テスト戦略
ユニットテスト(Testing Library)
フレームワーク固有のテストライブラリを使用してコンポーネントのレンダリング出力を検証します。これらのテストは正しいHTML構造とARIA属性を確認します。
- ARIA属性(role="button"、tabindex)
- キーボード操作(SpaceキーとEnterキーでのアクティブ化)
- 無効状態の処理
- jest-axeによるアクセシビリティ検証
E2Eテスト(Playwright)
すべてのフレームワークで実際のブラウザ環境でコンポーネントの動作を検証します。これらのテストはインタラクションとフレームワーク間の一貫性をカバーします。
- ライブブラウザでのARIA構造
- キーボードでのアクティブ化(SpaceキーとEnterキー)
- クリック操作の動作
- 無効状態のインタラクション
- axe-coreによるアクセシビリティスキャン
- フレームワーク間の一貫性チェック
重要:
SpaceキーとEnterキーの両方がボタンをアクティブ化します。これはEnterキーのみに応答するリンクとは異なります。
カスタム実装では、ページスクロールを防止するためにSpaceキーで event.preventDefault() を呼び出す必要があります。
テストカテゴリ
高優先度: APGキーボード操作(Unit + E2E)
| テスト | 説明 |
|---|---|
Space key | ボタンをアクティブ化 |
Enter key | ボタンをアクティブ化 |
Space preventDefault | Spaceキー押下時のページスクロールを防止 |
IME composing | IME入力中はSpace/Enterキーを無視 |
Tab navigation | Tabキーでボタン間のフォーカスを移動 |
Disabled Tab skip | 無効なボタンはTabオーダーでスキップされる |
高優先度: ARIA属性(Unit + E2E)
| テスト | 説明 |
|---|---|
role="button" | 要素がbuttonロールを持つ |
tabindex="0" | 要素がキーボードでフォーカス可能 |
aria-disabled | 無効時に "true" に設定 |
tabindex="-1" | 無効時にTabオーダーから除外するために設定 |
Accessible name | テキストコンテンツ、aria-label、またはaria-labelledbyから名前を取得 |
高優先度: クリック動作(Unit + E2E)
| テスト | 説明 |
|---|---|
Click activation | クリックでボタンがアクティブ化される |
Disabled click | 無効なボタンはクリックイベントを無視 |
Disabled Space | 無効なボタンはSpaceキーを無視 |
Disabled Enter | 無効なボタンはEnterキーを無視 |
中優先度: アクセシビリティ(Unit + E2E)
| テスト | 説明 |
|---|---|
axe violations | WCAG 2.1 AAの違反がない(jest-axeによる) |
disabled axe | 無効状態での違反がない |
aria-label axe | aria-label使用時の違反がない |
低優先度: プロパティ & 属性(Unit)
| テスト | 説明 |
|---|---|
className | カスタムクラスが適用される |
data-* attributes | カスタムdata属性が渡される |
children | 子コンテンツがレンダリングされる |
低優先度: フレームワーク間の一貫性(E2E)
| テスト | 説明 |
|---|---|
All frameworks have buttons | React、Vue、Svelte、Astroすべてがカスタムボタン要素をレンダリング |
Same button count | すべてのフレームワークで同じ数のボタンをレンダリング |
Consistent ARIA | すべてのフレームワークで一貫したARIA構造 |
テストツール
- Vitest (opens in new tab) - ユニットテストランナー
- Testing Library (opens in new tab) - フレームワーク別テストユーティリティ(React、Vue、Svelte)
- Playwright (opens in new tab) - E2Eテスト用ブラウザ自動化
- axe-core/playwright (opens in new tab) - E2Eでの自動アクセシビリティテスト
詳細なドキュメントは testing-strategy.md (opens in new tab) を参照してください。
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 { Button } from './Button';
describe('Button', () => {
// 🔴 High Priority: APG ARIA Attributes
describe('APG ARIA Attributes', () => {
it('has role="button" on element', () => {
render(<Button>Click me</Button>);
expect(screen.getByRole('button')).toBeInTheDocument();
});
it('has tabindex="0" on element', () => {
render(<Button>Click me</Button>);
const button = screen.getByRole('button');
expect(button).toHaveAttribute('tabindex', '0');
});
it('has accessible name from text content', () => {
render(<Button>Submit Form</Button>);
expect(screen.getByRole('button', { name: 'Submit Form' })).toBeInTheDocument();
});
it('has accessible name from aria-label', () => {
render(
<Button aria-label="Close dialog">
<span aria-hidden="true">×</span>
</Button>
);
expect(screen.getByRole('button', { name: 'Close dialog' })).toBeInTheDocument();
});
it('has accessible name from aria-labelledby', () => {
render(
<>
<span id="btn-label">Save changes</span>
<Button aria-labelledby="btn-label">Save</Button>
</>
);
expect(screen.getByRole('button', { name: 'Save changes' })).toBeInTheDocument();
});
it('sets aria-disabled="true" when disabled', () => {
render(<Button disabled>Disabled button</Button>);
const button = screen.getByRole('button');
expect(button).toHaveAttribute('aria-disabled', 'true');
});
it('sets tabindex="-1" when disabled', () => {
render(<Button disabled>Disabled button</Button>);
const button = screen.getByRole('button');
expect(button).toHaveAttribute('tabindex', '-1');
});
it('does not have aria-disabled when not disabled', () => {
render(<Button>Active button</Button>);
const button = screen.getByRole('button');
expect(button).not.toHaveAttribute('aria-disabled');
});
it('does not have aria-pressed (not a toggle button)', () => {
render(<Button>Not a toggle</Button>);
const button = screen.getByRole('button');
expect(button).not.toHaveAttribute('aria-pressed');
});
});
// 🔴 High Priority: APG Keyboard Interaction
describe('APG Keyboard Interaction', () => {
it('calls onClick on Space key', async () => {
const handleClick = vi.fn();
const user = userEvent.setup();
render(<Button onClick={handleClick}>Click me</Button>);
const button = screen.getByRole('button');
button.focus();
await user.keyboard(' ');
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('calls onClick on Enter key', async () => {
const handleClick = vi.fn();
const user = userEvent.setup();
render(<Button onClick={handleClick}>Click me</Button>);
const button = screen.getByRole('button');
button.focus();
await user.keyboard('{Enter}');
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('does not scroll page on Space key', async () => {
const handleClick = vi.fn();
render(<Button onClick={handleClick}>Click me</Button>);
const button = screen.getByRole('button');
button.focus();
const spaceEvent = new KeyboardEvent('keydown', {
key: ' ',
bubbles: true,
cancelable: true,
});
const preventDefaultSpy = vi.spyOn(spaceEvent, 'preventDefault');
button.dispatchEvent(spaceEvent);
expect(preventDefaultSpy).toHaveBeenCalled();
});
it('does not call onClick when event.isComposing is true', () => {
const handleClick = vi.fn();
render(<Button onClick={handleClick}>Click me</Button>);
const button = screen.getByRole('button');
const event = new KeyboardEvent('keydown', {
key: 'Enter',
bubbles: true,
});
Object.defineProperty(event, 'isComposing', { value: true });
button.dispatchEvent(event);
expect(handleClick).not.toHaveBeenCalled();
});
it('does not call onClick when event.defaultPrevented is true', () => {
const handleClick = vi.fn();
render(<Button onClick={handleClick}>Click me</Button>);
const button = screen.getByRole('button');
const event = new KeyboardEvent('keydown', {
key: 'Enter',
bubbles: true,
cancelable: true,
});
event.preventDefault();
button.dispatchEvent(event);
expect(handleClick).not.toHaveBeenCalled();
});
it('calls onClick on click', async () => {
const handleClick = vi.fn();
const user = userEvent.setup();
render(<Button onClick={handleClick}>Click me</Button>);
await user.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('does not call onClick when disabled (click)', async () => {
const handleClick = vi.fn();
const user = userEvent.setup();
render(
<Button onClick={handleClick} disabled>
Disabled
</Button>
);
await user.click(screen.getByRole('button'));
expect(handleClick).not.toHaveBeenCalled();
});
it('does not call onClick when disabled (Space key)', async () => {
const handleClick = vi.fn();
const user = userEvent.setup();
render(
<Button onClick={handleClick} disabled>
Disabled
</Button>
);
const button = screen.getByRole('button');
button.focus();
await user.keyboard(' ');
expect(handleClick).not.toHaveBeenCalled();
});
it('does not call onClick when disabled (Enter key)', async () => {
const handleClick = vi.fn();
const user = userEvent.setup();
render(
<Button onClick={handleClick} disabled>
Disabled
</Button>
);
const button = screen.getByRole('button');
button.focus();
await user.keyboard('{Enter}');
expect(handleClick).not.toHaveBeenCalled();
});
});
// 🔴 High Priority: Focus Management
describe('Focus Management', () => {
it('is focusable via Tab', async () => {
const user = userEvent.setup();
render(<Button>Click me</Button>);
await user.tab();
expect(screen.getByRole('button')).toHaveFocus();
});
it('is not focusable when disabled', async () => {
const user = userEvent.setup();
render(
<>
<button>Before</button>
<Button disabled>Disabled button</Button>
<button>After</button>
</>
);
await user.tab();
expect(screen.getByRole('button', { name: 'Before' })).toHaveFocus();
await user.tab();
expect(screen.getByRole('button', { name: 'After' })).toHaveFocus();
});
it('moves focus between multiple buttons with Tab', async () => {
const user = userEvent.setup();
render(
<>
<Button>Button 1</Button>
<Button>Button 2</Button>
<Button>Button 3</Button>
</>
);
await user.tab();
expect(screen.getByRole('button', { name: 'Button 1' })).toHaveFocus();
await user.tab();
expect(screen.getByRole('button', { name: 'Button 2' })).toHaveFocus();
await user.tab();
expect(screen.getByRole('button', { name: 'Button 3' })).toHaveFocus();
});
});
// 🟡 Medium Priority: Accessibility
describe('Accessibility', () => {
it('has no axe violations', async () => {
const { container } = render(<Button>Click me</Button>);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no axe violations when disabled', async () => {
const { container } = render(<Button disabled>Disabled button</Button>);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no axe violations with aria-label', async () => {
const { container } = render(<Button aria-label="Close">×</Button>);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
// 🟢 Low Priority: HTML Attribute Inheritance
describe('HTML Attribute Inheritance', () => {
it('applies className to element', () => {
render(<Button className="custom-button">Styled</Button>);
const button = screen.getByRole('button');
expect(button).toHaveClass('custom-button');
});
it('passes through data-* attributes', () => {
render(
<Button data-testid="my-button" data-custom="value">
Button
</Button>
);
const button = screen.getByTestId('my-button');
expect(button).toHaveAttribute('data-custom', 'value');
});
it('sets id attribute', () => {
render(<Button id="main-button">Main</Button>);
const button = screen.getByRole('button');
expect(button).toHaveAttribute('id', 'main-button');
});
});
}); リソース
- WAI-ARIA APG: Button パターン (opens in new tab)
- MDN: <button> element (opens in new tab)
- AI Implementation Guide (llm.md) (opens in new tab) - ARIA specs, keyboard support, test checklist