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.
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 selectInstalls 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.
1 Install the dependencies
2 Use it
Import from @enigmax/primitives/react/select.
The theme comes with it. <Select.Root> injects its stylesheet once and prepends it to
<head>, so anything your page already has outranks it on source order - a popup with no
styles is transparent text lying on top of the page, which is broken rather than merely
plain. styles={false} turns it off, and @enigmax/primitives/select.css is the same sheet
if you would rather import it yourself.
Every colour and distance is a custom property on :root, so a select that matches your
brand is a handful of declarations and no selectors:
:root {
--enigma-select-bg: #12161f;
--enigma-select-panel-bg: #12161f;
--enigma-select-active-bg: #1a1f2b;
--enigma-select-accent: #e0a458;
--enigma-select-radius: 8px;
}| 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.