メインコンテンツまでスキップ

ロジック層とデザイン層: 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 と 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]: という書き方

属性の名前を角括弧で囲んで 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 の公式ドキュメントのコード例と同じく、角括弧の無い形で書きます。

境界を崩す書き方​

境界がどこにあるかは、崩した例を見ると分かりやすくなります。次のコードは動きますが、ロジック層が持っている情報を、デザイン層のために自分でもう一度持っています。

src/examples/boundary-violation.tsx
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 が付いた状態で要素がしばらく残ります。この間に閉じる遷移が再生されます
  • 見た目を変えたいだけのときは、状態を複製せず、属性を見て書きます

次に読む​

Footnotes​

  1. Dialog (Modal) Pattern の「WAI-ARIA Roles, States, and Properties」の節です。aria-modal は ARIA 1.1 で入った属性で、ダイアログの外が操作できないことを支援技術へ伝える役目を aria-hidden から引き継ぐものだと説明されています。aria-hidden を使う形は以前からの実装とされていて、その場合は、ダイアログの外の層を構成する要素すべてに付けることと、ダイアログ自身が aria-hidden の付いた要素の子孫にならないことが条件に挙げられています。 ↩

  2. Dialog の API reference で、Dialog.Root の modal の説明に記載があります。タッチ操作のスクリーンリーダーの利用者がダイアログから出られるようにするため、とされています。 ↩

  3. 同じく Dialog.Root の modal の説明です。initialFocus と finalFocus は Dialog.Popup の props の表にあります。タッチ操作で開いたときは、画面のキーボードが出ないよう、最初の要素ではなくダイアログ本体にフォーカスが当たります。 ↩

  4. Base UI 1.8 のソースでは、この span がフォーカスを受けると、requestAnimationFrame で次のフレームに送り直しを予約しています (FloatingFocusManager と enqueueFocus)。送り直しが終わる前に次の予約が入ると、前の予約は取り消されます。公式ドキュメントに載っている仕様ではなく実装の詳細なので、版が上がれば変わりえます。 ↩

  5. Hover, focus, and other states の「Data attributes」の節です。 ↩