Menus

DropdownMenu, ContextMenu, Menubar and NavigationMenu are one page because they are one implementation. They differ in what opens them; every part below the trigger is shared, so the prop tables would otherwise be copied four times and drift three ways.

import { DropdownMenu, ContextMenu, Menubar, NavigationMenu } from '@apostel/bedrock'
Loading demo…
Source
import { useState } from 'react'
import { DropdownMenu } from '../../src/index'

/**
 * Arrow keys, Home/End and typeahead are roving tabindex, which is the one
 * place bedrock does write keyboard handling — the platform has no menu widget.
 * The panel itself is a popover, so dismissal is still the browser's.
 */
export default function DropdownMenuDemo() {
  const [chosen, setChosen] = useState('')

  return (
    <>
      <DropdownMenu.Root>
        <DropdownMenu.Trigger>Actions</DropdownMenu.Trigger>
        <DropdownMenu.Content side="bottom" align="start" sideOffset={6}>
          <DropdownMenu.Label>Edit</DropdownMenu.Label>
          <DropdownMenu.Item onClick={() => setChosen('cut')}>Cut</DropdownMenu.Item>
          <DropdownMenu.Item onClick={() => setChosen('copy')}>Copy</DropdownMenu.Item>
          <DropdownMenu.Separator />
          <DropdownMenu.Sub>
            <DropdownMenu.SubTrigger>Share</DropdownMenu.SubTrigger>
            <DropdownMenu.SubContent>
              <DropdownMenu.Item onClick={() => setChosen('email')}>Email</DropdownMenu.Item>
              <DropdownMenu.Item onClick={() => setChosen('link')}>Copy link</DropdownMenu.Item>
            </DropdownMenu.SubContent>
          </DropdownMenu.Sub>
        </DropdownMenu.Content>
      </DropdownMenu.Root>
      {chosen ? <output>chose: {chosen}</output> : null}
    </>
  )
}

What is native here, and what is not

The panel is a <div popover>: top layer, light dismiss, Escape, and stacking are all the browser's.

The keyboard is not. There is no menu widget in HTML, so arrow keys, Home, End and typeahead are a roving tabindex implemented in src/roving.ts. This is the one place bedrock writes keyboard handling, and it is written down here rather than hidden, because it is the part that would be a lie to call native.

Anatomy

<DropdownMenu.Root>
  <DropdownMenu.Trigger />
  <DropdownMenu.Content>
    <DropdownMenu.Label />
    <DropdownMenu.Item />
    <DropdownMenu.CheckboxItem />
    <DropdownMenu.RadioGroup>
      <DropdownMenu.RadioItem />
    </DropdownMenu.RadioGroup>
    <DropdownMenu.Separator />
    <DropdownMenu.Group />
    <DropdownMenu.Sub>
      <DropdownMenu.SubTrigger />
      <DropdownMenu.SubContent />
    </DropdownMenu.Sub>
  </DropdownMenu.Content>
</DropdownMenu.Root>

ContextMenu and Menubar use the same parts. NavigationMenu uses List, Item, Link and Viewport instead of the item family.

The four roots

root opened by notes
DropdownMenu.Root its Trigger, an invoker The ordinary case.
ContextMenu.Root right-click anywhere in its subtree Wraps the target; the trigger is the region, not a button.
Menubar.Root its Menubar.Triggers Roving focus across the bar itself.
NavigationMenu.Root its Triggers Site navigation, not commands.

Each takes onOpenChange, which reports and cannot refuse. Import from @apostel/bedrock/controlled for a veto.

ContextMenu opens on contextmenu, which means it must preventDefault() the browser's own menu — the one deliberate interception in the library. Opening is deferred by a task, because showing a popover inside the same gesture that opened it means the gesture immediately light-dismisses it.

Trigger

Renders <button type="button" commandfor command="toggle-popover">, plus an anchor-name so the panel can be positioned against it.

asChild is supported and the child must render a <button>, enforced at mount. ContextMenu is the exception: its trigger is the region you right-click and is not required to be a button.

Content

Renders <div popover data-bedrock-menu>.

prop type notes
side Side 'top' | 'right' | 'bottom' | 'left'.
align Align 'start' | 'center' | 'end'.
sideOffset number Pixels between trigger and panel.
avoidCollisions boolean Default true. Becomes position-try-fallbacks.
loop boolean Whether arrow keys wrap at the ends.
id Not forwarded; commandfor points at it.

Children mount only while it is open. A menu that holds a checkbox item therefore starts from your state every time, not from whatever it was left in.

Items

part renders props beyond the element's own
Item <button role="menuitem"> closeOnSelect (default true)
CheckboxItem <button role="menuitemcheckbox"> checked, onCheckedChange
RadioGroup <div role="group"> value, onValueChange
RadioItem <button role="menuitemradio"> value (required)
Label <div>
Group <div role="group">
Separator <div role="separator">

closeOnSelect is off for checkbox and radio items, because ticking three boxes in a row is the point of having them.

Every item takes asChild. Items are <button> rather than <div role> so that activation, disabled handling and focus are the element's own.

Submenus

Sub, SubTrigger and SubContent. The submenu is its own popover, opened by its trigger and nested inside the parent's panel.

Nested roving containers do not double-step: a key handled by the submenu marks the event handled, and the parent stands down. That was a real bug, and there is a test for it.

NavigationMenu

part renders notes
Root <nav> Landmark, so it is one per region.
List <ul> Roving focus across the top level.
Item <li>
Link <a> Takes active for the current page.
Viewport <div> Optional shared panel container.

A navigation menu holds links, not commands, so its items are anchors and middle-click, copy-link and open-in-new-tab all work. Radix renders a button that calls router.push, which breaks all three.

Keyboard

key behaviour
ArrowDown/Up Move between items. Wraps when loop.
ArrowRight/Left Open/close a submenu. On Menubar, move between menus.
Home/End First and last item.
az Typeahead. Jumps to the next item starting with that letter.
Escape Closes. Platform light dismiss.
Tab Closes the menu and moves on. Not trapped.

What is not here