Select

A listbox that replaces the native select - icons, disabled rows, many values as tags, groups, an optional filter and the whole keyboard.

The native <select> cannot hold an icon, a second line, a checkbox or a tag, and its popup is drawn by the operating system: not themable, not stylable, a different control on every platform. This is a listbox instead - and everything the native element used to give you for free (the typeahead, the keyboard, the value the form posts, what a screen reader says) is given back deliberately.

Filter
Optionsan empty list says so instead of opening
import { Select } from "@enigmax/primitives/react/select";

const countries = [
    { value: "es", label: "Spain", group: "Europe", icon: <Flag code="es" size={14} decorative /> },
    // ...
];

<Select
    options={countries}
    value={country}
    onValueChange={setCountry}
    placeholder="Country"
/>

Usage

import { Select } from "@enigmax/primitives/react/select";

<Select
    options={[
        { value: "es", label: "Spain", icon: <Flag code="es" /> },
        { value: "fr", label: "France" },
        { value: "de", label: "Germany", disabled: true }
    ]}
    value={country}
    onValueChange={setCountry}
/>

multiple changes the types with it - value becomes a list, and so does what the change reports, checked by the compiler at the call site:

<Select multiple options={markets} value={chosen} onValueChange={setChosen} />

Everything it renders is a part, so any of them can be yours instead:

<Select.Root options={countries} value={country} onValueChange={setCountry}>
    <Select.Trigger asChild>
        <MyButton><Select.Value placeholder="Country" /></MyButton>
    </Select.Trigger>

    <Select.Content>
        <Select.Search placeholder="Find a country" />
        <Select.List>
            {(option) => <Row option={option} />}
        </Select.List>
    </Select.Content>
</Select.Root>

The filter

A list of eight or more brings its own field. That is the default (searchable="auto") and not a rule - searchable and searchable={false} decide it outright.

Matching is the search component’s, which means the same three levels:

<Select options={countries} />                    // accent-insensitive substring, no dependency
<Select options={countries} fuse={Fuse} />        // fuzzy, if you have Fuse.js
<Select options={countries} matcher={mine} />     // your own ranking

It reads the label, the description, the group and any keywords you put on an option - so “espana” finds Spain if that is the synonym your visitors type.

A long list stays fast

A select of every country is 250 rows and 250 flags, and about seven of them can be seen. The list renders a window - 40 rows, then another chunk whenever the scroll reaches the end, and immediately whatever the highlight is heading towards, so the keyboard never runs into a row that is not there. chunk={Infinity} on Select.List renders the lot.

Icons are images: <Flag> already asks the browser to defer the ones off screen.

What it gives back

Typeahead Type m on a closed select and it opens on Mexico, the way the native one does
Keyboard Arrows wrap, Home/End, PageUp/PageDown, Enter chooses, Escape closes and returns focus
Backspace Empties a single select and drops the last tag of a multiple one, since the × sits inside the trigger and cannot be tabbed to
Disabled rows Listed, announced, never highlighted and never chosen - including by a click
Forms name renders a hidden field per value, so a plain form still posts it
Screen readers role="listbox", aria-selected per row, aria-activedescendant on the field
Both directions The panel opens upwards when there is no room below it

Nothing to choose from

An empty options list is its own state, not a panel with one line of apology: the trigger says emptyLabel and never opens. loading is a third state, distinct from empty - a select that has not heard back yet and one that never will look identical otherwise, and only one of them is worth waiting for.

<Select options={[]} emptyLabel="No countries yet" />
<Select options={countries} loading={isFetching} loadingLabel="Loading countries..." />

The clear button

clearable puts a × in the caret’s slot, revealed while the pointer is on the control or it holds focus - not beside the caret, where the two are targets a pixel apart and one of them throws the choice away, and where the trigger changes width the moment a value appears. Backspace does the same thing from the keyboard, since a control inside the trigger button cannot be a tab stop of its own.

An empty string is nothing chosen, so value="" shows the placeholder and posts nothing - which is what value="" means in React and what <option value=""> means in HTML.

Labelling it

Point at the caption, do not wrap the control:

<span id="country-label">Country</span>
<Select options={countries} triggerProps={{ "aria-labelledby": "country-label" }} />

A <label> around it looks right and misbehaves: the trigger is a button, which a label labels, so a click on the caption is forwarded to the trigger. The caption is outside the select, so that press also closes an open panel - and the forwarded click reopens it, which is a select that refuses to be dismissed from its own label.

Installation

Adds the package and prints the import to use. This is the way to use them: the package keeps getting fixes, and a dependency is how they reach you.

enigma add select

Installs with your project's own package manager - npm, pnpm, yarn or bun - read from its packageManager field or its lockfile.

--copy writes the source into your project instead. Reach for it to EDIT a component, not to use one: a copy is frozen at today's version and stops receiving fixes.

Prop
options { value, label, icon?, description?, group?, disabled?, keywords? }[]
value / defaultValue A string, or a list when multiple. Controlled and uncontrolled both work
onValueChange (value, option) - a string and an option, or a list of each
multiple Many values, tags on the trigger, and the panel stays open
searchable "auto" (eight rows or more), true, false
fuse / fuseOptions / matcher The filter, exactly as the search component takes them
tags / maxTags Removable tags, and how many before the rest collapse into +N
clearable A × that takes the caret’s place while the control is hovered or focused
name / required The hidden field, for a form that posts
disabled The whole control
loading The options have not arrived yet. Never opens, distinct from emptyLabel
emptyLabel / loadingLabel What the trigger says with no options at all, or while loading
open / defaultOpen / onOpenChange The panel, if you want to own it
renderOption Draw a row yourself
styles false leaves the markup bare for a theme of your own

Styling hooks: [data-enigma-select-trigger] with [data-empty] and [data-loading], [data-enigma-select-content] with [data-side="top"|"bottom"] (portaled to <body>, or into the dialog it was opened from, so no overflow: hidden ancestor can clip it), [data-enigma-select-indicator] with [data-enigma-select-caret] and [data-enigma-select-clear] stacked in it, [data-enigma-select-option] with [data-active], [data-selected] and [data-disabled], [data-enigma-select-tag], [data-enigma-select-search], [data-enigma-select-empty], [data-enigma-select-value] with [data-placeholder], [data-empty] and [data-loading]. The panel is as wide as the trigger unless --enigma-select-panel-width says otherwise, and sits on --enigma-floating-z (10000), the layer the colour picker and the context menu share.