本文へ

開発記録

UI ライブラリを置き換えるときは、ページが依存する契約を守る

このサイトの共通コンポーネント層と Button、Drawer のイベント変換を例に、基盤のコンポーネントを置き換える際のページ側の依存、互換レイヤーの責任、実際の操作で確かめるべき動作を整理します。

林予晴
公開
  • コンポーネント設計
  • Astro
  • Starwind UI
  • リファクタリング

ボタンを別のコンポーネントライブラリのものに置き換えても、見た目はほとんど変わらないかもしれません。ただ、ページが依存しているのは見た目だけではありません。リンクとして出力すること、デフォルトではフォームを送信しないこと、データ属性を通じて Drawer のトリガーになることも、必要な場合があります。

各ページが新しいパッケージの内部的な命名まで知る必要があると、置き換えの影響は利用側全体に広がります。後から下層の実装をもう一度替えるときも、同じ細部を確認し直さなければなりません。

2026 年 10 月 24 日時点のこのサイトでは、ページと Starwind のソースコードや runtime の間に、サイト側で所有する共通コンポーネント層を残していました。ページが下層のコンポーネントライブラリを使うための、固定の入口です。このノートでは、その時点の使用箇所をもとに、この境界が何を守り、何までは単独で証明できないのかを整理します。すべてのコンポーネントを置き換えるための一般的な手順ではありません。

ページはサイトのインターフェースに依存し、そのインターフェースが下層の実装を選びます

この共通コンポーネント層は、サイト独自のインターフェースを提供します。ページはサイト側の ui/button や ui/drawer からコンポーネントを参照し、Starwind のソースコードを置くディレクトリや runtime には直接依存しません。

関係を簡略化すると、次のようになります。

ページと記事のレイアウト
  → サイトの共通コンポーネント層
    → ローカルの variants、Starwind primitive、runtime

どの層でも、同じコンポーネントをさらに一つ包む必要があるという意味ではありません。共通コンポーネント層の中には、primitive を直接組み合わせるものもあれば、ローカルの variants を使いながらネイティブ HTML の出力を保つものもあります。重要なのは wrapper の数ではなく、外部への依存をどこで解釈するかです。

何を契約として残すのかも、切り分ける必要があります。props の名前、デフォルト値、slot、ネイティブのタグ、data-slot は、すでにページやスクリプトから使われている可能性があります。import 名だけを残しても、互換性を保ったことにはなりません。

Button:同じ名前でも、同じインターフェースとは限りません

このサイトの共通 Button は、as="button"、as="a"、as="summary" に対応しています。button を出力する分岐ではデフォルトが type="button" で、フォームを送信する必要があるときは type="submit" を明示します。リンクの分岐では、実際の anchor を出力します。

また、variants と呼び出し側の class をマージし、data-slot を保持します。data-slot に Drawer や Popover の trigger/close が指定されている場合は、下層の runtime が認識する対応するデータ属性を、このコンポーネントが補います。

つまり、この入口は、上流にある同名の Button を単に再エクスポートしているわけではありません。button と anchor だけに対応した別のコンポーネントに替えると、デフォルトの見た目が同じでも、summary や overlay に関わる使い方を保てない可能性があります。

Button の見た目と HTML のセマンティクスでは、どの要素を選ぶべきかを説明しています。ここで守りたいのは、すでにページ側で選んだものを、下層の置き換えに紛れて変えてしまわないことです。

イベントも契約であり、名前だけを変えればよいものではありません

記事の目次のスクリプトは、Drawer に drawer:set を送り、{ open: false } で閉じるよう要求します。また、drawer:change と drawer:change-complete を監視し、状態が変わった時点と閉じ終わった時点で更新処理をスケジュールします。

ここで扱う互換レイヤーは、これらのサイト側のイベントを Starwind の controller に接続します。drawer:set を受け取ると、まず detail.open が真偽値であることを確認し、それから setOpen を呼び出します。runtime の openChange と closeComplete は、それぞれサイトが従来から監視しているイベントに変換します。

実際の責任を対応させると、次のようになります。

サイト側の依存 互換レイヤーが処理していること
drawer:set を送る detail.open を確認してから controller に切り替えを要求
drawer:change を監視する openChange の状態情報を転送
drawer:change-complete を監視する closeComplete の完了情報を転送
イベントの発生元を表すフィールド runtime の event を originalEvent に対応付ける

「閉じるよう要求する」「状態が変わる」「閉じ終わる」は、それぞれ別の時点です。イベント名が似ているからといって、すべてを同じ callback につなぎ、ページ側の後続処理に影響しないと考えてはいけません。

この表が示しているのは、列挙した変換だけです。すべてのイベントについて、バブリング、キャンセル、発火のタイミングが完全に等価だと保証するものではありません。互換レイヤーには、開閉の意図を伝えるための変換もあります。キャンセルが関わる動作は、その処理経路をたどって確認する必要があります。CustomEvent が存在するというだけでは、互換性が全面的に保たれているとは判断できません。

スタイルの対応付け、イベント変換、操作の検証は別の仕事です

共通の色で新しいコンポーネントをサイトの見た目になじませ、variants でボタンの外観を保ち、互換レイヤーでイベントを引き継ぐことはできます。ただし、それだけでフォーカス、Escape、外側のクリックが引き続き想定どおりに動くと証明できるわけではありません。

記事の目次で使うモーダルな Drawer なら、下層の実装を替えた後も実際に開いて、フォーカスが入る位置、Tab と Shift + Tab、閉じた後のフォーカス先、Escape の処理を確認する必要があります。WAI のモーダルダイアログのパターンでも、これらは具体的なインタラクションの責任として挙げられています。閉じるときは通常、フォーカスをトリガー要素に戻しますが、ワークフローに合った例外もあります。[5]

このサイトの互換レイヤーには、pagehide でのクリーンアップと pageshow での再登録もあります。コードにこうした分岐があることから分かるのは、ライフサイクルの処理経路が用意されているという点までです。ページへ戻った後もリスナーが重複せず、操作が有効なままであることを確かめるには、対応するブラウザーの状況で検証する必要があります。ソースコードを読むだけで、BFCache を実際に検証したことにはなりません。

JavaScript なしの状態も、個別に確認する必要があります。記事本文とネイティブのリンクは読める状態に保つべきで、目次には noscript の一覧もあります。このコンテンツの代替経路があるからといって、runtime のインタラクティブなコンポーネントも JavaScript なしで同じように動くとは限りません。

互換レイヤーは、引き受ける差分を説明できる必要があります

共通コンポーネント層を残すことは、古い動作をすべて永久に固定することではありません。ページの契約自体が不適切なら、変えることはできます。ただし、パッケージの置き換えに紛れ込ませるのではなく、明示的なインターフェース変更として扱うべきです。

私はまず、実際の使用箇所を一つ選び、その箇所が依存している props、HTML、slot、データ属性、イベントを明確にします。置き換えた後は、出力と操作を分けて確認します。Button のタグやデフォルト属性を確認しても、Drawer のフォーカスや閉じる動作のテストの代わりにはなりません。デモページで動いても、すべてのページが正しく接続されているとは言えません。

ローカル UI コンポーネントの保守責任では、ソースコードをプロジェクトに取り込んだ後の差分管理を扱っています。このノートは、置き換えの際にもう一つの問いを加えます。各ページが下層の実装を学び直さずに済むよう、どの差分をサイト自身の入口に残すべきか、という問いです。

このノートは、前述の観察時点の契約と変換を整理したものです。すべての overlay、ライフサイクル、サードパーティーのバージョンを改めて検証したわけではなく、置き換えで何時間の作業を削減できたかも測定していません。共通コンポーネント層があれば、差分を追える場所ができます。ただし、例外を増やし続けなければ維持できない状態になったら、ラッパーの中にさらにコストを隠すのではなく、ページの契約を見直すべきです。

参考資料

[5] https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal — W3C WAI — Dialog (Modal) Pattern