Closed33

Radix UI の Trigger がめんどくさいもんだいについての調査

NiaNia

Radix UI で例えば DropdownMenu の Item が Dialog Trigger である場合、非常にめんどくさくなるような気がする。このあたりについて、HTML のアクセシビリティや Radix UI の実装を追いかけながら検証する。

NiaNia
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
NiaNia
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
NiaNia
App.tsx
import { Theme } from '@radix-ui/themes';
import './App.css'
import '@radix-ui/themes/styles.css';

function App() {
  return (
    <Theme>

    </Theme>
  )
}

export default App
NiaNia
App.tsx
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;
NiaNia

Dropdown Menu の特定の項目を押すと Dialog が開くような実装をしたい。例えば、Delete を押すと Confirmation Dialog が表示されるようなケースを想定してみる。

NiaNia
App.tsx
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;
NiaNia

試すとわかるけど、Dialog が開いた瞬間閉じてしまう。

NiaNia

Radix Theme なのでちょっとわかりにくいが、

https://github.com/radix-ui/themes/blob/main/packages/radix-ui-themes/src/components/dropdown-menu.tsx#L66

内部的には 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 の内容。

NiaNia

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 も消えてしまう。

NiaNia
App.tsx
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 が消えずにちゃんと成立する。

しかしこのやり方は「内部で利用する機能をより外側で準備する」構造になるため、非直感的だし、場合によってはメンテナンス性を損ねる。

NiaNia

Radix UI の中身に潜っていく。そもそも、.Root は何をやっているのか。

NiaNia

Radix UI のコード、結構ネストしてるというか、内部でパッケージをバラバラに分割して依存していっているので読みづらいんだよな。

NiaNia
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 をかぶせている。

NiaNia

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 を持っているのがけっこう事態をややこしくしている気がするんだよな。

NiaNia
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;
NiaNia
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;
NiaNia

Trigger がとりあえず思ったよりいろいろな属性を適切に指定してくれているっぽいことはわかった。

NiaNia

__scopeXxx が何なのかわからんな。外部に露出していないので、内部でだけ使っているのだけど、外部から与えられないのにどうやったら値が入るんだ?(undefined は受け入れていそうだけど)

NiaNia

Scope という機能の目的がわからないので実装を読んでもなにもわからんな。なにかうまく構造化してデータを扱えるようにしている。

NiaNia

__scopeXxx には実際には値は入っていないっぽい。使い方によっては入ることもあるのか?

NiaNia

けどどう考えても普通に呼び出しただけだと __scopeXxx には何も入ってこないんだよな。これなんのためにあるんだろう?

NiaNia

安易に createDialogScope をつかって {...dialogScope} とかで __scopeDialog を渡してみたけど普通に壊れた。

NiaNia
() => {
  const dialogScope = useDialog({});
  return (
    <Dialog.Root {...dialogScope}>
      <Dialog.Trigger {...dialogScope}> xxx </Dialog.Trigger>
    </Dialog.Root>
  )
}

こんな感じだと成立した。Radix Theme だと Dialog.Portal に props を渡す手段がなくて破滅した。

NiaNia

dialogScope をつかって多重 Dialog が処理できるのかと思ったけどそんなことはなさそうだった。普通に最も近い Dialog の Context を拾う。

NiaNia

うーん、よくわかんないんだけど

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>
)

みたいに実装できたほうが平和だったんじゃないのか。

NiaNia

まあともかく、Radix UI の現状の実装だと、Dialog.Trigger などが Dialog を拾うためには Context が用いられている(Scopeによる制限の仕組みがよくわからないのと、Scope が導入された Issue で解決したいことと Scope の目的がなんかあんま噛み合ってない気がするので、なにか文脈を見落としている気がするけど)ため、2種類の Dialog の Trigger を1つの DropdownMenu に共存させることはできないってこと。 Dialog の中身を Dialog.Trigger から動的に注入する仕組みが必要になりそうだけど、それをすると Accessibility 上は「同じ Dialog を起動する2つの UI」のように見えるはずで、イケてない気がするんだよな……。

NiaNia

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;
NiaNia

contentId は Root で作られた useId が入っているため、やはり Trigger と Root は接続されていますね。

NiaNia

__dialogScope の値によって読み替えが起こりそうに見えるんだけどな〜

このスクラップは2024/11/20にクローズされました