ロジック層とデザイン層: data 属性でつなぐ境界
振る舞いは Base UI に、見た目は Tailwind CSS に分けると決めました。分けるからには、どこが境界かを言えなければなりません。
この章では、Base UI のダイアログが実際に DOM へ何を出すかを見ます。境界は、その出力の中にあります。
この章で学ぶこと
- ロジック層とデザイン層がそれぞれ受け持つもの
- Base UI のダイアログが DOM に付ける属性
- フォーカスの閉じ込めと復帰が何をしているか
- 開閉の状態を伝える
data-*属性と、Tailwind CSS での受け方 - 境界を崩した書き方と、その直し方
この章で扱わないこと
| 観点 | 参照先 |
|---|---|
hover: や focus: など、要素自身の状態を見る variant | 状態バリアント |
| ダイアログの実装の全体 | 03 Dialog を実装する |
| 属性が付いていることをテストで確かめる方法 | 05 役割でテストする |
2 つの層の責務
本ガイドでは、振る舞いを受け持つ側をロジック層、見た目を受け持つ側をデザイン層と呼びます。ロジック層は Base UI、デザイン層は Tailwind CSS のクラスです。
2 つの層は直接やり取りしません。ロジック層が状態を DOM の属性として出し、デザイン層がその属性を CSS のセレクタで読みます。ヘッドレス UIヘッドレス UI (Headless UI)見た目を持たず、振る舞いと HTML の構造と支援技術向けの属性だけを提供する UI 部品、またはその部品集。色や余白などのスタイルは使う側が書く。 と CSS のあいだに JavaScript の受け渡しが無いことが、この分け方の要点です。
受け持つものを並べると次のようになります。
| 観点 | ロジック層 (Base UI) | デザイン層 (Tailwind CSS) |
|---|---|---|
| 役割と名前 | role="dialog" を付け、タイトルと説明をダイアログに結びつける | 受け持たない |
| キーボード操作 | Esc キーで閉じる。Tab キーの移動を中に留める | 受け持たない |
| フォーカス | 開いたら中へ移し、閉じたら開いたボタンへ戻す | フォーカスが当たった要素の見せ方 (focus-visible:) |
| 背景 | ダイアログの外を支援技術から隠し、スクロールを止める | 背景の幕の色と濃さ |
| 開閉の状態 | 状態を持ち、data-* 属性として DOM に出す | 属性を見て、見た目と遷移を切り替える |
| 見た目 | 受け持たない | 色、余白、角丸、影、配置、画面幅への対応 |
左の列は、どのダイアログでも同じ内容です。右の列は、ダイアログごと、プロダクトごとに変わります。変わらないものをライブラリへ寄せ、変わるものを手元に残しています。
ロジック層が DOM に出すもの
次の HTML は、03 章で作るダイアログを開いたときの DOM です。実ブラウザから書き出したもので、クラス名と、Base UI が内部で使う data-base-ui- で始まる属性は省いています。id の値は Base UI が自動で振ったものです。
<!-- 開くボタン -->
<button type="button" tabindex="0" id="base-ui-_r_1_"
aria-haspopup="dialog" aria-expanded="true" aria-controls="_r_0_" data-popup-open>
お知らせ
</button>
<!-- ダイアログ本体。body の直下に作られた要素の中に描画される -->
<div role="dialog" id="_r_0_" tabindex="-1"
aria-labelledby="base-ui-_r_3_" aria-describedby="base-ui-_r_4_"
data-open style="--nested-dialogs: 0;">
<h2 id="base-ui-_r_3_">お知らせ</h2>
<p id="base-ui-_r_4_">未読のお知らせが 1 件あります。</p>
</div>
aria-controls の値がダイアログの id と、aria-labelledby の値が h2 の id と、それぞれ一致しています。01 章の自作のモーダルで useId を使って手で結んでいた部分です。
role と、aria- で始まる属性は、WAI-ARIA という W3C の仕様が定めているものです。HTML の要素だけでは伝わらない役割や状態を、スクリーンリーダーなどの支援技術へ伝えます。付いている属性の意味は次のとおりです。
| 属性 | 付く要素 | 意味 |
|---|---|---|
aria-haspopup="dialog" | 開くボタン | 押すとダイアログが開くことを伝えます |
aria-expanded="true" | 開くボタン | いま開いていることを伝えます。閉じている間は false になります |
aria-controls | 開くボタン | 開いたダイアログの id を指します。開いている間だけ付きます |
role="dialog" | ダイアログ本体 | この要素がダイアログであることを伝えます |
aria-labelledby | ダイアログ本体 | タイトルの id を指します。タイトルの文字がダイアログの名前になります |
aria-describedby | ダイアログ本体 | 説明文の id を指します |
tabindex="-1" | ダイアログ本体 | Tab キーでは止まらず、プログラムからはフォーカスを当てられる状態にします |
aria-hidden="true" | ダイアログの外にある要素 | 開いている間、背景の内容を支援技術から隠します |
aria-modal の扱い
01 章で触れた APG (WAI-ARIA Authoring Practices Guide) のモーダルダイアログのパターンは、ダイアログ本体に aria-modal="true" を付ける方法を挙げています。上の DOM に、この属性はありません。
Base UI は代わりに、ダイアログの外にある要素へ aria-hidden="true" を付けています。どちらも「いまはダイアログの外を読ませない」ことを支援技術へ伝える手段で、APG は両方を説明しています1。自作のモーダルで aria-modal を手で書いていた場合、Base UI では書く場所がなくなります。その役目は、背景の側に付く aria-hidden が果たしています。
ここから 1 つ、実務上の注意が出ます。開いている間は背景の全体が支援技術から隠れるので、ダイアログの中に閉じる手段が必ず要ります。Base UI の公式ドキュメントも、モーダルにするときは閉じるボタンをダイアログの中に置くよう求めています2。
フォーカスの閉じ込めと復帰
フォーカスの閉じ込め (フォーカストラップ) は、ダイアログが開いている間、Tab キーで動くフォーカスをダイアログの中に留めることです。閉じ込めが無いと、キーボードで操作している人は、画面上は幕に隠れている背景のボタンへ知らないうちに移動してしまいます。
Base UI のダイアログは、フォーカスについて次の 3 つを行います。
| 場面 | 既定の動作 | 変えるための props |
|---|---|---|
| 開いたとき | 中で最初にフォーカスできる要素へ移す | Dialog.Popup の initialFocus |
| 開いている間 | Tab キーと Shift + Tab キーの移動を中で一巡させる | Dialog.Root の modal |
| 閉じたとき | 開く操作をしたボタンへ戻す | Dialog.Popup の finalFocus |
modal は既定で true です。true のあいだは、フォーカスの閉じ込めに加えて、ページのスクロールの固定と、ダイアログの外へのポインター操作の無効化も行われます。'trap-focus' を渡すと閉じ込めだけが残り、false を渡すとどれも行われません3。
DOM をもう少し詳しく見ると、ダイアログ本体の前後に、画面には見えずフォーカスだけを受け取れる span が 1 つずつ置かれています。Base UI が閉じ込めのために足している要素です。
閉じ込めは、この span を使って実現されています。中の最後のボタンから Tab キーを押すと、フォーカスはいったん後ろの span に移ります。Base UI はそれを受けて、次の描画のタイミングで、中の最初のボタンへフォーカスを送り直します4。逆向きも同じです。送り直しは 1 フレームのうちに終わるので、操作している人には、端から端へ直接移ったように見えます。
この「いったん外へ出て、次のフレームで戻る」という動きは、06 章と 08 章でテストを書くときに効いてきます。
こうした内部の要素は版が変われば増減しうるので、スタイルやテストで「ダイアログの隣の要素」のような位置関係に頼らないようにします。頼ってよいのは、公式ドキュメントに載っている属性と、role や名前のような意味を持つ情報です。
開閉の状態を伝える data 属性
ロジック層は、開閉の状態を data-* 属性でも出します。ARIA の属性が支援技術へ伝えるためのものであるのに対し、こちらはスタイルを当てるためのものです。
| 属性 | 付く要素 | 付いている間 |
|---|---|---|
data-popup-open | 開くボタン | ダイアログが開いている間 |
data-open | ダイアログ本体と背景の幕 | 開いている間 |
data-starting-style | ダイアログ本体と背景の幕 | 開いた直後のわずかな間 |
data-closed | ダイアログ本体と背景の幕 | 閉じる操作をしてから、DOM から外れるまで |
data-ending-style | ダイアログ本体と背景の幕 | 閉じる操作をしてから、DOM から外れるまで |
ダイアログ本体に付く属性を、開いて閉じるまで順に記録すると、次のように移り変わります。
(DOM に無い) 閉じている間
data-open data-starting-style 開く操作の直後
data-open 開いている間
data-closed data-ending-style 閉じる操作の直後
(DOM に無い) 閉じる遷移が終わったあと
読み取れることが 2 つあります。
1 つは、閉じている間、ダイアログ本体は DOM に存在しないことです。隠しているのではなく、描画していません。
もう 1 つは、閉じる操作をしても、すぐには DOM から外れないことです。data-ending-style が付いた状態でしばらく残り、そのあとで外れます。この「しばらく」が、閉じるときの遷移を再生するための時間です。01 章の自作のモーダルに足りなかった 2 つめがここで埋まります。仕組みは 03 章で見ます。
Tailwind CSS で属性を受ける書き方
Tailwind CSS には、属性の有無でスタイルを切り替える data-* の variant があります5。属性があるかどうかだけを見るなら、属性の名前をそのまま書きます。
| 書き方 | 効く条件 |
|---|---|
data-open:opacity-100 | 要素に data-open 属性があるとき |
data-starting-style:opacity-0 | 要素に data-starting-style 属性があるとき |
data-[side=top]:mb-2 | 要素の data-side 属性の値が top のとき |
3 行めのように値まで見るときは、角括弧で囲みます。Dialog が出す属性に値を持つものはありませんが、Popover が出す data-side (表示されている向き) のような属性で使います。
03 章のダイアログで使う data-starting-style:opacity-0 は、ビルドすると次の CSS になります。
.data-starting-style\:opacity-0[data-starting-style]{opacity:0}
data-starting-style 属性が付いている要素だけ、不透明度が 0 になります。属性の付け外しは Base UI が行い、付いている間の見た目だけを Tailwind CSS が決めています。
属性の名前を角括弧で囲んで data-[open]: と書くこともできます。ビルドした CSS を比べると、どちらも同じ [data-open] というセレクタになります。
.data-open\:underline[data-open]{text-decoration-line:underline}
.data-\[open\]\:overline[data-open]{text-decoration-line:overline}
本ガイドは、Base UI の公式ドキュメントのコード例と同じく、角括弧の無い形で書きます。
境界を崩す書き方
境界がどこにあるかは、崩した例を見ると分かりやすくなります。次のコードは動きますが、ロジック層が持っている情報を、デザイン層のために自分でもう一度持っています。
import { Dialog } from '@base-ui/react/dialog'
import { useState } from 'react'
/**
* 層の境界を崩した書き方の例。
*
* Base UI がすでに持っている「開いているかどうか」を、見た目を切り替えるためだけに
* 自分でもう一度持っている。
*/
export function BoundaryBroken() {
// ✗ 見た目のために、開閉の状態を複製している
const [open, setOpen] = useState(false)
return (
<Dialog.Root open={open} onOpenChange={setOpen}>
<Dialog.Trigger
// ✗ Base UI が付ける aria-expanded を、手でも書いている
aria-expanded={open}
// ✗ 状態を JavaScript で見て、クラス名を出し分けている
className={open ? 'bg-slate-200' : 'bg-white'}
>
お知らせ
</Dialog.Trigger>
<Dialog.Portal>
{/* ✗ 透明かどうかも JavaScript で出し分けている */}
<Dialog.Popup className={open ? 'opacity-100' : 'opacity-0'}>
<Dialog.Title>お知らせ</Dialog.Title>
<Dialog.Close>閉じる</Dialog.Close>
</Dialog.Popup>
</Dialog.Portal>
</Dialog.Root>
)
}
/** 開いている間の見た目の切り替えを、境界を保って書いた例。状態は 1 行も持たない。 */
export function BoundaryKept() {
return (
<Dialog.Root>
{/* ○ 開いている間は Trigger に data-popup-open が付く。CSS だけで出し分けられる */}
<Dialog.Trigger className="bg-white data-popup-open:bg-slate-200">お知らせ</Dialog.Trigger>
<Dialog.Portal>
{/* ○ 開く瞬間と閉じる間だけ付く属性を使い、その間を transition でつなぐ */}
<Dialog.Popup className="transition-opacity data-starting-style:opacity-0 data-ending-style:opacity-0">
<Dialog.Title>お知らせ</Dialog.Title>
<Dialog.Close>閉じる</Dialog.Close>
</Dialog.Popup>
</Dialog.Portal>
</Dialog.Root>
)
}
BoundaryBroken の ✗ は 4 か所で、余計なものは 3 種類あります。
- 開閉の状態を
useStateで複製しています。見た目を切り替えるためだけに持っているので、Base UI の状態と二重になります aria-expandedを手で書いています。Base UI が付ける属性と同じものです- 状態を JavaScript で見て、クラスを出し分けています (ボタンの色と、ダイアログ本体の不透明度の 2 か所)。
data-popup-openとdata-starting-styleを見れば、CSS だけで書けます
BoundaryKept は、開いている間のボタンの色と、ダイアログの現れ方を、状態を 1 つも持たずに書いています。属性の付け外しに合わせて CSS が切り替わるので、開閉の遷移も付けられます。
誤解しやすい点を 1 つ補います。open と onOpenChange を渡して開閉を外から制御すること自体は、Base UI が用意している正規の使い方です。フォームの送信が終わったら閉じる、別の場所のボタンから開く、といった場面で使います。崩れているのは、見た目のためだけに状態を持ち出している点です。開閉をアプリの都合で動かす必要があるときは制御し、見た目を変えたいだけなら属性を見る、と使い分けます。
まとめ
- ロジック層 (Base UI) は、役割と名前、キーボード操作、フォーカス、開閉の状態を受け持ちます
- デザイン層 (Tailwind CSS) は、色や余白と、状態に応じた見た目を受け持ちます
- 2 つの層をつなぐのは、Base UI が DOM に出す
data-*属性です。JavaScript での受け渡しはありません - Base UI は
aria-modalを付けず、ダイアログの外の要素にaria-hidden="true"を付けます - 閉じる操作をしたあとも、
data-ending-styleが付いた状態で要素がしばらく残ります。この間に閉じる遷移が再生されます - 見た目を変えたいだけのときは、状態を複製せず、属性を見て書きます
次に読む
- 03 Dialog を実装する — ここで見た属性を使って、ダイアログを組み立てます
- 状態バリアント —
hover:やgroup-*など、Tailwind CSS のほかの variant