abaabil

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.

Right-click this region, long-press it on Android, or focus it and press Shift+F10.

Import

import ContextMenu from 'abaabil/context-menu'         // structure only, no ARIA
import ContextMenu from 'abaabil/context-menu/styled'  // + CSS
import ContextMenu from 'abaabil/context-menu/a11y'    // + APG menu semantics and keyboard

Two 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

PropTypeDefaultTierDescription
idstring— (required)allPanel id. Not generated, for the same reason as popover and menu: useId would mean a client boundary.
itemsArray<{ key?, label, href?, onSelect?, disabled? }>—allAn item with href renders an <a>, otherwise a <button>. Same shape as menu's.
childrenReactNode—allThe region the menu belongs to.
classNamestring—allMerged onto the wrapping element.
labelstring—a11yNames the menu. Warns in development when missing.