DropdownMenu Menu
A primitive for building dropdown menus with full keyboard navigation, typeahead matching, radio groups, checkbox items, and Radix-compatible positioning.
import { DropdownMenu } from "@areia/slots";
document.querySelector("#root")!.innerHTML = `
<div data-slot="dropdown-menu">
<button data-slot="dropdown-menu-trigger" class="win95-button">Options</button>
<div data-slot="dropdown-menu-content" class="win95-menu">
<div data-slot="dropdown-menu-group">
<div data-slot="dropdown-menu-label" class="win95-label">Actions</div>
<button data-slot="dropdown-menu-item" data-value="edit" class="win95-menu-item">
Edit
</button>
<button data-slot="dropdown-menu-item" data-value="copy" class="win95-menu-item">
<span>Copy</span>
<span data-slot="dropdown-menu-shortcut" class="float-right ml-8 text-neutral-700">⌘C</span>
</button>
</div>
<div data-slot="dropdown-menu-separator" class="win95-separator"></div>
<button
data-slot="dropdown-menu-item"
data-value="delete"
data-variant="destructive"
class="win95-menu-item"
>
Delete
</button>
<button
data-slot="dropdown-menu-item"
data-value="disabled"
data-disabled
class="win95-menu-item"
>
Disabled item
</button>
</div>
</div>
`;
const root = document.querySelector<HTMLElement>(
'[data-slot="dropdown-menu"]',
)!;
if (root) {
const controller = DropdownMenu.createDropdownMenu(root, {
onSelect: (value) => console.log("selected", value),
onValueChange: (value) =>
console.log("value changed", value),
});
}Import
import { DropdownMenu } from "@areia/slots";Usage
Call createDropdownMenu with the root element and optional configuration. The primitive reads slot attributes from the DOM and manages open/close, item highlight, typeahead, keyboard navigation, radio and checkbox selection, and positioning.
import { DropdownMenu } from "@areia/slots";
const el = document.querySelector<HTMLElement>(
'[data-slot="dropdown-menu"]',
)!;
if (!el) throw new Error("No root element found");
const controller = DropdownMenu.createDropdownMenu(el, {
side: "bottom",
align: "start",
sideOffset: 4,
closeOnSelect: true,
onSelect: (value) => console.log("selected", value),
onValueChange: (value) => console.log("radio value", value),
onValuesChange: (values) =>
console.log("checkbox values", values),
});
// Programmatic control
controller.open();
// controller.close();
// controller.set({ value: "option-b" });
// controller.destroy();Auto-bind
Use the create helper to discover and bind every unattached root in a scope.
import { DropdownMenu } from "@areia/slots";
const controllers = DropdownMenu.create(document);
// controllers.forEach((c) => { ... });Expected Markup
The primitive expects the following slot structure.
<div data-slot="dropdown-menu">
<button data-slot="dropdown-menu-trigger">Options</button>
<div data-slot="dropdown-menu-content">
<div data-slot="dropdown-menu-group">
<div data-slot="dropdown-menu-label">Actions</div>
<button data-slot="dropdown-menu-item" data-value="edit">
Edit
</button>
<button data-slot="dropdown-menu-item" data-value="copy">
<span>Copy</span>
<span data-slot="dropdown-menu-shortcut">⌘C</span>
</button>
</div>
<div data-slot="dropdown-menu-separator"></div>
<button
data-slot="dropdown-menu-item"
data-value="delete"
data-variant="destructive"
>
Delete
</button>
<button
data-slot="dropdown-menu-item"
data-value="disabled"
data-disabled
>
Disabled item
</button>
<div data-slot="dropdown-menu-separator"></div>
<button
data-slot="dropdown-menu-checkbox-item"
data-value="show-sidebar"
>
Show sidebar
</button>
<button
data-slot="dropdown-menu-radio-item"
data-value="option-a"
>
Option A
</button>
<button
data-slot="dropdown-menu-radio-item"
data-value="option-b"
>
Option B
</button>
</div>
</div>Slot Reference
| Slot | Purpose |
|---|---|
dropdown-menu |
Root element. Controller is bound here. |
dropdown-menu-trigger |
Button that toggles the menu. |
dropdown-menu-content |
Popup container positioned relative to the trigger. |
dropdown-menu-item |
Selectable menu action. |
dropdown-menu-label |
Non-interactive label for a group of items. |
dropdown-menu-group |
Wrapper that groups items under a label. |
dropdown-menu-separator |
Visual divider between item groups. |
dropdown-menu-shortcut |
Keyboard shortcut hint displayed alongside an item. |
dropdown-menu-checkbox-item |
Toggleable item that emits checked state changes. |
dropdown-menu-radio-item |
Mutually exclusive selection item within the menu. |
Data Attributes
| Attribute | Applies to | Description |
|---|---|---|
data-value |
*-item, *-checkbox-item, *-radio-item |
Value emitted on selection or state change. |
data-disabled |
*-item, *-checkbox-item, *-radio-item |
Prevents selection, highlight, and hover interaction. |
data-variant="destructive" |
*-item |
Marks the item as a destructive action. |
data-type |
*-item |
Custom type annotation attached to the element. |
The controller manages these state attributes on the root:
| Attribute | Values | Description |
|---|---|---|
data-state |
"open" / "closed" |
Current open state. |
data-open |
(present) | Present when the menu is open. |
data-closed |
(present) | Present when the menu is closed. |
Items receive the following state attributes:
| Attribute | Description |
|---|---|
data-highlighted |
Present on the currently highlighted item. |
data-checked |
Present on a checked checkbox/radio item. |
Events
All events bubble from the root element (data-slot="dropdown-menu").
Outbound
| Event | Detail | Description |
|---|---|---|
dropdown-menu:open-change |
{ open, previousOpen, source, reason } |
Fires when the menu opens or closes. |
dropdown-menu:highlight-change |
{ value, previousValue, item, previousItem, source } |
Fires when the highlighted item changes. |
dropdown-menu:select |
{ value, item, itemType, source, checked? } |
Cancelable. Fires when a user selects an item, before the value commits. |
dropdown-menu:value-change |
{ value, previousValue, item, previousItem, source } |
Fires when the committed radio value changes. |
dropdown-menu:values-change |
{ values, previousValues, changedValue, checked, item, source } |
Fires when the committed checkbox values change. |
Inbound
| Event | Detail | Description |
|---|---|---|
dropdown-menu:set |
{ open?, value?, values?, highlightedValue?, source? } |
Set open/highlight/selection state programmatically. |
dropdown-menu:open-change
interface DropdownMenuOpenChangeDetail {
open: boolean;
previousOpen: boolean;
source: "pointer" | "keyboard" | "programmatic" | "init";
reason:
| "trigger"
| "select"
| "outside"
| "escape"
| "tab"
| "programmatic"
| "init";
}dropdown-menu:select
interface DropdownMenuSelectDetail {
value: string;
item: HTMLElement;
itemType: "item" | "radio" | "checkbox";
source: "pointer" | "keyboard";
checked?: boolean;
}This event is cancelable. Call event.preventDefault() to prevent the selection from being committed.
dropdown-menu:value-change
interface DropdownMenuValueChangeDetail {
value: string | null;
previousValue: string | null;
item: HTMLElement | null;
previousItem: HTMLElement | null;
source: "pointer" | "keyboard" | "programmatic" | "restore";
}dropdown-menu:values-change
interface DropdownMenuValuesChangeDetail {
values: string[];
previousValues: string[];
changedValue: string | null;
checked: boolean | null;
item: HTMLElement | null;
source: "pointer" | "keyboard" | "programmatic" | "restore";
}dropdown-menu:highlight-change
interface DropdownMenuHighlightChangeDetail {
value: string | null;
previousValue: string | null;
item: HTMLElement | null;
previousItem: HTMLElement | null;
source: "pointer" | "keyboard" | "programmatic" | "restore";
}API Reference
Options
DropdownMenu.createDropdownMenu(root, options)
| Option | Type | Default | Description |
|---|---|---|---|
defaultOpen |
boolean |
false |
Initial open state. |
defaultValue |
string | null |
— | Initial radio selection value. |
defaultValues |
string[] |
— | Initial checkbox selection values. |
onOpenChange |
(open: boolean) => void |
— | Called when open state changes. |
onPortalMounted |
(container: HTMLElement) => void |
— | After portaled menu mounts on open. |
onSelect |
(value: string) => void |
— | Called when a user selects an item. |
onValueChange |
(value: string | null) => void |
— | Called when the radio value changes. |
onValuesChange |
(values: string[]) => void |
— | Called when checkbox values change. |
closeOnClickOutside |
boolean |
true |
Close when clicking outside the menu. |
closeOnEscape |
boolean |
true |
Close when pressing Escape. |
closeOnSelect |
boolean |
true |
Close when an item is selected. |
side |
"top" | "right" | "bottom" | "left" |
"bottom" |
Preferred side of the trigger to render against. |
align |
"start" | "center" | "end" |
"start" |
Preferred alignment against the trigger. |
sideOffset |
number |
4 |
Distance in px from the trigger. |
alignOffset |
number |
0 |
Offset in px from the alignment edge. |
avoidCollisions |
boolean |
true |
Override side/align to prevent viewport collisions. |
collisionPadding |
number |
8 |
Padding between content and viewport edges when avoiding collisions. |
lockScroll |
boolean |
true |
Lock body scroll when the menu is open. |
highlightItemOnHover |
boolean |
true |
Whether moving the pointer over items highlights them. |
Options can also be set via data-* attributes on the root element, using kebab-case names (e.g. data-close-on-select="false"). JS options take precedence over data attributes.
Controller
| Member | Type | Description |
|---|---|---|
isOpen |
boolean (getter) |
Current open state. |
value |
string | null (getter) |
Current committed radio selection value. |
values |
string[] (getter) |
Current committed checkbox selection values. |
highlightedValue |
string | null (getter) |
data-value of the currently highlighted item. |
open() |
() => void |
Open the menu. |
close() |
() => void |
Close the menu. |
toggle() |
() => void |
Toggle the menu open state. |
set(detail) |
(detail: DropdownMenuSetDetail) => void |
Set open, value, values, or highlightedValue programmatically. |
destroy() |
() => void |
Remove all event listeners and clean up. |
Controller
The controller is the imperative handle returned by createDropdownMenu. Use it for programmatic control and reading selection state.
import { DropdownMenu } from "@areia/slots";
const el = document.querySelector<HTMLElement>(
'[data-slot="dropdown-menu"]',
)!;
if (!el) throw new Error("No root element");
const ctrl = DropdownMenu.createDropdownMenu(el);
// Read state
console.log(ctrl.isOpen); // boolean
console.log(ctrl.value); // string | null (radio)
console.log(ctrl.values); // string[] (checkbox)
console.log(ctrl.highlightedValue); // string | null
// Open / close
ctrl.open();
ctrl.close();
ctrl.toggle();
// Set state programmatically
ctrl.set({ open: true });
ctrl.set({ value: "option-a" });
ctrl.set({ values: ["show-sidebar", "autosave"] });
ctrl.set({
open: false,
value: null,
values: [],
highlightedValue: null,
source: "programmatic",
});
// Tear down all listeners and clean up
ctrl.destroy();Keyboard Navigation
When the menu is open, the following keyboard interactions are supported:
| Key | Behavior |
|---|---|
ArrowDown |
Move highlight to the next enabled item. |
ArrowUp |
Move highlight to the previous enabled item. |
Home |
Move highlight to the first enabled item. |
End |
Move highlight to the last enabled item. |
Enter / Space |
Activate the highlighted item. |
Escape |
Close the menu. |
Tab |
Close the menu. |
| Printable keys | Typeahead: jump to the next item whose text starts with the typed character(s). |
When closeOnSelect is false, selecting a radio or checkbox item toggles the checked state without closing the menu. When true (default), the menu closes after any item selection.