Select

One choice from a list, in a box that matches the field beside it.

import { Select } from "@dede-style/ui/select", then <Select value={v} onChange={setV} options={options} />. It is controlled, and onChange hands over the option’s value rather than the event or the label. Put it in a Field to give it a name.

  • Start
  • Center
  • End
  • Stretch

It is a list we own, not a native <select> we paint. The styleable native select was prototyped first and it works — a styled trigger, a styled popup, a check on the chosen row, with the keyboard, type-ahead and the iOS wheel all still native. It is Chromium-only, and red in both Firefox and Safari including their preview builds.

That is disqualifying for a design system more than it would be for an app. Our picker in Chrome and the operating system’s in Safari is two designs, and this page would be arguing about a tick roughly a third of its readers could not see.

The cost is paid with eyes open: there is no native picker on a phone. On iOS this is a popup rather than the wheel. That was the native version’s strongest card and it is spent on being one design everywhere.

The box is the field’s box

Not a copy of it — the same declaration, read twice.

    • Start
    • Center
    • End
    • Stretch
    Select
  • TextInput

A select and a field standing side by side in a form have to be the same box until you open one. The height ramp, the inset, the fill, the corner, the --color-control-border edge, the hover that doubles it and the focus that recolours it are all declared once, in text-input.css, against a selector holding both classes.

The obvious alternative is copying the declarations into select.css, and it is the failure this system keeps finding: two recipes for one box drift from the first edit. Written this way, moving a size step or the edge token moves both components and there is nothing to keep in step.

The keyboard is the whole cost

Everything a native select does for free, written out and asserted.

  • Thin
  • Light
  • Regular
  • Medium
  • Semibold
  • Bold
  • Black

Closed, Enter, Space, Down and Up open the list; Home and End open it at the first or last option; and typing a character selects the next match without opening anything, which is what a native select does and the behaviour most often lost when one is replaced.

Open, the arrows move the highlight without scrolling the page under it, typing jumps to the next match and several characters typed quickly match the string. Enter and Space commit, Escape closes without committing, and Tab commits and moves on. One weight above is disabled, and every one of those steps over it — including type-ahead, which is where that rule usually breaks.

Focus never moves into the list. It stays on the trigger, which points at the highlighted row with aria-activedescendant — the select-only combobox pattern. Moving focus into the list would put a second tab stop inside one field, and Tab commits and moves on would stop being expressible.

Sizes

The control ramp, so a select, a field and a button in one row share a height.

    • Start
    • Center
    • End
    • Stretch
    8 — 32px
    • Start
    • Center
    • End
    • Stretch
    10 — 40px
    • Start
    • Center
    • End
    • Stretch
    12 — 48px

Three --size-element-* steps, TextInput’s exactly. There is no 24px step: it is precisely WCAG 2.5.8’s minimum target, so it would pass with zero headroom.

The trigger’s type does not ramp with its height, and the reason is inherited rather than chosen: iOS zooms the page on focus for any control under 16px and does not undo it, so every size takes a literal 16px at touch widths and only the dense one drops to sm above them. The row height is on the fixed element ramp; the type is on a scale that shrinks with the viewport, and the two run in opposite directions.

In a Field

Nothing in Field changed to make this work.

  • How the content sits in its lane.

    • Start
    • Center
    • End
    • Stretch
    in a Field
    • Start
    • Center
    • End
    • Stretch
    aria-label

Field owns the label, the hint, the error and the wiring that binds all three to one control, and it knows nothing about what it wraps. Select reads the same context TextInput does — the id, the aria-describedby, the invalid state — and writes none of it itself. That is the cheapest proof the split was made at the right level: a second control arrived and the label component did not move.

The error is Field’s too, and only the edge is this component’s: a Field holding an error string sets invalid through the same context, so the two can never disagree. It is not demonstrated here, because a docs field left permanently in error is a picture of an error — the toggle is on the Properties tab.

Standing on its own is a supported arrangement, not a mistake. With no Field above it the context is empty and the control falls back to its own props, which is what this site’s own properties tables do: a props table is not a form, and the Prop column is already the label.

Keep option labels short

An option is something you scan against its alternatives. A sentence is not scannable, and a reader comparing eight of them is reading a paragraph rather than choosing from a list.

The list does what it can with a long one — it widens to keep the label on a single line, up to a cap, and wraps past that. All of that is damage control. If an option needs explaining, the explanation belongs in the Field’s hint, where it is read once, rather than in every row of the list.

What it deliberately is not

There is no multiple selection and no filter box. Multi-select changes the value from a string to a list, the trigger from one label to a summary, and the ARIA with it — so it is an API change rather than an addition, and nothing here asks for one.

Filtering is three different things wearing one word. A search field inside this popup is a Select feature: the trigger stays a button, the role does not move, and it is what an icon picker of ninety-four glyphs would want. A combobox is the genuinely different one — there the text input is the trigger. It gets the list built here rather than a second one, which is the same relationship Field and TextInput already have.

The label, the hint, the error and the optional marker are not missing — they are one level up. Other systems carry seven such props here because their select is a field and a control in one component; dede split them, and one Field wraps a text input, a switch and a select alike. Only invalid belongs on the control, because the control is what paints the error edge.