APG Patterns
English
English

Dialog (Modal)

プライマリウィンドウの上に重ねて表示され、その下のコンテンツを非アクティブにするウィンドウです。

デモ

基本的なダイアログ

タイトル、説明文、クローズ機能を備えたシンプルなモーダルダイアログです。

説明なし

タイトルとコンテンツのみのダイアログです。

デモのみを開く →

アクセシビリティ

WAI-ARIA ロール

ロール 対象要素 説明
dialog ダイアログコンテナ その要素がダイアログウィンドウであることを示す

WAI-ARIA dialog role (opens in new tab)

WAI-ARIA プロパティ

属性 対象要素 必須 説明
aria-modal dialog true はい これがモーダルダイアログであることを示す
aria-labelledby dialog タイトル要素への ID 参照 はい ダイアログのタイトルを参照する
aria-describedby dialog 説明への ID 参照 いいえ オプションの説明テキストを参照する

フォーカス管理

イベント 振る舞い
ダイアログが開く ダイアログ内の最初のフォーカス可能な要素にフォーカスが移動する
ダイアログが閉じる ダイアログを開いた要素にフォーカスが戻る
フォーカストラップ Tab/Shift+Tab はダイアログ内のフォーカス可能な要素間のみをサイクルする
背景 ダイアログ外のコンテンツは不活性化される(フォーカス不可・操作不可)

キーボードサポート

キー アクション
Tab ダイアログ内の次のフォーカス可能な要素にフォーカスを移動する。最後の要素にフォーカスがある場合は最初の要素に移動する。
Shift + Tab ダイアログ内の前のフォーカス可能な要素にフォーカスを移動する。最初の要素にフォーカスがある場合は最後の要素に移動する。
Escape ダイアログを閉じて、開いた要素にフォーカスを戻す

section.additionalNotes

  • アクセシビリティのためにダイアログのタイトルは必須であり、ダイアログの目的を明確に説明する必要があります
  • ダイアログが開いている間はページのスクロールが無効になります
  • オーバーレイ(背景)をクリックするとデフォルトでダイアログが閉じます
  • 閉じるボタンにはスクリーンリーダー向けのアクセシブルなラベルが付いています

ソースコード

Dialog.vue
<template>
  <slot name="trigger" :open="openDialog" />

  <Teleport to="body">
    <dialog
      ref="dialogRef"
      :class="`apg-dialog ${className}`.trim()"
      :aria-labelledby="titleId"
      :aria-describedby="description ? descriptionId : undefined"
      @click="handleDialogClick"
      @close="handleClose"
    >
      <div class="apg-dialog-header">
        <h2 :id="titleId" class="apg-dialog-title">
          {{ title }}
        </h2>
        <button
          type="button"
          class="apg-dialog-close"
          @click="closeDialog"
          aria-label="Close dialog"
        >
          <svg
            xmlns="http://www.w3.org/2000/svg"
            width="24"
            height="24"
            viewBox="0 0 24 24"
            fill="none"
            stroke="currentColor"
            stroke-width="2"
            stroke-linecap="round"
            stroke-linejoin="round"
            aria-hidden="true"
          >
            <line x1="18" y1="6" x2="6" y2="18" />
            <line x1="6" y1="6" x2="18" y2="18" />
          </svg>
        </button>
      </div>
      <p v-if="description" :id="descriptionId" class="apg-dialog-description">
        {{ description }}
      </p>
      <div class="apg-dialog-body">
        <slot />
      </div>
    </dialog>
  </Teleport>
</template>

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

export interface DialogProps {
  /** Dialog title (required for accessibility) */
  title: string;
  /** Optional description text */
  description?: string;
  /** Default open state */
  defaultOpen?: boolean;
  /** Close on overlay click */
  closeOnOverlayClick?: boolean;
  /** Additional CSS class */
  className?: string;
}

const props = withDefaults(defineProps<DialogProps>(), {
  description: undefined,
  defaultOpen: false,
  closeOnOverlayClick: true,
  className: '',
});

const emit = defineEmits<{
  openChange: [open: boolean];
}>();

const dialogRef = ref<HTMLDialogElement>();
const previousActiveElement = ref<HTMLElement | null>(null);
const instanceId = ref('');

onMounted(() => {
  instanceId.value = `dialog-${Math.random().toString(36).substr(2, 9)}`;

  // Open on mount if defaultOpen
  if (props.defaultOpen && dialogRef.value) {
    dialogRef.value.showModal();
    emit('openChange', true);
  }
});

const titleId = computed(() => `${instanceId.value}-title`);
const descriptionId = computed(() => `${instanceId.value}-description`);

const openDialog = () => {
  if (dialogRef.value) {
    previousActiveElement.value = document.activeElement as HTMLElement;
    dialogRef.value.showModal();
    emit('openChange', true);
  }
};

const closeDialog = () => {
  dialogRef.value?.close();
};

const handleClose = () => {
  emit('openChange', false);
  // Return focus to trigger
  if (previousActiveElement.value) {
    previousActiveElement.value.focus();
  }
};

const handleDialogClick = (event: MouseEvent) => {
  // Close on backdrop click
  if (props.closeOnOverlayClick && event.target === dialogRef.value) {
    closeDialog();
  }
};

// Expose methods for external control
defineExpose({
  open: openDialog,
  close: closeDialog,
});
</script>

使い方

Example
<template>
  <Dialog
    title="Dialog Title"
    description="Optional description text"
    @open-change="handleOpenChange"
  >
    <template #trigger="{ open }">
      <button @click="open" class="btn-primary">Open Dialog</button>
    </template>
    <p>Dialog content goes here.</p>
  </Dialog>
</template>

<script setup>
import Dialog from './Dialog.vue';

function handleOpenChange(open) {
  console.log('Dialog:', open);
}
</script>

API

Props

プロパティ デフォルト 説明
title string 必須 ダイアログのタイトル(アクセシビリティ用)
description string - オプションの説明文
defaultOpen boolean false 初期の開閉状態
closeOnOverlayClick boolean true オーバーレイクリックで閉じる

Events

イベント ペイロード 説明
openChange boolean 開閉状態が変更されたときに発火

Slots

スロット Props 説明
trigger { open: () => void } ダイアログを開くトリガー要素
default - ダイアログのコンテンツ

テスト

テストは、キーボードインタラクション、ARIA属性、アクセシビリティ要件全体でAPG準拠を検証します。Dialogコンポーネントは2層のテスト戦略を採用しています。

テスト戦略

ユニットテスト(Testing Library)

フレームワーク固有のテストライブラリを使用してコンポーネントのレンダリング出力を検証します。これらのテストは正しいHTML構造とARIA属性を確認します。

  • ARIA属性(aria-labelledby、aria-describedby)
  • Escapeキーでダイアログを閉じる
  • 開閉時のフォーカス管理
  • jest-axeによるアクセシビリティ検証

E2Eテスト(Playwright)

すべてのフレームワークで実際のブラウザ環境でコンポーネントの動作を検証します。これらのテストはインタラクションとフレームワーク間の一貫性をカバーします。

  • モーダル動作(showModal、backdrop)
  • フォーカストラップの検証
  • 閉じた時のフォーカス復元
  • オーバーレイクリックで閉じる
  • ライブブラウザでのARIA構造検証
  • axe-coreによるアクセシビリティスキャン
  • フレームワーク間の一貫性チェック

テストカテゴリ

高優先度: APG キーボードインタラクション ( Unit + E2E )

テスト 説明
Escape key ダイアログを閉じる

高優先度: APG ARIA 属性 ( Unit + E2E )

テスト 説明
role="dialog" ダイアログ要素にdialogロールが設定されている
aria-modal="true" モーダル動作を示す
aria-labelledby ダイアログのタイトルを参照
aria-describedby 説明を参照(提供されている場合)

高優先度: フォーカス管理 ( Unit + E2E )

テスト 説明
Initial focus 開いた時に最初のフォーカス可能な要素にフォーカスが移動
Focus restore 閉じた時にトリガー要素にフォーカスが戻る
Focus trap Tabサイクルがダイアログ内に留まる(ネイティブdialog経由)

中優先度: アクセシビリティ ( Unit + E2E )

テスト 説明
axe violations WCAG 2.1 AA違反なし(jest-axe経由)

低優先度: Props と動作 ( Unit )

テスト 説明
closeOnOverlayClick オーバーレイクリックの動作を制御
defaultOpen 初期の開閉状態
onOpenChange 開閉時にコールバックが発火
className カスタムクラスが適用される

E2E テストコード

e2e/dialog.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

/**
 * E2E Tests for Dialog (Modal) Pattern
 *
 * A window overlaid on the primary content, requiring user interaction.
 * Modal dialogs trap focus and prevent interaction with content outside.
 *
 * Key differences from Alert Dialog:
 * - role="dialog" (not "alertdialog")
 * - Escape key closes the dialog
 * - Has close button (×)
 * - aria-describedby is optional
 * - Initial focus on close button or first focusable element
 *
 * APG Reference: https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/
 */

const frameworks = ['react', 'vue', 'svelte', 'astro'] as const;

// ============================================
// Helper Functions
// ============================================

const getDialog = (page: import('@playwright/test').Page) => {
  // Use getByRole which only returns visible elements with the role
  // This works for both native <dialog> (implicit role) and custom role="dialog"
  return page.getByRole('dialog');
};

const openDialog = async (page: import('@playwright/test').Page) => {
  const trigger = page.getByRole('button', { name: /open dialog/i }).first();
  await trigger.click();
  // Wait for dialog to be visible (native <dialog> has implicit role="dialog")
  await getDialog(page).waitFor({ state: 'visible' });
  return trigger;
};

// ============================================
// Framework-specific Tests
// ============================================

for (const framework of frameworks) {
  test.describe(`Dialog (${framework})`, () => {
    test.beforeEach(async ({ page }) => {
      await page.goto(`patterns/dialog/${framework}/demo/`);
      // Wait for the trigger button to be visible (indicates hydration complete)
      await page
        .getByRole('button', { name: /open dialog/i })
        .first()
        .waitFor();
    });

    // ------------------------------------------
    // 🔴 High Priority: APG ARIA Structure
    // ------------------------------------------
    test.describe('APG: ARIA Structure', () => {
      test('has role="dialog"', async ({ page }) => {
        await openDialog(page);

        const dialog = getDialog(page);
        await expect(dialog).toBeVisible();
        await expect(dialog).toHaveRole('dialog');
      });

      test('supports native <dialog> or custom role="dialog"', async ({ page }) => {
        await openDialog(page);

        const dialog = getDialog(page);
        const tagName = await dialog.evaluate((el) => el.tagName.toLowerCase());

        // Native <dialog> or a custom element with role="dialog" are both acceptable
        expect(tagName === 'dialog' || (await dialog.getAttribute('role')) === 'dialog').toBe(true);
      });

      test('has aria-modal="true" (for custom dialog) or uses showModal (for native)', async ({
        page,
      }) => {
        await openDialog(page);
        const dialog = getDialog(page);

        const isNative = await dialog.evaluate((el) => el.tagName.toLowerCase() === 'dialog');
        if (isNative) {
          // Native <dialog> opened via showModal() is implicitly modal
          // aria-modal is not required when using showModal()
          const hasOpenAttribute = await dialog.evaluate((el) => el.hasAttribute('open'));
          expect(hasOpenAttribute).toBe(true);
        } else {
          // Custom dialog must have aria-modal="true"
          await expect(dialog).toHaveAttribute('aria-modal', 'true');
        }
      });

      test('is modal (opened via showModal for native dialog)', async ({ page }) => {
        await openDialog(page);

        const dialog = getDialog(page);
        await expect(dialog).toBeVisible();

        const isNative = await dialog.evaluate((el) => el.tagName.toLowerCase() === 'dialog');
        if (isNative) {
          // Verify dialog has 'open' attribute (showModal sets this)
          const hasOpenAttribute = await dialog.evaluate((el) => el.hasAttribute('open'));
          expect(hasOpenAttribute).toBe(true);

          // Verify backdrop exists (showModal() creates ::backdrop)
          const hasBackdrop = await dialog.evaluate((el) => {
            const style = window.getComputedStyle(el, '::backdrop');
            return style.display !== 'none';
          });
          expect(hasBackdrop).toBe(true);
        } else {
          // Custom dialog should have aria-modal="true"
          await expect(dialog).toHaveAttribute('aria-modal', 'true');
        }
      });

      test('has aria-labelledby referencing title', async ({ page }) => {
        await openDialog(page);

        const dialog = getDialog(page);
        const labelledbyId = await dialog.getAttribute('aria-labelledby');

        expect(labelledbyId).toBeTruthy();
        const titleElement = page.locator(`[id="${labelledbyId}"]`);
        await expect(titleElement).toBeVisible();

        // Verify it's an actual title element (heading)
        const tagName = await titleElement.evaluate((el) => el.tagName.toLowerCase());
        expect(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']).toContain(tagName);
      });

      test('has aria-describedby when description is provided', async ({ page }) => {
        await openDialog(page);

        const dialog = getDialog(page);
        const describedbyId = await dialog.getAttribute('aria-describedby');

        // aria-describedby is optional for Dialog
        if (describedbyId) {
          const descriptionElement = page.locator(`[id="${describedbyId}"]`);
          await expect(descriptionElement).toBeVisible();
        }
      });

      test('has close button with accessible label', async ({ page }) => {
        await openDialog(page);

        const dialog = getDialog(page);
        const closeButton = dialog.getByRole('button', { name: /close/i });

        await expect(closeButton).toBeVisible();
      });
    });

    // ------------------------------------------
    // 🔴 High Priority: Keyboard Interaction
    // ------------------------------------------
    test.describe('APG: Keyboard Interaction', () => {
      test('Escape closes the dialog', async ({ page }) => {
        await openDialog(page);

        const dialog = getDialog(page);
        await expect(dialog).toBeVisible();

        await page.keyboard.press('Escape');

        // Dialog should be closed
        await expect(dialog).not.toBeVisible();
      });

      test('Tab moves focus to next element', async ({ page }) => {
        await openDialog(page);

        const dialog = getDialog(page);

        // Get all focusable elements in dialog
        const focusableElements = dialog.locator(
          'button:not([disabled]), [tabindex="0"], input:not([disabled]), textarea:not([disabled]), select:not([disabled]), a[href]'
        );
        const count = await focusableElements.count();
        expect(count).toBeGreaterThanOrEqual(1);

        // Focus the first element explicitly
        const first = focusableElements.first();
        await first.focus();
        await expect(first).toBeFocused();

        // Tab should move to next element
        await page.keyboard.press('Tab');

        // If there's more than one focusable element, focus should have moved
        if (count > 1) {
          await expect(focusableElements.nth(1)).toBeFocused();
        }
      });

      test('Tab wraps from last to first element (focus trap)', async ({ page }) => {
        await openDialog(page);

        const dialog = getDialog(page);

        // Get all focusable elements in dialog
        const focusableElements = dialog.locator(
          'button:not([disabled]), [tabindex="0"], input:not([disabled]), textarea:not([disabled]), select:not([disabled]), a[href]'
        );
        const count = await focusableElements.count();

        // Tab through all elements plus one more to verify wrap
        for (let i = 0; i <= count; i++) {
          await page.keyboard.press('Tab');
        }

        // Focus should still be within dialog
        const focusedElement = page.locator(':focus');
        const isWithinDialog = await focusedElement.evaluate(
          (el) => el.closest('dialog, [role="dialog"]') !== null
        );
        expect(isWithinDialog).toBe(true);
      });

      test('Shift+Tab moves focus to previous element', async ({ page }) => {
        await openDialog(page);

        const dialog = getDialog(page);
        const closeButton = dialog.getByRole('button', { name: /close/i });

        // Click close button to ensure focus is in dialog
        await closeButton.click();
        // Dialog closes on click, so reopen
        await page
          .getByRole('button', { name: /open dialog/i })
          .first()
          .click();
        await getDialog(page).waitFor({ state: 'visible' });

        // Tab once to move focus into dialog
        await page.keyboard.press('Tab');

        // Shift+Tab should move backwards but stay in dialog
        await page.keyboard.press('Shift+Tab');

        // Focus should still be within dialog
        const isWithinDialog = await page.evaluate(() => {
          const focused = document.activeElement;
          return focused?.closest('dialog, [role="dialog"]') !== null;
        });
        expect(isWithinDialog).toBe(true);
      });

      test('Shift+Tab wraps from first to last element', async ({ page }) => {
        await openDialog(page);

        const dialog = getDialog(page);

        // Get all focusable elements
        const focusableElements = dialog.locator(
          'button:not([disabled]), [tabindex="0"], input:not([disabled]), textarea:not([disabled]), select:not([disabled]), a[href]'
        );
        const count = await focusableElements.count();

        // Shift+Tab through all elements to test wrap
        for (let i = 0; i <= count; i++) {
          await page.keyboard.press('Shift+Tab');
        }

        // Focus should still be within dialog
        const focusedElement = page.locator(':focus');
        const isWithinDialog = await focusedElement.evaluate(
          (el) => el.closest('dialog, [role="dialog"]') !== null
        );
        expect(isWithinDialog).toBe(true);
      });
    });

    // ------------------------------------------
    // 🔴 High Priority: Focus Management
    // ------------------------------------------
    test.describe('APG: Focus Management', () => {
      test('focuses first focusable element on open', async ({ page }) => {
        await openDialog(page);

        // Focus should be within dialog
        const focusedElement = page.locator(':focus');
        const isWithinDialog = await focusedElement.evaluate(
          (el) => el.closest('dialog, [role="dialog"]') !== null
        );
        expect(isWithinDialog).toBe(true);
      });

      test('returns focus to trigger on close via Escape', async ({ page }) => {
        const trigger = await openDialog(page);

        await page.keyboard.press('Escape');

        // Focus should return to trigger
        await expect(trigger).toBeFocused();
      });

      test('returns focus to trigger on close via close button', async ({ page }) => {
        const trigger = await openDialog(page);

        const dialog = getDialog(page);
        const closeButton = dialog.getByRole('button', { name: /close/i });
        await closeButton.click();

        // Focus should return to trigger
        await expect(trigger).toBeFocused();
      });

      test('traps focus within dialog', async ({ page }) => {
        await openDialog(page);

        const dialog = getDialog(page);

        // Get count of focusable elements
        const focusableElements = dialog.locator(
          'button:not([disabled]), [tabindex="0"], input:not([disabled]), textarea:not([disabled]), select:not([disabled]), a[href]'
        );
        const count = await focusableElements.count();

        // Tab many times - focus should never leave dialog
        // First Tab may move focus into dialog, then subsequent Tabs should stay within
        const tabCount = Math.max(count * 3, 10);
        for (let i = 0; i < tabCount; i++) {
          await page.keyboard.press('Tab');
        }

        // After many Tabs, focus should still be within dialog
        const isWithinDialog = await page.evaluate(() => {
          const focused = document.activeElement;
          return focused?.closest('dialog, [role="dialog"]') !== null;
        });
        expect(isWithinDialog).toBe(true);
      });
    });

    // ------------------------------------------
    // 🔴 High Priority: Click Interaction
    // ------------------------------------------
    test.describe('APG: Click Interaction', () => {
      test('clicking close button closes dialog', async ({ page }) => {
        await openDialog(page);

        const dialog = getDialog(page);
        const closeButton = dialog.getByRole('button', { name: /close/i });
        await closeButton.click();

        await expect(dialog).not.toBeVisible();
      });

      test('clicking overlay closes dialog', async ({ page }) => {
        await openDialog(page);

        const dialog = getDialog(page);
        await expect(dialog).toBeVisible();

        // Get viewport size and dialog bounds to find a safe click position outside dialog
        const viewportSize = page.viewportSize();
        const dialogBox = await dialog.boundingBox();

        if (viewportSize && dialogBox) {
          // Find a safe position outside dialog, handling edge cases
          // Try multiple positions: top, left, right, bottom of dialog
          const candidates = [
            // Above dialog (if there's space)
            { x: dialogBox.x + dialogBox.width / 2, y: Math.max(1, dialogBox.y - 20) },
            // Left of dialog (if there's space)
            { x: Math.max(1, dialogBox.x - 20), y: dialogBox.y + dialogBox.height / 2 },
            // Right of dialog (if there's space)
            {
              x: Math.min(viewportSize.width - 1, dialogBox.x + dialogBox.width + 20),
              y: dialogBox.y + dialogBox.height / 2,
            },
            // Below dialog (if there's space)
            {
              x: dialogBox.x + dialogBox.width / 2,
              y: Math.min(viewportSize.height - 1, dialogBox.y + dialogBox.height + 20),
            },
          ];

          // Find first candidate that's outside dialog bounds
          const isOutsideDialog = (x: number, y: number) =>
            x < dialogBox.x ||
            x > dialogBox.x + dialogBox.width ||
            y < dialogBox.y ||
            y > dialogBox.y + dialogBox.height;

          const safePosition = candidates.find((pos) => isOutsideDialog(pos.x, pos.y));

          if (safePosition) {
            await page.mouse.click(safePosition.x, safePosition.y);
          } else {
            // Fallback: click at viewport corner (1,1)
            await page.mouse.click(1, 1);
          }
        } else {
          // Fallback: click at viewport corner
          await page.mouse.click(1, 1);
        }

        // Dialog should close when clicking overlay
        await expect(dialog).not.toBeVisible();
      });
    });

    // ------------------------------------------
    // 🟢 Low Priority: Accessibility
    // ------------------------------------------
    test.describe('Accessibility', () => {
      test('has no axe-core violations', async ({ page }) => {
        await openDialog(page);

        const dialog = getDialog(page);
        await expect(dialog).toBeVisible();

        const results = await new AxeBuilder({ page })
          .include('dialog')
          .disableRules(['color-contrast'])
          .analyze();

        expect(results.violations).toEqual([]);
      });
    });
  });
}

// ============================================
// Cross-framework Consistency Tests
// ============================================

test.describe('Dialog - Cross-framework Consistency', () => {
  test('all frameworks render dialog with role="dialog"', async ({ page }) => {
    for (const framework of frameworks) {
      await page.goto(`patterns/dialog/${framework}/demo/`);
      await page
        .getByRole('button', { name: /open dialog/i })
        .first()
        .waitFor();

      // Open dialog
      const trigger = page.getByRole('button', { name: /open dialog/i }).first();
      await trigger.click();
      await getDialog(page).waitFor({ state: 'visible' });

      const dialog = getDialog(page);
      await expect(dialog).toBeVisible();
      await expect(dialog).toHaveRole('dialog');

      // Close dialog for next iteration
      await page.keyboard.press('Escape');
    }
  });

  test('all frameworks have aria-labelledby', async ({ page }) => {
    for (const framework of frameworks) {
      await page.goto(`patterns/dialog/${framework}/demo/`);
      await page
        .getByRole('button', { name: /open dialog/i })
        .first()
        .waitFor();

      const trigger = page.getByRole('button', { name: /open dialog/i }).first();
      await trigger.click();
      await getDialog(page).waitFor({ state: 'visible' });

      const dialog = getDialog(page);
      const labelledbyId = await dialog.getAttribute('aria-labelledby');
      expect(labelledbyId).toBeTruthy();

      await page.keyboard.press('Escape');
    }
  });

  test('all frameworks close on Escape', async ({ page }) => {
    for (const framework of frameworks) {
      await page.goto(`patterns/dialog/${framework}/demo/`);
      await page
        .getByRole('button', { name: /open dialog/i })
        .first()
        .waitFor();

      const trigger = page.getByRole('button', { name: /open dialog/i }).first();
      await trigger.click();
      await getDialog(page).waitFor({ state: 'visible' });

      const dialog = getDialog(page);
      await expect(dialog).toBeVisible();

      await page.keyboard.press('Escape');
      await expect(dialog).not.toBeVisible();
    }
  });

  test('all frameworks trap focus within dialog', async ({ page }) => {
    for (const framework of frameworks) {
      await page.goto(`patterns/dialog/${framework}/demo/`);
      await page
        .getByRole('button', { name: /open dialog/i })
        .first()
        .waitFor();

      const trigger = page.getByRole('button', { name: /open dialog/i }).first();
      await trigger.click();
      await getDialog(page).waitFor({ state: 'visible' });

      // Tab multiple times
      for (let i = 0; i < 10; i++) {
        await page.keyboard.press('Tab');
      }

      // After many Tabs, focus should still be within dialog
      const isWithinDialog = await page.evaluate(() => {
        const focused = document.activeElement;
        return focused?.closest('dialog, [role="dialog"]') !== null;
      });
      expect(isWithinDialog).toBe(true);

      await page.keyboard.press('Escape');
    }
  });
});

テストの実行

          
            # Dialogのユニットテストを実行
npm run test -- dialog

# DialogのE2Eテストを実行(全フレームワーク)
npm run test:e2e:pattern --pattern=dialog
          
        

テストツール

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

Dialog.test.vue.ts
import { render, screen, within } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import Dialog from './Dialog.vue';

// テスト用のラッパーコンポーネント
const TestDialog = {
  components: { Dialog },
  props: {
    title: { type: String, default: 'Test Dialog' },
    description: { type: String, default: undefined },
    closeOnOverlayClick: { type: Boolean, default: true },
    defaultOpen: { type: Boolean, default: false },
    className: { type: String, default: '' },
  },
  emits: ['openChange'],
  template: `
    <Dialog
      :title="title"
      :description="description"
      :close-on-overlay-click="closeOnOverlayClick"
      :default-open="defaultOpen"
      :class-name="className"
      @open-change="$emit('openChange', $event)"
    >
      <template #trigger="{ open }">
        <button @click="open">Open Dialog</button>
      </template>
      <slot>
        <p>Dialog content</p>
      </slot>
    </Dialog>
  `,
};

describe('Dialog (Vue)', () => {
  // 🔴 High Priority: APG 準拠の核心
  describe('APG: キーボード操作', () => {
    it('Escape キーでダイアログを閉じる', async () => {
      const user = userEvent.setup();
      const onOpenChange = vi.fn();
      render(TestDialog, {
        props: { onOpenChange },
        attrs: { onOpenChange },
      });

      // ダイアログを開く
      await user.click(screen.getByRole('button', { name: 'Open Dialog' }));
      expect(screen.getByRole('dialog')).toBeInTheDocument();

      // Escape で閉じる
      await user.keyboard('{Escape}');
      expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
    });
  });

  describe('APG: ARIA 属性', () => {
    it('role="dialog" を持つ', async () => {
      const user = userEvent.setup();
      render(TestDialog);

      await user.click(screen.getByRole('button', { name: 'Open Dialog' }));
      expect(screen.getByRole('dialog')).toBeInTheDocument();
    });

    it('aria-modal="true" を持つ', async () => {
      const user = userEvent.setup();
      render(TestDialog);

      await user.click(screen.getByRole('button', { name: 'Open Dialog' }));
      expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'true');
    });

    it('aria-labelledby でタイトルを参照', async () => {
      const user = userEvent.setup();
      render(TestDialog, {
        props: { title: 'My Dialog Title' },
      });

      await user.click(screen.getByRole('button', { name: 'Open Dialog' }));
      const dialog = screen.getByRole('dialog');
      const titleId = dialog.getAttribute('aria-labelledby');

      expect(titleId).toBeTruthy();
      expect(document.getElementById(titleId!)).toHaveTextContent('My Dialog Title');
    });

    it('description がある場合 aria-describedby で参照', async () => {
      const user = userEvent.setup();
      render(TestDialog, {
        props: { description: 'This is a description' },
      });

      await user.click(screen.getByRole('button', { name: 'Open Dialog' }));
      const dialog = screen.getByRole('dialog');
      const descriptionId = dialog.getAttribute('aria-describedby');

      expect(descriptionId).toBeTruthy();
      expect(document.getElementById(descriptionId!)).toHaveTextContent('This is a description');
    });

    it('description がない場合 aria-describedby なし', async () => {
      const user = userEvent.setup();
      render(TestDialog);

      await user.click(screen.getByRole('button', { name: 'Open Dialog' }));
      const dialog = screen.getByRole('dialog');

      expect(dialog).not.toHaveAttribute('aria-describedby');
    });
  });

  describe('APG: フォーカス管理', () => {
    it('開いた時に最初のフォーカス可能要素にフォーカス', async () => {
      const user = userEvent.setup();
      render(TestDialog);

      await user.click(screen.getByRole('button', { name: 'Open Dialog' }));

      // ダイアログ内の最初のフォーカス可能要素(Close ボタン)にフォーカス
      await vi.waitFor(
        () => {
          expect(screen.getByRole('button', { name: 'Close dialog' })).toHaveFocus();
        },
        { timeout: 1000 }
      );
    });

    // Note: フォーカス復元テストは jsdom の showModal() 制限により flaky。
    // E2E テスト(Playwright)でカバー済み。
    // See: e2e/dialog.spec.ts - Focus Management section
    it.todo('閉じた時にトリガーにフォーカス復元');

    // Note: フォーカストラップはネイティブ <dialog> 要素の showModal() が処理する。
    // jsdom では showModal() のフォーカストラップ動作が未実装のため、
    // これらのテストはブラウザでの E2E テスト(Playwright)で検証することを推奨。
  });

  // 🟡 Medium Priority: アクセシビリティ検証
  describe('アクセシビリティ', () => {
    it('axe による違反がない', async () => {
      const user = userEvent.setup();
      const { container } = render(TestDialog, {
        props: { description: 'Description' },
      });

      await user.click(screen.getByRole('button', { name: 'Open Dialog' }));

      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });
  });

  describe('Props', () => {
    it('title が表示される', async () => {
      const user = userEvent.setup();
      render(TestDialog, {
        props: { title: 'Custom Title' },
      });

      await user.click(screen.getByRole('button', { name: 'Open Dialog' }));
      expect(screen.getByText('Custom Title')).toBeInTheDocument();
    });

    it('description が表示される', async () => {
      const user = userEvent.setup();
      render(TestDialog, {
        props: { description: 'Custom Description' },
      });

      await user.click(screen.getByRole('button', { name: 'Open Dialog' }));
      expect(screen.getByText('Custom Description')).toBeInTheDocument();
    });

    it('closeOnOverlayClick=true でオーバーレイクリックで閉じる', async () => {
      const user = userEvent.setup();
      render(TestDialog, {
        props: { closeOnOverlayClick: true },
      });

      await user.click(screen.getByRole('button', { name: 'Open Dialog' }));
      const dialog = screen.getByRole('dialog');

      await user.click(dialog);
      expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
    });

    it('closeOnOverlayClick=false でオーバーレイクリックしても閉じない', async () => {
      const user = userEvent.setup();
      render(TestDialog, {
        props: { closeOnOverlayClick: false },
      });

      await user.click(screen.getByRole('button', { name: 'Open Dialog' }));
      const dialog = screen.getByRole('dialog');

      await user.click(dialog);
      expect(screen.getByRole('dialog')).toBeInTheDocument();
    });

    it('defaultOpen=true で初期表示', async () => {
      render(TestDialog, {
        props: { defaultOpen: true },
      });
      expect(screen.getByRole('dialog')).toBeInTheDocument();
    });
  });

  // 🟢 Low Priority: 拡張性
  describe('HTML 属性継承', () => {
    it('className がダイアログに適用される', async () => {
      const user = userEvent.setup();
      render(TestDialog, {
        props: { className: 'custom-class' },
      });

      await user.click(screen.getByRole('button', { name: 'Open Dialog' }));
      expect(screen.getByRole('dialog')).toHaveClass('custom-class');
    });
  });
});

リソース