Skip to content

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 to showContextMenu() or usePanelContextMenu for your use case.

Quick reference ​

ScenarioSolution
Right-click on a panel tab or a taskbar chipBuilt-in — nothing to add
Add dynamic items to a panel's right-click tab menuusePanelContextMenu(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 ​

WhereHow to trigger
Docked panel tabRight-click the tab
Minimized taskbar chipRight-click the chip
Floating window header ⋮ buttonAppears 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:

tsx
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:

tsx
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. Use useWorkspace() 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 pass event. workspace.showContextMenu() also passes the workspace's own direction, which a menu opened with only x/y then uses. useContextMenu() passes nothing extra, so a menu it opens with only x/y follows <html dir> — pass dir: 'rtl' (or an event) 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 keyboard contextmenu event (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:

tsx
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 ​

ScenarioSetup
Typical app — DockableDesktopProvider manages the menuNothing extra needed. showContextMenu() and useContextMenu() work from any component in the provider tree, including siblings of <RddDesktop>.
Custom adapterPass contextMenuAdapter={myAdapter} to <DockableDesktopProvider>.
User-controlled placementWrap <DockableDesktopProvider> in <RddContextMenu>; the provider detects it and defers to it.
Completely standalone — no desktopWrap 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) ​

PropDefaultDescription
adapterthe built-in menuContext 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:

tsx
<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>:

tsx
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 ​

typescript
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 ​

typescript
interface ContextMenuSeparator {
  separator: true;
}
typescript
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:

typescript
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:

tsx
{
  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:

tsx
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:

tsx
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 ​

MethodDescription
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 ​

PropDefaultDescription
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.
adapterthe built-in menuWith 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:

KeyAction
↓ / ↑Next / previous item (disabled items are skipped; wraps around). From the menu itself, ↓ reaches the first item and ↑ the last
Home / EndFirst / last item
→ (← under RTL), Enter, SpaceOpen a sub-menu and focus its first item
← (→ under RTL) in a sub-menuClose it and return to its parent item
Enter / SpaceActivate the focused item
Esc, TabClose 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.

Released under the MIT License.