Escapeキーによる複数コンポーネントの同時クローズを解決

shoelace-style/webawesome

ネストされたオーバーレイコンポーネント(drawer、dialog、select、dropdown、tooltip、popover、color-picker)が開いている状態でEscapeキーを押すと、すべてのコンポーネントが同時に閉じる問題を修正しました。dismissible stackという共有メカニズムを導入することで、最前面のコンポーネントのみがEscapeキーに応答するようになります。

背景

Web Awesomeの各オーバーレイコンポーネントは、独立して documentkeydown イベントリスナーを登録していました。#2085 で報告されたように、<wa-drawer> 内の <wa-select> でメニューを開いた状態でEscapeキーを押すと、selectのメニューだけでなくdrawerも閉じてしまいます。これはアクセシビリティ上の問題でもあり、キーボードユーザーは複数階層のUIが同時に閉じることを期待しません。

各コンポーネントが独自にキーイベントを処理する実装では、イベント伝播の制御だけでは解決できません。すべてのコンポーネントが同じ document レベルでリスナーを登録しているため、Escapeキーが押されるとすべてのリスナーが同時に呼び出されます。開いているコンポーネント間で優先順位を調整する仕組みが必要でした。

技術的な変更

src/internal/dismissible-stack.ts に新しい共有モジュールを追加し、開いているdismissibleコンポーネントをスタック構造で管理します。

const dismissibleStack: object[] = [];

export function registerDismissible(key: object): void {
  dismissibleStack.push(key);
}

export function unregisterDismissible(key: object): void {
  for (let i = dismissibleStack.length - 1; i >= 0; i--) {
    if (dismissibleStack[i] === key) {
      dismissibleStack.splice(i, 1);
      break;
    }
  }
}

export function isTopDismissible(key: object): boolean {
  return dismissibleStack.length > 0 && dismissibleStack[dismissibleStack.length - 1] === key;
}

このモジュールは3つの関数を提供します。registerDismissible() はコンポーネントが開いたときにスタックに追加、unregisterDismissible() は閉じたときにスタックから削除、isTopDismissible() は指定されたコンポーネントがスタックの最上位かを判定します。コンポーネントのインスタンス自体を識別キーとして使用することで、型に依存しない汎用的な実装になっています。

各オーバーレイコンポーネント(<wa-dialog><wa-drawer><wa-select><wa-dropdown><wa-tooltip><wa-popover><wa-color-picker>)に以下の変更を適用しました。

変更前:

private handleDocumentKeyDown = (event: KeyboardEvent) => {
  if (event.key === 'Escape' && this.open) {
    event.preventDefault();
    event.stopPropagation();
    this.requestClose(this.dialog);
  }
};

変更後:

import { isTopDismissible, registerDismissible, unregisterDismissible } from '../../internal/dismissible-stack.js';

private addOpenListeners() {
  document.addEventListener('keydown', this.handleDocumentKeyDown);
  registerDismissible(this);
}

private removeOpenListeners() {
  document.removeEventListener('keydown', this.handleDocumentKeyDown);
  unregisterDismissible(this);
}

private handleDocumentKeyDown = (event: KeyboardEvent) => {
  if (event.key === 'Escape' && this.open && isTopDismissible(this)) {
    event.preventDefault();
    event.stopPropagation();
    this.requestClose(this.dialog);
  }
};

Escapeキーのハンドラに isTopDismissible(this) 条件を追加することで、スタックの最上位にあるコンポーネントのみがイベントを処理します。コンポーネントが開くときに registerDismissible(this) を呼び出してスタックに登録し、閉じるときに unregisterDismissible(this) で登録を解除します。

<wa-select> では disconnectedCallback() にも登録解除処理を追加し、コンポーネントがDOMから削除される際にスタックからも確実に削除されるようにしています。

disconnectedCallback() {
  super.disconnectedCallback();
  this.removeOpenListeners();
}

各コンポーネントのテストファイルには、dismissible stackの動作を検証するテストケースが追加されました。例えば <wa-dialog> のテストでは、dialog内のpopoverを開いた状態でEscapeキーを押すと、popoverのみが閉じてdialogは開いたままになることを確認しています。

設計判断

dismissible stackは src/internal/scroll.ts の scroll lock パターンをモデルにしています。scroll lockも複数のコンポーネントが同時にスクロールを制御する必要がある場合に、カウンタ方式で最後にロックを解除したコンポーネントまでスクロールを無効に保ちます。

dismissible stackではカウンタではなく配列を使用し、開いた順序を保持します。これにより「最後に開いたコンポーネント」を特定でき、ネストの深さに関係なく正しい優先順位を実現できます。コンポーネントのインスタンスを直接識別キーとして使用することで、型システムや継承階層に依存しない実装になっています。

internal/dismissible-stack.ts を独立したモジュールとして切り出すことで、各コンポーネントの実装は最小限の変更で済んでいます。既存のイベントハンドラに条件を1つ追加し、開閉処理に登録・登録解除の呼び出しを追加するだけです。

コントリビューターガイド(docs/docs/resources/contributing.md)に「Dismissible Overlays」セクションが追加され、今後新しいオーバーレイコンポーネントを追加する際の実装パターンが文書化されました。これにより、同じ問題が再発することを防ぎます。

まとめ

本PRは、複数のオーバーレイコンポーネントが開いている状態でのEscapeキー処理を調整する共有メカニズムを導入しました。dismissible stackによって各コンポーネントが開いた順序を追跡し、最前面のコンポーネントのみがキーイベントに応答するようになります。これにより、ネストされたUIでのキーボード操作が直感的になり、アクセシビリティが向上しました。

記事メタデータ

Generated by:
Claude Sonnet 4.5 for DiffDaily

この記事はAIによって自動生成されています。内容の正確性については、必ずソースコードやPRを確認してください。

品質レビュー結果

Review Status:
承認済み
Review Count:
1回
Reviewed by:
Gemini 2.5 Pro for DiffDaily

Review Criteria:

記事構成 ✓ PASS

Title, Context, Technical Detailの存在と明確さ

リード文(総論)→セクション群(各論)→まとめ(結論)の3部構成が明確に適用されており、必須要素(背景、技術的変更)と任意要素(設計判断)もすべて含まれています。

カスタムMarkdown構文 ✓ PASS

シンタックスハイライト・GitHubリンク記法の正確性

ファイル名付きシンタックスハイライト、GitHubのIssue/PRへのリンク記法が正しく使用されています。

対象読者への適合性 ✓ PASS

エンジニア向けの適切な技術レベルと表現

Webコンポーネントライブラリの内部実装に関する内容であり、専門知識を持つエンジニアという対象読者に適した技術レベルと表現です。

パラグラフ・ライティング ✓ PASS

トピックセンテンス・1段落1トピック・段落長

各セクションが総論→各論の構成になっており、各段落はトピックセンテンスで始まり、1段落1トピックの原則が守られています。可読性が高いです。

Diff内容との照合 ✓ PASS

コードブロックとDiff内容の一致

記事内のコード引用は、Diffに存在するコードを正確に反映しています。コメントの省略は可読性を高めるための適切な判断です。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

「dismissible stack」「keydownイベントリスナー」「disconnectedCallback」など、技術用語が文脈に応じて正確に使用されています。

説明の技術的正確性 ✓ PASS

技術的主張の正確性と論理性

共有スタックによるイベント処理の優先順位付けの仕組みなど、コード変更に関する説明は技術的に正確で論理的です。

事実の突合 ✓ PASS

PR情報による主張の裏付け(ハルシネーション検出)

記事内のすべての主張(問題の背景、解決策、設計判断など)は、PRのDescriptionやDiff内のコード、ドキュメント変更によって裏付けられており、ハルシネーションは見られません。

数値・固有名詞の確認 ⚠ WARNING

PR番号・コミットID・バージョン等の正確性

PR番号(#2096)やIssue番号(#2085)は正確です。ただし、コードブロックのファイルパスがリポジトリのルートからではなく、`packages/webawesome`ディレクトリからの相対パスで記述されています。

タイトル・説明との一致 ✓ PASS

記事タイトル・説明とPR内容の一致

記事のタイトルはPRが解決した「問題」に焦点を当てており、PRのタイトル(実装内容)と合わせて、内容を的確に表現しています。

外部知識の正確性 ✓ PASS

PRに記載のない外部知識(LTS、サポート状況など)の不使用

バージョンサポート情報やリリース日程など、PR情報に基づかない外部知識の追加はありません。

時間表現の正確性 ✓ PASS

時間表現がPR情報と一致しているか

「修正しました」などの過去形の表現が使われており、完了した変更を報告するPRの内容と時間表現が一致しています。