Select
A primitive for building custom select menus. Supports single value selection, keyboard navigation (arrow keys, typeahead), scroll locking when open, Escape/outside-click dismissal, item groups and separators, disabled items, and automatic native <select> element generation for form submissions.
import { Select } from "@areia/slots";
document.querySelector("#root")!.innerHTML = `
<div data-slot="select">
<button data-slot="select-trigger" class="win95-button min-w-40 text-left">
<span data-slot="select-value"></span>
</button>
<div data-slot="select-content" class="win95-menu">
<div data-slot="select-group">
<div data-slot="select-label" class="win95-label">Fruits</div>
<div data-slot="select-item" data-value="apple" class="win95-menu-item">
<span data-slot="select-item-text">Apple</span>
</div>
<div data-slot="select-item" data-value="banana" class="win95-menu-item">
<span data-slot="select-item-text">Banana</span>
</div>
<div data-slot="select-separator" class="win95-separator"></div>
<div
data-slot="select-item"
data-value="cherry"
data-disabled
class="win95-menu-item">
<span data-slot="select-item-text">Cherry</span>
</div>
</div>
</div>
</div>
`;
const root = document.querySelector('[data-slot="select"]')!;
const controller = Select.createSelect(root, {
placeholder: "Choose an option",
onValueChange: (value) => console.log("selected:", value),
});
Import
import { Select } from "@areia/slots";
Usage
import { Select } from "@areia/slots";
// Auto-bind a single root
const root = document.querySelector('[data-slot="select"]');
const controller = Select.createSelect(root, {
defaultValue: "apple",
placeholder: "Choose a fruit",
onValueChange: (value) => console.log("selected:", value),
onOpenChange: (open) => console.log("open:", open),
});
// Auto-bind all unbound roots in scope
const controllers = Select.create();
Expected Markup
The select expects a trigger containing a value display, and a content panel with items. Items are identified by data-value. Groups wrap sets of items with an optional label. Separators create visual dividers between items.
<div data-slot="select">
<button data-slot="select-trigger">
<span data-slot="select-value"></span>
</button>
<div data-slot="select-content">
<div data-slot="select-group">
<div data-slot="select-label">Fruits</div>
<div data-slot="select-item" data-value="apple">
<span data-slot="select-item-text">Apple</span>
</div>
<div data-slot="select-item" data-value="banana">
<span data-slot="select-item-text">Banana</span>
</div>
<div data-slot="select-separator"></div>
<div
data-slot="select-item"
data-value="cherry"
data-disabled
>
<span data-slot="select-item-text">Cherry</span>
</div>
</div>
</div>
</div>
Data Slots
| Slot | Required | Description |
|---|---|---|
select | Yes | Root container. Supports data-placeholder for placeholder text. |
select-trigger | Yes | Button that opens and closes the content panel. |
select-value | No | Displays the currently selected item's text. |
select-content | Yes | Panel containing the list of items. |
select-item | Yes | A selectable option. Requires data-value. Supports data-disabled. |
select-item-text | No | Text content of the item, used for typeahead matching. |
select-group | No | Groups related items together. |
select-label | No | Label for a group of items. |
select-separator | No | Visual divider between items. |
Generated Attributes
The controller writes the following attributes on initialization and updates them as state changes:
| Attribute | Element(s) | Description |
|---|---|---|
data-state | Root | "open" or "closed". |
data-open | Root | Present when the select is open. |
data-closed | Root | Present when the select is closed. |
data-disabled | Root, trigger | Present when the select is disabled. |
data-placeholder | Root | Present when no value is selected and a placeholder exists. |
data-selected | select-item | Present on the currently selected item. |
data-highlighted | select-item | Present on the currently highlighted item (keyboard focus). |
data-disabled | select-item | Present when the item is disabled. |
aria-expanded | Trigger | "true" when open, "false" when closed. |
aria-controls | Trigger | Points to the content element id. |
Native Form Integration
The controller automatically generates a hidden <select> element appended to the root. This native element stays in sync with the selected value so that the custom select participates in standard form submissions. The name option controls the name attribute on this hidden element.
Events
All events are dispatched from the root element (data-slot="select").
Outbound
| Event | Detail | Description |
|---|---|---|
select:change | { value: string | null } | Fired when the selected value changes. |
select:open-change | { open: boolean } | Fired when the open state changes. |
Inbound
| Event | Detail | Description |
|---|---|---|
select:select | { value: string } | Dispatch on the root to select an item by its data-value. |
select:open | { open: boolean } | Dispatch on the root to programmatically set the open state. |
Send select:select on the root element to set the value without a controller reference:
const root = document.querySelector('[data-slot="select"]');
root?.dispatchEvent(
new CustomEvent("select:select", {
bubbles: true,
detail: { value: "banana" },
}),
);
API Reference
Options
Select.createSelect(root, options)
| Option | Type | Default | Description |
|---|---|---|---|
defaultValue | string | — | Initial selected value. |
onValueChange | (value: string | null) => void | — | Called when the selected value changes. |
defaultOpen | boolean | false | Initial open state. |
onOpenChange | (open: boolean) => void | — | Called when the open state changes. |
placeholder | string | — | Text displayed when no value is selected. |
disabled | boolean | false | Disable the entire select. |
required | boolean | false | Mark the select as required (sets required on the native element). |
name | string | — | Name attribute for the hidden native <select>. |
position | "item-aligned" | "popper" | "item-aligned" | Positioning strategy. "item-aligned" aligns content to the selected item. |
side | "top" | "bottom" | "bottom" | Preferred side relative to the trigger. |
align | "start" | "center" | "end" | "start" | Preferred alignment relative to the trigger. |
sideOffset | number | 0 | Offset in pixels from the trigger. |
alignOffset | number | 0 | Offset in pixels along the alignment axis. |
avoidCollisions | boolean | true | Flip side and alignment to avoid viewport collisions. |
collisionPadding | number | 0 | Padding from viewport edges when avoiding collisions. |
Controller
| Member | Type | Description |
|---|---|---|
value | string | null (getter) | Currently selected value. null when nothing is selected. |
isOpen | boolean (getter) | Current open state. |
select(value) | (value: string) => void | Select the item identified by value. |
open() | () => void | Open the select menu. |
close() | () => void | Close the select menu. |
toggle() | () => void | Toggle the select menu open state. |
setOpen(open) | (open: boolean) => void | Set the open state programmatically. |
destroy() | () => void | Remove all event listeners and clean up. |
Controller
The controller is returned by Select.createSelect() and provides imperative control over the select's state.
const controller = Select.createSelect(root, {
placeholder: "Choose a fruit",
});
// Read current value
console.log(controller.value); // "apple" | null
// Check if open
console.log(controller.isOpen); // true | false
// Select an item programmatically
controller.select("banana");
// Open / close / toggle
controller.open();
controller.close();
controller.toggle();
controller.setOpen(true);
// Tear down
controller.destroy();
Behavior
- Scroll locking: When the select is open, body scroll is locked to prevent background scrolling.
- Escape dismiss: Pressing
Escapecloses the select menu. - Outside click dismiss: Clicking outside the select content closes the menu.
- Keyboard navigation: Arrow keys move highlight between items.
EnterorSpaceselects the highlighted item.HomeandEndjump to the first or last item. - Typeahead: Typing characters while the select is open jumps to items matching the typed prefix.
- Disabled items: Items with
data-disabledare not selectable and are skipped during keyboard navigation. - Hidden native select: A hidden
<select>element is maintained in sync for standard form submissions. Thenameandrequiredoptions control its attributes.