@radix-ui/react-dialog から見る UI ライブラリの作り方
はじめに
僕は radix-ui に最近ハマっている。書き心地がよい。radix-ui とは何かというと公式にも書いてある通りユーザーがスタイルを後から付けることができる headless UI ライブラリ。
Unstyled, accessible, open source React primitives for high-quality web apps and design systems.
高品質な Web アプリやデザインシステムのための、スタイルがなく、アクセスしやすい、オープンソースのReactプリミティブ。
switch が好きなので radix-ui で書かれたコードを見てみる。僕はこの見た目がかなり好きだ。なのでどのようにして styling しやすいように細かくコンポーネント分割をしつつ、どのようにして共有値を管理しながら wai-aria やロジックを実装しているのが調査することにした。
import React from "react";
import * as Switch from "@radix-ui/react-switch";
import "./styles.css";
const SwitchDemo = () => (
<form>
<div style={{ display: "flex", alignItems: "center" }}>
<label className="Label" htmlFor="airplane-mode" style={{ paddingRight: 15 }}>
Airplane mode
</label>
<Switch.Root className="SwitchRoot" id="airplane-mode">
<Switch.Thumb className="SwitchThumb" />
</Switch.Root>
</div>
</form>
);
export default SwitchDemo;
@radix-ui 頻出コードの一部を読む
まず radix-ui を読むために頻出の実装を読んでみる。
Slot
slot は cloneElement を利用して受け取った children に Slot で受け取った prop を横流しする。
Primitive
radix-ui/xxx で使用される primitive component が定義されており、radix-ui が使われているかどうかを window に挿してる。(これは何の意味があるんだろう?)
asChild で Slot 化する機能も付与されている。
createContext
ただの object context hook
createContextScope
scope を指定することで使用できる Context を制限している
radix は JSX をネストさせることで細かい styling をできるようにしているので間違った Context.Provider で囲われることもある。これを阻止する部分。
これが起きたときにエラーで教えてくれる。
@radix-ui/react-dialog を読む
この形がどのようにしてできてるのかを読んでみる。
const Component = () => (
<Dialog.Root>
<Dialog.Trigger className={triggerClass()}>open</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className={overlayClass()} />
<Dialog.Content className={contentDefaultClass()}>
<Dialog.TitleBooking>info</Dialog.Title>
<Dialog.Description>Please enter the info for your booking below.</Dialog.Description>
<Dialog.Close className={closeClass()}>close</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
Dialog.Root
大きな Context になっていて children がアクセスする用の scope を付与して全体で必要な情報を context に詰めている。
HTMLElement 間で同じ値にすべきところは DialogRoot 内で useId を利用して componentId を渡していて
- DialogTrigger の aria-controls と DialogContent の id に同じ値
- DialogDescription の id と DialogContent の aria-describedby に同じ値
- DialogTitle の id と DialogContent の aria-labelledby に同じ値
が入ってる。
DialogTrigger
Props は Primitive で定義されている PropsWithoutRefReact.ComponentProps<T>
なので button
で定義したときに受け入れられる prop をすべて受け入れることができる。
ここで Dialog を出すのに最も重要な onOpenToggle が context から取得され onClick に渡されている。これによって DialogTrigger 経由で DialogContent に通知を送ることができる。
Primitive.button は slot になっているので DialogTrigger は asChild という prop を付与することでここで付与されている wai-aria を children の button に横流しできる。
DialogPortal
dispatcher を受け入れていないので変更通知を受けて表示が変わるだけの存在。
挙動自体はほぼほぼ ReactDOM.createPortal と同じ。
DialogPortal に渡される props は portal の scopedContext につめられる。
forceMount と DialogRoot から渡ってくる open で表示制御が行われている。
DialogOverlay
dialog と portal で作った context から state を受け取って表示を変えるだけ。
どうやら radix-ui 的には modal であるということは overlay があるということらしい?
overlay 自体の実装
本当に最低限の styling だけが当たっている。HTMLElement style property を使用しているので className や css による styling に上書きされないようになってる。
DialogContent
Modal が閉じたら button に focus が戻るようになってる
If the event is a right-click, we shouldn't close because it is effectively as if we right-clicked the
Overlay
.
翻訳後: イベントが右クリックの場合、
Overlay
を右クリックしたのと同じことになるので、閉じるべきではありません。オーバーレイ`を右クリックしたのと同じことだからです。
When focus is trapped, a
focusout
event may still happen. We make sure we don't trigger ouronDismiss
in such case.
翻訳後: フォーカスがトラップされたときにも
focusout
イベントが発生することがあります。このような場合にonDismiss
イベントが発生しないようにする。
らしい。へ~って感じだ。
DialogContentImpl
実際の Dialog の実装。
何か楽をしているというわけではなく wai-aria を丁寧に手書きしているようだ。
DialogTitle, DialogDescription, DialogClose
ここまで読んだら大体予想つくと思うがあとはひたすら context で state と dispatcher を受け取り、state から wai-aria をわりあて、適切な場所で dispatcher を実行するの繰り返し。この 3 つは自分でも読んでみてほしい。
DialogTitle
DialogDescription
DialogClose
まとめ
radix-ui の dialog を一つ取り出して読んでみた。
もともとは hook じゃなくて JSX をネストして作られるこの方法は Context でどう実現されているのだろう?という好奇心だったが、wai-aria の割り当て方や dialog の表示・非表示を切り替えたときにどうすべきなのか書かれていて非常に参考になった。
react が使えない環境で a11y に対応したい場合は radix-ui を参考にしてもいいかもしれない。
Discussion