Escapeキーによる複数コンポーネントの同時クローズを解決
ネストされたオーバーレイコンポーネント(drawer、dialog、select、dropdown、tooltip、popover、color-picker)が開いている状態でEscapeキーを押すと、すべてのコンポーネントが同時に閉じる問題を修正しました。dismissible stackという共有メカニズムを導入することで、最前面のコンポーネントのみがEscapeキーに応答するようになります。
背景
Web Awesomeの各オーバーレイコンポーネントは、独立して document の keydown イベントリスナーを登録していました。#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でのキーボード操作が直感的になり、アクセシビリティが向上しました。