Context Menu

Pro

The right-click context menu built on Dropdown Menu — same items, theming and keyboard nav, opened at the cursor via `triggerMode="contextmenu"` or the public `openAt(x, y)` method instead of a click-toggled trigger.

</>sv-dropdown-menu context menuright clickcontextmenumenu
import { SvDropdownMenuComponent } from '@styloviz-pro/overlays';

Demo & usage

Basic usage

Pro

Usage example — open the gallery for the full interactive demo.

Pro

An interactive demo for this component lives in the gallery.

Open live demo

More examples

Wrap one element

Pro

One shared menu across many rows

Pro

Keep the browser menu on links and fields

Pro

When to use

  • A per-row or per-cell action menu in a table, list, canvas node, or file tree, triggered by right-click.
  • One menu instance shared across many rows — repopulate `items`/`groups` and call `openAt()` per target, rather than mounting a menu per row.
  • For a single wrapped element with nothing else driving it, `triggerMode="contextmenu"` is simpler than wiring `openAt()` yourself.

Async states

  • loading
  • error
  • retry

Inputs

NameTypeDefaultDescription
openboolean (two-way)falseControls menu visibility. Use `[(open)]` for two-way binding.
loadingbooleanfalseTrue while the menu's contents are being fetched — a permissions-aware or workspace-scoped menu is a list the server owns.
errorSvDataError | string | nullnullLoad failure. Use `toSvDataError()` from `@styloviz/core` to normalize an `HttpErrorResponse`.
itemsDropdownMenuItem[][]Flat list of menu items, rendered as a single ungrouped section; ignored when `groups` is non-empty.
groupsDropdownMenuGroup[][]Grouped menu items. Takes precedence over `items`.
placementDropdownMenuPlacement'bottom-start'Where the menu opens relative to its anchor. Same flip/clamp resolution applies whether the anchor is the trigger's bounding rect (click mode) or a point (`openAt()` / right-click) — for a point, `bottom-start` means "top-left corner of the panel at the cursor," which is the conventional context-menu placement.
itemActionDropdownMenuItemAction | nullnullThe work an item performs, run by the menu. WITHOUT IT A SLOW ACTION IS A DEAD CLICK. `(itemClick)` is a notification: the menu emits the id and closes in the same tick, so an item that starts a request leaves the user looking at the page it came from with nothing to say the click registered — and clicking the trigger again fires it a second time. GIVE THE MENU THE WORK AND IT CAN SAY SO: the menu stays open, the row shows a spinner and goes `aria-busy`, a second click on it is REFUSED rather than queued (`createAsyncAction` is first-wins — see `docs/async-contract-adr.md`), and a rejection is rendered in the menu instead of vanishing with it. `(itemClick)` and `(itemSelect)` still emit for every accepted click, so nothing that exists today changes. Wiring both means doing the work twice.
triggerModeDropdownMenuTriggerMode'click'How the menu opens — left-click toggle, or right-click at the cursor. See DropdownMenuTriggerMode.
variantDropdownMenuVariant'default'Panel theme.
sizeDropdownMenuSize'md'Density preset.
offsetnumber4Gap between the trigger and the panel (px).
flipbooleantrueFlip to the opposite side when there's no room; always clamps to viewport.
closeOnSelectbooleantrueClose the menu after selecting an item (per-item `keepOpen` overrides).
disabledbooleanfalsePrevent the menu from opening.
minWidthnumber160Minimum width of the menu panel (px).
triggerRolestring | nullnullARIA role for the trigger WRAPPER, or `null` to leave it generic. The wrapper is a layout `<div>` that provides the bounding rect to anchor against. That is invisible to a host — until the host is a composite whose own role requires the trigger to be one of its owned children: a `role="menubar"` must own `role="menuitem"`s, and a roleless div in between breaks that ownership. Passing `'none'` takes the wrapper out of the accessibility tree so the composite owns the button directly. `'none'` also stops the menu writing `aria-haspopup`, `aria-expanded`, `aria-controls`, `aria-owns` and `aria-activedescendant` at all, so the composite is the single writer: `panelId` and `activeDescendantId` are public for exactly that, and `sv-menubar` binds all five itself. Note this is now the only thing the input does. The menu no longer publishes its state from this wrapper in the default case either — see `_resolveAriaHost`, which puts it on the projected trigger, the element that actually takes focus.
ariaLabelstring'Menu'Accessible label for the menu (role="menu").
targetLabelstring''What these actions act ON — a row, a file, a record.
nativeMenuSelectorstring | nullnullRight-clicks inside an element matching this selector keep the BROWSER's context menu.
customClassstring''Extra CSS classes merged onto the menu panel.
sheetOnNarrowbooleanfalseBecome a bottom sheet on a narrow viewport.

Outputs

EventPayload typeDescription
retryvoidThe host should re-run the failed load.
itemClickstringEmits the id of the clicked item.
itemActionFailed{ item: DropdownMenuItem; error: SvDataError }An `[itemAction]` rejected. The menu stays open and shows the message.
itemSelectDropdownMenuItemEmits the full clicked item object.
openedvoidEmitted after the menu opens (by any means, including `[(open)]`).
closedvoidEmitted after the menu closes (by any means, including `[(open)]`).
openChangebooleanEmits the new `open` whenever it changes — the output half of `[(open)]`.

Wire it to your API

This component is headless about transport: it renders what you give it and reports what the user asks for. Nothing below requires @styloviz/data — drive it from whatever you already use.

Template

Pro

Bound to the names this component actually exposes.

Host class

Pro

Drive the three async inputs from your own fetch.

  • This component does not emit a query, so it renders what you give it. Fetch on your own trigger and bind `items`, `loading` and `error`.
  • Normalize failures with `toSvDataError()` from `@styloviz/core`. It derives `retryable` from the status, so a 404 does not get a "Try again" button that can only fail again.

Full walkthrough: Connecting to a backend.

Customization & theming

  • Everything Dropdown Menu supports — items/groups, icons, descriptions, shortcuts, badges, danger items, themes and sizes — applies unchanged.
  • `triggerMode="contextmenu"` disables the wrapped trigger's left-click toggle in favor of its native right-click.
  • `openAt(x, y)` works regardless of `triggerMode`, for menus with no rendered trigger element at all; `openAtElement(el)` anchors to an element's live rect instead — the kebab-button path.
  • `nativeMenuSelector` (opt-in) names the regions inside the trigger where the browser's own context menu wins — links, fields, prose — so suppression is narrow rather than blanket.
  • `[sheetOnNarrow]="true"` turns the panel into a bottom sheet below 640px — full-bleed, rounded at the top only, padded clear of the home indicator and lifted above the virtual keyboard. At 375px an anchored panel is clamped until it covers the thing it points at, and a context menu's anchor is a cursor POINT, which is the case with the least room of all. Off by default, because it changes how an existing app looks on a phone.
  • Three ways in, not one: right-click, a long press on touch (500ms, abandoned if the finger travels — a slow scroll over a right-clickable list is not a menu request), and Space or Enter from the keyboard. The keyboard path anchors the menu to the trigger rather than to a cursor, which is the APG position: there is no pointer in a keyboard interaction, and inventing one would put the menu where the user is not looking.

Best practices

  • Do: Always ship an equivalent visible action path — a kebab button, a toolbar, a row action. Right-click is undiscoverable, unavailable on touch, and the keyboard routes (Shift+F10, the ContextMenu key, Enter/Space on a promoted trigger) are not something most users will guess. Open the shared menu from the kebab with `openAtElement(button)` so a keyboard user's menu appears at the button, and set `targetLabel` so it announces "Actions for Invoice 4021".
  • Don't: Don't put an action ONLY in a context menu. If it is the only way to reach something, most users never reach it.
  • Do: Repopulate `items`/`groups` for the clicked target before or alongside calling `openAt()`, so the menu never shows stale actions from the previous target.
  • Do: Call `event.preventDefault()` in your own `(contextmenu)` handler when using `openAt()` directly, so the native browser context menu does not also appear.
  • Tip: Re-anchoring is safe while the menu is already open — right-clicking a second target just moves it, no need to close first.
  • Don't: Don't mount one `sv-dropdown-menu` per row for a large list — share a single instance and call `openAt()` with that row's data instead.

Accessibility

  • Same `menu`/`menuitem` ARIA roles and keyboard navigation as Dropdown Menu — a context menu opened at the cursor is still fully keyboard-operable once focus reaches it.
  • It can be OPENED from the keyboard, not just navigated once open: with nothing focusable projected as a trigger, the wrapper is promoted to `role="button"` with a tab stop, and both Space and Enter open the menu. Enter used to do nothing at all, so the keyboard path worked only if you guessed which of the two keys a button answers to.
  • And from touch: a right-click menu has no touch path at all — there is no right button, and iOS Safari fires no `contextmenu` event for a long press on a non-selectable element — so a held touch opens it. Mouse and pen are excluded deliberately: a mouse has the right button already, and a held left press is how a drag or a text selection starts.
  • ★ SET `targetLabel` TO WHAT THE ACTIONS ACT ON. Without it the panel announces itself as "Menu" and then reads "Edit, Duplicate, Delete" — every item true, and no way to tell which of forty rows they belong to. With it the panel's name becomes "Actions for Invoice 4021". A shared menu with no projected trigger (the `openAt()` / `openAtElement()` pattern) moves DOM focus into the panel on open, so that name is read as focus arrives and `aria-activedescendant` is published on the focused panel; Escape returns focus to the kebab that opened it. A promoted wrap-one-element trigger keeps focus and carries the target in its own name ("Open Actions for Invoice 4021").
  • Shift+F10 and the ContextMenu key open it from the focused trigger in `contextmenu` mode — the platform-standard keyboard equivalents of a right-click.
  • The native context menu is suppressed on the trigger whenever `triggerMode="contextmenu"` is set. ⚠️ Shift+right-click is NOT a reliable escape hatch: that bypass is Firefox behaviour, not Chromium or WebKit. Suppression has a real cost — copy, paste, open-in-new-tab and translate all go with it — so narrow it with `nativeMenuSelector` (e.g. `a[href], input, textarea`) and never suppress on a region whose value is those actions.

Feedback on Context Menu

Spotted a gap in this page, or something that didn't work as described? Comments are GitHub Discussions in the public styloviz/community repo, so posting needs a GitHub account.

Loads giscus.app and GitHub — nothing is fetched until you click.