@xsolla/xui-context-menu
v0.216.1
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
Downloads
15,164
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.
- Surface colour — the panel surface follows the theme by default. When a design calls for a bespoke surface, set it once via the background prop so the panel, the sticky search header and the arrow stay in sync. Do not tint the panel and the arrow separately.
- 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.
- A custom surface set via background must keep at least a 4.5:1 contrast ratio against the option label colour, and the panel border must stay distinguishable from the surface behind it.
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>). |
| statusTopAlignment | boolean | Aligns status to the first line of the label instead of centring it on the whole cell. Defaults to true. |
| 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.
Status alignment
Every cell lays its slots out on a single centred row, which reads correctly while the label is one line. As soon as the label wraps or a description is present the cell grows taller and a vertically-centred status dot drifts away from the text it annotates.
statusTopAlignment (default true) pins the status slot to the top of the cell and sizes it to one label line, so the dot centres on the first line of the label and stays there no matter how tall the cell becomes. The line height is read from the label's typography token for the active size, so the alignment holds across sm / md / lg / xl, both themes, and every responsive breakpoint.
// Default — dot sits on the first line of a wrapping label.
<ContextMenuItem
type="option"
label="Deployment blocked by a failing health check"
description="Retried 3 times in the last hour"
status={<Status palette="alert" size="lg" />}
/>
// Opt out — dot is centred on the full height of the cell.
<ContextMenuItem
type="option"
label="Deployment blocked by a failing health check"
description="Retried 3 times in the last hour"
status={<Status palette="alert" size="lg" />}
statusTopAlignment={false}
/>Breaking change.
trueis the default, so multi-line cells that already pass astatusslot will render the indicator higher than before. PassstatusTopAlignment={false}to keep the previous centred rendering.
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 by default). |
| description | ReactNode | Optional helper line beneath. |
| isUppercase | boolean | Uppercase the label, with the tracking that pairs with it. Defaults to true; pass false for sentence-case labels. |
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. |
| withArrow | boolean | Renders an arrow on the panel edge pointing at the trigger. Default false. |
| background | string | Panel surface colour (any CSS colour). Drives the panel, the sticky search header and the arrow together. Defaults to the layer.float theme token. |
| noPadding | boolean | Drops the panel's own padding (8px by default) to 0 so cells and custom children sit flush to its edges. Cell padding is unaffected. Defaults to false. |
| 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. |
Panel padding
The panel pads its own content by 8px on every side at every size, so cells never touch the rounded border. Set noPadding when a child should be full-bleed — a banner, a sticky footer row, or a custom list that draws its own insets:
<ContextMenu noPadding trigger={<Button>Open</Button>} aria-label="Actions">
<FullBleedBanner />
<ContextMenuItem type="option" label="Edit" />
</ContextMenu>noPadding only zeroes the panel padding. Each ContextMenuItem keeps its own itemPaddingHorizontal / itemPaddingVertical, so rows stay readable.
Custom surface colour
The panel surface, the sticky search header and the withArrow arrow are three separate elements that all read the layer.float theme token. background resolves them from one value, so they can never drift apart.
<ContextMenu
background="#233134"
withArrow
placement="bottom-center"
trigger={<Button>Open</Button>}
items={items}
/>Use background rather than style={{ backgroundColor }}. style is merged onto the panel element only — the arrow keeps rendering in the theme colour and reads as a mismatched notch above the panel.
// ✅ panel, sticky search header and arrow all tinted
<ContextMenu background="#233134" withArrow />
// ❌ panel tinted, arrow still on the theme token
<ContextMenu style={{ backgroundColor: "#233134" }} withArrow />The prop takes any CSS colour string. Prefer a design token from your product theme over a literal hex where one exists, and check the label contrast — the toolkit cannot re-derive the content colours from a custom surface.
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 |
