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),
};
Menu Behavior¶
- Auto-closes on Escape, outside click, scroll, and resize
- Repositioned to stay within the viewport
- Async
onSelectshows 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
downloadopens a format selector (webp / png / jpeg) for images; direct download for videosmetadatais always rendered last as aCustomAction- Local media automatically excludes
copyUrlandopenInNewTab
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
editnavigates to/products/{id}archive/unarchivetoggles the archive flagdeleteincludes aConfirmConfigwith 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} />
context → data 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:
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 |