インライン要素の迷宮:フォーカストラップにおける「見えない境界線」の設計論
フロントエンドのアーキテクチャにおいて、「フォーカストラップ(Focus Trap)」は一見すると単純なDOM操作に見える。しかし、モーダルやドロップダウンの中に``や``、``が混在する複雑なDOM構造を扱う際、この実装はしばしばメモリリークやアクセシビリティの崩壊、あるいは予期せぬレイアウトシフトを引き起こす「地雷原」へと変貌する。
本稿では、単なるライブラリの使用ではなく、ブラウザのフォーカス管理メカニズムとレンダリングパイプラインを深く理解した上での、堅牢なフォーカストラップの実装戦略を論じる。
---
1. フォーカストラップの死角:インライン要素がもたらす「非直感」
多くのエンジニアが陥る罠は、`tabindex`を闇雲に付与したり、すべてのフォーカス可能要素をフラットに取得しようとすることだ。特に``タグや、場合によっては`contenteditable`が適用されたインライン要素が混在する状況下では、ブラウザのフォーカスリングの遷移はレンダリングエンジン側で複雑な計算を要する。
フォーカストラップにおいて最も重要なのは、「フォーカス可能か否か」の判定を、レンダリング負荷の低い静的解析と、非同期処理を考慮した動的監視の二段構えで行うことである。
---
2. アーキテクチャ設計:TypeScriptによる型安全なトラップ制御
まずは、フォーカス可能な要素を厳格に抽出するユーティリティを定義しよう。ここで重要なのは、`display: none`や`visibility: hidden`で描画から外された要素を、リフローコストを払わずに判定する仕組みだ。
/
- フォーカス可能な要素を抽出するためのCSSセレクタ定数
- ブラウザの内部挙動に従い、非表示要素はCSSで除外する
/
const FOCUSABLE_SELECTOR = [
'a[href]',
'area[href]',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'button:not([disabled])',
'iframe',
'[tabindex]:not([tabindex="-1"])',
'[contenteditable]'
].join(',');
/
- 対象要素が視覚的に存在し、インタラクション可能かを判定
- offsetParentはリフローを伴う可能性があるため、必要最小限のチェックに留める
/
const isVisible = (el: HTMLElement): boolean => {
return !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length);
};
---
3. 非同期競合とメモリ効率の最適化
モーダルが動的に生成されるWebアプリケーションでは、`MutationObserver`を適切に活用しなければならない。しかし、これを不用意に実装すると、DOMツリーの変化のたびにイベントリスナーが再生成され、メモリリークの温床となる。
ここで推奨されるのは、「フォーカス管理のロジックをコンテナ単位でカプセル化し、脱着時に完全にイベントをクリーンアップする」という設計だ。
class FocusTrap {
private container: HTMLElement;
private firstElement: HTMLElement | null = null;
private lastElement: HTMLElement | null = null;
constructor(container: HTMLElement) {
this.container = container;
this.handleKeyDown = this.handleKeyDown.bind(this);
}
public activate() {
this.updateFocusableElements();
this.container.addEventListener('keydown', this.handleKeyDown);
this.firstElement?.focus();
}
private updateFocusableElements() {
// コンテナ内の全フォーカス可能要素を再走査
const elements = Array.from(
this.container.querySelectorAll
).filter(isVisible);
this.firstElement = elements[0] || null;
this.lastElement = elements[elements.length - 1] || null;
}
private handleKeyDown(e: KeyboardEvent) {
if (e.key !== 'Tab') return;
if (e.shiftKey) {
// Shift + Tab の逆順遷移処理
if (document.activeElement === this.firstElement) {
e.preventDefault();
this.lastElement?.focus();
}
} else {
// 通常 Tab の順方向遷移処理
if (document.activeElement === this.lastElement) {
e.preventDefault();
this.firstElement?.focus();
}
}
}
public deactivate() {
// メモリリークを防ぐためのクリーンアップ
this.container.removeEventListener('keydown', this.handleKeyDown);
}
}
---
4. プロの視点:エッジケースを攻略する
上級者であれば、以下のエッジケースを考慮せずに実装を完結させてはならない。
1. インライン要素の装飾(``, ``など)の誤判定:
これらは通常フォーカスを受け取らないが、`tabindex`が動的に付与される設計になっている場合がある。`querySelectorAll`の結果を常に最新のDOM状態と同期させるための仕組み(`MutationObserver`との併用)が必須となる。
2. `time`要素の罠:
`time`要素自体はフォーカスを受け取らないが、ツールチップを表示させるためのトリガーとして使われることがある。この場合、フォーカス可能なのは`time`ではなくその親のボタンであることを認識し、DOM階層を再帰的に走査する必要がある。
3. リフローの抑制:
`document.activeElement`の取得や`focus()`の呼び出しは、タイミングによってはブラウザの再描画を強制する。アニメーション中のモーダルに対してこれを行うと、カクつき(Jank)が発生する。`requestAnimationFrame`を使用して、描画サイクルの最後にフォーカス移動を同期させるのが定石だ。
結論:コードの向こう側にあるUX
フォーカストラップは、ただキーボードの挙動を縛るだけの処理ではない。それは、ユーザーが「今、どこで何をしているのか」というコンテキストを壊さないための、極めて繊細な配慮である。
インライン要素が入り乱れるマークアップであっても、上記のような型安全な設計と、レンダリングコストを意識したイベント管理を徹底すれば、大規模なWebアプリケーションにおいても破綻することのない堅牢なUIを提供できるはずだ。技術の細部に宿る「泥臭さ」を愛する諸君にとって、この実装は必ずや良質な武器となるだろう。

コメント