@xsolla/xui-context-menu
v0.187.0
Published
<!-- BEGIN:xui-mcp-instructions:context-menu --> A floating panel containing a vertical list of ContextMenuCell rows. Serves as the shared dropdown layer for multiple input components — Select, Multiselect, Combobox, StatusDropdown, Phone input — as well
Readme
Context Menu
A floating panel containing a vertical list of ContextMenuCell rows. Serves as the shared dropdown layer for multiple input components — Select, Multiselect, Combobox, StatusDropdown, Phone input — as well as for contextual action menus triggered by right-click or an overflow button. Each instance is a preset configuration of cells — the Type prop selects a ready-made composition suited to a specific use case. All cells within one menu share the same Size.
When to use
- As the dropdown panel for Select — single-value picker from a predefined list
- As the dropdown panel for Multiselect — multiple-value picker; use Type=Checkbox so selections stay visible
- As the dropdown panel for Autocomplete — use Type=Search + options
- As the dropdown panel for StatusDropdown — inline status picker; use Type=Status
- As the dropdown panel for Phone input — country dial-code picker; use Type=Phone
- To surface contextual actions for a selected item or element (right-click menu, overflow "⋯" button)
- When the number of actions or options is too large for inline buttons but too small for a full page
When not to use
- As the primary navigation of a page and top-level site navigation
- When there are only 1–2 actions — use ToggleButtonGroup, Radio or Checkboxes
- When the selection requires multiple steps or complex input — use a Dialog or a dedicated panel
Content guidelines
- Option labels — concise imperative verbs for actions ("Delete", "Rename", "Export"); nouns for selections ("English", "Admin", "Dark mode"). Keep to 1–4 words.
- Heading labels — short noun phrases in title case or uppercase; describe the group below. Examples: "Actions", "Sort by", "FILTERS".
- Destructive label colour — use the Alert/red colour token for the label text of destructive options. Do not use red for informational or reversible actions.
- Search placeholder — use "Search…" or a more specific variant: "Search countries…", "Search members…".
- Empty state message — be specific: "No countries match", "No team members found". Avoid generic "No results" when a more informative string is available.
- Menu length — aim for 7 or fewer options before considering grouping, search, or a different pattern. More than 12 options without search significantly degrades usability.
Behaviour guidelines
- Trigger — the menu opens from a trigger element: right-click on a target, click on an overflow button (⋯), or programmatic call. The trigger element should have aria-haspopup="menu" and aria-expanded updated on open/close.
- Positioning — the panel is positioned relative to the trigger. Default placement is bottom-start; fallback to top-start, bottom-end, or top-end when the default position would clip the viewport. Never let the panel appear partially off-screen.
- Width — the panel width is set by Size × Type. Do not stretch it to fill the viewport or a parent container. If option labels are longer than the panel width, truncate with ellipsis and show a tooltip on hover.
- Single-select close — for Type=List, Phone, Avatar, BrandLogo, Status, and Radio, the menu closes automatically after the user selects an option. The trigger updates to reflect the new value.
- Multi-select stay open — for Type=Checkbox, the menu stays open after each selection. Close on: clicking outside, pressing Escape, or an explicit "Apply" / close action.
- Loading state — show Type=Loading immediately when the menu opens and the option list has not yet been fetched. The panel renders at its normal dimensions with a centered Loader spinner replacing all cell content. Switch to the real Type as soon as data arrives. Do not show an empty panel while loading.
- Empty state — if search filtering produces no results, show an empty state message inside the panel (e.g. "No results found") rather than closing the menu or showing a blank panel.
- Search filtering — when a Type=Search cell is present, typing filters visible options in real time with ~200ms debounce. Heading cells and dividers that become empty after filtering should be hidden alongside their options to avoid orphaned labels.
- Sub-menus — an option with a chevron in its right slot opens a nested ContextMenu on hover (desktop) or tap (touch). The parent item remains in Hover/Active state while the sub-menu is open. ← / Escape closes the sub-menu and returns focus to the parent item.
- Destructive actions — place destructive options (delete, remove, revoke) at the bottom of the list, separated from safe actions by a Type=Divider cell. Render in the Alert/red colour token.
- Scroll — if the option list exceeds the viewport height, the panel body becomes scrollable. Pin the Type=Search cell to the top so it remains visible while the user scrolls through options. Pin a footer action row to the bottom if one is present.
- Focus management — when the menu opens, focus moves to the first interactive option (or to the Search input if present). When the menu closes, focus returns to the trigger element.
Accessibility
- The panel must have role="menu" (for action menus) or role="listbox" (for selection menus). Each Type=Option cell must have role="menuitem", role="menuitemcheckbox", or role="option" accordingly.
- The trigger element must have aria-haspopup="menu" and aria-expanded="true" / "false" to reflect the open state.
- Type=Heading cells must either wrap their option group in role="group" with aria-labelledby pointing to the heading, or carry role="presentation" themselves.
- Type=Divider cells must have role="separator".
- Type=Search input must have role="searchbox" and aria-label (e.g. aria-label="Search options").
- When the menu opens, move focus to the first focusable option (or the Search input if present).
- Keyboard navigation: ↑ / ↓ move between options (skipping Heading and Divider); Enter / Space activate the focused option; Escape closes the menu and returns focus to the trigger; Tab closes the menu and moves focus to the next element in the page.
- For sub-menus: → / Enter opens the sub-menu; ← / Escape closes it and returns focus to the parent item.
- When the menu closes, focus must return to the trigger element that opened it.
- For Type=Checkbox (multi-select), each option must have aria-checked="true" / "false". For Type=Radio and single-select list types, use aria-selected or aria-checked consistently with the chosen ARIA role.
Installation
yarn add @xsolla/xui-context-menuTwo API paths
Preset path
Pass type and items. The panel renders the preset's chrome and composes each option with the right control or slot.
import { ContextMenu } from "@xsolla/xui-context-menu";
<ContextMenu
type="list"
trigger={<Button>Open</Button>}
items={[
{ type: "option", label: "Edit" },
{ type: "option", label: "Duplicate" },
{ type: "option", label: "Delete", destructive: true },
]}
/>;Custom path
Compose cells as children when you need full control of the layout, want to mix headings and dividers freely, or render a slot the preset path doesn't cover.
import { ContextMenu, ContextMenuItem } from "@xsolla/xui-context-menu";
<ContextMenu trigger={<Button>Open</Button>} aria-label="Actions">
<ContextMenuItem type="heading" label="Workspace" />
<ContextMenuItem type="option" label="Personal" />
<ContextMenuItem type="option" label="Acme Inc." />
<ContextMenuItem type="divider" />
<ContextMenuItem type="option" label="Sign out" destructive />
</ContextMenu>;Choose the preset path for typical menus where the data is uniform; choose the custom path when cells differ structurally or when you need to drop in bespoke nodes between cells.
ContextMenuItem reference
ContextMenuItem is a discriminated union on type. All cell types accept size, data-testid and theme-override props.
type="option"
| Prop | Type | Purpose |
| --- | --- | --- |
| label | ReactNode | Primary cell text (required). |
| description | ReactNode | Secondary line beneath the label. |
| leadingControl | "checkbox" \| "radio" | Renders a Checkbox or Radio at the start. |
| leadingIcon | ReactNode | Icon node before the label group. |
| status | ReactNode | Status indicator slot (e.g. <Status>). |
| iconWrapper | ReactNode | Wrapped icon / avatar slot. |
| slot / slotContent | ReactNode | Generic slot before the label. |
| value | ReactNode | Right-side primary text (e.g. shortcut value, dial code). |
| hint | ReactNode | Right-side secondary text below value. |
| trailingIcon | ReactNode | Trailing icon at the end of the cell. |
| keyboardShortcut | string | Display-only shortcut rendered as <kbd> and exposed via aria-keyshortcuts. |
| hasSubmenu | boolean | Marks the cell as a submenu trigger and renders a chevron. |
| submenu | ReactNode | A nested <ContextMenu> opened on hover/ArrowRight/Enter. |
| checked | boolean | Fully controlled checked state. |
| disabled | boolean | Disables interaction and applies the disabled style. |
| destructive | boolean | Applies the destructive content colour. |
| onSelect | () => void | Fires on activation (click, Enter, Space). |
| onCheckedChange | (checked: boolean) => void | Optional change callback for controls. |
Render order (left → right): leadingControl, leadingIcon, status, iconWrapper, slotContent, label (with optional description below), value (with optional hint below), keyboardShortcut, submenu chevron, trailingIcon.
type="search"
| Prop | Type | Purpose |
| --- | --- | --- |
| value | string | Controlled value (required). |
| onValueChange | (value: string) => void | Change callback (required). |
| placeholder | string | Defaults to "Search". |
| autoFocus | boolean | Focuses the input on mount. |
| aria-label | string | Defaults to "Search options". |
type="heading"
| Prop | Type | Purpose |
| --- | --- | --- |
| label | ReactNode | Section title (uppercase styling). |
| description | ReactNode | Optional helper line beneath. |
type="divider"
A horizontal rule with role="separator". No content props.
ContextMenu reference
| Prop | Type | Purpose |
| --- | --- | --- |
| type | "list" \| "loading" \| "phone" \| "checkbox" \| "status" \| "brandLogo" \| "radio" \| "avatar" | Panel preset; works with items. |
| items | ReadonlyArray<Option \| Heading \| Divider> | Data-driven cells for the preset path. |
| children | ReactNode | Custom-composition cells (alternative to items). |
| size | "sm" \| "md" \| "lg" \| "xl" | Controls cell sizing across the panel. Default md. |
| searchable | boolean | Auto-renders a sticky search cell and filters options. |
| loading | boolean | Renders a centred spinner instead of the cell list. |
| emptyMessage | string | Custom message for the default empty state. |
| empty | ReactNode | Replace the empty state entirely. |
| trigger | ReactNode | Element that toggles the panel; receives aria-haspopup / aria-expanded. |
| placement | "bottom-start" \| "top-start" \| "bottom-end" \| "top-end" | Initial placement. Auto-flips when clipped. |
| isOpen | boolean | Controlled open state. |
| onOpenChange | (open: boolean) => void | Open-state callback. |
| closeOnSelect | boolean | Override the per-preset default. |
| width | number | Forces panel width (px). |
| maxHeight | number | Caps panel height (px); body scrolls and search stays sticky. |
| onSelect | (item: ContextMenuOptionItemProps) => void | Fires for the preset path on option activation. |
| aria-label | string | Accessible name for the menu container. |
| testID | string | Testing handle; forwarded as data-testid when data-testid is not provided. |
| data-testid | string | Testing handle. |
Behaviour & accessibility
- The panel root is
role="menu"(or hostsrole="menuitemcheckbox"/role="menuitemradio"cells whencheckedis provided). Headings render asrole="presentation", dividers asrole="separator", and the search cell asrole="searchbox". closeOnSelectdefaults totruefor every preset exceptcheckbox, where multi-select keeps the panel open.- On open, focus moves to the search input when present, otherwise to the first option.
- On close, focus returns to the trigger.
- The trigger element receives
aria-haspopup="menu"andaria-expandedsynced to the open state.
Keyboard reference
| Key | Action |
| --- | --- |
| ↑ / ↓ | Move active option up/down (skips heading/divider). |
| Enter / Space | Activate the focused option. |
| Esc | Close the menu and return focus to the trigger. |
| Tab | Close the menu and continue natural focus order. |
| Home / End | Jump to the first/last option. |
| → / Enter | Open a submenu when on a hasSubmenu option. |
| ← / Esc | Close the submenu and return focus to its parent option. |
Content guidelines
- Use short, imperative labels ("Edit", "Duplicate", "Sign out").
- Use sentence case for option labels.
- Use uppercase for headings — the heading cell already applies the visual treatment.
- Place destructive options at the bottom of the list, ideally separated by a divider.
- Prefer specific empty messages ("No countries match") over the generic default.
- Aim for seven options or fewer per panel; group with headings or split into submenus when longer.
Migration from prior API
| Old | New |
| --- | --- |
| ContextMenuCheckboxItem | <ContextMenuItem type="option" leadingControl="checkbox" /> |
| ContextMenuRadioItem | <ContextMenuItem type="option" leadingControl="radio" /> |
| ContextMenuRadioGroup | type="radio" panel preset with shared selected state |
| ContextMenuGroup | <ContextMenuItem type="heading" /> |
| ContextMenuSeparator | <ContextMenuItem type="divider" /> |
| ContextMenuSearch | <ContextMenuItem type="search" /> (or set searchable: true on the panel for auto-render) |
| Size scale s / m / l / xl | sm / md / lg / xl |
