APG Patterns
English
English

Button

role="button" を使用してアクションやイベントをトリガーする要素。

デモ

Click me Disabled 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>&lt;span role="button"&gt;</code> を使用しています。本番環境では、ネイティブの <code>&lt;button&gt;</code> 要素を優先してください。

WAI-ARIA プロパティ

tabindex (カスタムボタン要素をキーボードナビゲーションでフォーカス可能にします。ネイティブの <code>&lt;button&gt;</code> はデフォルトでフォーカス可能です。無効時は -1 に設定します。)

カスタムボタン要素をキーボードナビゲーションでフォーカス可能にします。ネイティブの <button> はデフォルトでフォーカス可能です。無効時は -1 に設定します。

"0" | "-1"
必須 はい(カスタム実装の場合)

aria-disabled (ボタンがインタラクティブでなく、アクティブ化できないことを示します。ネイティブの <code>&lt;button disabled&gt;</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 パターン を参照してください。

参考資料

ソースコード

Button.astro
---
/**
 * 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>

使い方

Example
---
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 preventDefaultSpaceキー押下時のページスクロールを防止
IME composingIME入力中はSpace/Enterキーを無視
Tab navigationTabキーでボタン間のフォーカスを移動
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 violationsWCAG 2.1 AAの違反がない(jest-axeによる)
disabled axe無効状態での違反がない
aria-label axearia-label使用時の違反がない

低優先度: プロパティ & 属性(Unit)

テスト説明
classNameカスタムクラスが適用される
data-* attributesカスタムdata属性が渡される
children子コンテンツがレンダリングされる

低優先度: フレームワーク間の一貫性(E2E)

テスト説明
All frameworks have buttonsReact、Vue、Svelte、Astroすべてがカスタムボタン要素をレンダリング
Same button countすべてのフレームワークで同じ数のボタンをレンダリング
Consistent ARIAすべてのフレームワークで一貫したARIA構造

テストツール

詳細なドキュメントは testing-strategy.md (opens in new tab) を参照してください。

Button.test.astro.ts
/**
 * 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();
    });
  });
});

リソース