🌟

@radix-ui/react-dialog から見る UI ライブラリの作り方

2023/11/02に公開

はじめに

僕は radix-ui に最近ハマっている。書き心地がよい。radix-ui とは何かというと公式にも書いてある通りユーザーがスタイルを後から付けることができる headless UI ライブラリ。

https://www.radix-ui.com/primitives

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;

https://www.radix-ui.com/primitives/docs/components/switch

@radix-ui 頻出コードの一部を読む

まず radix-ui を読むために頻出の実装を読んでみる。

Slot

slot は cloneElement を利用して受け取った children に Slot で受け取った prop を横流しする。

https://github.com/radix-ui/primitives/blob/main/packages/react/slot/src/Slot.tsx#L64-L67

Primitive

radix-ui/xxx で使用される primitive component が定義されており、radix-ui が使われているかどうかを window に挿してる。(これは何の意味があるんだろう?)

https://github.com/radix-ui/primitives/blob/c31c97274ff357aea99afe6c01c1c8c58b6356e0/packages/react/primitive/src/Primitive.tsx#L5-L22

https://github.com/radix-ui/primitives/blob/c31c97274ff357aea99afe6c01c1c8c58b6356e0/packages/react/primitive/src/Primitive.tsx#L50

asChild で Slot 化する機能も付与されている。

https://github.com/radix-ui/primitives/blob/c31c97274ff357aea99afe6c01c1c8c58b6356e0/packages/react/primitive/src/Primitive.tsx#L47

createContext

ただの object context hook
https://github.com/radix-ui/primitives/blob/main/packages/react/context/src/createContext.tsx#L3-L27

createContextScope

scope を指定することで使用できる Context を制限している
radix は JSX をネストさせることで細かい styling をできるようにしているので間違った Context.Provider で囲われることもある。これを阻止する部分。

https://github.com/radix-ui/primitives/blob/main/packages/react/context/src/createContext.tsx#L67

これが起きたときにエラーで教えてくれる。

https://github.com/radix-ui/primitives/blob/main/packages/react/context/src/createContext.tsx#L72

@radix-ui/react-dialog を読む

この形がどのようにしてできてるのかを読んでみる。

https://www.radix-ui.com/primitives/docs/components/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 に同じ値

が入ってる。

https://github.com/radix-ui/primitives/blob/main/packages/react/dialog/src/Dialog.tsx#L70-L79

DialogTrigger

Props は Primitive で定義されている PropsWithoutRefReact.ComponentProps<T> なので button で定義したときに受け入れられる prop をすべて受け入れることができる。

https://github.com/radix-ui/primitives/blob/main/packages/react/dialog/src/Dialog.tsx#L96
https://github.com/radix-ui/primitives/blob/main/packages/react/primitive/src/Primitive.tsx#L28-L30

ここで Dialog を出すのに最も重要な onOpenToggle が context から取得され onClick に渡されている。これによって DialogTrigger 経由で DialogContent に通知を送ることができる。

https://github.com/radix-ui/primitives/blob/main/packages/react/dialog/src/Dialog.tsx#L112C67-L112C67

Primitive.button は slot になっているので DialogTrigger は asChild という prop を付与することでここで付与されている wai-aria を children の button に横流しできる。

https://github.com/radix-ui/primitives/blob/main/packages/react/dialog/src/Dialog.tsx#L103-L114

DialogPortal

dispatcher を受け入れていないので変更通知を受けて表示が変わるだけの存在。

https://github.com/radix-ui/primitives/blob/main/packages/react/dialog/src/Dialog.tsx#L145

挙動自体はほぼほぼ ReactDOM.createPortal と同じ。
https://github.com/radix-ui/primitives/blob/main/packages/react/portal/src/Portal.tsx#L25

DialogPortal に渡される props は portal の scopedContext につめられる。
https://github.com/radix-ui/primitives/blob/main/packages/react/dialog/src/Dialog.tsx#L149

forceMount と DialogRoot から渡ってくる open で表示制御が行われている。
https://github.com/radix-ui/primitives/blob/main/packages/react/dialog/src/Dialog.tsx#L151

DialogOverlay

dialog と portal で作った context から state を受け取って表示を変えるだけ。

https://github.com/radix-ui/primitives/blob/main/packages/react/dialog/src/Dialog.tsx#L178

どうやら radix-ui 的には modal であるということは overlay があるということらしい?
https://github.com/radix-ui/primitives/blob/main/packages/react/dialog/src/Dialog.tsx#L183

overlay 自体の実装
https://github.com/radix-ui/primitives/blob/main/packages/react/dialog/src/Dialog.tsx#L204-L212

本当に最低限の styling だけが当たっている。HTMLElement style property を使用しているので className や css による styling に上書きされないようになってる。

DialogContent

https://github.com/radix-ui/primitives/blob/main/packages/react/dialog/src/Dialog.tsx#L232

Modal が閉じたら button に focus が戻るようになってる
https://github.com/radix-ui/primitives/blob/main/packages/react/dialog/src/Dialog.tsx#L279

If the event is a right-click, we shouldn't close because it is effectively as if we right-clicked the Overlay.

翻訳後: イベントが右クリックの場合、Overlay を右クリックしたのと同じことになるので、閉じるべきではありません。オーバーレイ`を右クリックしたのと同じことだからです。

https://github.com/radix-ui/primitives/blob/main/packages/react/dialog/src/Dialog.tsx#L286C14-L288

When focus is trapped, a focusout event may still happen. We make sure we don't trigger our onDismiss in such case.

翻訳後: フォーカスがトラップされたときにも focusout イベントが発生することがあります。このような場合に onDismiss イベントが発生しないようにする。

https://github.com/radix-ui/primitives/blob/main/packages/react/dialog/src/Dialog.tsx#L290-L294C11

らしい。へ~って感じだ。

DialogContentImpl

実際の Dialog の実装。

何か楽をしているというわけではなく wai-aria を丁寧に手書きしているようだ。

https://github.com/radix-ui/primitives/blob/main/packages/react/dialog/src/Dialog.tsx#L290-L294C11

DialogTitle, DialogDescription, DialogClose

ここまで読んだら大体予想つくと思うがあとはひたすら context で state と dispatcher を受け取り、state から wai-aria をわりあて、適切な場所で dispatcher を実行するの繰り返し。この 3 つは自分でも読んでみてほしい。

DialogTitle

https://github.com/radix-ui/primitives/blob/main/packages/react/dialog/src/Dialog.tsx#L290-L294C11

DialogDescription

https://github.com/radix-ui/primitives/blob/main/packages/react/dialog/src/Dialog.tsx#L454

DialogClose

https://github.com/radix-ui/primitives/blob/main/packages/react/dialog/src/Dialog.tsx#L473

まとめ

radix-ui の dialog を一つ取り出して読んでみた。
もともとは hook じゃなくて JSX をネストして作られるこの方法は Context でどう実現されているのだろう?という好奇心だったが、wai-aria の割り当て方や dialog の表示・非表示を切り替えたときにどうすべきなのか書かれていて非常に参考になった。

react が使えない環境で a11y に対応したい場合は radix-ui を参考にしてもいいかもしれない。

Discussion