Skip to content

ActionsMenu

Collapses per-record actions into a ⋮ menu, so a row or card reads as its record rather than as its controls.

import { ActionsMenu } from "@evolve-storefront/ui/actions-menu";
Design notes

Actions are data, not children, and that is not a style preference. A menu item unmounts when the menu closes on select — so an item wrapping its own dialog trigger takes that trigger with it, and the dialog never opens. An action therefore sets state, and the surface renders its dialog outside the menu. Every consumer meets this, and the failure does not point at its cause, which is why it is worth a shared component rather than a documented pattern.

Reaching one of these from CSS: the trigger is a button and carries no name of its own, because a button's identity is invariant. Compose the row's own name with the trigger's role — the menu is portalled, so the trigger is a sibling of it and aria-haspopup is what distinguishes it from other buttons in the row:

[data-component="associate-row"] [data-component="button"][aria-haspopup] {
	--control-height: 2rem;
}

Preview

DefaultOpen in Storybook ↗

Usage

<ActionsMenu
label={t("rowActions")}
actions={[
{ id: "rename", label: t("rename"), onSelect: onRename },
{ id: "duplicate", label: t("duplicate"), onSelect: onDuplicate },
{ id: "delete", label: t("delete"), destructive: true, onSelect: onDelete },
]}
/>;

Props

PropTypeDescription
actionsMenuAction[]

The actions, as data rather than children — see the note on the component. Renders nothing when empty, so a caller can pass a filtered list without guarding the menu itself.

labelstring

Accessible name for the trigger. Required, and deliberately not defaulted: ui ships no i18n, so a default would put an untranslatable English string in every consumer's DOM.

variant?ButtonVariant
tone?ButtonTone
data-component?undefined

Not a caller's to rename — buttonProps owns it, and spreads last. Typed away so a rename fails to compile here exactly as it does on Button; without this the attribute was silently overwritten, which is the same defect one door along.

placement?DropdownMenuPlacement

Where the menu opens. Defaults to bottom end, which is right in a row.

Any other props go to the root element, like on a plain HTML element.

Examples

No actionsAn empty list renders nothing, so a caller can filter without guarding.
// Filter freely: an empty list renders nothing, so no guard is needed.
<ActionsMenu label={t("rowActions")} actions={actions.filter((action) => !action.disabled)} />;
Open in Storybook ↗
With a confirm dialogThe reason actions are data.
const [confirming, setConfirming] = useState(false);
<ActionsMenu
label={t("rowActions")}
actions={[
{ id: "rename", label: t("rename"), onSelect: onRename },
{ id: "delete", label: t("delete"), destructive: true, onSelect: () => setConfirming(true) },
]}
/>
{/* outside the menu, or it unmounts with the item that opened it */}
<AlertDialog open={confirming} onOpenChange={setConfirming}>…</AlertDialog>
Open in Storybook ↗