Skip to content

Select

A custom select primitive with keyboard navigation, typeahead, scroll locking, and native form integration.

Updated View as Markdown

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 Escape closes the select menu.
  • Outside click dismiss: Clicking outside the select content closes the menu.
  • Keyboard navigation: Arrow keys move highlight between items. Enter or Space selects the highlighted item. Home and End jump 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-disabled are not selectable and are skipped during keyboard navigation.
  • Hidden native select: A hidden <select> element is maintained in sync for standard form submissions. The name and required options control its attributes.
Navigation

Type to search…

↑↓ navigate↵ selectEsc close