Dropdown
Recursive action menus with consistent keyboard behavior.
Usage
import { Button, Dropdown, type DropdownMenu } from '@commenda/pathfinder';
const menu: DropdownMenu = {
groups: [
{
id: 'actions',
items: [
{ id: 'edit', type: 'action', label: 'Edit details', onClick: onEdit },
{
id: 'export',
type: 'submenu',
label: 'Export',
menu: {
groups: [
{
id: 'formats',
items: [
{
id: 'csv',
type: 'action',
label: 'Export CSV',
onClick: onExportCsv,
},
],
},
],
},
},
],
},
],
};
<Dropdown
trigger={<Button variant="secondary">Entity actions</Button>}
menu={menu}
/>;Installation
pnpm --filter your-app add "@commenda/pathfinder@workspace:*"The package currently lives in the Commenda workspace. External installation requires a published version or packed tarball. Import the precompiled stylesheet once; no application Tailwind setup is required.
API reference
| Prop | Type | Behavior |
|---|---|---|
triggerRequired | ReactElement<any, string | React.JSXElementConstructor<any>> | Required element forwarding refs and events; use Button or IconButton. |
menuRequired | DropdownMenuView fields | Required menu definition. See the object fields and menu structure below. |
openConditional | boolean | Controlled visibility. Provide onOpenChange when using this prop. |
defaultOpen | boolean | Initial uncontrolled visibility; defaults to false. |
onOpenChangeConditional | (open: boolean) => void | Called when visibility changes. Required in controlled mode. |
side | Side | Preferred side; defaults to bottom. Adjusts for viewport collisions. |
align | Align | Preferred alignment; defaults to start. |
sideOffset | number | Gap from the trigger in pixels; defaults to 4. |
portalContainer | HTMLElement|ShadowRoot|React.RefObject<HTMLElement | ShadowRoot | null>|null | Optional destination for the menu portal. |
disabled | boolean | Disables the trigger. |
disabledReason | string | Explanation for an unavailable trigger. |
Menu structure
menu.groups is an array of DropdownGroup objects:
| Field | Type | Behavior |
|---|---|---|
id | string | Required stable group identity. |
label | string | Optional visible group heading. |
items | DropdownItem[] | Required ordered menu items. |
Every item has these shared fields:
| Field | Type | Behavior |
|---|---|---|
id | string | Required stable identity within its group. |
label | string | Required visible text and typeahead label. |
leadingIcon | ReactNode | Optional decorative icon. |
description | string | Optional supporting text. |
trailingText | string | Optional display hint; does not register a shortcut. |
disabled | boolean | Prevents activation. |
disabledReason | string | Explanation for an unavailable item. |
Choose exactly one item kind:
| Kind | Required field | Behavior |
|---|---|---|
action | onClick: () => void | Runs an action. Supports optional destructive. |
link | linkConfig: NavigationLinkConfig | Navigates to a URL. |
submenu | menu: DropdownMenu | Opens another menu using the same recursive structure. |
type: 'action'requiresonClick, permitsdestructive, and cannot havemenu.type: 'link'requireslinkConfig: { href; target?; rel?; render? }and renders a native anchor. Its optional render adapter forwards anchor props and ref for Next Link. It cannot have onClick or a submenu.type: 'submenu'requires anotherDropdownMenuand cannot haveonClickordestructive.
DROPDOWN-RECURSION-01: Supply an acyclic tree with stable sibling IDs. Submenus recurse with the same rules at every level; there is no fixed depth limit. Prefer shallow menus for usability. Shortcuts in trailing text are display hints, not registered key bindings. Actions close the menu by default; callbacks work from both pointer and keyboard.
Keyboard and accessibility
Enter/Space/ArrowDown opens. Arrows navigate items; inline-end opens a submenu and inline-start returns (directions reverse in RTL). Escape closes the current submenu before its parent. Focus returns to the invoking trigger. Disabled branches never open or execute. Disabled reasons are visible/described on rows. Labels support typeahead. Portal positioning constrains menus to available viewport space.
Menu versus Select
DROPDOWN-SEMANTICS-01: This component invokes actions. Select and MultiSelect are separate field components using selection primitives. Their popup/row appearance can share private presentation; roles, keyboard behavior and value state cannot be copied from Menu. A checkmark alone does not define a Select. Dropdown itself does not expose search, selection values, checkbox rows, or remote loading props.
DROPDOWN-ACTIONS-01: Group related actions with dividers. Put destructive actions last, never first or alone, and confirm consequential destruction in the application. Use icons for actions; trailing text holds shortcuts, counts, formats or ages. No actions shows an empty message.
Import @commenda/pathfinder/styles.css once at the app root. Event handlers require a client component in Next.js App Router.
Design rules
Figma guidance and reviewed implementation decisions for this component. Rule IDs remain stable for source comments and AI references.
Menus and selection
-
DROPDOWN-SEMANTICS-01— Pathfinder: Dropdown invokes actions. Select and MultiSelect have their own value, search and selection contracts and appropriate semantics. A checkmark does not determine the component role. Use Dropdown for commands, and Select or MultiSelect for field values. -
DROPDOWN-RECURSION-01— Pathfinder: acyclic menu data, stable sibling IDs, mutually exclusive action/link/submenu types, identical behavior at every recursive depth. Based on Kakashi's data approach, rebuilt over Base UI without private workspace runtime dependencies. Prefer shallow navigation even though the renderer has no fixed depth limit. -
DROPDOWN-ACTIONS-01— Figma: group with dividers; destructive action last, never first or alone, usually followed by application confirmation. Icons identify actions; flags identify entities/jurisdictions. Trailing text is right-aligned shortcut/count/format/age metadata. Only one row is highlighted. Open below the trigger with collision handling. Pointer opening may animate from the trigger; keyboard opening is immediate.