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.vue
<template>
  <span
    role="button"
    :tabindex="props.disabled ? -1 : 0"
    :aria-disabled="props.disabled ? 'true' : undefined"
    class="apg-button"
    v-bind="$attrs"
    @click="handleClick"
    @keydown="handleKeyDown"
    @keyup="handleKeyUp"
  >
    <slot />
  </span>
</template>

<script setup lang="ts">
import { ref } from 'vue';

/**
 * 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/
 */
defineOptions({
  inheritAttrs: false,
});

export interface ButtonProps {
  /** Whether the button is disabled */
  disabled?: boolean;
  /** Callback fired when button is activated */
  onClick?: (event: MouseEvent | KeyboardEvent) => void;
}

const props = withDefaults(defineProps<ButtonProps>(), {
  disabled: false,
  onClick: undefined,
});

// Track if Space was pressed on this element (for keyup activation)
const spacePressed = ref(false);

const handleClick = (event: MouseEvent) => {
  if (props.disabled) {
    event.preventDefault();
    event.stopPropagation();
    return;
  }

  props.onClick?.(event);
};

const handleKeyDown = (event: KeyboardEvent) => {
  // Ignore if composing (IME input) or already handled
  if (event.isComposing || event.defaultPrevented) {
    return;
  }

  if (props.disabled) {
    return;
  }

  // Space: prevent scroll on keydown, activate on keyup (native button behavior)
  if (event.key === ' ') {
    event.preventDefault();
    spacePressed.value = true;
    return;
  }

  // Enter: activate on keydown (native button behavior)
  if (event.key === 'Enter') {
    event.preventDefault();
    (event.currentTarget as HTMLElement).click();
  }
};

const handleKeyUp = (event: KeyboardEvent) => {
  // Space: activate on keyup if Space was pressed on this element
  if (event.key === ' ' && spacePressed.value) {
    spacePressed.value = false;

    if (props.disabled) {
      return;
    }

    event.preventDefault();
    (event.currentTarget as HTMLElement).click();
  }
};
</script>

使い方

Example
<script setup>
import Button from './Button.vue';

const handleClick = () => {
  console.log('Clicked!');
};
</script>

<template>
  <!-- 基本的なボタン -->
  <Button :onClick="handleClick">Click me</Button>

  <!-- 無効なボタン -->
  <Button disabled>Disabled</Button>

  <!-- アイコンボタン用のaria-label -->
  <Button :onClick="handleSettings" aria-label="Settings">
    <SettingsIcon />
  </Button>
</template>

API

プロパティ デフォルト 説明
onClick (event) => void - クリック/Space/Enterイベントハンドラ
disabled boolean false ボタンが無効かどうか

その他の属性は、$attrs を介して内部の <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 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.vue.ts
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 Button from './Button.vue';

describe('Button (Vue)', () => {
  // 🔴 High Priority: APG ARIA Attributes
  describe('APG ARIA Attributes', () => {
    it('has role="button" on element', () => {
      render(Button, {
        slots: { default: 'Click me' },
      });
      expect(screen.getByRole('button')).toBeInTheDocument();
    });

    it('has tabindex="0" on element', () => {
      render(Button, {
        slots: { default: 'Click me' },
      });
      const button = screen.getByRole('button');
      expect(button).toHaveAttribute('tabindex', '0');
    });

    it('has accessible name from text content', () => {
      render(Button, {
        slots: { default: 'Submit Form' },
      });
      expect(screen.getByRole('button', { name: 'Submit Form' })).toBeInTheDocument();
    });

    it('has accessible name from aria-label', () => {
      render(Button, {
        attrs: { 'aria-label': 'Close dialog' },
        slots: { default: '×' },
      });
      expect(screen.getByRole('button', { name: 'Close dialog' })).toBeInTheDocument();
    });

    it('has accessible name from aria-labelledby', () => {
      render({
        components: { Button },
        template: `
          <div>
            <span id="btn-label">Save changes</span>
            <Button aria-labelledby="btn-label">Save</Button>
          </div>
        `,
      });
      expect(screen.getByRole('button', { name: 'Save changes' })).toBeInTheDocument();
    });

    it('sets aria-disabled="true" when disabled', () => {
      render(Button, {
        props: { disabled: true },
        slots: { default: 'Disabled button' },
      });
      const button = screen.getByRole('button');
      expect(button).toHaveAttribute('aria-disabled', 'true');
    });

    it('sets tabindex="-1" when disabled', () => {
      render(Button, {
        props: { disabled: true },
        slots: { default: 'Disabled button' },
      });
      const button = screen.getByRole('button');
      expect(button).toHaveAttribute('tabindex', '-1');
    });

    it('does not have aria-disabled when not disabled', () => {
      render(Button, {
        slots: { default: 'Active button' },
      });
      const button = screen.getByRole('button');
      expect(button).not.toHaveAttribute('aria-disabled');
    });

    it('does not have aria-pressed (not a toggle button)', () => {
      render(Button, {
        slots: { default: 'Not a toggle' },
      });
      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, {
        props: { onClick: handleClick },
        slots: { default: 'Click me' },
      });

      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, {
        props: { onClick: handleClick },
        slots: { default: 'Click me' },
      });

      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, {
        props: { onClick: handleClick },
        slots: { default: 'Click me' },
      });

      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, {
        props: { onClick: handleClick },
        slots: { default: 'Click me' },
      });

      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, {
        props: { onClick: handleClick },
        slots: { default: 'Click me' },
      });

      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, {
        props: { onClick: handleClick },
        slots: { default: 'Click me' },
      });

      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, {
        props: { onClick: handleClick, disabled: true },
        slots: { default: 'Disabled' },
      });

      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, {
        props: { onClick: handleClick, disabled: true },
        slots: { default: 'Disabled' },
      });

      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, {
        props: { onClick: handleClick, disabled: true },
        slots: { default: 'Disabled' },
      });

      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, {
        slots: { default: 'Click me' },
      });

      await user.tab();
      expect(screen.getByRole('button')).toHaveFocus();
    });

    it('is not focusable when disabled', async () => {
      const user = userEvent.setup();
      render({
        components: { Button },
        template: `
          <div>
            <button>Before</button>
            <Button disabled>Disabled button</Button>
            <button>After</button>
          </div>
        `,
      });

      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({
        components: { Button },
        template: `
          <div>
            <Button>Button 1</Button>
            <Button>Button 2</Button>
            <Button>Button 3</Button>
          </div>
        `,
      });

      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, {
        slots: { default: 'Click me' },
      });
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has no axe violations when disabled', async () => {
      const { container } = render(Button, {
        props: { disabled: true },
        slots: { default: 'Disabled button' },
      });
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has no axe violations with aria-label', async () => {
      const { container } = render(Button, {
        attrs: { 'aria-label': 'Close' },
        slots: { default: '×' },
      });
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });
  });

  // 🟢 Low Priority: HTML Attribute Inheritance
  describe('HTML Attribute Inheritance', () => {
    it('applies class to element', () => {
      render(Button, {
        attrs: { class: 'custom-button' },
        slots: { default: 'Styled' },
      });
      const button = screen.getByRole('button');
      expect(button).toHaveClass('custom-button');
    });

    it('passes through data-* attributes', () => {
      render(Button, {
        attrs: { 'data-testid': 'my-button', 'data-custom': 'value' },
        slots: { default: 'Button' },
      });
      const button = screen.getByTestId('my-button');
      expect(button).toHaveAttribute('data-custom', 'value');
    });

    it('sets id attribute', () => {
      render(Button, {
        attrs: { id: 'main-button' },
        slots: { default: 'Main' },
      });
      const button = screen.getByRole('button');
      expect(button).toHaveAttribute('id', 'main-button');
    });
  });
});

リソース