APG Patterns
English GitHub
English GitHub

Dialog (Modal)

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

🤖 AI Implementation Guide

デモ

基本的な Dialog

タイトル、説明、クローズ機能を持つシンプルなモーダルダイアログ。

Dialog Title

This is a description of the dialog content. It provides additional context for users.

This is the main content of the dialog. You can place any content here, such as text, forms, or other components.

Press Escape or click outside to close.

説明なし

タイトルとコンテンツのみを持つダイアログ。

Simple Dialog

This dialog has no description, only a title and content.

アクセシビリティ

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 ダイアログを閉じて、開いた要素にフォーカスを戻す

補足事項

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

ソースコード

Dialog.svelte
<script lang="ts" module>
  import type { Snippet } from 'svelte';

  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;
    /** Callback when open state changes */
    onOpenChange?: (open: boolean) => void;
    /** Trigger snippet - receives open function */
    trigger: Snippet<[{ open: () => void }]>;
    /** Dialog content */
    children: Snippet;
  }
</script>

<script lang="ts">
  import { onMount } from 'svelte';

  let {
    title,
    description = undefined,
    defaultOpen = false,
    closeOnOverlayClick = true,
    className = '',
    onOpenChange = () => {},
    trigger,
    children,
  }: DialogProps = $props();

  let dialogElement = $state<HTMLDialogElement | undefined>(undefined);
  let previousActiveElement: HTMLElement | null = null;
  let instanceId = $state('');

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

    // Open on mount if defaultOpen
    if (defaultOpen && dialogElement) {
      dialogElement.showModal();
      onOpenChange(true);
    }
  });

  let titleId = $derived(`${instanceId}-title`);
  let descriptionId = $derived(`${instanceId}-description`);

  export function open() {
    if (dialogElement) {
      previousActiveElement = document.activeElement as HTMLElement;
      dialogElement.showModal();
      onOpenChange(true);
    }
  }

  export function close() {
    dialogElement?.close();
  }

  function handleClose() {
    onOpenChange(false);
    // Return focus to trigger
    if (previousActiveElement) {
      previousActiveElement.focus();
    }
  }

  function handleDialogClick(event: MouseEvent) {
    // Close on backdrop click
    if (closeOnOverlayClick && event.target === dialogElement) {
      close();
    }
  }
</script>

<!-- Trigger snippet -->
{@render trigger({ open })}

<!-- Native Dialog Element -->
<dialog
  bind:this={dialogElement}
  class={`apg-dialog ${className}`.trim()}
  aria-labelledby={titleId}
  aria-describedby={description ? descriptionId : undefined}
  onclick={handleDialogClick}
  onclose={handleClose}
>
  <div class="apg-dialog-header">
    <h2 id={titleId} class="apg-dialog-title">
      {title}
    </h2>
    <button type="button" class="apg-dialog-close" onclick={close} 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>
  {#if description}
    <p id={descriptionId} class="apg-dialog-description">
      {description}
    </p>
  {/if}
  <div class="apg-dialog-body">
    {@render children()}
  </div>
</dialog>

使い方

使用例
<script>
  import Dialog from './Dialog.svelte';

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

<Dialog
  title="Dialog Title"
  description="Optional description text"
  onOpenChange={handleOpenChange}
>
  {#snippet trigger({ open })}
    <button onclick={open} class="btn-primary">Open Dialog</button>
  {/snippet}
  {#snippet children()}
    <p>Dialog content goes here.</p>
  {/snippet}
</Dialog>

API

Props

プロパティ デフォルト 説明
title string 必須 ダイアログのタイトル(アクセシビリティ用)
description string - オプションの説明テキスト
defaultOpen boolean false 初期のオープン状態
closeOnOverlayClick boolean true オーバーレイをクリックしたときに閉じるかどうか
onOpenChange (open: boolean) => void - オープン状態が変更されたときのコールバック

Snippets

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

エクスポートされた関数

関数 説明
open() プログラムでダイアログを開く
close() プログラムでダイアログを閉じる

テスト

テストは、キーボード操作、ARIA 属性、およびアクセシビリティ要件全体にわたる APG 準拠を検証します。

テストカテゴリ

高優先度: APG キーボード操作

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

高優先度: APG ARIA 属性

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

高優先度: フォーカス管理

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

中優先度: アクセシビリティ

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

低優先度: プロパティと動作

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

テストツール

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

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

describe('Dialog (Svelte)', () => {
  // 🔴 High Priority: APG 準拠の核心
  describe('APG: キーボード操作', () => {
    it('Escape キーでダイアログを閉じる', async () => {
      const user = userEvent.setup();
      const onOpenChange = vi.fn();
      render(DialogTestWrapper, {
        props: { 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();
      expect(onOpenChange).toHaveBeenLastCalledWith(false);
    });
  });

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

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

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

      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(DialogTestWrapper, {
        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(DialogTestWrapper, {
        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(DialogTestWrapper);

      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(DialogTestWrapper);

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

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

    it('閉じた時にトリガーにフォーカス復元', async () => {
      const user = userEvent.setup();
      render(DialogTestWrapper);

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

      await user.keyboard('{Escape}');
      expect(trigger).toHaveFocus();
    });

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

  // 🟡 Medium Priority: アクセシビリティ検証
  describe('アクセシビリティ', () => {
    it('axe による違反がない', async () => {
      const user = userEvent.setup();
      const { container } = render(DialogTestWrapper, {
        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(DialogTestWrapper, {
        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(DialogTestWrapper, {
        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(DialogTestWrapper, {
        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(DialogTestWrapper, {
        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('onOpenChange が開閉時に呼ばれる', async () => {
      const user = userEvent.setup();
      const onOpenChange = vi.fn();
      render(DialogTestWrapper, {
        props: { onOpenChange },
      });

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

      await user.keyboard('{Escape}');
      expect(onOpenChange).toHaveBeenCalledWith(false);
    });

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

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

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

リソース