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.