Select
A native select, stripped and repainted. The box reads like an Item and carries the chevron of an Input; the list that drops out of it belongs to the operating system.
API
Props
| prop | type | default | |
|---|---|---|---|
options? | readonly T[] | [] | the rows; the component renders the options |
itemExtractor? | ({ row, index }) => string | — | the caption of each row; an option holds text, not markup |
valueExtractor? | ({ row, index }) => string | number | — | the value each row submits; also its key |
placeholder? | string | — | the caption of the empty row, shown when nothing is chosen |
canBeEmpty? | boolean | false | keeps the empty row selectable, so the field can be cleared |
active? | boolean | false | keeps the box highlighted regardless of focus; also exposed as data-margo-active |
clickable? | boolean | true | when false, the box and the control ignore the pointer and the tab order |
…rest | props of select | — | value, onChange, name, required, disabled — all native |
Examples
Usage
You pass the rows and two functions: one says what a row reads as, the other what it submits. The component renders the option elements, so there are no children to write.
const [value, setValue] = useState("");
<Label htmlFor="language">language</Label>
<Select
id="language"
value={value}
onChange={(event) => setValue(event.target.value)}
options={languages}
placeholder="choose a language"
valueExtractor={({ row }) => row.code}
itemExtractor={({ row }) => row.label}
/>An empty value
placeholder is the caption of the empty row. By default that row is only a prompt — hidden from the list and impossible to choose back. With canBeEmpty it stays in the list, which is how the field is cleared.
const [value, setValue] = useState("en");
<Label htmlFor="optional">language, optional</Label>
<Select
id="optional"
canBeEmpty
value={value}
onChange={(event) => setValue(event.target.value)}
options={languages}
placeholder="no language"
valueExtractor={({ row }) => row.code}
itemExtractor={({ row }) => row.label}
/>States
Active and disabled. Disabled is the native attribute on the control, so the select also leaves the tab order; the box only dims and stops answering the pointer.
<Select
active
defaultValue="it"
options={languages}
valueExtractor={({ row }) => row.code}
itemExtractor={({ row }) => row.label}
/>
<Select
disabled
defaultValue="it"
options={languages}
valueExtractor={({ row }) => row.code}
itemExtractor={({ row }) => row.label}
/>In a form
The element is a select, so it serialises under its name and required works as it does anywhere: the empty row is the empty string, and the browser refuses the submission while it is the one selected.
<form onSubmit={handleSubmit}>
<Select
name="language"
required
defaultValue=""
options={languages}
placeholder="choose a language"
valueExtractor={({ row }) => row.code}
itemExtractor={({ row }) => row.label}
/>
<Button as="button" type="submit">
<Button.Label label="save" />
</Button>
</form>Details
Stripped, then repainted
The control gets appearance: none and a transparent background, so what remains is a box the kit draws around it. The states are read from the control with has-*, the way Input does it, and the chevron is a sibling that ignores the pointer.
export const selectBaseClassName = `…
hover:border-on-main hover:text-on-main hover:shadow-item
focus-within:border-primary focus-within:text-on-main
has-[select:disabled]:pointer-events-none has-[select:disabled]:opacity-40`;The open list is the one part the kit does not own — it is drawn by the operating system, and on Windows that means a white panel with a blue row under a dark theme. What a browser does accept there are the colours of the option elements, so the rows take bg-main and text-on-main and the selected one primary-darken: the panel follows the theme instead of contradicting it. The rest is color-scheme, which the kit already declares on both themes, and which is what tells the system to paint its own chrome light or dark.
The options are rendered for you
Select owns its children: it walks options and builds one option per row, taking the value from valueExtractor and the caption from itemExtractor. Without a value extractor the index is used, which is enough for a list of plain strings.
<List
array={options}
itemExtractor={({ row, index }) => (
<option key={valueExtractor?.({ row, index }) ?? index} value={valueExtractor?.({ row, index })}>
{itemExtractor?.({ row, index })}
</option>
)}
/>Both extractors return a string, and that is not a simplification: an option holds text. Markup put inside one reaches the DOM and goes no further, because the open list is drawn by the operating system and only the text survives — so the type promises what the browser actually shows.
The placeholder is a row like the others, with the empty string as its value — the value a select reports when nothing has been chosen.
<option value="" disabled={!canBeEmpty} hidden={!canBeEmpty}>
{placeholder}
</option>The placeholder colour
A select has no ::placeholder: the text in the box is the selected option, whatever it is. The dimmed colour is a selector on the empty row instead, so it follows the selection without a line of JavaScript and without requiring the field.
export const selectControlClassName = `…
appearance-none text-on-main
has-[option[value='']:checked]:text-medium`;