ContextMenu presents a short, horizontal row of actions that apply to one selected object or region. It is transient popup content, not inline page content: you render it as the anchored, focusable content of a trigger such as Button or Container, and you keep the trigger mounted for as long as the menu is open.import { type MouseEvent, useLayoutEffect, useRef, useState } from 'react';
import {
Button,
type ButtonHandle,
ButtonContextMenuItemView,
ContextMenu,
DismissReason,
TooltipMode,
} from '@wearables-ui-toolkit/mrbd';
// bookmarkIcon, bellIcon, and archiveIcon are `IconSource` values you supply.
export function RouteActions() {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const triggerRef = useRef<ButtonHandle>(null);
const shouldRestoreFocus = useRef(false);
const closeMenu = (event?: MouseEvent<HTMLElement>) => {
// The popup is a React child of the trigger, so an item click bubbles to
// the trigger's own handler. Stop it before it reopens the menu.
event?.stopPropagation();
shouldRestoreFocus.current = true;
setIsMenuOpen(false);
};
const handleDismiss = (_reason: DismissReason) => {
// The popup returns focus to the trigger itself when it closes with focus
// still inside it, so no dismissal needs a manual restore here.
setIsMenuOpen(false);
};
useLayoutEffect(() => {
if (!isMenuOpen && shouldRestoreFocus.current) {
shouldRestoreFocus.current = false;
triggerRef.current?.getElement()?.focus({ preventScroll: true });
}
}, [isMenuOpen]);
return (
<Button
ref={triggerRef}
title="Route actions"
alwaysShowText
onClick={() => setIsMenuOpen(open => !open)}
tooltipMode={isMenuOpen ? TooltipMode.FOCUSED : TooltipMode.NONE}
tooltipFocusable
tooltipHidesFocusState
tooltipContentDescription="Route actions"
tooltipContent={
<ContextMenu aria-label="Route actions" onDismiss={handleDismiss}>
<ButtonContextMenuItemView
title="Save route"
icon={bookmarkIcon}
onClick={closeMenu}
/>
<ButtonContextMenuItemView
title="Mute alerts"
icon={bellIcon}
onClick={closeMenu}
/>
<ButtonContextMenuItemView
title="Archive"
icon={archiveIcon}
onClick={closeMenu}
/>
</ContextMenu>
}
/>
);
}
tooltipFocusable is what makes the menu reachable: without it the popup renders but never receives focus and does not install the transient Back behavior.ContextMenuHandle when the interesting item is not the first one, and scroll it into view once the popup is up.import { useEffect, useRef } from 'react';
import {
ContextMenu,
type ContextMenuHandle,
type DismissReason,
EmojiContextMenuItemView,
} from '@wearables-ui-toolkit/mrbd';
interface Reaction {
emoji: string;
label: string;
}
export function ReactionMenu({
reactions,
selectedIndex,
onSelect,
onDismiss,
}: {
reactions: readonly Reaction[];
selectedIndex: number;
onSelect: (reaction: Reaction) => void;
onDismiss: (reason: DismissReason) => void;
}) {
const menuRef = useRef<ContextMenuHandle>(null);
useEffect(() => {
menuRef.current?.scrollToItem(selectedIndex, false);
}, [selectedIndex]);
return (
<ContextMenu ref={menuRef} aria-label="Reactions" onDismiss={onDismiss}>
{reactions.map((reaction, index) => (
<EmojiContextMenuItemView
key={reaction.emoji}
emoji={reaction.emoji}
ariaLabel={reaction.label}
isEmojiSelected={index === selectedIndex}
onClick={() => onSelect(reaction)}
/>
))}
</ContextMenu>
);
}
ContextMenuProps. It is declared as an extension of StaticContainerProps, but the menu owns its own material, shape, height, and background, and it forwards only the props listed here. Anything else the type happens to allow — other container props, arbitrary DOM attributes, extra event handlers — is accepted by TypeScript and dropped at runtime.| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | Menu items. Use ContextMenuItemView or one of its two presets. Each direct child counts as exactly one item for arrow movement and for scrollToItem indices. |
onDismiss | (reason: DismissReason) => void | — | Called when the menu asks to be dismissed. Clear your open state here. |
showTail | boolean | true | Whether to draw the tail pointing at the anchor. The tail is only drawn when the anchoring system also supplies a direction; hiding it also reduces the gap to the anchor. |
fadingEdgeLength | number | Medium fading-edge length | Length, in CSS pixels, of each horizontal fade. Set to 0 to disable both fades. |
role | string | 'menu' | Role for the popup root. |
aria-label | string | — | Accessible name for the menu as a whole. |
aria-disabled | boolean \| 'true' \| 'false' | — | Marks the whole menu as unavailable. |
className | string | '' | Extra class on the popup root, appended after the component’s own class. |
style | CSSProperties | {} | Inline styles merged onto the popup root after the component’s own layout values. |
| Prop | Type | Description |
|---|---|---|
tailDirection | 'up' \| 'down' | Which way the tail points, chosen by the resolved placement. |
tailCenterX | number | Horizontal centre of the tail, relative to the menu’s left edge. |
maxWidth | number | Widest popup width the placement allows. Also caps the menu’s own max-width. |
focusSearchOrigin | HTMLElement \| null | Anchor element used as the origin for a focus search when no menu item currently holds focus. |
DismissReason| Member | Value | Meaning |
|---|---|---|
NAVIGATION | 'navigation' | Up or Down was pressed, or focus left the menu entirely. |
BACK_BUTTON | 'back_button' | System Back, or Escape during desktop testing, was requested while the menu was focused. |
ContextMenuHandle, obtained with ref:| Method | Signature | Description |
|---|---|---|
scrollToItem | (index: number, smooth?: boolean) => void | Scrolls the item at index into view. smooth defaults to true; pass false to jump. An out-of-range index does nothing. |
getElement | () => HTMLDivElement \| null | The popup root element, or null before mount and after unmount. |
tailDirection accordingly.onDismiss with NAVIGATION. There is no vertical movement inside the menu.onDismiss with BACK_BUTTON and follows the same dismissal path. Closing the menu another way removes the temporary entry. The popup infrastructure returns focus to the trigger whenever the menu closes with focus still inside it, so restore focus explicitly only after item activation or another path that moved focus first. Do not add another Back listener or history entry around the menu.onDismiss with NAVIGATION on the next tick. Focus moving between items inside the menu never triggers this.--uit-color-background-elevation3 and lifted by a drop shadow that tilts with the tail direction. Items draw no idle fill of their own, so they read as part of that surface until focused.menu by default; each item is a menuitem.aria-label on the menu (and, on the trigger, tooltipContentDescription) so the popup is announced with a meaningful name. The component does not invent one.aria-hidden, so nothing decorative is announced.BACK_BUTTON dismissal already performs that restoration.event.stopPropagation() in item handlers when the trigger toggles the same open state.ContextMenuHandle and call scrollToItem when the relevant action is not first in the row.width, material, and backgroundStyle are dropped.