Dialog を実装する: 状態も ARIA も書かないモーダル
この章で、ガイド全体の題材になるダイアログを作ります。お知らせの一覧を表示し、まとめて既読にできるダイアログです。
書き終えたコードには、useState も、aria- で始まる WAI-ARIAWAI-ARIAW3C が定めた、HTML の要素に役割・状態・性質を補うための属性の仕様。role 属性と aria- で始まる属性から成り、スクリーンリーダーなどの支援技術に UI の意味を伝える。 の属性も、onKeyDown も出てきません。role は 1 か所だけ手で書いていますが、Base UI ではなく Tailwind CSS の側の事情によるものです。01 章で数えた仕事がどこへ行ったのかを、コードと対応させながら確かめます。
この章で学ぶこと
- Base UI と Tailwind CSS をプロジェクトに入れる手順
- Dialog を構成するパーツと、それぞれの役割
classNameにクラスを直接渡す書き方data-starting-styleとdata-ending-styleを使った開閉の遷移- 状態と ARIA の属性を書かずに済む理由
- 押すと無効になるボタンで、フォーカスを失わない書き方
- 描画する要素を
renderprop で差し替える方法
この章で扱わないこと
| 観点 | 参照先 |
|---|---|
| Tailwind CSS の導入の詳細と、各クラスの意味 | 環境構築 と Tailwind CSS ガイド |
transition と duration の基本 | アニメーション |
| このコンポーネントのテスト | 06 単体テスト 以降 |
プロジェクトを用意する
Vite の React と TypeScript のテンプレートから始めます。
npm create vite@latest base-ui-dialog-lab -- --template react-ts
作られたディレクトリで、Base UI と Tailwind CSS を入れます。Base UI は、すべての部品が @base-ui/react という 1 つのパッケージに入っています。
npm install
npm install @base-ui/react
npm install tailwindcss @tailwindcss/vite
Tailwind CSS は Vite のプラグインとして組み込みます。vite.config.ts に import を 1 行足し、plugins に加えます。
import tailwindcss from '@tailwindcss/vite'
// tailwindcss() を足すと、Vite が CSS を処理するときに Tailwind CSS のクラスが生成される
plugins: [react(), tailwindcss()],
CSS のファイルは、テンプレートが生成した中身を消して次の内容にします。
/* Tailwind CSS v4 は、この 1 行で全ユーティリティを読み込む (tailwind.config.js は要らない) */
@import "tailwindcss";
/*
* Base UI のダイアログは Portal で body の直下に描画される。
* アプリ側を独立した重なりの層にしておくと、アプリ内の z-index に関係なく
* ダイアログが必ず手前に出る。
*/
#root {
isolation: isolate;
}
/*
* iOS 26 以降の Safari では、背景の幕を position: absolute で敷く必要がある。
* ページをスクロールした後も幕が画面全体を覆うよう、body を位置の基準にする。
*/
body {
position: relative;
}
1 行めが Tailwind CSS の読み込みです。残りの 2 つは Base UI のための設定で、どちらも公式ドキュメントの Quick start が求めているものです1。
| 設定 | 理由 |
|---|---|
#root に isolation: isolate | ダイアログは body の直下に描画されます。アプリ側を独立した重なりの層にしておくと、アプリの中でどんな z-index を使っていても、ダイアログが必ず手前に出ます |
body に position: relative | iOS 26 以降の Safari 向けです。背景の幕を画面全体に敷くための基準になります |
表示用の値を作る関数を置く
コンポーネントの前に、お知らせのデータの型と、表示用の値を作る関数を用意します。未読の件数、バッジの文言、説明文を作る関数と、すべて既読にした配列を返す関数です。
/** お知らせ 1 件ぶんのデータ。 */
export type Notification = {
id: string
title: string
/** 既読なら true。 */
read: boolean
}
/** バッジに出す件数の上限。これを超えたら「99+」と表示する。 */
const BADGE_MAX = 99
/**
* 未読のお知らせの件数を数える。
*
* @param notifications - お知らせの一覧
* @returns 未読 (`read` が false) の件数
*/
export function countUnread(notifications: readonly Notification[]): number {
return notifications.filter((notification) => !notification.read).length
}
/**
* 未読件数を、ボタンに添えるバッジの文言に変える。
*
* @param count - 未読件数
* @returns 0 件以下なら空文字、上限を超えたら「99+」、それ以外は件数そのまま
*/
export function formatUnreadBadge(count: number): string {
if (count <= 0) return ''
if (count > BADGE_MAX) return `${BADGE_MAX}+`
return String(count)
}
/**
* ダイアログの説明文を組み立てる。
*
* @param notifications - お知らせの一覧
* @returns 件数に応じた説明文
*/
export function describeNotifications(notifications: readonly Notification[]): string {
if (notifications.length === 0) return 'お知らせはありません。'
const unread = countUnread(notifications)
if (unread === 0) return 'すべて確認済みです。'
return `未読のお知らせが ${unread} 件あります。`
}
/**
* すべて既読にした新しい配列を返す。
*
* 引数の配列は書き換えない。React の state 更新関数にそのまま渡せる。
*
* @param notifications - お知らせの一覧
* @returns 全件の `read` を true にした新しい配列
*/
export function markAllRead(notifications: readonly Notification[]): Notification[] {
return notifications.map((notification) => ({ ...notification, read: true }))
}
件数の計算や文言の組み立てを、コンポーネントの中ではなく別のファイルに置いています。こう分ける理由は、05 章でテストと一緒に取り上げます。ここでは「お知らせの配列を渡すと、表示用の値が返る関数がある」とだけ捉えてください。
ダイアログを組み立てる
コンポーネントの全体です。お知らせの配列を props で受け取り、開くボタンとダイアログを描画します。
import { Button } from '@base-ui/react/button'
import { Dialog } from '@base-ui/react/dialog'
import {
countUnread,
describeNotifications,
formatUnreadBadge,
type Notification,
} from '../lib/notifications'
type NotificationsDialogProps = {
/** 表示するお知らせ。取得は親の仕事で、このコンポーネントは受け取って表示するだけ。 */
notifications: readonly Notification[]
/** 「すべて既読にする」が押されたときに呼ばれる。 */
onMarkAllRead: () => void
/** 最初から開いた状態で表示するか。Storybook に「開いた見た目」を載せるために使う。 */
defaultOpen?: boolean
}
/*
* ボタン 3 つ (開く / すべて既読にする / 閉じる) に共通する見た目。
* focus-visible: は、キーボードでフォーカスしたときだけ枠を出す指定。
* フォーカスを「どこへ動かすか」は Base UI が決め、「どう見せるか」はここで決める。
* 無効のときの見た目は data-disabled: で受ける。Base UI のボタンは、無効の間この属性を出す。
*/
const buttonClass =
'inline-flex h-9 items-center justify-center gap-2 rounded-md border border-slate-300 bg-white px-3 text-sm font-medium text-slate-900 select-none hover:not-data-disabled:bg-slate-100 active:not-data-disabled:bg-slate-200 data-disabled:cursor-not-allowed data-disabled:text-slate-400 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600'
export function NotificationsDialog({
notifications,
onMarkAllRead,
defaultOpen = false,
}: NotificationsDialogProps) {
// 表示用の値は純粋関数で作り、コンポーネントは結果を並べるだけにする
const unreadCount = countUnread(notifications)
const badge = formatUnreadBadge(unreadCount)
return (
// Root が開閉の状態を持つ。useState を書かなくても開閉できるのは、ここが管理しているから
<Dialog.Root defaultOpen={defaultOpen}>
{/* Trigger は <button> を描画し、押すと開く。aria-expanded などの属性も自動で付く */}
<Dialog.Trigger className={buttonClass}>
お知らせ
{badge !== '' && (
<span className="rounded-full bg-blue-600 px-2 py-0.5 text-xs font-bold text-white">
{badge}
{/* sr-only は画面には出さず、スクリーンリーダーにだけ読ませる */}
<span className="sr-only"> 件の未読</span>
</span>
)}
</Dialog.Trigger>
{/* Portal の中身は body の直下に描画され、親要素の overflow や z-index の影響を受けない */}
<Dialog.Portal>
{/*
* Backdrop は背景の幕。
* 開く瞬間は data-starting-style、閉じる間は data-ending-style が付く。
* その間だけ透明にしておくと、transition が間をつないでフェードになる。
*/}
<Dialog.Backdrop className="fixed inset-0 min-h-dvh bg-black/40 transition-opacity duration-200 data-starting-style:opacity-0 data-ending-style:opacity-0 supports-[-webkit-touch-callout:none]:absolute" />
{/* Popup がダイアログ本体。role="dialog" と、タイトル・説明との関連付けが自動で付く */}
<Dialog.Popup className="fixed top-1/2 left-1/2 flex w-96 max-w-[calc(100vw-2rem)] -translate-x-1/2 -translate-y-1/2 flex-col gap-4 rounded-xl bg-white p-6 text-slate-900 shadow-xl transition-[opacity,scale] duration-200 data-starting-style:scale-95 data-starting-style:opacity-0 data-ending-style:scale-95 data-ending-style:opacity-0">
<div className="flex flex-col gap-1">
{/* Title は <h2> を描画し、ダイアログの名前 (aria-labelledby の参照先) になる */}
<Dialog.Title className="text-lg font-bold">お知らせ</Dialog.Title>
{/* Description は <p> を描画し、ダイアログの説明 (aria-describedby の参照先) になる */}
<Dialog.Description className="text-sm text-slate-600">
{describeNotifications(notifications)}
</Dialog.Description>
</div>
{notifications.length > 0 && (
/*
* Tailwind CSS は、リストの行頭記号を既定で消す。記号の無いリストは、VoiceOver が
* リストとして読み上げない。role="list" を付けて、リストであることを保つ。
*/
<ul role="list" className="flex flex-col divide-y divide-slate-200 text-sm">
{notifications.map((notification) => (
<li key={notification.id} className="flex items-center justify-between gap-3 py-2">
<span className={notification.read ? 'text-slate-500' : 'font-medium'}>
{notification.title}
</span>
{!notification.read && (
<span className="shrink-0 rounded bg-blue-50 px-1.5 py-0.5 text-xs text-blue-700">
未読
</span>
)}
</li>
))}
</ul>
)}
<div className="flex justify-end gap-2">
{/*
* Button は <button> を描画する。押すと未読が 0 件になり、このボタン自身が無効になる。
* focusableWhenDisabled を付けると、無効になってもフォーカスがこのボタンに残る。
* 付けないと、フォーカスを持ったまま無効になる。Chromium では、その時点で
* フォーカスがダイアログの外 (body) へ外れる。
*/}
<Button
className={buttonClass}
onClick={onMarkAllRead}
disabled={unreadCount === 0}
focusableWhenDisabled
>
すべて既読にする
</Button>
{/* Close は <button> を描画し、押すと閉じる。onClick を書く必要はない */}
<Dialog.Close className={buttonClass}>閉じる</Dialog.Close>
</div>
</Dialog.Popup>
</Dialog.Portal>
</Dialog.Root>
)
}
パーツの役割
Base UI の部品は、複数のパーツの組み合わせでできています。Dialog では、次の 8 つを使いました2。
| パーツ | 描画する要素 | 役割 |
|---|---|---|
Dialog.Root | なし | 開閉の状態を持ち、配下のパーツをまとめます |
Dialog.Trigger | <button> | 押すと開きます |
Dialog.Portal | <div> | 中身を body の直下へ移します |
Dialog.Backdrop | <div> | 背景の幕です |
Dialog.Popup | <div> | ダイアログ本体です。role="dialog" が付きます |
Dialog.Title | <h2> | ダイアログの名前になる見出しです |
Dialog.Description | <p> | ダイアログの説明です |
Dialog.Close | <button> | 押すと閉じます |
パーツは入れ子の位置で互いを見つけます。Dialog.Title を Dialog.Popup の中に置くだけで、Dialog.Popup の aria-labelledby にタイトルの id が入ります。id を自分で用意して渡す必要はありません。こうして結ばれたタイトルの文字が、ダイアログのアクセシブルネームアクセシブルネーム (Accessible Name)支援技術が要素を読み上げたり探したりするときに使う名前。ボタンなら中の文字、ダイアログなら aria-labelledby が指す要素の文字というように、要素の内容や属性から決まる。 (支援技術が読み上げる名前) になります。
Dialog.Portal を使うのは、ダイアログを親要素のスタイルから切り離すためです。開くボタンが overflow: hidden の要素の中にあっても、ダイアログは body の直下に出るので切り取られません。
このほかに、中身が長くてスクロールが要るときの配置に使う Dialog.Viewport があります。本ガイドのダイアログは短いので使っていません。
「すべて既読にする」のボタンは Dialog のパーツではなく、Button という別の部品です。あとの節で扱います。
クラスを直接渡す
Dialog.Root を除く各パーツは HTML の要素を 1 つ描画するので、className をそのまま受け取ります。スタイルのためにパーツを包むコンポーネントを作る必要はありません。
上のコードでは、ボタンのクラスを buttonClass という定数にまとめています。開くボタン (Dialog.Trigger)、「すべて既読にする」の Button、閉じるボタン (Dialog.Close) の 3 つに同じ見た目を当てるためです。
className には、文字列のほかに、パーツの状態を受け取ってクラスを返す関数も渡せます3。ただし、状態に応じた見た目の切り替えは、次の節のように data-* 属性で書けることがほとんどです。本ガイドでは関数の形を使いません。
見た目の層が外した意味を戻す
お知らせの一覧の ul には、role="list" を付けています。Base UI のパーツではない素の要素で、このコードの中で role を手で書いた唯一の箇所です。
Tailwind CSS は、リストの行頭記号を既定で消します。公式ドキュメントは、行頭記号の無いリストを VoiceOver がリストとして読み上げない、と注意しています。内容が本当にリストなら role="list" を足すように、という案内です4。
ul はもともとリストの役割を持つ要素なので、同じ役割を書くことを冗長として指摘する lint の規則があります。eslint-plugin-jsx-a11y の no-redundant-roles は、既定では nav への navigation だけを許可しています5。この規則を使っているプロジェクトでは、ul への list も許可する設定を足します。本ガイドのテンプレートに入っている lint (oxlint) の既定の設定では、この行は指摘されませんでした。
01 章で、見た目は使う側の仕事として手元に残る、と書きました。見た目を自分で書くと、スタイルが意味に及ぼす影響も自分で見ることになります。ここはその一例です。
開閉の遷移を CSS だけで書く
開くときにふわっと現れ、閉じるときに消えていく動きは、背景の幕とダイアログ本体のクラスだけで付けています。該当する部分を抜き出します。
<Dialog.Portal>
{/*
* Backdrop は背景の幕。
* 開く瞬間は data-starting-style、閉じる間は data-ending-style が付く。
* その間だけ透明にしておくと、transition が間をつないでフェードになる。
*/}
<Dialog.Backdrop className="fixed inset-0 min-h-dvh bg-black/40 transition-opacity duration-200 data-starting-style:opacity-0 data-ending-style:opacity-0 supports-[-webkit-touch-callout:none]:absolute" />
{/* Popup がダイアログ本体。role="dialog" と、タイトル・説明との関連付けが自動で付く */}
<Dialog.Popup className="fixed top-1/2 left-1/2 flex w-96 max-w-[calc(100vw-2rem)] -translate-x-1/2 -translate-y-1/2 flex-col gap-4 rounded-xl bg-white p-6 text-slate-900 shadow-xl transition-[opacity,scale] duration-200 data-starting-style:scale-95 data-starting-style:opacity-0 data-ending-style:scale-95 data-ending-style:opacity-0">
背景の幕に付けたクラスのうち、遷移に関わるのは次の 4 つです。
| クラス | 働き |
|---|---|
transition-opacity | 不透明度が変わるとき、時間をかけて変化させます |
duration-200 | 変化にかける時間を 200 ミリ秒にします |
data-starting-style:opacity-0 | data-starting-style が付いている間、透明にします |
data-ending-style:opacity-0 | data-ending-style が付いている間、透明にします |
この 4 つで起きることを、02 章で見た属性の移り変わりに重ねます。
| 時点 | 付いている属性 | 不透明度 |
|---|---|---|
| 開く操作の直後 | data-open と data-starting-style | 0 (透明) |
| 次の瞬間 | data-open | 0 から 1 へ、200 ミリ秒かけて変化 |
| 開いている間 | data-open | 1 |
| 閉じる操作の直後 | data-closed と data-ending-style | 1 から 0 へ、200 ミリ秒かけて変化 |
| 変化が終わったあと | DOM から外れている | 値なし |
開くときは、最初に透明な状態で DOM に入り、直後に data-starting-style が外れます。外れた瞬間に不透明度の指定が変わるので、transition が働いてフェードインになります。
閉じるときは、data-ending-style が付いて透明へ向かいます。Base UI はこの変化が終わるのを待ってから、要素を DOM から外します6。01 章の自作のモーダルは、閉じた瞬間に DOM から外していたので、消えていく動きを付ける余地がありませんでした。
ダイアログ本体も同じ仕組みで、不透明度に加えて大きさ (scale-95) を変えています。transition-[opacity,scale] は、その 2 つの性質だけを変化の対象にする指定です。
開閉の動きは @keyframes を使ったアニメーションでも書けます。Base UI の公式ドキュメントは transition のほうを勧めています。開いている途中で閉じる操作をしたときに、transition なら途中の状態からそのまま逆向きに変化できるためです。
状態と ARIA を書かずに済む理由
01 章の自作のモーダルで番号を振った仕事が、このコードのどこに行ったのかを対応させます。
| 01 章で書いた仕事 | このコードでは |
|---|---|
| 1. 開閉の状態を持つ | Dialog.Root が持ちます。useState は書いていません |
2. タイトルと結ぶ id を用意する | Dialog.Title を置くだけで結ばれます |
| 3. 開いたらフォーカスを中へ移す | Base UI が行います。移す先は initialFocus で変えられます |
| 4. Esc キーで閉じる | Base UI が行います |
| 5. 背景のスクロールを止める | Base UI が行います |
| 6. 閉じたらフォーカスを戻す | Base UI が行います。戻す先は finalFocus で変えられます |
7. aria-haspopup と aria-expanded を付ける | Dialog.Trigger に自動で付きます |
| 8. 背景の幕と、外側を押したら閉じる処理 | 幕は Dialog.Backdrop です。外側を押すと閉じる処理は Base UI が行います |
9. role と aria-* を付ける | Dialog.Popup に自動で付きます |
| 足りなかった、フォーカスの閉じ込め | Base UI が行います |
| 足りなかった、閉じるときの遷移 | 前の節の data-ending-style で書けます |
このコンポーネントが自分で書いているのは、アプリの都合で決まることだけです。お知らせの中身をどう並べるか、どんな見た目にするか、ボタンをいつ無効にするか、押されたことをどう親へ伝えるかです。開閉やフォーカスの動かし方は 1 行も書いていません。先に見た role="list" は、見た目を書いた側の後始末として足したものです。
開閉の状態を持たない書き方を、Base UI は非制御と呼んでいます。Dialog.Root に何も渡さなければ非制御になり、開閉は Base UI の中で完結します。このコードで defaultOpen を渡しているのは、最初から開いた状態で描画できるようにするためです。04 章の Storybook で使います。
アプリの側から開閉を動かしたいときは、open と onOpenChange を渡して制御します。フォームの送信が終わったら閉じる、といった場面です。02 章で触れたとおり、見た目を変えるためだけに制御へ切り替える必要はありません。
押すと無効になるボタン
「すべて既読にする」は、素の <button> ではなく Base UI の Button で書いています。<button> を描画する部品で、@base-ui/react/button から import します。
Button を使ったのは、このボタンが押すと自分で無効になるからです。押すと未読が 0 件になり、disabled に渡している式が true に変わります。その時点で、フォーカスはこのボタンにあります。
素の disabled 属性で同じことをすると、フォーカスが行き場を失います。Chromium で確かめたところ、押したあとの document.activeElement は body になりました。モーダルの背景は支援技術から隠されているので、キーボードやスクリーンリーダーで操作している人は、ダイアログの中での現在位置を失います。
focusableWhenDisabled を付けると、Base UI は disabled 属性の代わりに aria-disabled="true" を付け、フォーカスを受けられる状態を保ちます7。Chromium の上で同じ操作をして、違いを記録しました。ボタンは Enter キーで押しています。
focusableWhenDisabled なし | focusableWhenDisabled あり | |
|---|---|---|
| 無効の間に付く属性 | disabled | aria-disabled="true" |
| 押したあとにフォーカスを持つ要素 | body | 「すべて既読にする」のボタン |
| そこから Tab キーを押したときの移動先 | 「閉じる」だけ | 「閉じる」と「すべて既読にする」を行き来する |
| 続けて Enter キーをもう一度押す | 親へ知らせない。フォーカスが body にあり、キー入力がボタンに届かない | 親へ知らせない。フォーカスはボタンにあるが、無効なので働かない |
| Esc キーを押す | 閉じる | 閉じる |
| 未読が無い状態で開いたときの、最初のフォーカス | 「閉じる」のボタン | 「すべて既読にする」のボタン (無効のまま) |
最後の行は、focusableWhenDisabled を付けたことで変わる点です。未読が無い状態で開くと、フォーカスは無効の「すべて既読にする」から始まります。無効でもフォーカスを受ける要素として残したためです。これが合わない場面では、Dialog.Popup の initialFocus で、最初に移す先を変えられます。
フォーカスを保つ方法は、ほかにもあります。押したあとに「閉じる」へフォーカスを移す処理を自分で書く方法や、ボタンを無効にせず、押しても何も起きないようにする方法です。本ガイドでは、Base UI が用意している指定を使い、フォーカスの動かし方を自分では書かない形を選びました。
無効のときの見た目は、どちらの場合も data-disabled で受けられます。Base UI のボタンは、無効の間この属性を出します。buttonClass の data-disabled:text-slate-400 と hover:not-data-disabled:bg-slate-100 がその指定です。disabled: で書くと disabled 属性があるときにしか効かないので、aria-disabled で無効にしたボタンには当たりません。
ここでも、aria-disabled を自分では書いていません。「無効だがフォーカスは受ける」という振る舞いを Base UI に頼み、見た目だけを data-disabled で書いています。
描画する要素を差し替える
パーツが描画する要素は、render prop で差し替えられます。自前のボタンのコンポーネントを開くボタンとして使いたい、といったときに使います。
import { Dialog } from '@base-ui/react/dialog'
import type { ComponentProps } from 'react'
/**
* 自前のボタン。
*
* 受け取った props をすべて <button> へ渡す。React 19 では ref も props に含まれるので、
* これだけで Base UI が渡してくる ref とイベントハンドラが <button> に届く。
*/
function PrimaryButton({ className = '', ...props }: ComponentProps<'button'>) {
return (
<button
type="button"
{...props}
className={`rounded-md bg-blue-600 px-3 py-2 text-sm font-medium text-white ${className}`}
/>
)
}
export function RenderPropExample() {
return (
<Dialog.Root>
{/*
* render に渡した要素が、Trigger が本来描画する <button> の代わりに使われる。
* 押すと開く動作や aria-* の属性は、PrimaryButton の props として届く。
*/}
<Dialog.Trigger render={<PrimaryButton />}>お知らせ</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Popup>
<Dialog.Title>お知らせ</Dialog.Title>
{/* Description が描画する <p> を <div> に替える。要素の種類だけを差し替える使い方 */}
<Dialog.Description render={<div />}>中にリストを入れたいときなどに使います。</Dialog.Description>
<Dialog.Close render={<PrimaryButton />}>閉じる</Dialog.Close>
</Dialog.Popup>
</Dialog.Portal>
</Dialog.Root>
)
}
render に渡した要素は、パーツが本来描画する要素の代わりになります。押したら開く動作も、aria-expanded などの属性も、渡した要素の props として届きます。そのため、渡すコンポーネントは受け取った props を自分の DOM 要素へすべて渡す必要があります8。PrimaryButton が {...props} と書いているのはそのためです。
Radix UI を使ったことがあれば、asChild と同じ目的の仕組みだと考えてください。書き方が違います。asChild は子要素として渡し、render は prop として渡します。asChild の仕組みは Radix UI の Slot が扱っています。Base UI に asChild はありません。
アプリに組み込む
最後に、アプリから使う側のコードです。
import { useState } from 'react'
import { NotificationsDialog } from './components/notifications-dialog'
import { markAllRead, type Notification } from './lib/notifications'
// 画面の確認用のサンプルデータ。実際のアプリでは API から取得した結果がここに入る
const initialNotifications: Notification[] = [
{ id: 'n1', title: 'メンテナンスを 10 月 12 日 2 時から行います', read: false },
{ id: 'n2', title: 'レポートの書き出しに CSV を追加しました', read: false },
{ id: 'n3', title: '9 月分の請求書を発行しました', read: true },
]
function App() {
// お知らせのデータは親が持つ。ダイアログの開閉の状態は持たない (Base UI の Root が持つ)
const [notifications, setNotifications] = useState(initialNotifications)
return (
<main className="mx-auto flex max-w-2xl flex-col gap-4 p-8">
<h1 className="text-2xl font-bold text-slate-900">ダッシュボード</h1>
<p className="text-slate-600">右のボタンからお知らせを確認できます。</p>
<div>
<NotificationsDialog
notifications={notifications}
onMarkAllRead={() => setNotifications(markAllRead)}
/>
</div>
</main>
)
}
export default App
App が持っている状態は、お知らせのデータだけです。ダイアログが開いているかどうかは持っていません。
| 状態 | 持っている場所 | 理由 |
|---|---|---|
| お知らせのデータ | App (親) | アプリのデータだからです。ほかの画面でも使います |
| ダイアログの開閉 | Dialog.Root (Base UI) | 画面の一時的な状態で、ほかのどこからも参照しないからです |
NotificationsDialog はデータを props で受け取り、「すべて既読にする」が押されたことを onMarkAllRead で親へ知らせます。データをどこから取ってくるか、既読にしたことをどこへ保存するかを、このコンポーネントは知りません。この形にしておくと、Storybook とテストのどちらにも、配列を 1 つ渡すだけで載せられます。次の章でそれを確かめます。
npm run dev で開発サーバーを起動すると、この画面を確かめられます。
まとめ
- Dialog は 8 つのパーツの組み合わせで書きます。パーツは入れ子の位置で互いを見つけ、
idやaria-*を自動で結びます - 各パーツは
classNameを受け取るので、Tailwind CSS のクラスを直接渡せます - 開閉の遷移は、
data-starting-styleとdata-ending-styleが付いている間の見た目を指定するだけで書けます。Base UI は遷移が終わるのを待ってから DOM から外します - 開閉の状態は
Dialog.Rootが持ちます。アプリの都合で動かしたいときだけ、openとonOpenChangeで制御します - 押すと無効になるボタンは、
ButtonのfocusableWhenDisabledでフォーカスを保ちます。見た目はdata-disabledで受けます - 描画する要素を替えたいときは
renderprop を使います - コンポーネントはデータを props で受け取り、取得や保存を知らない形にしておきます
次に読む
- 04 Storybook を導入する — このダイアログの「開いた状態」を、アプリを操作せずに見られるようにします
- アニメーション —
transitionとdurationのほかの指定