Skip to content

Action Menu System

Global programmatic context menu. Replaces ad-hoc inline dropdowns and supports nested submenus, async actions, confirmations, and custom React content.

See UI Providers for ActionMenuProvider internals, the full ClientProviders tree, and related providers (PredictionContextMenuProvider, PredictionDropdownProvider).


useActionMenu()

import { useActionMenu } from "@/providers/ActionMenuProvider";

const menu = useActionMenu();

// Open at cursor position
menu.open(event, {
  actions: myActions,
  context: myEntity,
});

// menu.close()
// menu.isOpen — boolean

open(position, options)

Parameter Type Description
position React.MouseEvent \| React.KeyboardEvent \| { x: number; y: number } Viewport coordinates for the menu
options.actions Action<T>[] Array of action definitions
options.context T Data passed to every action handler
options.onClose () => void Called after the menu closes

Action<T> Union Type

Type Key fields Purpose
StandardAction onSelect(ctx), confirm? Clickable item with optional confirmation
SubmenuAction actions[] Nested submenu
CustomAction render(ctx), keepOpenOnInteract? Custom React content
SeparatorAction Visual divider
ActionGroup label, actions[] Labeled group

ActionBase — Shared Fields

All StandardAction and SubmenuAction entries inherit:

Field Type Description
id string Unique identifier (used for exclude filtering)
label string Display text
icon LucideIcon Optional icon
disabled boolean \| string true = disabled; string = disabled with tooltip message
danger boolean Red destructive styling
hidden boolean Conditionally hide the item
shortcut string Display-only keyboard shortcut hint

ActionContext<T>

Passed to onSelect and render:

interface ActionContext<T> {
  data: T;           // The context object passed to open()
  closeMenu: () => void;
}

ConfirmConfig

Add to any StandardAction to show a confirmation dialog before running onSelect:

const deleteAction: StandardAction<Product> = {
  type: "action",
  id: "delete",
  label: "Delete",
  danger: true,
  confirm: {
    title: "Delete product?",
    description: "This cannot be undone.",
    confirmLabel: "Delete",
    destructive: true,
  },
  onSelect: ({ data }) => deleteProduct(data.id),
};

  • Auto-closes on Escape, outside click, scroll, and resize
  • Repositioned to stay within the viewport
  • Async onSelect shows a loading spinner on the item; errors surface as a toast notification
  • Submenus expand on hover

Pre-built Action Hooks

useMediaResourceActions(resource, options?)

Standard context-menu actions for any media resource.

import { useMediaResourceActions } from "@/hooks/actions/useMediaResourceActions";

const actions = useMediaResourceActions(resource, {
  exclude: ["copyUrl"],
  extraActions: [shareAction],
});

Default action IDs: openInNewTab, download, copyUrl, toggleClipboard, metadata

  • download opens a format selector (webp / png / jpeg) for images; direct download for videos
  • metadata is always rendered last as a CustomAction
  • Local media automatically excludes copyUrl and openInNewTab

useProductActions(product, options?)

Standard actions for a product entity.

import { useProductActions } from "@/hooks/actions/useProductActions";

const actions = useProductActions(product, {
  exclude: ["archive"],
});

Default action IDs: edit, archive/unarchive, delete

  • edit navigates to /products/{id}
  • archive/unarchive toggles the archive flag
  • delete includes a ConfirmConfig with destructive styling

Building Custom use*Actions Hooks

Follow this pattern for any entity:

import type { Action } from "@/types/actionMenu";
import type { ActionHookOptions } from "@/hooks/actions/types";

export function useWidgetActions(
  widget: Widget,
  options?: ActionHookOptions,
): Action<Widget>[] {
  const actions: Action<Widget>[] = [
    {
      type: "action",
      id: "edit",
      label: "Edit",
      icon: Pencil,
      onSelect: ({ data }) => router.push(`/widgets/${data.id}`),
    },
    // ...
  ];

  const filtered = options?.exclude
    ? actions.filter((a) => !("id" in a) || !options.exclude!.includes(a.id))
    : actions;

  return options?.extraActions
    ? [...filtered, ...options.extraActions]
    : filtered;
}

Wiring to MediaResourceRenderer

The renderer handles the right-click → open() call internally. You choose how much you want to control:

1. Omit actions — renderer uses the standard set automatically

// The renderer calls useMediaResourceActions(resource) internally
<MediaResourceRenderer resource={resource} context="card" />

2. Extend defaults — override specific parts of the standard set

const actions = useMediaResourceActions(resource, {
  exclude: ["copyUrl"],
  extraActions: [shareAction],
});

<MediaResourceRenderer resource={resource} actions={actions} />

3. Replace entirely — full custom action list

const customActions: Action<MediaResourceInput>[] = [
  { type: "action", id: "share", label: "Share", icon: Share2,
    onSelect: ({ data }) => shareResource(data) },
];

<MediaResourceRenderer resource={resource} actions={customActions} />

contextdata naming

When the renderer calls menu.open(event, { context: resource }), the value passed as context becomes ctx.data inside every action handler. This is intentional — ActionContext<T> always exposes the context object as data:

onSelect: ({ data }) => console.log(data.url), // data = the MediaResourceInput

onContextMenu is additive

onContextMenu fires alongside the action menu — it does not replace it. To suppress the menu entirely, pass actions={[]} and use onContextMenu alone:

<MediaResourceRenderer
  resource={resource}
  actions={[]}
  onContextMenu={(resource, e) => handleCustomRightClick(resource, e)}
/>

Source Files

File Contents
src/types/actionMenu.ts Action<T> union, ActionBase, ConfirmConfig, ActionMenuService types
src/providers/ActionMenuProvider.tsx Provider + useActionMenu() hook
src/hooks/actions/types.ts ActionHookOptions type
src/hooks/actions/useMediaResourceActions.tsx Standard media actions
src/hooks/actions/useProductActions.ts Standard product actions