APG Patterns
English GitHub
English GitHub

Dialog (Modal)

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

🤖 AI Implementation Guide

デモ

基本的なダイアログ

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

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.astro
---
/**
 * APG Dialog (Modal) Pattern - Astro Implementation
 *
 * A window overlaid on the primary window, rendering the content underneath inert.
 * Uses native <dialog> element with Web Components for enhanced control.
 *
 * @see https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/
 */

export interface Props {
  /** Dialog title (required for accessibility) */
  title: string;
  /** Optional description text */
  description?: string;
  /** Trigger button text */
  triggerText: string;
  /** Close on overlay click */
  closeOnOverlayClick?: boolean;
  /** Additional CSS class for trigger button */
  triggerClass?: string;
  /** Additional CSS class for dialog */
  class?: string;
}

const {
  title,
  description,
  triggerText,
  closeOnOverlayClick = true,
  triggerClass = '',
  class: className = '',
} = Astro.props;

// Generate unique ID for this instance
const instanceId = `dialog-${Math.random().toString(36).substring(2, 11)}`;
const titleId = `${instanceId}-title`;
const descriptionId = `${instanceId}-description`;
---

<apg-dialog data-close-on-overlay={closeOnOverlayClick}>
  <!-- Trigger Button -->
  <button type="button" class={`apg-dialog-trigger ${triggerClass}`.trim()} data-dialog-trigger>
    {triggerText}
  </button>

  <!-- Native Dialog Element -->
  <dialog
    class={`apg-dialog ${className}`.trim()}
    aria-labelledby={titleId}
    aria-describedby={description ? descriptionId : undefined}
    data-dialog-content
  >
    <div class="apg-dialog-header">
      <h2 id={titleId} class="apg-dialog-title">
        {title}
      </h2>
      <button type="button" class="apg-dialog-close" data-dialog-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>
          <line x1="6" y1="6" x2="18" y2="18"></line>
        </svg>
      </button>
    </div>
    {
      description && (
        <p id={descriptionId} class="apg-dialog-description">
          {description}
        </p>
      )
    }
    <div class="apg-dialog-body">
      <slot />
    </div>
  </dialog>
</apg-dialog>

<script>
  class ApgDialog extends HTMLElement {
    private trigger: HTMLButtonElement | null = null;
    private dialog: HTMLDialogElement | null = null;
    private closeButton: HTMLButtonElement | null = null;
    private closeOnOverlayClick = true;

    connectedCallback() {
      this.trigger = this.querySelector('[data-dialog-trigger]');
      this.dialog = this.querySelector('[data-dialog-content]');
      this.closeButton = this.querySelector('[data-dialog-close]');

      if (!this.trigger || !this.dialog) {
        console.warn('apg-dialog: required elements not found');
        return;
      }

      this.closeOnOverlayClick = this.dataset.closeOnOverlay !== 'false';

      // Attach event listeners
      this.trigger.addEventListener('click', this.open);
      this.closeButton?.addEventListener('click', this.close);
      this.dialog.addEventListener('click', this.handleDialogClick);
      this.dialog.addEventListener('close', this.handleClose);
    }

    disconnectedCallback() {
      this.trigger?.removeEventListener('click', this.open);
      this.closeButton?.removeEventListener('click', this.close);
      this.dialog?.removeEventListener('click', this.handleDialogClick);
      this.dialog?.removeEventListener('close', this.handleClose);
    }

    private open = () => {
      if (!this.dialog) return;

      this.dialog.showModal();

      // Focus first focusable element (close button by default)
      const focusableElements = this.getFocusableElements(this.dialog);
      if (focusableElements.length > 0) {
        focusableElements[0].focus();
      }

      // Dispatch custom event
      this.dispatchEvent(
        new CustomEvent('dialogopen', {
          bubbles: true,
        })
      );
    };

    private close = () => {
      this.dialog?.close();
    };

    private handleClose = () => {
      // Return focus to trigger
      this.trigger?.focus();

      // Dispatch custom event
      this.dispatchEvent(
        new CustomEvent('dialogclose', {
          bubbles: true,
        })
      );
    };

    private handleDialogClick = (e: Event) => {
      // Close on backdrop click (clicking the dialog element itself, not its contents)
      if (this.closeOnOverlayClick && e.target === this.dialog) {
        this.close();
      }
    };

    private getFocusableElements(container: HTMLElement): HTMLElement[] {
      const focusableSelectors = [
        'a[href]',
        'button:not([disabled])',
        'input:not([disabled])',
        'select:not([disabled])',
        'textarea:not([disabled])',
        '[tabindex]:not([tabindex="-1"])',
      ].join(',');

      return Array.from(container.querySelectorAll<HTMLElement>(focusableSelectors)).filter(
        (el) => el.offsetParent !== null
      );
    }
  }

  // Register the custom element
  if (!customElements.get('apg-dialog')) {
    customElements.define('apg-dialog', ApgDialog);
  }
</script>

使い方

使用例
---
import Dialog from './Dialog.astro';
---

<Dialog
  title="Dialog Title"
  description="Optional description text"
  triggerText="Open Dialog"
>
  <p>Dialog content goes here.</p>
</Dialog>

API

プロパティ

プロパティ デフォルト 説明
title string 必須 ダイアログのタイトル(アクセシビリティ用)
description string - オプションの説明テキスト
triggerText string 必須 トリガーボタンのテキスト
closeOnOverlayClick boolean true オーバーレイクリックで閉じる
triggerClass string - トリガーボタンの追加CSSクラス
class string - ダイアログの追加CSSクラス

スロット

スロット 説明
default ダイアログのコンテンツ

カスタムイベント

イベント 説明
dialogopen ダイアログが開いたときに発火
dialogclose ダイアログが閉じたときに発火

テスト

テストは、キーボード操作、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) を参照してください。

リソース