Dropdown Menu
ProPremium action menu with checkbox/radio items, 2 themes, 3 sizes, groups, descriptions, badges, header/footer slots, type-ahead keyboard nav and auto-flipping placement.
import { SvDropdownMenuComponent } from '@styloviz-pro/overlays';Demo & usage
Basic usage
ProUsage example — open the gallery for the full interactive demo.
An interactive demo for this component lives in the gallery.
Open live demoMore examples
Grouped with placement
ProCheckbox items (stay open)
ProRadio items (single select)
ProDark theme, compact
ProAsync item — [itemAction]
ProThe item action itself
ProHeader & footer slots
ProWhen to use
- Offering a list of actions or links from a trigger (kebab/overflow menus).
- Toggling options (checkbox items) or picking one of several (radio items).
- You want grouped items, icons, descriptions, shortcuts, badges and danger actions.
- For a single form choice, use a Select; for global search, a Command Palette.
Async states
- loading
- error
- retry
Inputs
| Name | Type | Default | Description |
|---|---|---|---|
| open | boolean (two-way) | false | Controls menu visibility. Use `[(open)]` for two-way binding. |
| loading | boolean | false | True while the menu's contents are being fetched — a permissions-aware or workspace-scoped menu is a list the server owns. |
| error | SvDataError | string | null | null | Load failure. Use `toSvDataError()` from `@styloviz/core` to normalize an `HttpErrorResponse`. |
| items | DropdownMenuItem[] | [] | Flat list of menu items, rendered as a single ungrouped section; ignored when `groups` is non-empty. |
| groups | DropdownMenuGroup[] | [] | Grouped menu items. Takes precedence over `items`. |
| placement | DropdownMenuPlacement | '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. |
| itemAction | DropdownMenuItemAction | null | null | The 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. |
| triggerMode | DropdownMenuTriggerMode | 'click' | How the menu opens — left-click toggle, or right-click at the cursor. See DropdownMenuTriggerMode. |
| variant | DropdownMenuVariant | 'default' | Panel theme. |
| size | DropdownMenuSize | 'md' | Density preset. |
| offset | number | 4 | Gap between the trigger and the panel (px). |
| flip | boolean | true | Flip to the opposite side when there's no room; always clamps to viewport. |
| closeOnSelect | boolean | true | Close the menu after selecting an item (per-item `keepOpen` overrides). |
| disabled | boolean | false | Prevent the menu from opening. |
| minWidth | number | 160 | Minimum width of the menu panel (px). |
| triggerRole | string | null | null | ARIA 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. |
| ariaLabel | string | 'Menu' | Accessible label for the menu (role="menu"). |
| targetLabel | string | '' | What these actions act ON — a row, a file, a record. |
| nativeMenuSelector | string | null | null | Right-clicks inside an element matching this selector keep the BROWSER's context menu. |
| customClass | string | '' | Extra CSS classes merged onto the menu panel. |
| sheetOnNarrow | boolean | false | Become a bottom sheet on a narrow viewport. |
Outputs
| Event | Payload type | Description |
|---|---|---|
| retry | void | The host should re-run the failed load. |
| itemClick | string | Emits the id of the clicked item. |
| itemActionFailed | { item: DropdownMenuItem; error: SvDataError } | An `[itemAction]` rejected. The menu stays open and shows the message. |
| itemSelect | DropdownMenuItem | Emits the full clicked item object. |
| opened | void | Emitted after the menu opens (by any means, including `[(open)]`). |
| closed | void | Emitted after the menu closes (by any means, including `[(open)]`). |
| openChange | boolean | Emits 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
ProBound to the names this component actually exposes.
Host class
ProDrive 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
- Two themes (default · dark) and three density sizes (sm · md · lg).
- Action, checkbox and radio item types; per-item icon, description, shortcut, badge, danger, divider, keepOpen and link (`href`).
- Flat `items` or `groups` with section labels; optional `[slot=header]` / `[slot=footer]` (auto-hide when empty).
- Portaled to `document.body`, re-anchored on scroll of any ancestor, auto-flips + clamps to the viewport; `closeOnSelect`, `offset`, `minWidth`, `customClass`.
- Placement arithmetic is shared with Popover, Popout and Tooltip: `start`/`end` mirror for RTL text, coordinates are corrected for a transformed ancestor that has become the containing block, and a trigger that is removed or collapses while the menu is open closes it instead of leaving a menu of actions for a row that no longer exists.
- Responsive: the menu caps its width and height to the viewport and a long list scrolls internally, so it never overflows a phone or short landscape screen.
- `triggerMode="contextmenu"` and the public `openAt(x, y)` method turn this into a right-click context menu — see the dedicated Context Menu entry.
- `[itemAction]` hands an item's async work to the menu: the row shows a spinner and goes `aria-busy`, every row refuses input while one is working, a rejection renders inside the menu with a Try again button when the failure is retryable, and closing the menu aborts the request. One async contract, shared with the rest of the kit — `createAsyncAction` in `@styloviz/core`.
Best practices
- Don't: Don't grow a menu into a panel. `role="menu"` promises a list of commands: the header/footer slots are `role="none"` wrappers, so what you project there sits directly among the items, out of reach of the arrow keys. The hard line — if it has a text field, or more than one interactive control per row, it is not a menu, it is a Popover. In dev mode the menu warns when a slot holds a field, button or link.
- Do: Group related actions and separate destructive ones with a divider.
- Do: Use `type: "checkbox"` with `keepOpen: true` for multi-toggle menus so the menu stays open.
- Do: Mark destructive items as danger and keep them away from the default focus.
- Tip: Show keyboard shortcuts inline so users learn the faster path.
- Do: Use `[itemAction]` for anything asynchronous rather than starting the work in `(itemClick)`. The menu closes in the same tick as that event, so a slow action is a dead click the user will happily fire twice — and the rejection disappears with the menu. With `[itemAction]` the menu stays open, the row spins, the second click is refused, and the error is rendered where the click happened.
Accessibility
- Trigger exposes `aria-haspopup`/`aria-expanded`/`aria-controls`; the menu uses `menu` + `menuitem`/`menuitemcheckbox`/`menuitemradio` roles with `aria-checked` and `aria-activedescendant` tracking the highlighted item — correct across groups and disabled items.
- Keyboard: ↑/↓ navigate (skipping disabled), Home/End jump to ends, type-ahead jumps to a matching label, Enter selects, Esc closes.
Feedback on Dropdown 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.