rules/base-ui-components.md
This project uses Base UI (@base-ui/react) for all headless UI primitives. Do not use Radix UI (@radix-ui/*) for any new components. This ensures:
If you need a component not yet wrapped in src/components/ui/, build it using Base UI primitives following the existing patterns in that directory.
The ContextMenu in src/components/ui/context-menu.tsx uses Base UI's native ContextMenu primitive (@base-ui/react/context-menu), which handles right-click and long-press detection automatically. Key differences from Radix's API:
onClick instead of onSelect on ContextMenuItemContextMenuTrigger renders a <div> wrapper — no asChild needed (use the render prop if you need to change the element type)// Correct usage
<ContextMenu>
<ContextMenuTrigger>
<div>Right-click me</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={() => doSomething()}>Action</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
Select onValueChange handlers receive string | null, not just string.
Guard null before parsing or casting values, especially when writing settings
selectors.
Native disabled controls reject programmatic focus. When optimistic UI moves
a control and focus must follow it while persistence is pending, keep it
focusable with aria-disabled, guard repeat activation synchronously, and
restore focus with { preventScroll: true }.
TooltipTrigger from @base-ui/react/tooltip (wrapped in src/components/ui/tooltip.tsx) renders a <button> by default. Wrapping another button-like element (<button>, <Button>, <DropdownMenuTrigger>, <PopoverTrigger>, <MiniSelectTrigger>, <ToggleGroupItem>) inside it creates invalid nested <button> HTML. Use the render prop instead:
// Wrong: nested buttons
<TooltipTrigger><Button onClick={fn}>Click</Button></TooltipTrigger>
// Correct: render prop merges into a single element
<TooltipTrigger render={<Button onClick={fn} />}>Click</TooltipTrigger>
ToggleGroupItem in TooltipTrigger without render also breaks :first-child/:last-child CSS selectors for rounded corners on the group.title attribute over Tooltip — tooltips appear immediately on hover and interfere with drag interactions, while title has a built-in delay.Base UI derives a SubmenuTrigger's accessible name from all descendant text and
labels. If a menu row contains badges, secondary text, or a separately labeled
chevron, give the trigger an explicit aria-label that describes both the row's
primary action and how to open its submenu. Do not put a separate aria-label on
a non-interactive chevron nested inside the trigger; it is not independently
focusable or exposed as a separate control to assistive technology. Because an
explicit name replaces descendant text, include meaningful visible state such
as quota, selection, and disclosure badges in that name.
With openOnHover={false}, Base UI opens a submenu on mousedown, before a
consumer onClick runs. When only part of a submenu trigger should open the
submenu, cancel Base UI's handler with event.preventBaseUIHandler() from both
onMouseDown and onClick for the trigger's primary action.
For navigation-only submenus, set openOnHover, delay, and closeDelay on
DropdownMenuSubTrigger. Base UI enables its safe pointer corridor when
openOnHover is true, so diagonal travel into the submenu does not close it.
Keep hybrid rows click-only when the row selects an item and only its chevron
opens configuration; hover-opening those rows makes selection ambiguous.
Keep hover-open triggers stationary while async menu content loads. Render the trigger before dynamic rows or reserve its exact space so newly inserted rows cannot move the trigger beneath a stationary pointer and open it accidentally.
The Accordion component in src/components/ui/accordion.tsx wraps @base-ui/react/accordion, not Radix or shadcn. The APIs differ:
type or collapsible props — these are Radix/shadcn-only. Reviewers may suggest type="single" collapsible but these props don't exist on Base UI's Accordion.multiple (boolean, default false) to allow multiple items open at once.defaultValue (array of item values) to control which items start expanded.