Context Menus
Context menus in react-dockable-desktop work at two levels: the workspace automatically handles right-click on panel tabs and taskbar chips (and gives a floating window a ⋮ menu button when its panel adds items) — and your panels can add custom items or trigger their own menus with a single function call.
Using
DockableDesktopProvider? The context menu is set up automatically — you do not need to place any<RddContextMenu>component. Jump toshowContextMenu()orusePanelContextMenufor your use case.
Quick reference
| Scenario | Solution |
|---|---|
| Right-click on a panel tab or a taskbar chip | Built-in — nothing to add |
| Add dynamic items to a panel's right-click tab menu | usePanelContextMenu(items) |
| Trigger a menu imperatively (WebGL canvas, map, game view) | useContextMenu() or useWorkspace().showContextMenu() |
| Context menu on a surface outside all providers | <RddContextMenu> — with children, or standalone with a ref |
Built-in trigger surfaces
| Where | How to trigger |
|---|---|
| Docked panel tab | Right-click the tab |
| Minimized taskbar chip | Right-click the chip |
Floating window header ⋮ button | Appears automatically when a panel has custom items |
usePanelContextMenu hook
Inject dynamic custom items into a panel's right-click menu from inside the panel component:
import { usePanelContextMenu, type ContextMenuItem } from 'react-dockable-desktop';
function MyPanel() {
const [isDirty, setIsDirty] = useState(false);
const items: ContextMenuItem[] = isDirty
? [
{ label: 'Save', icon: <SaveIcon />, action: handleSave },
{ separator: true },
{ label: 'Discard Changes', icon: <ResetIcon />, action: handleDiscard },
]
: [{ label: 'Discard Changes', icon: <ResetIcon />, action: handleDiscard }];
usePanelContextMenu(items);
return <Editor onChange={() => setIsDirty(true)} />;
}Key behaviours:
- Items are re-read on every menu open — state-driven enable/disable updates automatically.
- No panel ID is needed; the hook reads it from the panel's context.
- When the panel unmounts, its items are automatically unregistered.
- Custom items appear after the built-in system items (Float, Minimize, Close) with a separator between them. A panel that can't be dragged, minimized or closed (the locked pattern) has no tab menu, so its custom items don't appear there.
- The
⋮button in the floating window header appears only when custom items exist.
For panels hosting WebGL canvases or other native surfaces where the browser's contextmenu event doesn't carry a meaningful cursor position, use showContextMenu() instead.
Imperative trigger — showContextMenu
Panels that host WebGL canvases (maps, 3D viewers, game views) cannot use usePanelContextMenu for a canvas-level right-click because the browser's contextmenu event fires on the wrapping <div>, not in a meaningful position relative to the canvas content. Instead, call showContextMenu() from useWorkspace() to open the shared workspace menu from any panel:
import { useWorkspace, type ContextMenuItem } from 'react-dockable-desktop';
function MapPanel() {
const mapRef = useRef(null);
const { showContextMenu } = useWorkspace();
useEffect(() => {
const map = createMap(mapRef.current);
// LuciadRIA example — fires from the native map interaction pipeline
map.onShowContextMenu = (position, contextMenu) => {
if (contextMenu.items.length === 0) return;
const items: ContextMenuItem[] = contextMenu.items.map(item =>
item.separator
? { separator: true as const }
: { label: item.label, action: item.action }
);
showContextMenu({ x: position[0], y: position[1], items });
};
return () => map.destroy();
}, [showContextMenu]);
return <div ref={mapRef} style={{ width: '100%', height: '100%' }} />;
}showContextMenu delegates to whichever context menu is provided above it — by default the one <DockableDesktopProvider> sets up automatically. This means a single menu instance is shared across the entire workspace, regardless of how many map panels are open.
Which hook? Use
useContextMenu()when the context menu is the only thing you need. UseuseWorkspace()when you are already calling it in the same component for panel management (openPanel,focusPanel, etc.).Direction. The menu is rendered into
<body>, so it takes its direction from where it was opened: the event's target when you passevent.workspace.showContextMenu()also passes the workspace's own direction, which a menu opened with onlyx/ythen uses.useContextMenu()passes nothing extra, so a menu it opens with onlyx/yfollows<html dir>— passdir: 'rtl'(or anevent) for an RTL workspace inside an LTR page.
Initial focus. A menu opens with focus on the menu itself and no item highlighted, the same however it was opened. That matters most here: a map library opens the menu from script after its own right-click handling, and focusing an item made the browser show or hide its focus ring depending on the user's previous click or key press. Arrow keys still work at once: ↓ reaches the first item, ↑ the last. For a menu you open from the keyboard, pass
initialFocus: 'first-item'. When you pass a keyboardcontextmenuevent (the ContextMenu key or Shift+F10, which report no pointer position), that is the default.
Both showContextMenu() and usePanelContextMenu() rely on the context menu that DockableDesktopProvider sets up automatically. The next section explains how to provide one yourself.
<RddContextMenu> and useContextMenu
<DockableDesktopProvider> automatically provides a context menu to all of its children — <RddDesktop>, <RddSidebar>, <RddSidePanels>, <RddModals> — so showContextMenu() and useContextMenu() work everywhere without any extra setup.
For advanced placement control — or when you want to use the context menu outside a <DockableDesktopProvider> entirely — wrap part of the tree in <RddContextMenu>. With children, it provides a menu to them:
import { RddContextMenu, useContextMenu } from 'react-dockable-desktop';
function App() {
return (
<RddContextMenu>
<MyApp />
</RddContextMenu>
);
}
function MyComponent() {
const showContextMenu = useContextMenu();
return (
<div
onContextMenu={e => {
e.preventDefault();
showContextMenu({ event: e, items: [...] });
}}
/>
);
}Placement options
| Scenario | Setup |
|---|---|
Typical app — DockableDesktopProvider manages the menu | Nothing extra needed. showContextMenu() and useContextMenu() work from any component in the provider tree, including siblings of <RddDesktop>. |
| Custom adapter | Pass contextMenuAdapter={myAdapter} to <DockableDesktopProvider>. |
| User-controlled placement | Wrap <DockableDesktopProvider> in <RddContextMenu>; the provider detects it and defers to it. |
| Completely standalone — no desktop | Wrap any surface in <RddContextMenu>; call useContextMenu() inside it. |
When an <RddContextMenu> with children is present in the ancestor tree, <DockableDesktopProvider> detects it and defers — there is always exactly one mounted menu instance.
RddContextMenu props (with children)
| Prop | Default | Description |
|---|---|---|
adapter | the built-in menu | Context menu adapter to mount. |
formatMessageProvider | — | i18n formatter forwarded to the adapter component. When using DockableDesktopProvider, the provider's own formatMessage prop is forwarded automatically — the provider prop, not a formatMessage given to createWorkspace(). It formats message-descriptor labels in your own items; the built-in items arrive already formatted with the workspace's formatter. |
onShow | — | Fired when the menu opens. |
onHide | — | Fired when the menu closes. |
| All other props | — | Forwarded directly to adapter.Component (see RddContextMenuProps). |
Example — custom adapter with light theme:
<RddContextMenu
adapter={myCustomAdapter}
theme="light"
formatMessageProvider={intl.formatMessage}
>
{children}
</RddContextMenu>All three trigger patterns above accept ContextMenuItem[] arrays. The type reference below covers every available item shape.
ContextMenuAdapter — custom implementation
If your project has its own design-system context menu (or requires a WCAG-certified accessible implementation), implement the ContextMenuAdapter interface and pass it to <DockableDesktopProvider>:
import {
type ContextMenuAdapter,
type ContextMenuHandle,
type RddContextMenuProps,
} from 'react-dockable-desktop';
type MenuProps = Omit<RddContextMenuProps, 'adapter' | 'children'>;
const MyMenu = forwardRef<ContextMenuHandle, MenuProps>((props, ref) => {
useImperativeHandle(ref, () => ({
show({ event, x, y, items, dir, initialFocus }) {
// render your own menu here; since it's portaled, give it a direction:
// the event target's when there is an event, else dir, else the page's.
// initialFocus is 'first-item' when the menu was opened from the keyboard.
},
}));
return null; // or your menu portal
});
const myAdapter: ContextMenuAdapter = { Component: MyMenu };
// Covers RddDesktop, RddSidebar, RddSidePanels and RddModals:
<DockableDesktopProvider contextMenuAdapter={myAdapter} ... />
// A surface outside the provider:
<RddContextMenu adapter={myAdapter}>...</RddContextMenu>The adapter receives items: ContextMenuItem[] via show() and is responsible for rendering them. It also receives dir when the caller knows the direction (a workspace always passes its own): use the event target's direction when there is an event, else dir, because a menu portaled to <body> can't inherit the workspace's direction. The built-in menu is used when no adapter is provided.
Item type reference
ContextMenuItem is a union of three shapes, all exported from react-dockable-desktop:
Simple item
interface ContextMenuSimpleItem {
label: string | MessageDescriptor;
icon?: ReactNode; // SVG or any node; shown in fixed-width column
title?: string; // tooltip on hover
action?: () => void; // called on click, then menu closes
cyAction?: string; // data-cy-action attribute for Cypress tests
disabled?: boolean; // true = greyed out, non-interactive (default: false)
checkbox?: ContextMenuCheckbox;
}Separator
interface ContextMenuSeparator {
separator: true;
}Sub-menu
interface ContextMenuSubMenu {
label: string | MessageDescriptor;
title?: string;
items?: ContextMenuItem[]; // one level of nesting supported
}Checkbox variant
Add a checkbox field to a simple item to show a checkmark column:
interface ContextMenuCheckbox {
active?: boolean; // false hides the checkbox column entirely (default: true)
enabled?: boolean; // false = item is greyed out and non-interactive (default: true)
value: boolean; // true = checkmark shown
}Example — a "Wrap lines" toggle:
{
label: 'Wrap Lines',
checkbox: { enabled: true, value: wrapLines },
action: () => setWrapLines(v => !v),
}Icons
Always pass an icon node to items that appear alongside built-in actions — the icon column is fixed-width and keeps text aligned:
const SaveIcon = (
<span className="rdd-menu-icon">
<svg width="14" height="14" viewBox="0 0 24 24" ...>...</svg>
</span>
);
{ label: 'Save', icon: SaveIcon, action: handleSave }Standalone <RddContextMenu ref> — outside any provider tree
If you need a context menu on a surface that lives entirely outside any provider tree — a third-party shell, an iframe, or a widget rendered outside the workspace — render <RddContextMenu> without children and drive it through its ref:
import {
RddContextMenu,
type ContextMenuHandle,
type ContextMenuItem,
} from 'react-dockable-desktop';
function MyMap() {
const menuRef = useRef<ContextMenuHandle>(null);
const items: ContextMenuItem[] = [
{ label: 'Copy coordinates', action: copyCoords },
{ separator: true },
{ label: 'Zoom in', action: zoomIn },
{ label: 'Zoom out', action: zoomOut },
];
return (
<>
<canvas
onContextMenu={e => {
e.preventDefault();
menuRef.current?.show({ event: e, items });
}}
/>
<RddContextMenu ref={menuRef} />
</>
);
}The component renders via createPortal to document.body at position: fixed, clamped to the viewport. It inherits the active skin's design tokens automatically when rendered inside a workspace.
ContextMenuHandle API
| Method | Description |
|---|---|
show({ event?, x?, y?, items, dir?, initialFocus? }) | Open the menu at the event's cursor position (or explicit x/y). Its direction is the event target's when there is an event, else dir when given, else the page's. initialFocus: 'menu' (default; the menu itself is focused, nothing highlighted) or 'first-item'; a keyboard contextmenu event defaults to 'first-item'. |
RddContextMenuProps
| Prop | Default | Description |
|---|---|---|
theme | 'dark' | CSS modifier class suffix (rdd-context-menu--{theme}). The built-in menu styles itself from the workspace's --rdd-* tokens, so it follows the skin and colour scheme without one; pass a string to hook your own .rdd-context-menu--my-theme rules. |
formatMessageProvider | — | i18n formatter for MessageDescriptor labels. Pass intl.formatMessage here, or use DockableDesktopProvider's formatMessage prop, which forwards it automatically (a formatMessage given only to createWorkspace() doesn't reach descriptor labels in your own items; the built-in items are formatted before they reach the menu). |
onShow | — | Fired when the menu opens. |
onHide | — | Fired when the menu closes. |
onOpenChange | — | Combined open/close callback: (open: boolean) => void. |
className, style | — | Applied to the menu element. |
adapter | the built-in menu | With children only: the implementation to provide. |
children | — | With children, the component provides a menu to them instead of being a single ref-driven menu. |
Keyboard behaviour
Opening a menu moves focus into it: onto the menu itself, with no item highlighted, so a menu looks the same every time it opens, whether it was opened with the mouse, from script or after a key press. Menus opened from the keyboard start on their first enabled item instead: those the library opens itself (ContextMenu or Shift+F10 on a tab or taskbar item, Enter or Space on a window's ⋮ button), a keyboard contextmenu event, and any menu shown with initialFocus: 'first-item'. The menu follows the WAI-ARIA menu pattern:
| Key | Action |
|---|---|
↓ / ↑ | Next / previous item (disabled items are skipped; wraps around). From the menu itself, ↓ reaches the first item and ↑ the last |
Home / End | First / last item |
→ (← under RTL), Enter, Space | Open a sub-menu and focus its first item |
← (→ under RTL) in a sub-menu | Close it and return to its parent item |
Enter / Space | Activate the focused item |
Esc, Tab | Close the menu; focus returns to where it was before the menu opened |
A sub-menu also opens on click or tap; one opened by hovering takes no focus. ContextMenu and Shift+F10 on a focused tab or taskbar item open that element's menu, placed at the element.
An item with keyboard focus draws the skin's focus ring (:focus-visible), never the browser's default. Restyle it with --rdd-context-menu-focus-ring, or every library ring at once with --rdd-focus-ring.