非表示コンテナ内のカルーセルでスライドが操作不能になるバグを修正
<wa-carousel> を非表示のタブパネル内に配置すると、タブを開いてもスライドのコンテンツが操作できないバグが修正されました。ResizeObserver を用いた一度限りの再同期処理により、レイアウト確定後に正しく inert 属性が解除されるようになります。
背景
<wa-carousel> を display: none のコンテナ(例: 非アクティブなタブパネル)内に配置すると、スライド内のボタンやリンクが一切クリックできないという問題が発生していました。この問題は #1164 として報告されていました。
原因は初期化タイミングの競合にあります。firstUpdated() が呼ばれて initializeSlides() が実行される時点では、親コンテナが display: none のためレイアウト計算が行われていません。initializeSlides() が内部で呼ぶ synchronizeSlides() は root: this.scrollContainer を指定した IntersectionObserver を生成しますが、レイアウト寸法がゼロのため全スライドが「非交差」と判定され、全スライドに inert 属性が付与されます。
問題を深刻にしているのは、IntersectionObserver がその後切断されることです。タブが後から表示されても再同期をトリガーするイベントが存在しないため、ユーザーがカルーセルを手動で操作(スクロールなど)するまで inert 属性は残り続けます。視覚的には正常に見えるにもかかわらず、スライド内のすべての対話要素が無効化されるという、見つけにくいバグでした。
技術的な変更
firstUpdated() に一度限りの ResizeObserver を追加し、カルーセルが実際に表示されてレイアウト寸法を持った時点で synchronizeSlides() を再実行する仕組みが導入されました。
追加コード(carousel.ts):
this.resizeObserver = new ResizeObserver(() => {
if (this.scrollContainer?.clientWidth || this.scrollContainer?.clientHeight) {
this.synchronizeSlides();
this.resizeObserver?.disconnect();
this.resizeObserver = undefined;
}
});
this.resizeObserver.observe(this);
scrollContainer の clientWidth または clientHeight が非ゼロになった瞬間を検知し、synchronizeSlides() を呼び出してスライドの inert 状態を修正します。その後、オブザーバーは即座に自分自身を切断し、undefined に設定されます。これにより、再同期は厳密に1回だけ実行されます。
安全網として、disconnectedCallback() にも resizeObserver?.disconnect() が追加されています。カルーセルが表示される前に DOM から削除された場合でも、オブザーバーが確実にクリーンアップされます。
disconnectedCallback(): void {
super.disconnectedCallback();
this.mutationObserver?.disconnect();
this.resizeObserver?.disconnect(); // 追加
}
最初から表示されているカルーセルへの影響はありません。ResizeObserver は非ゼロ寸法で即座に発火し、synchronizeSlides() を1回呼び出して切断するため、既存の動作と実質的に同等です。
設計判断
ResizeObserver を一度限りのトリガーとして使用する方式 が採用されました。
このアプローチが合理的な理由は、問題の本質が「表示タイミングの検知」にあるからです。display: none から display: block への変化を直接観察するAPIは存在しませんが、その副作用としてレイアウト寸法が非ゼロになるという事実を ResizeObserver で捉えられます。既存の synchronizeSlides() ロジックを再利用するだけで修正が完結しており、コンポーネントの状態管理の複雑さを最小限に抑えています。
オブザーバーが役割を果たした後すぐに切断・破棄される設計も重要です。resizeObserver フィールドを undefined に戻すことで、参照を明示的に解放し、ライフサイクル管理を明確にしています。
テストでは、display: none のラッパー要素内にカルーセルを配置し、container.style.display = 'block' で表示に切り替えた後、ResizeObserver と IntersectionObserver のコールバックが落ち着くまで待機してからアサートする形式が採られています。これにより、今後の変更でこの挙動がリグレッションした場合に確実に検出できます。
まとめ
この修正は、コンポーネントの初期化タイミングとブラウザのレイアウトサイクルのずれという本質的な課題に対して、既存APIの組み合わせで最小限の変更として対処した好例です。ResizeObserver の「寸法がゼロでなくなった瞬間」というセマンティクスを活用することで、display: none から display: block への変化という直接観察できないイベントを間接的に捉えています。