Radix UI Testing Guide: Dialog, Select, Dropdown, and Keyboard Navigation

Radix UI Testing Guide: Dialog, Select, Dropdown, and Keyboard Navigation

Radix UI provides unstyled, accessible React primitives that handle complex interaction patterns — keyboard navigation, focus management, ARIA attributes — so you don't have to. Testing Radix UI components effectively means verifying these accessibility behaviors work in your application context, while also validating your custom logic and styling don't break the primitives underneath.

Understanding Radix UI's Testing Characteristics

Radix UI components have specific behaviors that affect testing:

  • Portals: Dialog, Select, DropdownMenu render in portals (appended to document.body by default). Testing Library handles this automatically — screen.getByRole() searches the entire document.
  • Animation: Components use CSS transitions. In tests, animations complete instantly since jsdom doesn't run CSS.
  • Data attributes: Radix exposes [data-state], [data-open], [data-highlighted] for styling. These are useful selectors in tests.
  • Browser APIs: Radix uses ResizeObserver, PointerEvent, and MutationObserver. Mock these before testing.

Setup

// vitest.setup.ts
import "@testing-library/jest-dom";

// Required for Radix UI
global.ResizeObserver = class ResizeObserver {
  observe() {}
  unobserve() {}
  disconnect() {}
};

Object.defineProperty(window, "matchMedia", {
  writable: true,
  value: vi.fn().mockImplementation((query: string) => ({
    matches: false,
    media: query,
    onchange: null,
    addEventListener: vi.fn(),
    removeEventListener: vi.fn(),
    dispatchEvent: vi.fn(),
  })),
});

// Radix UI Tooltip/Popover
Element.prototype.hasPointerCapture = () => false;
Element.prototype.releasePointerCapture = () => {};
Element.prototype.setPointerCapture = () => {};
HTMLElement.prototype.scrollIntoView = () => {};

Testing Dialog

Radix Dialog manages open/close state, focus trapping, and ARIA roles automatically. Test that these work in your specific implementation:

import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import * as Dialog from "@radix-ui/react-dialog";

function ConfirmationDialog({
  onConfirm,
  onCancel,
}: {
  onConfirm: () => void;
  onCancel: () => void;
}) {
  const [open, setOpen] = React.useState(false);

  return (
    <Dialog.Root open={open} onOpenChange={setOpen}>
      <Dialog.Trigger asChild>
        <button>Delete Account</button>
      </Dialog.Trigger>
      <Dialog.Portal>
        <Dialog.Overlay className="overlay" />
        <Dialog.Content aria-describedby="confirm-desc">
          <Dialog.Title>Delete Account</Dialog.Title>
          <Dialog.Description id="confirm-desc">
            This action cannot be undone.
          </Dialog.Description>
          <button
            onClick={() => {
              onConfirm();
              setOpen(false);
            }}
          >
            Yes, delete
          </button>
          <Dialog.Close asChild>
            <button onClick={onCancel}>Cancel</button>
          </Dialog.Close>
        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  );
}

it("opens dialog and shows correct content", async () => {
  const user = userEvent.setup();
  render(<ConfirmationDialog onConfirm={vi.fn()} onCancel={vi.fn()} />);

  await user.click(screen.getByRole("button", { name: "Delete Account" }));

  const dialog = screen.getByRole("dialog");
  expect(dialog).toBeInTheDocument();
  expect(screen.getByText("This action cannot be undone.")).toBeVisible();
});

it("calls onConfirm and closes on confirmation", async () => {
  const user = userEvent.setup();
  const handleConfirm = vi.fn();
  render(<ConfirmationDialog onConfirm={handleConfirm} onCancel={vi.fn()} />);

  await user.click(screen.getByRole("button", { name: "Delete Account" }));
  await user.click(screen.getByRole("button", { name: "Yes, delete" }));

  expect(handleConfirm).toHaveBeenCalledOnce();
  await waitFor(() => {
    expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
  });
});

it("calls onCancel and closes on cancel", async () => {
  const user = userEvent.setup();
  const handleCancel = vi.fn();
  render(<ConfirmationDialog onConfirm={vi.fn()} onCancel={handleCancel} />);

  await user.click(screen.getByRole("button", { name: "Delete Account" }));
  await user.click(screen.getByRole("button", { name: "Cancel" }));

  expect(handleCancel).toHaveBeenCalledOnce();
});

it("closes on Escape key", async () => {
  const user = userEvent.setup();
  render(<ConfirmationDialog onConfirm={vi.fn()} onCancel={vi.fn()} />);

  await user.click(screen.getByRole("button", { name: "Delete Account" }));
  await user.keyboard("{Escape}");

  await waitFor(() => {
    expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
  });
});

it("has correct ARIA attributes", async () => {
  const user = userEvent.setup();
  render(<ConfirmationDialog onConfirm={vi.fn()} onCancel={vi.fn()} />);

  await user.click(screen.getByRole("button", { name: "Delete Account" }));

  const dialog = screen.getByRole("dialog");
  expect(dialog).toHaveAttribute("aria-describedby", "confirm-desc");
  expect(screen.getByRole("heading", { name: "Delete Account" })).toBeInTheDocument();
});

Testing Select

Select keyboard navigation is a common test gap:

import * as Select from "@radix-ui/react-select";

function LanguageSelect({ onChange }: { onChange: (lang: string) => void }) {
  return (
    <Select.Root onValueChange={onChange}>
      <Select.Trigger aria-label="Select language">
        <Select.Value placeholder="Choose language" />
      </Select.Trigger>
      <Select.Portal>
        <Select.Content>
          <Select.Viewport>
            <Select.Item value="en"><Select.ItemText>English</Select.ItemText></Select.Item>
            <Select.Item value="fr"><Select.ItemText>French</Select.ItemText></Select.Item>
            <Select.Item value="de"><Select.ItemText>German</Select.ItemText></Select.Item>
            <Select.Item value="ja" disabled>
              <Select.ItemText>Japanese (coming soon)</Select.ItemText>
            </Select.Item>
          </Select.Viewport>
        </Select.Content>
      </Select.Portal>
    </Select.Root>
  );
}

it("selects option via mouse click", async () => {
  const user = userEvent.setup();
  const onChange = vi.fn();
  render(<LanguageSelect onChange={onChange} />);

  await user.click(screen.getByRole("combobox", { name: "Select language" }));
  await user.click(screen.getByRole("option", { name: "French" }));

  expect(onChange).toHaveBeenCalledWith("fr");
});

it("navigates options with keyboard", async () => {
  const user = userEvent.setup();
  const onChange = vi.fn();
  render(<LanguageSelect onChange={onChange} />);

  // Open with keyboard
  await user.click(screen.getByRole("combobox"));
  
  // Arrow down to first option, then down again to French
  await user.keyboard("{ArrowDown}");
  await user.keyboard("{ArrowDown}");
  await user.keyboard("{Enter}");

  expect(onChange).toHaveBeenCalledWith("fr");
});

it("skips disabled options during keyboard navigation", async () => {
  const user = userEvent.setup();
  render(<LanguageSelect onChange={vi.fn()} />);

  await user.click(screen.getByRole("combobox"));

  const disabledOption = screen.getByRole("option", { name: /Japanese/ });
  expect(disabledOption).toHaveAttribute("aria-disabled", "true");
});

Testing DropdownMenu

DropdownMenu supports submenus, checkboxes, and radio groups. Test each:

import * as DropdownMenu from "@radix-ui/react-dropdown-menu";

function ActionsMenu({ onAction }: { onAction: (action: string) => void }) {
  const [bookmarked, setBookmarked] = React.useState(false);

  return (
    <DropdownMenu.Root>
      <DropdownMenu.Trigger asChild>
        <button>Actions</button>
      </DropdownMenu.Trigger>
      <DropdownMenu.Portal>
        <DropdownMenu.Content>
          <DropdownMenu.Item onSelect={() => onAction("edit")}>
            Edit
          </DropdownMenu.Item>
          <DropdownMenu.CheckboxItem
            checked={bookmarked}
            onCheckedChange={setBookmarked}
          >
            Bookmarked
          </DropdownMenu.CheckboxItem>
          <DropdownMenu.Separator />
          <DropdownMenu.Item
            onSelect={() => onAction("delete")}
            className="text-red-600"
          >
            Delete
          </DropdownMenu.Item>
        </DropdownMenu.Content>
      </DropdownMenu.Portal>
    </DropdownMenu.Root>
  );
}

it("opens menu and lists all items", async () => {
  const user = userEvent.setup();
  render(<ActionsMenu onAction={vi.fn()} />);

  await user.click(screen.getByRole("button", { name: "Actions" }));

  expect(screen.getByRole("menu")).toBeInTheDocument();
  expect(screen.getByRole("menuitem", { name: "Edit" })).toBeInTheDocument();
  expect(screen.getByRole("menuitemcheckbox", { name: "Bookmarked" })).toBeInTheDocument();
  expect(screen.getByRole("menuitem", { name: "Delete" })).toBeInTheDocument();
});

it("calls onAction when item is selected", async () => {
  const user = userEvent.setup();
  const handleAction = vi.fn();
  render(<ActionsMenu onAction={handleAction} />);

  await user.click(screen.getByRole("button", { name: "Actions" }));
  await user.click(screen.getByRole("menuitem", { name: "Edit" }));

  expect(handleAction).toHaveBeenCalledWith("edit");
});

it("toggles checkbox item state", async () => {
  const user = userEvent.setup();
  render(<ActionsMenu onAction={vi.fn()} />);

  await user.click(screen.getByRole("button", { name: "Actions" }));
  const checkbox = screen.getByRole("menuitemcheckbox", { name: "Bookmarked" });

  expect(checkbox).toHaveAttribute("aria-checked", "false");
  await user.click(checkbox);

  // Reopen menu after selection closes it
  await user.click(screen.getByRole("button", { name: "Actions" }));
  expect(screen.getByRole("menuitemcheckbox", { name: "Bookmarked" }))
    .toHaveAttribute("aria-checked", "true");
});

it("closes menu when clicking outside", async () => {
  const user = userEvent.setup();
  render(
    <div>
      <ActionsMenu onAction={vi.fn()} />
      <div data-testid="outside">Outside content</div>
    </div>
  );

  await user.click(screen.getByRole("button", { name: "Actions" }));
  expect(screen.getByRole("menu")).toBeInTheDocument();

  await user.click(screen.getByTestId("outside"));
  await waitFor(() => {
    expect(screen.queryByRole("menu")).not.toBeInTheDocument();
  });
});

Testing Keyboard Navigation Comprehensively

Build a keyboard navigation test helper to verify full keyboard flows:

async function navigateWithKeyboard(
  user: ReturnType<typeof userEvent.setup>,
  keys: string[],
  assertions: (() => void)[]
) {
  for (let i = 0; i < keys.length; i++) {
    await user.keyboard(keys[i]);
    if (assertions[i]) assertions[i]();
  }
}

it("fully navigates DropdownMenu with keyboard only", async () => {
  const user = userEvent.setup();
  const handleAction = vi.fn();
  render(<ActionsMenu onAction={handleAction} />);

  // Focus the trigger
  await user.tab();
  expect(screen.getByRole("button", { name: "Actions" })).toHaveFocus();

  // Open with Enter
  await user.keyboard("{Enter}");
  expect(screen.getByRole("menu")).toBeInTheDocument();

  // Navigate down to "Delete"
  await user.keyboard("{ArrowDown}"); // Edit
  await user.keyboard("{ArrowDown}"); // Bookmarked
  await user.keyboard("{ArrowDown}"); // Delete
  await user.keyboard("{Enter}");     // Select Delete

  expect(handleAction).toHaveBeenCalledWith("delete");
});

Testing Tooltip

import * as Tooltip from "@radix-ui/react-tooltip";

function IconButton({ label }: { label: string }) {
  return (
    <Tooltip.Provider>
      <Tooltip.Root>
        <Tooltip.Trigger asChild>
          <button aria-label={label}>?</button>
        </Tooltip.Trigger>
        <Tooltip.Portal>
          <Tooltip.Content>{label}</Tooltip.Content>
        </Tooltip.Portal>
      </Tooltip.Root>
    </Tooltip.Provider>
  );
}

it("shows tooltip on hover", async () => {
  const user = userEvent.setup();
  render(<IconButton label="More information" />);

  await user.hover(screen.getByRole("button", { name: "More information" }));

  await waitFor(() => {
    expect(screen.getByRole("tooltip", { name: "More information" })).toBeInTheDocument();
  });
});

it("hides tooltip on unhover", async () => {
  const user = userEvent.setup();
  render(<IconButton label="More information" />);

  await user.hover(screen.getByRole("button", { name: "More information" }));
  await user.unhover(screen.getByRole("button"));

  await waitFor(() => {
    expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
  });
});

Summary

Testing Radix UI primitives effectively:

  1. Mock browser APIs (ResizeObserver, matchMedia, pointer capture) before any tests run
  2. Query by ARIA rolesgetByRole("dialog"), getByRole("option"), getByRole("menu") — not by CSS classes or Radix data attributes
  3. Always waitFor after interactions that open/close portaled content
  4. Test keyboard navigation explicitly — it's Radix UI's core value proposition
  5. Check aria-disabled for disabled items — they're rendered in the DOM but not interactive
  6. Test checkbox and radio items by their state attributes: aria-checked, aria-selected

Read more