APG Patterns
English
English

ボタン

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

デモ

Click meDisabled 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> など)をボタンとして動作させる必要がある場合にのみ使用してください。

機能ネイティブ <button>カスタム 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> は暗黙的にこのロールを持ちます。

この実装は教育目的で <span role="button"> を使用しています。本番環境では、ネイティブの <button> 要素を優先してください。

WAI-ARIA プロパティ

tabindex

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

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

aria-disabled

ボタンがインタラクティブでなく、アクティブ化できないことを示します。ネイティブの <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';
---

<!-- Basic button -->
<Button>Click me</Button>

<!-- Disabled button -->
<Button disabled>Disabled</Button>

<!-- With aria-label for icon buttons -->
<Button aria-label="Settings">
  <SettingsIcon />
</Button>

<!-- With custom event listener (JavaScript) -->
<Button id="my-button">Interactive Button</Button>

<script>
  document.getElementById('my-button')
    ?.addEventListener('button-activate', (e) => {
      console.log('Button activated');
    });
</script>

API

プロパティ型デフォルト説明
disabledbooleanfalseボタンが無効かどうか
classstring''追加のCSSクラス

Custom Events

イベントDetail説明
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構造

テストツール

詳細はテスト戦略ガイドを参照してください。

Button.test.astro.ts
/**
 * Button Astro Component Tests using Container API
 *
 * These tests verify the initial server-rendered HTML of Button.astro.
 * Web Component behavior (click, Space/Enter activation, disabled handling,
 * IME composition and the button-activate event) is covered by E2E tests
 * in e2e/button.spec.ts.
 *
 * @see https://docs.astro.build/en/reference/container-reference/
 */
import { experimental_AstroContainer as AstroContainer } from 'astro/container';
import { describe, it, expect, beforeEach } from 'vitest';
import { JSDOM } from 'jsdom';
import Button from './Button.astro';

describe('Button (Astro Container API)', () => {
  let container: AstroContainer;

  beforeEach(async () => {
    container = await AstroContainer.create();
  });

  async function renderButton(
    props: Record<string, unknown> = {},
    label = 'Click me'
  ): Promise<Document> {
    const html = await container.renderToString(Button, {
      props,
      slots: { default: label },
    });
    return new JSDOM(html).window.document;
  }

  // 🔴 High Priority: HTML Structure
  describe('HTML Structure', () => {
    it('renders apg-button custom element wrapper', async () => {
      const doc = await renderButton();
      expect(doc.querySelector('apg-button')).not.toBeNull();
    });

    it('renders a span with role="button"', async () => {
      const doc = await renderButton();
      const button = doc.querySelector('[role="button"]');
      expect(button?.tagName).toBe('SPAN');
    });

    it('renders the slot content as the accessible name', async () => {
      const doc = await renderButton({}, 'Submit Form');
      const button = doc.querySelector('[role="button"]');
      expect(button?.textContent?.trim()).toBe('Submit Form');
    });
  });

  // 🔴 High Priority: ARIA Attributes
  describe('ARIA Attributes', () => {
    it('does not have aria-pressed (not a toggle button)', async () => {
      const doc = await renderButton();
      const button = doc.querySelector('[role="button"]');
      expect(button?.hasAttribute('aria-pressed')).toBe(false);
    });

    it('has aria-disabled="true" when disabled', async () => {
      const doc = await renderButton({ disabled: true });
      const button = doc.querySelector('[role="button"]');
      expect(button?.getAttribute('aria-disabled')).toBe('true');
    });

    it('does not have aria-disabled when not disabled', async () => {
      const doc = await renderButton();
      const button = doc.querySelector('[role="button"]');
      expect(button?.hasAttribute('aria-disabled')).toBe(false);
    });
  });

  // 🔴 High Priority: Focus Management
  describe('Focus Management', () => {
    it('has tabindex="0" by default', async () => {
      const doc = await renderButton();
      const button = doc.querySelector('[role="button"]');
      expect(button?.getAttribute('tabindex')).toBe('0');
    });

    it('has tabindex="-1" when disabled', async () => {
      const doc = await renderButton({ disabled: true });
      const button = doc.querySelector('[role="button"]');
      expect(button?.getAttribute('tabindex')).toBe('-1');
    });
  });

  // 🟢 Low Priority: HTML Attribute Inheritance
  describe('HTML Attributes', () => {
    it('has the apg-button class', async () => {
      const doc = await renderButton();
      const button = doc.querySelector('[role="button"]');
      expect(button?.getAttribute('class')).toBe('apg-button');
    });

    it('applies a custom class alongside apg-button', async () => {
      const doc = await renderButton({ class: 'custom-class' });
      const button = doc.querySelector('[role="button"]');
      expect(button?.getAttribute('class')).toBe('apg-button custom-class');
    });
  });
});

リソース