Context menu
A region that owns a menu of actions, opened by right-click, by a long-press on Android, or from the keyboard. The native Popover API supplies the surface; this supplies the menu and the point it opens at.
Example (a11y tier)
Right-click the region, or Tab to it and press Shift+F10. Then Up and Down, Home and End, or type a letter; Tab closes it and carries on, Escape closes it and puts focus back.
Import
import ContextMenu from 'abaabil/context-menu' // structure only, no ARIAimport ContextMenu from 'abaabil/context-menu/styled' // + CSSimport ContextMenu from 'abaabil/context-menu/a11y' // + APG menu semantics and keyboardTwo properties and one clamp
The Popover API gives the panel its top layer, dismissal on an outside click and dismissal on Escape. None of that is reimplemented here. What the browser does not do is put the panel where the pointer is, so the component writes the event's clientX and clientY into inline left and top, then clamps both to the viewport once, after showPopover() has given the panel a size.
That is the whole positioning story: no portal, no resize observer, no scroll listener. A popover in the top layer with position: fixed stays put on its own, and a scroll or a resize light-dismisses it anyway. Physical left and top rather than logical properties, because pointer coordinates are physical.
The menu inside is menu's menu
The panel is the same APG menu as menu: role="menu" named by label, role="menuitem" on each entry, a roving tabindex, Up and Down with wrapping, Home and End, multi-character typeahead, Tab to close, and focus moving to the first item on open and back to where it was on close. Items share menu's shape, so one array can feed both.
The keyboard handling is a port of those sixty lines, not an import of them. Importing would pull menu's stylesheet and code into this entry point, and a consumer of one should not pay for the other.
Shift+F10 and the ContextMenu key open the panel at the region's top-left corner. The wrapper gets tabIndex={0} so a region with nothing focusable in it is still reachable; pass tabIndex={-1} if it already contains the tab stop you want. There is no aria-haspopup on the region, deliberately: that attribute describes a control, and a region is not one.
iOS Safari, and the client boundary
iOS Safari fires no contextmenu event on long-press, and this component adds no timer to fake one. On iOS the region is a plain region, so give the actions another route.
The lower two tiers contain no hooks and carry no directive, but they attach onContextMenu unconditionally, and a Server Component cannot pass a function. So every tier has to sit below a client boundary; the a11y tier is a client component outright.
Props
| Prop | Type | Default | Tier | Description |
|---|---|---|---|---|
id | string | — (required) | all | Panel id. Not generated, for the same reason as popover and menu: useId would mean a client boundary. |
items | Array<{ key?, label, href?, onSelect?, disabled? }> | — | all | An item with href renders an <a>, otherwise a <button>. Same shape as menu's. |
children | ReactNode | — | all | The region the menu belongs to. |
className | string | — | all | Merged onto the wrapping element. |
label | string | — | a11y | Names the menu. Warns in development when missing. |