Radix UI の Trigger がめんどくさいもんだいについての調査
Radix UI で例えば DropdownMenu の Item が Dialog Trigger である場合、非常にめんどくさくなるような気がする。このあたりについて、HTML のアクセシビリティや Radix UI の実装を追いかけながら検証する。
❯ npm create vite@latest
Need to install the following packages:
create-vite@5.5.2
Ok to proceed? (y) y
✔ Project name: … vite-project
✔ Select a framework: › React
✔ Select a variant: › TypeScript
❯ npm install
added 191 packages, and audited 192 packages in 30s
41 packages are looking for funding
run `npm fund` for details
found 0 vulnerabilities
❯ npm run dev
> vite-project@0.0.0 dev
> vite
Re-optimizing dependencies because lockfile has changed
VITE v5.4.4 ready in 287 ms
➜ Local: http://localhost:5173/
➜ Network: use --host to expose
➜ press h + enter to show help
❯ npm install @radix-ui/themes
added 68 packages, and audited 260 packages in 8s
41 packages are looking for funding
run `npm fund` for details
found 0 vulnerabilities
テストプロジェクトなのでCSSをいちいち書くのはめんどくさい。Radix UI Theme を入れる。
import { Theme } from '@radix-ui/themes';
import './App.css'
import '@radix-ui/themes/styles.css';
function App() {
return (
<Theme>
</Theme>
)
}
export default App
import { Button, DropdownMenu, Theme } from "@radix-ui/themes";
import "@radix-ui/themes/styles.css";
function App() {
return (
<Theme>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button variant="soft">
Options
<DropdownMenu.TriggerIcon />
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Item shortcut="⌘ E">Edit</DropdownMenu.Item>
<DropdownMenu.Item shortcut="⌘ D">Duplicate</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item shortcut="⌘ N">Archive</DropdownMenu.Item>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger>More</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent>
<DropdownMenu.Item>Move to project…</DropdownMenu.Item>
<DropdownMenu.Item>Move to folder…</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item>Advanced options…</DropdownMenu.Item>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Separator />
<DropdownMenu.Item>Share</DropdownMenu.Item>
<DropdownMenu.Item>Add to favorites</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item shortcut="⌘ ⌫" color="red">
Delete
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Theme>
);
}
export default App;
Dropdown Menu の特定の項目を押すと Dialog が開くような実装をしたい。例えば、Delete を押すと Confirmation Dialog が表示されるようなケースを想定してみる。
import { Button, Dialog, DropdownMenu, Flex, Theme } from "@radix-ui/themes";
import "@radix-ui/themes/styles.css";
function App() {
return (
<Theme>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button variant="soft">
Options
<DropdownMenu.TriggerIcon />
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Item shortcut="⌘ E">Edit</DropdownMenu.Item>
<DropdownMenu.Item shortcut="⌘ D">Duplicate</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item shortcut="⌘ N">Archive</DropdownMenu.Item>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger>More</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent>
<DropdownMenu.Item>Move to project…</DropdownMenu.Item>
<DropdownMenu.Item>Move to folder…</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item>Advanced options…</DropdownMenu.Item>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Separator />
<DropdownMenu.Item>Share</DropdownMenu.Item>
<DropdownMenu.Item>Add to favorites</DropdownMenu.Item>
<DropdownMenu.Separator />
<Dialog.Root>
<Dialog.Trigger>
<DropdownMenu.Item shortcut="⌘ ⌫" color="red">
Delete
</DropdownMenu.Item>
</Dialog.Trigger>
<Dialog.Content>
<Dialog.Title>Confirm</Dialog.Title>
<Dialog.Description size="2" mb="4">
Delete?
</Dialog.Description>
<Flex gap="3" mt="4" justify="end">
<Dialog.Close>
<Button variant="soft" color="gray">
Cancel
</Button>
</Dialog.Close>
<Dialog.Close>
<Button>Save</Button>
</Dialog.Close>
</Flex>
</Dialog.Content>
</Dialog.Root>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Theme>
);
}
export default App;
試すとわかるけど、Dialog が開いた瞬間閉じてしまう。
Radix Theme なのでちょっとわかりにくいが、
内部的には Portal が使われている。実際、HTML は以下のようになっている。
<html lang="en">
<head>
<title>Vite + React + TS</title>
</head>
<body style="pointer-events: none;">
<div id="root" data-aria-hidden="true" aria-hidden="true">
<div data-is-root-theme="true" data-accent-color="indigo" data-gray-color="slate" data-has-background="true" data-panel-background="translucent" data-radius="medium" data-scaling="100%" class="radix-themes">
<button type="button" aria-haspopup="menu" aria-expanded="true" data-state="open" aria-controls="radix-:r2b:">
Options <svg />
</button>
</div>
</div>
<div data-radix-popper-content-wrapper="" dir="ltr">
</div>
</body>
</html>
data-radix-popper-content-wrapper
が Dropdown Menu の内容。
Dialog を開くと、一瞬だけコンポーネントが追加されるが、すぐに data-radix-popper-content-wrapper
ごと消えてしまう。DropdownMenu.Content が消えるときに Dialog.Root が消えるため、Dialog.Portal も一緒に消えてしまう。
Dialog.Portal は「内容を document.body に転送している」だけで、React が管理している Component Tree 上では DropdownMenu.Content 内部にある。そのため、DropdownMenu.Content が消えることで Dialog.Portal も消えてしまう。
import { Button, Dialog, DropdownMenu, Flex, Theme } from "@radix-ui/themes";
import "@radix-ui/themes/styles.css";
function App() {
return (
<Theme>
<Dialog.Root>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button variant="soft">
Options
<DropdownMenu.TriggerIcon />
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Item shortcut="⌘ E">Edit</DropdownMenu.Item>
<DropdownMenu.Item shortcut="⌘ D">Duplicate</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item shortcut="⌘ N">Archive</DropdownMenu.Item>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger>More</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent>
<DropdownMenu.Item>Move to project…</DropdownMenu.Item>
<DropdownMenu.Item>Move to folder…</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item>Advanced options…</DropdownMenu.Item>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Separator />
<DropdownMenu.Item>Share</DropdownMenu.Item>
<DropdownMenu.Item>Add to favorites</DropdownMenu.Item>
<DropdownMenu.Separator />
<Dialog.Trigger>
<DropdownMenu.Item shortcut="⌘ ⌫" color="red">
Delete
</DropdownMenu.Item>
</Dialog.Trigger>
</DropdownMenu.Content>
</DropdownMenu.Root>
<Dialog.Content>
<Dialog.Title>Confirm</Dialog.Title>
<Dialog.Description size="2" mb="4">
Delete?
</Dialog.Description>
<Flex gap="3" mt="4" justify="end">
<Dialog.Close>
<Button variant="soft" color="gray">
Cancel
</Button>
</Dialog.Close>
<Dialog.Close>
<Button>Save</Button>
</Dialog.Close>
</Flex>
</Dialog.Content>
</Dialog.Root>
</Theme>
);
}
export default App;
のように、Dialog.Root を DropdownMenu.Root の外側に置き、Dialog.Content を DropdownMenu.Root と並列に配置すれば、Portal が消えずにちゃんと成立する。
しかしこのやり方は「内部で利用する機能をより外側で準備する」構造になるため、非直感的だし、場合によってはメンテナンス性を損ねる。
Radix UI の中身に潜っていく。そもそも、.Root は何をやっているのか。
Radix UI のコード、結構ネストしてるというか、内部でパッケージをバラバラに分割して依存していっているので読みづらいんだよな。
const DropdownMenu: React.FC<DropdownMenuProps> = (props: ScopedProps<DropdownMenuProps>) => {
const {
__scopeDropdownMenu,
children,
dir,
open: openProp,
defaultOpen,
onOpenChange,
modal = true,
} = props;
const menuScope = useMenuScope(__scopeDropdownMenu);
const triggerRef = React.useRef<HTMLButtonElement>(null);
const [open = false, setOpen] = useControllableState({
prop: openProp,
defaultProp: defaultOpen,
onChange: onOpenChange,
});
return (
<DropdownMenuProvider
scope={__scopeDropdownMenu}
triggerId={useId()}
triggerRef={triggerRef}
contentId={useId()}
open={open}
onOpenChange={setOpen}
onOpenToggle={React.useCallback(() => setOpen((prevOpen) => !prevOpen), [setOpen])}
modal={modal}
>
<MenuPrimitive.Root {...menuScope} open={open} onOpenChange={setOpen} dir={dir} modal={modal}>
{children}
</MenuPrimitive.Root>
</DropdownMenuProvider>
);
};
DropdownMenu.displayName = DROPDOWN_MENU_NAME;
これが DropdownMenu.Root らしい。
まあ要するに DropdownMenuProvider に props を渡して、MenuPrimitive.Root をかぶせている。
DropdownMenuProvider は以下のように定義されている。
type DropdownMenuContextValue = {
triggerId: string;
triggerRef: React.RefObject<HTMLButtonElement>;
contentId: string;
open: boolean;
onOpenChange(open: boolean): void;
onOpenToggle(): void;
modal: boolean;
};
const [DropdownMenuProvider, useDropdownMenuContext] =
createDropdownMenuContext<DropdownMenuContextValue>(DROPDOWN_MENU_NAME);
createDropdownMenuContext がなんなのかまでは追いかけてないけど、まあ要するに Context の受け渡しができるようにってことっぽい。DropdownMenu.Root が open を持っているのがけっこう事態をややこしくしている気がするんだよな。
const Menu: React.FC<MenuProps> = (props: ScopedProps<MenuProps>) => {
const { __scopeMenu, open = false, children, dir, onOpenChange, modal = true } = props;
const popperScope = usePopperScope(__scopeMenu);
const [content, setContent] = React.useState<MenuContentElement | null>(null);
const isUsingKeyboardRef = React.useRef(false);
const handleOpenChange = useCallbackRef(onOpenChange);
const direction = useDirection(dir);
React.useEffect(() => {
// Capture phase ensures we set the boolean before any side effects execute
// in response to the key or pointer event as they might depend on this value.
const handleKeyDown = () => {
isUsingKeyboardRef.current = true;
document.addEventListener('pointerdown', handlePointer, { capture: true, once: true });
document.addEventListener('pointermove', handlePointer, { capture: true, once: true });
};
const handlePointer = () => (isUsingKeyboardRef.current = false);
document.addEventListener('keydown', handleKeyDown, { capture: true });
return () => {
document.removeEventListener('keydown', handleKeyDown, { capture: true });
document.removeEventListener('pointerdown', handlePointer, { capture: true });
document.removeEventListener('pointermove', handlePointer, { capture: true });
};
}, []);
return (
<PopperPrimitive.Root {...popperScope}>
<MenuProvider
scope={__scopeMenu}
open={open}
onOpenChange={handleOpenChange}
content={content}
onContentChange={setContent}
>
<MenuRootProvider
scope={__scopeMenu}
onClose={React.useCallback(() => handleOpenChange(false), [handleOpenChange])}
isUsingKeyboardRef={isUsingKeyboardRef}
dir={direction}
modal={modal}
>
{children}
</MenuRootProvider>
</MenuProvider>
</PopperPrimitive.Root>
);
};
Menu.displayName = MENU_NAME;
const DropdownMenuTrigger = React.forwardRef<DropdownMenuTriggerElement, DropdownMenuTriggerProps>(
(props: ScopedProps<DropdownMenuTriggerProps>, forwardedRef) => {
const { __scopeDropdownMenu, disabled = false, ...triggerProps } = props;
const context = useDropdownMenuContext(TRIGGER_NAME, __scopeDropdownMenu);
const menuScope = useMenuScope(__scopeDropdownMenu);
return (
<MenuPrimitive.Anchor asChild {...menuScope}>
<Primitive.button
type="button"
id={context.triggerId}
aria-haspopup="menu"
aria-expanded={context.open}
aria-controls={context.open ? context.contentId : undefined}
data-state={context.open ? 'open' : 'closed'}
data-disabled={disabled ? '' : undefined}
disabled={disabled}
{...triggerProps}
ref={composeRefs(forwardedRef, context.triggerRef)}
onPointerDown={composeEventHandlers(props.onPointerDown, (event) => {
// only call handler if it's the left button (mousedown gets triggered by all mouse buttons)
// but not when the control key is pressed (avoiding MacOS right click)
if (!disabled && event.button === 0 && event.ctrlKey === false) {
context.onOpenToggle();
// prevent trigger focusing when opening
// this allows the content to be given focus without competition
if (!context.open) event.preventDefault();
}
})}
onKeyDown={composeEventHandlers(props.onKeyDown, (event) => {
if (disabled) return;
if (['Enter', ' '].includes(event.key)) context.onOpenToggle();
if (event.key === 'ArrowDown') context.onOpenChange(true);
// prevent keydown from scrolling window / first focused item to execute
// that keydown (inadvertently closing the menu)
if (['Enter', ' ', 'ArrowDown'].includes(event.key)) event.preventDefault();
})}
/>
</MenuPrimitive.Anchor>
);
}
);
DropdownMenuTrigger.displayName = TRIGGER_NAME;
Trigger がとりあえず思ったよりいろいろな属性を適切に指定してくれているっぽいことはわかった。
__scopeXxx が何なのかわからんな。外部に露出していないので、内部でだけ使っているのだけど、外部から与えられないのにどうやったら値が入るんだ?(undefined は受け入れていそうだけど)
うーん、わからん。
Scope という機能の目的がわからないので実装を読んでもなにもわからんな。なにかうまく構造化してデータを扱えるようにしている。
__scopeXxx には実際には値は入っていないっぽい。使い方によっては入ることもあるのか?
けどどう考えても普通に呼び出しただけだと __scopeXxx には何も入ってこないんだよな。これなんのためにあるんだろう?
安易に createDialogScope をつかって {...dialogScope}
とかで __scopeDialog を渡してみたけど普通に壊れた。
() => {
const dialogScope = useDialog({});
return (
<Dialog.Root {...dialogScope}>
<Dialog.Trigger {...dialogScope}> xxx </Dialog.Trigger>
</Dialog.Root>
)
}
こんな感じだと成立した。Radix Theme だと Dialog.Portal に props を渡す手段がなくて破滅した。
dialogScope をつかって多重 Dialog が処理できるのかと思ったけどそんなことはなさそうだった。普通に最も近い Dialog の Context を拾う。
うーん、よくわかんないんだけど
const dialog = useDialog();
return (
<>
<Dialog.Trigger control={dialog}>
<button>Open</button>
</Dialog.Trigger>
<Dialog.Content control={dialog}>
Xxx
</Dialog.Content>
</>
)
return (
<Dialog.Root>
<Dialog.Trigger>
<button>Open</button>
</Dialog.Trigger>
<Dialog.Content>
Xxx
</Dialog.Content>
</Dialog.Root>
)
みたいに実装できたほうが平和だったんじゃないのか。
まあともかく、Radix UI の現状の実装だと、Dialog.Trigger などが Dialog を拾うためには Context が用いられている(Scopeによる制限の仕組みがよくわからないのと、Scope が導入された Issue で解決したいことと Scope の目的がなんかあんま噛み合ってない気がするので、なにか文脈を見落としている気がするけど)ため、2種類の Dialog の Trigger を1つの DropdownMenu に共存させることはできないってこと。 Dialog の中身を Dialog.Trigger から動的に注入する仕組みが必要になりそうだけど、それをすると Accessibility 上は「同じ Dialog を起動する2つの UI」のように見えるはずで、イケてない気がするんだよな……。
Trigger の onClick をハックできないかと思ったけど、丁寧に実装されていて context が読まれそうな感じになっていた。
const DialogTrigger = React.forwardRef<DialogTriggerElement, DialogTriggerProps>(
(props: ScopedProps<DialogTriggerProps>, forwardedRef) => {
const { __scopeDialog, ...triggerProps } = props;
const context = useDialogContext(TRIGGER_NAME, __scopeDialog);
const composedTriggerRef = useComposedRefs(forwardedRef, context.triggerRef);
return (
<Primitive.button
type="button"
aria-haspopup="dialog"
aria-expanded={context.open}
aria-controls={context.contentId}
data-state={getState(context.open)}
{...triggerProps}
ref={composedTriggerRef}
onClick={composeEventHandlers(props.onClick, context.onOpenToggle)}
/>
);
}
);
DialogTrigger.displayName = TRIGGER_NAME;
contentId は Root で作られた useId が入っているため、やはり Trigger と Root は接続されていますね。
__dialogScope の値によって読み替えが起こりそうに見えるんだけどな〜