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 パターン
を参照してください。
参考資料
ソースコード
---
/**
* APG Button Pattern - Astro Implementation
*
* A custom button using role="button" on a non-button element.
* Uses Web Components for enhanced interactivity.
*
* Note: This is a custom implementation for educational purposes.
* For production use, prefer native <button> elements.
*
* @see https://www.w3.org/WAI/ARIA/apg/patterns/button/
*/
export interface Props {
/** Whether the button is disabled */
disabled?: boolean;
/** Additional CSS class */
class?: string;
}
const { disabled = false, class: className = '' } = Astro.props;
---
<apg-button>
<span
role="button"
tabindex={disabled ? -1 : 0}
aria-disabled={disabled ? 'true' : undefined}
class={`apg-button ${className}`.trim()}
>
<slot />
</span>
</apg-button>
<script>
class ApgButton extends HTMLElement {
private spanElement: HTMLSpanElement | null = null;
private rafId: number | null = null;
// Track if Space was pressed on this element (for keyup activation)
private spacePressed = false;
connectedCallback() {
this.rafId = requestAnimationFrame(() => this.initialize());
}
private initialize() {
this.rafId = null;
this.spanElement = this.querySelector('span[role="button"]');
if (!this.spanElement) {
console.warn('apg-button: span element not found');
return;
}
this.spanElement.addEventListener('click', this.handleClick);
this.spanElement.addEventListener('keydown', this.handleKeyDown);
this.spanElement.addEventListener('keyup', this.handleKeyUp);
}
disconnectedCallback() {
if (this.rafId !== null) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
this.spanElement?.removeEventListener('click', this.handleClick);
this.spanElement?.removeEventListener('keydown', this.handleKeyDown);
this.spanElement?.removeEventListener('keyup', this.handleKeyUp);
this.spanElement = null;
}
private isDisabled(): boolean {
return this.spanElement?.getAttribute('aria-disabled') === 'true';
}
private handleClick = (event: MouseEvent) => {
if (this.isDisabled()) {
event.preventDefault();
event.stopPropagation();
return;
}
this.dispatchEvent(
new CustomEvent('button-activate', {
bubbles: true,
})
);
};
private handleKeyDown = (event: KeyboardEvent) => {
// Ignore if composing (IME input) or already handled
if (event.isComposing || event.defaultPrevented) {
return;
}
if (this.isDisabled()) {
return;
}
// Space: prevent scroll on keydown, activate on keyup (native button behavior)
if (event.key === ' ') {
event.preventDefault();
this.spacePressed = true;
return;
}
// Enter: activate on keydown (native button behavior)
if (event.key === 'Enter') {
event.preventDefault();
this.spanElement?.click();
}
};
private handleKeyUp = (event: KeyboardEvent) => {
// Space: activate on keyup if Space was pressed on this element
if (event.key === ' ' && this.spacePressed) {
this.spacePressed = false;
if (this.isDisabled()) {
return;
}
event.preventDefault();
this.spanElement?.click();
}
};
}
if (!customElements.get('apg-button')) {
customElements.define('apg-button', ApgButton);
}
</script> 使い方
---
import Button from './Button.astro';
---
<!-- 基本的なボタン -->
<Button>Click me</Button>
<!-- 無効なボタン -->
<Button disabled>Disabled</Button>
<!-- アイコンボタン用のaria-label -->
<Button aria-label="Settings">
<SettingsIcon />
</Button>
<!-- カスタムイベントリスナー (JavaScript) -->
<Button id="my-button">Interactive Button</Button>
<script>
document.getElementById('my-button')
?.addEventListener('button-activate', (e) => {
console.log('Button activated');
});
</script> API
| プロパティ | 型 | デフォルト | 説明 |
|---|---|---|---|
disabled | boolean | false | ボタンが無効かどうか |
class | string | '' | 追加の CSS クラス |
カスタムイベント
| イベント | 詳細 | 説明 |
|---|---|---|
button-activate | - | ボタンがアクティブ化されたとき(クリック、Space、または Enter)に発火 |
テスト
テストは、キーボード操作、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) を参照してください。
/**
* Button Web Component Tests
*
* Note: These are unit tests for the Web Component class.
* Full keyboard navigation and focus management tests require E2E testing
* with Playwright due to jsdom limitations with focus events.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
describe('Button (Web Component)', () => {
let container: HTMLElement;
// Web Component class extracted for testing
class TestApgButton extends HTMLElement {
private spanElement: HTMLSpanElement | null = null;
private rafId: number | null = null;
connectedCallback() {
this.rafId = requestAnimationFrame(() => this.initialize());
}
private initialize() {
this.rafId = null;
this.spanElement = this.querySelector('span[role="button"]');
if (!this.spanElement) {
return;
}
this.spanElement.addEventListener('click', this.handleClick);
this.spanElement.addEventListener('keydown', this.handleKeyDown);
}
disconnectedCallback() {
if (this.rafId !== null) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
this.spanElement?.removeEventListener('click', this.handleClick);
this.spanElement?.removeEventListener('keydown', this.handleKeyDown);
}
private handleClick = (event: MouseEvent) => {
if (this.isDisabled()) {
event.preventDefault();
return;
}
this.activate(event);
};
private handleKeyDown = (event: KeyboardEvent) => {
if (event.isComposing || event.defaultPrevented) {
return;
}
if (this.isDisabled()) {
return;
}
// Button activates on both Space and Enter (unlike links)
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault(); // Prevent Space from scrolling
this.activate(event);
}
};
private activate(_event: Event) {
this.dispatchEvent(
new CustomEvent('button-activate', {
bubbles: true,
})
);
}
private isDisabled(): boolean {
return this.spanElement?.getAttribute('aria-disabled') === 'true';
}
// Expose for testing
get _spanElement() {
return this.spanElement;
}
}
function createButtonHTML(
options: {
disabled?: boolean;
ariaLabel?: string;
text?: string;
} = {}
) {
const { disabled = false, ariaLabel, text = 'Click me' } = options;
const tabindex = disabled ? '-1' : '0';
const ariaDisabled = disabled ? 'aria-disabled="true"' : '';
const ariaLabelAttr = ariaLabel ? `aria-label="${ariaLabel}"` : '';
return `
<apg-button class="apg-button">
<span
role="button"
tabindex="${tabindex}"
${ariaDisabled}
${ariaLabelAttr}
>
${text}
</span>
</apg-button>
`;
}
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
// Register custom element if not already registered
if (!customElements.get('apg-button')) {
customElements.define('apg-button', TestApgButton);
}
});
afterEach(() => {
container.remove();
vi.restoreAllMocks();
});
describe('Initial Rendering', () => {
it('renders with role="button"', async () => {
container.innerHTML = createButtonHTML();
await new Promise((r) => requestAnimationFrame(r));
const span = container.querySelector('span[role="button"]');
expect(span).toBeTruthy();
});
it('renders with tabindex="0" by default', async () => {
container.innerHTML = createButtonHTML();
await new Promise((r) => requestAnimationFrame(r));
const span = container.querySelector('span[role="button"]');
expect(span?.getAttribute('tabindex')).toBe('0');
});
it('renders with tabindex="-1" when disabled', async () => {
container.innerHTML = createButtonHTML({ disabled: true });
await new Promise((r) => requestAnimationFrame(r));
const span = container.querySelector('span[role="button"]');
expect(span?.getAttribute('tabindex')).toBe('-1');
});
it('renders with aria-disabled="true" when disabled', async () => {
container.innerHTML = createButtonHTML({ disabled: true });
await new Promise((r) => requestAnimationFrame(r));
const span = container.querySelector('span[role="button"]');
expect(span?.getAttribute('aria-disabled')).toBe('true');
});
it('does not have aria-pressed (not a toggle button)', async () => {
container.innerHTML = createButtonHTML();
await new Promise((r) => requestAnimationFrame(r));
const span = container.querySelector('span[role="button"]');
expect(span?.hasAttribute('aria-pressed')).toBe(false);
});
it('renders with aria-label for accessible name', async () => {
container.innerHTML = createButtonHTML({ ariaLabel: 'Close dialog' });
await new Promise((r) => requestAnimationFrame(r));
const span = container.querySelector('span[role="button"]');
expect(span?.getAttribute('aria-label')).toBe('Close dialog');
});
it('has text content as accessible name', async () => {
container.innerHTML = createButtonHTML({ text: 'Submit Form' });
await new Promise((r) => requestAnimationFrame(r));
const span = container.querySelector('span[role="button"]');
expect(span?.textContent?.trim()).toBe('Submit Form');
});
});
describe('Click Interaction', () => {
it('dispatches button-activate event on click', async () => {
container.innerHTML = createButtonHTML();
await new Promise((r) => requestAnimationFrame(r));
const element = container.querySelector('apg-button') as HTMLElement;
const span = container.querySelector('span[role="button"]') as HTMLElement;
const activateHandler = vi.fn();
element.addEventListener('button-activate', activateHandler);
span.click();
expect(activateHandler).toHaveBeenCalledTimes(1);
});
it('does not dispatch event when disabled', async () => {
container.innerHTML = createButtonHTML({ disabled: true });
await new Promise((r) => requestAnimationFrame(r));
const element = container.querySelector('apg-button') as HTMLElement;
const span = container.querySelector('span[role="button"]') as HTMLElement;
const activateHandler = vi.fn();
element.addEventListener('button-activate', activateHandler);
span.click();
expect(activateHandler).not.toHaveBeenCalled();
});
});
describe('Keyboard Interaction', () => {
it('dispatches button-activate event on Space key', async () => {
container.innerHTML = createButtonHTML();
await new Promise((r) => requestAnimationFrame(r));
const element = container.querySelector('apg-button') as HTMLElement;
const span = container.querySelector('span[role="button"]') as HTMLElement;
const activateHandler = vi.fn();
element.addEventListener('button-activate', activateHandler);
const spaceEvent = new KeyboardEvent('keydown', {
key: ' ',
bubbles: true,
cancelable: true,
});
span.dispatchEvent(spaceEvent);
expect(activateHandler).toHaveBeenCalledTimes(1);
});
it('dispatches button-activate event on Enter key', async () => {
container.innerHTML = createButtonHTML();
await new Promise((r) => requestAnimationFrame(r));
const element = container.querySelector('apg-button') as HTMLElement;
const span = container.querySelector('span[role="button"]') as HTMLElement;
const activateHandler = vi.fn();
element.addEventListener('button-activate', activateHandler);
const enterEvent = new KeyboardEvent('keydown', {
key: 'Enter',
bubbles: true,
cancelable: true,
});
span.dispatchEvent(enterEvent);
expect(activateHandler).toHaveBeenCalledTimes(1);
});
it('prevents default on Space key to avoid page scrolling', async () => {
container.innerHTML = createButtonHTML();
await new Promise((r) => requestAnimationFrame(r));
const span = container.querySelector('span[role="button"]') as HTMLElement;
const spaceEvent = new KeyboardEvent('keydown', {
key: ' ',
bubbles: true,
cancelable: true,
});
const preventDefaultSpy = vi.spyOn(spaceEvent, 'preventDefault');
span.dispatchEvent(spaceEvent);
expect(preventDefaultSpy).toHaveBeenCalled();
});
it('does not dispatch event when disabled (Space key)', async () => {
container.innerHTML = createButtonHTML({ disabled: true });
await new Promise((r) => requestAnimationFrame(r));
const element = container.querySelector('apg-button') as HTMLElement;
const span = container.querySelector('span[role="button"]') as HTMLElement;
const activateHandler = vi.fn();
element.addEventListener('button-activate', activateHandler);
const spaceEvent = new KeyboardEvent('keydown', {
key: ' ',
bubbles: true,
cancelable: true,
});
span.dispatchEvent(spaceEvent);
expect(activateHandler).not.toHaveBeenCalled();
});
it('does not dispatch event when disabled (Enter key)', async () => {
container.innerHTML = createButtonHTML({ disabled: true });
await new Promise((r) => requestAnimationFrame(r));
const element = container.querySelector('apg-button') as HTMLElement;
const span = container.querySelector('span[role="button"]') as HTMLElement;
const activateHandler = vi.fn();
element.addEventListener('button-activate', activateHandler);
const enterEvent = new KeyboardEvent('keydown', {
key: 'Enter',
bubbles: true,
cancelable: true,
});
span.dispatchEvent(enterEvent);
expect(activateHandler).not.toHaveBeenCalled();
});
it('does not dispatch event when isComposing is true', async () => {
container.innerHTML = createButtonHTML();
await new Promise((r) => requestAnimationFrame(r));
const element = container.querySelector('apg-button') as HTMLElement;
const span = container.querySelector('span[role="button"]') as HTMLElement;
const activateHandler = vi.fn();
element.addEventListener('button-activate', activateHandler);
const enterEvent = new KeyboardEvent('keydown', {
key: 'Enter',
bubbles: true,
cancelable: true,
});
Object.defineProperty(enterEvent, 'isComposing', { value: true });
span.dispatchEvent(enterEvent);
expect(activateHandler).not.toHaveBeenCalled();
});
it('does not dispatch event when defaultPrevented is true', async () => {
container.innerHTML = createButtonHTML();
await new Promise((r) => requestAnimationFrame(r));
const element = container.querySelector('apg-button') as HTMLElement;
const span = container.querySelector('span[role="button"]') as HTMLElement;
const activateHandler = vi.fn();
element.addEventListener('button-activate', activateHandler);
const enterEvent = new KeyboardEvent('keydown', {
key: 'Enter',
bubbles: true,
cancelable: true,
});
enterEvent.preventDefault();
span.dispatchEvent(enterEvent);
expect(activateHandler).not.toHaveBeenCalled();
});
});
}); リソース
- 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