Components / overlays

Menu

The id prefix seeds every item id from useId. A constant default meant two default-configured menus on one page emitted the same ids, and aria-activedescendant then named whichever the document happened to contain first.

Rendered from the package · open on its own

Summary

A list of actions attached to a trigger.

APG patternMenu Button
Built onnative elements
Statusstable
Since0.1.0
<Menu label="Project" items={[
  { label: 'Copy link', icon: <Icon path={ICONS.linkSimple} /> },
  { separator: true },
  { label: 'Delete project', icon: <Icon path={ICONS.trash} />, intent: 'negative' }
]} onSelect={(item) => run(item.label)} />

Anatomy

A trigger plus a floater carrying role="menu". Menubar renders data-se="menu" for its own floaters, so one set of rules dresses both.

PartElementRequired
floater<div>yes
icon<svg>yes
item<button>yes
label<span>yes
separator<div>yes
triggerthe caller'sthe caller's

Properties

PropertyValuesDefaultWhat it does
refReact.Ref<HTMLButtonElement>requiredunknown
type'button'requiredunknown
onClick() => voidrequiredunknown
onKeyDown(e: React.KeyboardEvent) => voidrequiredunknown

States

StateExpressed as
Pressed:active
Disabled:disabled
Focused:focus-visible
Hover:hover
Disabledaria-disabled
Highlighteddata-highlighted
Open or closeddata-state

Behavior

Arrow keys, Home and End, Enter and Escape are wired. It dismisses on outside click and Escape, and returns focus to the trigger.

The trigger is the labeled outline button unless you hand one in. trigger receives the ref and the four attributes that make a button a menu button — aria-haspopup, aria-expanded, click and keys — and renders whatever the place needs: three dots beside a title, or a page title that is itself the menu. data-part="trigger" strips button chrome and draws the ring, so a title trigger reads as the heading it replaces.

Hover moves the highlight rather than painting a second state. Two highlights at once — one from the pointer, one from the keyboard — is a reader who cannot tell which will fire.

Accessibility

The highlight is drawn by the anatomy, because the browser cannot draw focus on an item the roving tabindex has not focused.

aria-haspopup and aria-expanded on the trigger say what it opens and whether it is open.

Keyboard

KeyResult
Arrow Down / Arrow UpMove the highlight, wrapping at the ends
Home / EndJump to the first or last item
EnterActivates the highlighted item
EscapeCloses and returns focus to the trigger
TabCloses the menu and moves on

Roles, states and properties

AttributeSet by
aria-activedescendantset at render
aria-disabledset at render
aria-expandedset at render
aria-haspopupalways menu
aria-hiddenalways true
aria-labelset at render
rolealways menu, always separator, always menuitem

Focus

DOM focus stays on the panel and aria-activedescendant names the current item, which is why the highlight is drawn by the anatomy — the browser has nothing to ring. Focus returns to the trigger on close.

Screen reader

Not recorded as verified. Stage 4 of the readiness ladder — driving each component with VoiceOver and NVDA — is unstarted across the package, and no component should claim otherwise until docs/journal/ records the session that did it.

Contrast and target size

npm run check:contrast audits every color pair this component uses across all four skins, light and dark, and fails the build on a shortfall. Two waivers are on the record in docs/audit/DEFERRED.md.

It takes the touch floor under (pointer: coarse) — the visual size is unchanged on a desktop pointer.

Tested

  • test/apg/batch-a.test.jsx
  • test/apg/batch-e.test.jsx
  • test/apg/partials-sweep.test.jsx
  • test/browser/keyboard.spec.mjs
  • test/browser/upstream-natives.spec.mjs

Known gaps

  • 1 upstream ref — duplicate of button-aria-controls
  • 1 upstream ref — duplicate of button-aria-expanded
  • 1 upstream ref — duplicate of button-aria-haspopup in their other example
  • 1 upstream ref — duplicate of button-down-arrow-or-space-or-enter
  • 1 upstream ref — duplicate of button-up-arrow
  • 1 upstream ref — duplicate of menu-character
  • 1 upstream ref — duplicate of menu-down-arrow
  • 1 upstream ref — duplicate of menu-end
  • 1 upstream ref — duplicate of menu-enter
  • 1 upstream ref — duplicate of menu-escape
  • 1 upstream ref — duplicate of menu-home
  • 1 upstream ref — duplicate of menu-up-arrow
  • 1 upstream ref — APG's own page or markup, not the pattern

Appearance

TokenUsed for
--se-accentintent
--se-bgbackground, border-color
--se-borderbackground, border, border-color
--se-border-widthborder, border-width
--se-control-hblock-size, inline-size, min-block-size
--se-control-h-lgblock-size, inline-size
--se-control-h-smblock-size, inline-size
--se-duration-fastanimation
--se-duration-instanttransition
--se-ease-decelerateanimation
--se-ease-standardtransition
--se-focus-coloroutline
--se-focus-offsetoutline-offset
--se-focus-widthoutline
--se-icon-mdblock-size, inline-size
--se-icon-smblock-size, font-size, inline-size
--se-layer-floatingz-index
--se-leading-uiline-height
--se-mix-downbackground, border-color
--se-mix-hoverbackground, border-color
--se-mix-lineborder-color
--se-mix-pressbackground
--se-mix-shadebackground, border-color
--se-mix-softbackground
--se-negativebackground, color, intent
--se-positiveintent
--se-radius-lgborder-radius
--se-radius-mdborder-radius
--se-radius-smborder-radius
--se-shadow-floatingbox-shadow
--se-space-1margin, padding, padding-block
--se-space-2gap, inset-block-start, padding-inline
--se-space-3padding-inline
--se-space-4padding-inline
--se-space-5padding-inline
--se-surfacebackground
--se-surface-highlightbackground
--se-surface-sunkbackground
--se-target-minblock-size, inline-size, min-block-size
--se-textcolor, intent
--se-text-disabledcolor
--se-text-mdfont-size
--se-text-mutedcolor
--se-text-on-intentcolor
--se-text-smfont-size
--se-text-xsfont-size

Motion

The floater rises over --se-duration-fast.

Content

Each item names an action. Copy link, Delete project. A menu of nouns is a navigation list and belongs in a nav.

A destructive item sits last, after a separator.

Usage

A Menu is for actions. If the panel holds fields or prose, that is a Popover. If it holds a single explanation, that is a Tooltip.

A title as the trigger holds a selection, not an action. A groceries list "for Easter lunch" that opens the other events is choosing what the page is about. Give those items role="menuitemradio" through trigger's panel or keep them as actions that navigate — either is honest; a select in a heading is not.

Not a Select. Menu, Select and Combobox are three components and one temptation: a menu fires an action and forgets; a select holds a value.

When not to use it. For a menu bar across the top of an application — that is Menubar, which has a different arrow contract.

Notes

Hand-written, not Radix. Radix is permitted for six patterns and a menu button is not one of them any more — the roving highlight here is aria-activedescendant with DOM focus parked on the panel, which is why the highlight is the anatomy's job rather than a focus ring.

The id prefix seeds every item id from useId. A constant default meant two default-configured menus on one page emitted the same ids, and aria-activedescendant then named whichever the document happened to contain first.