Select
Tier A. Follows the anatomy template and the conventions.
1. Purpose and native element
Section titled “1. Purpose and native element”Chooses one value from a list. Renders a native <select> with <option> and <optgroup> children. The
WAI-ARIA Combobox pattern covers custom selects; a
native <select> already provides the behaviour, so Tier A adds no ARIA. The browser opens its own list:
Tier A has no select popup and no overlay. A searchable custom listbox is a later component.
multiple and size > 1 render a native list box, which keeps the browser’s look and has no key. Tier A
styles only the single-line form. Label, help and error text come from Field.
2. Object
Section titled “2. Object”A key with a chevron: the Button key shape, holding the current value and a chevron.
| Part | Shape | Height at 50% | Moves |
|---|---|---|---|
| Key body | As Button | object.keyHeight (8) |
Down by object.keyTravel (6) while pressed |
| Key top | Captured face: value text on the left, chevron on the right | — | With the body |
The chevron is flat line art on the key top (section 10), not a separate mesh. The browser’s list is outside the object and is never drawn by the GPU.
3. Faces and materials
Section titled “3. Faces and materials”| Face | Colour role | Text role | Material role |
|---|---|---|---|
| Key top | field |
text |
field (matte) |
| Chevron | — | muted, 3:1 or better |
None |
| Key sides (Objects) | The top’s colour, darkened by the bevel | — | Same as the top |
A placeholder option (a disabled first option with an empty value) uses the muted text role.
4. Elevation per state
Section titled “4. Elevation per state”| State | Relief | Objects | CSS |
|---|---|---|---|
| Rest | elevation.raised (3) |
Key at keyHeight |
Relief recipe at raised |
| Hover | raised × state.hoverHeight |
Key rises keyHeight / 4 |
Larger shadow, brightness(1.04) |
Pressed (:active) |
raised × state.pressHeight |
Key travels keyTravel |
Smaller shadow |
| Focus-visible | Accent edge, focus.width |
Emissive glow | outline: focus.width solid accent |
Invalid (aria-invalid="true") |
danger edge and message |
Same, on the key | outline in danger |
| Disabled | Height 0, content at 38% | Key flattened | Flat, content opacity: 0.38 |
Selected and busy do not apply. A new value is a content change, so the face is captured fresh.
5. Relief primitives
Section titled “5. Relief primitives”One raised primitive: the <select> from its border box and radius.control.
6. Tokens it reads
Section titled “6. Tokens it reads”| Token | Prop |
|---|---|
colour.field, colour.text, colour.muted, colour.danger |
— |
radius.control, elevation.raised, state.* |
— |
object.keyHeight, object.keyTravel, object.taper |
— |
material.roles.field |
material |
density (control height), space.* |
— |
font.size.body |
— |
focus.width, focus.offset, motion.fast, motion.easing.press |
— |
target.min |
— |
7. DOM structure and overlay needs
Section titled “7. DOM structure and overlay needs”<select class="ug-select" data-strata-face="raised" id="size" name="size"> <option value="" disabled selected>Choose a size</option> <option value="s">Small</option> <option value="m">Medium</option></select>- The
<select>is the boundary element. It usesappearance: none, and the chevron is a background image on the same element, so there is no wrapper. - No overlay. The list is the browser’s own and is not captured, themed or hit-tested by Strata.
8. Keyboard and screen reader
Section titled “8. Keyboard and screen reader”The native behaviour is kept in full.
| Key | Action |
|---|---|
| Alt+Down, Space, Enter | Open the list (browser dependent) |
| Up, Down | Change the value, or move in the open list |
| A letter | Jump to a matching option |
| Home, End | First or last option |
| Esc | Close the list |
- Role as the browser exposes it (
comboboxorlistbox). The name comes from the<label>in Field, oraria-label. aria-invalidandaria-describedbypoint at the Field error and help.changeevents are native.
9. CSS presentation
Section titled “9. CSS presentation”The raised recipe with trailing padding for the chevron. Minimum size is the density’s control height and
never below target.min (24); the whole box opens the list. The page’s color-scheme follows the theme so the
browser’s list matches. Under forced-colors: Field, FieldText and a ButtonBorder outline; the chevron
uses ButtonText; no shadows.
10. Tests
Section titled “10. Tests”- Plain DOM: keyboard changes the value and fires
changeonce; the form submits the value; disabled andrequiredbehave natively. - GPU: GPU stays active while the list opens and closes and the value changes; the face shows the new
value in fresh pixels; a hit on the key reaches the
<select>. - Accessibility: axe in light, dark and high contrast; label association; chevron contrast of at least 3:1; accessibility snapshot equal in DOM and GPU modes.
- Parity: Relief and CSS within the P0.6 budget at rest, hover and focus.
11. Hardware forms: selector and display
Section titled “11. Hardware forms: selector and display”The same native select drawn as a RotarySelector’s dial or as a Display with two step buttons
(Instrument plan, design language section 6):
form (prop) or data-strata-form (on the drawn root) chooses selector or display. Without it, or with flush, the Select is the key with a
chevron above, in the markup and the look it had before, at every level; the premium character’s default form, selector, is reached by naming it (as the
Slider’s fader is), so a page that does not opt in is unchanged. Sections 1, 6 and 8 hold: the native <select> is the control, with its name, form
owner, value, required, id, Field wiring, every native key, type-ahead and the browser’s own list (which no form draws), and the accessibility
snapshot is the same with and without the form. multiple (a list box) is always the flush control.
<div class="ug-rotary" data-strata-form="selector" style="--strata-rotary-value: 0.5;"> <div class="ug-rotary__dial"> <span class="ug-rotary__scale" aria-hidden="true"> <span class="ug-rotary__mark" data-strata-value="sine" data-strata-selected="false" style="--strata-rotary-at: 0;"><span class="ug-rotary__tick"></span><span class="ug-rotary__text">Sine</span></span> <span class="ug-rotary__mark" data-strata-value="saw" data-strata-selected="true" style="--strata-rotary-at: 0.5;"><span class="ug-rotary__tick"></span><span class="ug-rotary__text">Saw</span></span> <span class="ug-rotary__mark" data-strata-value="square" data-strata-selected="false" style="--strata-rotary-at: 1;"><span class="ug-rotary__tick"></span><span class="ug-rotary__text">Square</span></span> </span> <span class="ug-rotary__skirt" data-strata-face="raised" aria-hidden="true"> <span class="ug-rotary__cap" data-strata-face="raised"><span class="ug-rotary__pointer"></span></span> </span> <select class="ug-rotary__select" id="wave-example" name="wave" aria-label="Wave"> <option value="sine">Sine</option> <option value="saw" selected>Saw</option> <option value="square">Square</option> </select> </div> <ol class="ug-rotary__legend" aria-hidden="true"> <li data-strata-selected="false">Sine</li> <li data-strata-selected="true">Saw</li> <li data-strata-selected="false">Square</li> </ol></div><span class="ug-select-display" data-strata-form="display"> <button type="button" class="ug-button ug-push ug-select-display__step ug-select-display__step--previous" tabindex="-1" aria-hidden="true" data-strata-form="momentary" data-strata-step="-1"> <span class="ug-push__object" aria-hidden="true"><span class="ug-push__bezel" data-strata-face="raised"></span><span class="ug-push__socket"></span><span class="ug-push__ring"></span></span> <span class="ug-push__cap" data-strata-face="raised"><span class="ug-select-display__glyph"></span></span> </button> <span class="ug-display ug-display--input ug-select-display__window" data-strata-form="display"> <span class="ug-display__object" aria-hidden="true"><span class="ug-display__bezel" data-strata-face="groove"><span class="ug-display__window" data-strata-face="flat"></span></span></span> <select class="ug-display__text ug-select-display__select" id="preset-example" name="preset" aria-label="Preset"> <option value="init">Init</option> <option value="warm-pad" selected>Warm Pad</option> <option value="glass-bell">Glass Bell</option> <option value="sub-bass">Sub Bass</option> </select> </span> <button type="button" class="ug-button ug-push ug-select-display__step ug-select-display__step--next" tabindex="-1" aria-hidden="true" data-strata-form="momentary" data-strata-step="1"> <span class="ug-push__object" aria-hidden="true"><span class="ug-push__bezel" data-strata-face="raised"></span><span class="ug-push__socket"></span><span class="ug-push__ring"></span></span> <span class="ug-push__cap" data-strata-face="raised"><span class="ug-select-display__glyph"></span></span> </button></span>selectoris the RotarySelector’s. The markup isRotarySelector’s, with the Select’s own<select>as its select and no legend title (the Field’s label, oraria-label, names it), so the CSS, the behaviour (attachStrataRotarySelectors: the vertical drag, ring click-to-set, cap taps, wheel, and the native popup on the keys) and the GPU helper at Relief, Objects and Instrument are the RotarySelector’s, unchanged: the skirt, the cap and the pointer onspring.detent, one printed position and one detent per<option>. A change of option is a uniform update (the pointer turns on its spring) plus the capture core makes of the nativeinput, as for the Knob. The root is a size container as wide as its dial, and the labels print as numbers with a legend under the dial where it is narrow, as the RotarySelector’s do.- The limit, and the rule that decides. A dial’s labels sit on a 270° arc, which fits about eight short words. A
selectoris drawn as aselectorfor up to 8 options with labels of at most 12 characters and no<optgroup>; otherwise it is drawn as adisplay, which keeps the hardware face and scales to any list, anddata-strata-formsays which was drawn. So are options a binding cannot read (a component that renders them: there are no labels to print), and a select with no<option>. A list box (multiple) is the flush control. The rule is one function (selectFormFor,shared/select-forms.ts), shared by every binding. displayis a Display (the TextField’s display form: the bezel, the window and the backlight, CSS and GPU alike) whose native element is the Select’s own<select>(.ug-display__text,appearance: none): the selected option’s label is the window’s text, printed under the glass and captured with the face, so a change of option is one capture of the text (as typing is in the TextField’s display), and the glass never shades it (the finish zone stays off, as for an input). The native list opens from the window (a click, Alt+Down, F4, Space), the browser’s own. A chevron is printed on the glass at its end.- Step buttons. Two momentary push buttons (the PushButton’s CSS, behaviour and GPU drawing: a machined cap with a lit
ring, a size smaller) stand either side, previous (
data-strata-step="-1") and next ("1"). A click selects the previous or next enabled option throughselectedIndex(across optgroups), theninputandchangefire on the select, as a native choice does (attachStrataSelectDisplays,stepStrataSelect); at the end of the list nothing changes and nothing fires (the select does not wrap). A press keeps the focus where it is, and the select takes it after the step, so the arrow keys carry on. Holding a button is a uniform write (the ring’s emitter, the cap’s travel): no capture, no hit-geometry update. - ARIA, decided. The select is the control, so the step buttons are
aria-hidden="true"withtabindex="-1": they duplicate the arrow keys the native select already has (the WAI-ARIA practice is not to repeat a control’s own increment keys as extra tab stops), and hiding them keeps the tab order and the accessibility tree exactly the select’s alone (ariaunchanged). They are a pointer’s way to step without opening a list, and the single-pointer alternative to the drag the selector needs; a disabled select disables them. - Props.
form, and for a selectorsweep(the cap’s turn,knob.sweepby default).classandstylereach the drawn root and every other attribute the select;size,materialandlevelgo on the root. React and Vue are controlled withvalueandonChange(v-model), or uncontrolled withdefaultValue; Astro takesoptionswithvalue, or<option>s in the slot (which hold their ownselected). formis also the native attribute (the id of the select’s form owner).selector,displayandflushare the form names and never an owner; any other string is the owner, as before, on the flush select. A drawn form has no owner prop: put the select in its<form>.- Presentations. Relief and Objects draw the selector, the display and the buttons as the RotarySelector, the Display and the PushButton do; the Instrument level draws the parts of all three. Forced colours, more contrast, reduced motion and reduced transparency are theirs.
Tests. ui-select-forms.test.mjs (plain DOM, touch at 390 and 320 px, preferences, GPU at Relief, Objects and Instrument at DPR 1 and 2),
ui-select-forms-bindings.test.mjs (React and Vue), ui-select-forms-astro.test.mjs.