@loke/ui
Version:
259 lines (219 loc) • 7.89 kB
Markdown
---
name: dropdown-menu
type: core
domain: overlays
requires: [loke-ui]
description: >
Trigger-based dropdown menus. DropdownMenu, DropdownMenuTrigger, DropdownMenuPortal,
DropdownMenuContent, DropdownMenuItem, DropdownMenuCheckboxItem,
DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSub,
DropdownMenuSubTrigger, DropdownMenuSubContent. modal=true default.
onSelect fires and auto-closes; onClick does not auto-close. Typeahead requires textValue for icon items.
CSS vars: --loke-dropdown-menu-content-available-height, -width, -transform-origin,
--loke-dropdown-menu-trigger-height, -width.
---
# Dropdown Menu
## Setup
Basic menu with portal, items, separator, and group.
```tsx
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@loke/ui/dropdown-menu";
function Example() {
return (
<DropdownMenu>
<DropdownMenuTrigger>Actions</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent>
<DropdownMenuGroup>
<DropdownMenuLabel>File</DropdownMenuLabel>
<DropdownMenuItem onSelect={() => console.log("new")}>
New
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => console.log("open")}>
Open
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => console.log("delete")}>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenu>
);
}
```
`DropdownMenu` defaults to `modal={true}`. `DropdownMenuPortal` renders into `document.body`. Arrow keys navigate items, typeahead jumps to matching items by first character.
## Core Patterns
### Checkbox items
```tsx
import { useState } from "react";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItemIndicator,
DropdownMenuPortal,
DropdownMenuTrigger,
} from "@loke/ui/dropdown-menu";
function ViewOptions() {
const [showGrid, setShowGrid] = useState(false);
const [showRulers, setShowRulers] = useState(true);
return (
<DropdownMenu>
<DropdownMenuTrigger>View</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent>
<DropdownMenuCheckboxItem
checked={showGrid}
onCheckedChange={setShowGrid}
>
<DropdownMenuItemIndicator>✓</DropdownMenuItemIndicator>
Show grid
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={showRulers}
onCheckedChange={setShowRulers}
>
<DropdownMenuItemIndicator>✓</DropdownMenuItemIndicator>
Show rulers
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenu>
);
}
```
`DropdownMenuItemIndicator` renders only when the item is checked. Style it with `data-state="checked"` / `data-state="unchecked"`.
### Radio items
```tsx
import { useState } from "react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItemIndicator,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@loke/ui/dropdown-menu";
function SortMenu() {
const [sort, setSort] = useState("name");
return (
<DropdownMenu>
<DropdownMenuTrigger>Sort by</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent>
<DropdownMenuRadioGroup value={sort} onValueChange={setSort}>
<DropdownMenuRadioItem value="name">
<DropdownMenuItemIndicator>•</DropdownMenuItemIndicator>
Name
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="date">
<DropdownMenuItemIndicator>•</DropdownMenuItemIndicator>
Date modified
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="size">
<DropdownMenuItemIndicator>•</DropdownMenuItemIndicator>
Size
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenu>
);
}
```
### Submenus
```tsx
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@loke/ui/dropdown-menu";
function MenuWithSubmenu() {
return (
<DropdownMenu>
<DropdownMenuTrigger>Options</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent>
<DropdownMenuItem onSelect={() => {}}>Cut</DropdownMenuItem>
<DropdownMenuItem onSelect={() => {}}>Copy</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>Share</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
<DropdownMenuItem onSelect={() => {}}>Email</DropdownMenuItem>
<DropdownMenuItem onSelect={() => {}}>Slack</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenu>
);
}
```
`DropdownMenuSubContent` must also be wrapped in `DropdownMenuPortal` to avoid stacking and clipping issues.
## Common Mistakes
### Using onClick instead of onSelect
`DropdownMenuItem` fires a `menu.itemSelect` custom event that calls `onSelect` and then auto-closes the menu. `onClick` does not trigger the close sequence.
```tsx
// Wrong — menu stays open after click
<DropdownMenuItem onClick={() => doAction()}>
Action
</DropdownMenuItem>
// Correct — menu closes after selection
<DropdownMenuItem onSelect={() => doAction()}>
Action
</DropdownMenuItem>
```
To prevent auto-close on selection (e.g., for a checkbox item you want to stay open), call `event.preventDefault()` inside `onSelect`.
Source: `src/components/menu/menu.tsx` — `MenuItem` dispatches `menu.itemSelect` custom event.
### Forgetting DropdownMenuPortal around DropdownMenuContent
Without a portal, the menu content renders in the DOM flow. It will be clipped by `overflow: hidden` ancestors and may appear behind other elements due to z-index stacking.
```tsx
// Wrong — may be clipped or obscured
<DropdownMenu>
<DropdownMenuTrigger>Actions</DropdownMenuTrigger>
<DropdownMenuContent>...</DropdownMenuContent>
</DropdownMenu>
// Correct
<DropdownMenu>
<DropdownMenuTrigger>Actions</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent>...</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenu>
```
Source: `src/components/dropdown-menu/dropdown-menu.tsx`.
### Not providing textValue for items with non-text children
Menu typeahead reads `textContent` from each item to match keystrokes. If an item contains icons, badges, or other non-text nodes, `textContent` includes the icon's text nodes and typeahead matches incorrectly or not at all.
```tsx
// Wrong — typeahead reads "Delete" plus any icon text
<DropdownMenuItem>
<TrashIcon /> Delete
</DropdownMenuItem>
// Correct — typeahead uses explicit value
<DropdownMenuItem textValue="Delete">
<TrashIcon /> Delete
</DropdownMenuItem>
```
Source: `src/components/menu/menu.tsx` — typeahead uses `textContent` by default.
## Cross-references
- See also: [references/menu-components.md](references/menu-components.md) — full sub-component prop reference
- See also: [popover](../popover/SKILL.md) — for custom floating panels that aren't menus
- See also: [choosing-the-right-component](../choosing-the-right-component/SKILL.md) — DropdownMenu vs Select decision