Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

141. A select is a closed control and a list

Date: 2026-08-19

Status

Accepted. Closes M2’s catalog: select was the last control in docs/core-widgets.md §3 with a specification, a metrics row and no code.

Context

§3 asks for “closed control + popup list (backend popup window, so it escapes window bounds); typeahead; keyboard open (Space/Alt+Down), arrows, Enter/Esc. Option model or inline KDL option children.”

Everything that sentence needs now exists and did not before. scroll unblocked a list longer than the screen (ADR-0116), host.popup(content, anchor, placement) measures and places a panel against a rectangle (ADR-0104), and ADR-0140 gives the control a way to ask for one.

The value model needed nothing new at all: it is segmented’s, which is radio-group’s, which §3 says outright.

Decision

option moved, because it now has two callers

§3 gives segmented and select the same child node. Option lived in …controls.segmented under ADR-0092’s rule about not generalising from one caller, and book/src/TODO.md recorded exactly what would move it. It is …controls.option now — one record, one CSS type, one markup name, two controls.

Option.within(…) went from package-private to public with it, and the visibility was not protecting what its comment claimed. Both controls rewrite every option on every build, so a selected an application sets is discarded before it is drawn. What keeps a set from having two selected options is that “exactly one” is computed in one place from the bound value and stored nowhere.

The drawing is not shared and does not need to be. A segment is a cell in a bar and a choice in a dropdown is a row: segmented option against select-list option. That is a descendant selector telling one widget’s two surroundings apart, which is not the improvisation ADR-0065 warns about — that one uses an ancestor to tell two different widgets apart. The difference is whether the selector describes where a thing is or what it is.

Two keyboards, and the option carries which one it is in

This is the one place the shared node genuinely diverges, and §3 says so in as many words. A radio-group — and therefore a segmented — has “arrow keys move selection (roving focus)”: the arrow is the choice. A select has “arrows, Enter/Esc”: the arrow moves and Enter chooses.

Option.inAList() is that difference, one flag rather than two because the two halves are one decision: a set where the keyboard chooses has no use for a separate commit, and a set with a commit must not choose before it. It also unlocks Enter, which every other control in this catalog refuses on the grounds that it belongs to a dialog’s default action — a list is in a popup over everything, and there is no default action behind it to take.

This was found by a test and not by reading. The first cut left the roving behaviour on, so the first Down in an open list selected a row, and selecting closes the list — leaving the second and third arrows with nothing to move. arrowsMoveTheHighlight is that failure, kept.

What it is made of

select                 stateful, styles nothing, holds whether the list is open
└── select-field       CSS type `select`: focusable, takes the click and the keys
    ├── select-value   the chosen label, or the placeholder — `.placeholder` marks which
    └── select-chevron the mark saying there is a list under this

select-list            in a popup window of its own, when open
└── option × n         the same widget a `segmented` puts in a bar, `.inAList()`

Stateful and unstyled for ADR-0116’s reason: a stateful widget that also carried the CSS type would put two select nodes in the cascade, one inside the other, and every rule would apply twice. Parity is checked against what the widget describes, which the parity test already knew how to do.

select-list is a sibling of menu rather than a use of it. The two are the same drawing and different meanings — §3’s list is a set of values, §8’s is a set of commands — and neither has an ancestor to be told apart by, because each is the root of its own tree (ADR-0103).

It anchors to itself, by rectangle and not by id

SelectField implements Located, so it is told where the last frame painted it and hands that to the state, which opens the popup against it. Anchoring by id was the alternative and is worse here: a select a document gave no id would have to be given a generated one to be able to open itself, and two of them in one window would then depend on that generation being unique.

The rule Located carries — a widget told where it is must not move itself — holds trivially: nothing here does anything with the rectangle until something is clicked.

The list opens on the row that is already chosen

Popup.focusOn(String id) is new. A popup focuses its first focusable node after its first frame so that an arrow has somewhere to start (ADR-0112); for a select showing its third option, that makes Down mean “the second option” whatever the value was, which is a control that loses the user’s place every time it opens.

Focused not “from the keyboard”, which matters more here than it does for a menu: a row focused from the keyboard would be chosen on the spot in a control whose options rove, so opening the list would report a change nobody asked for.

The field is a field

§3 files this row with text-input rather than with the buttons — “height 32 (28); padding-x 8; radius 4” — so it carries a border and a 4px radius where button carries 8 and none. Its fill is --gb-surface-2 rather than --gb-surface, because a field whose fill was the panel behind it is a control held together by one pixel of border. That is the defect controls-on-surface-* exists to catch, which is why select is in that scene rather than exempt from it.

Consequences

M2’s catalog is complete. Every §3 control is built.

Typeahead works on the closed control and not in the open list. A TextEvent goes to the focused node, which in an open list is an option, and there is no text capture phase for the list to intercept it in — onKeyCapture exists and onTextCapture does not. Recorded in book/src/TODO.md with what it needs.

A select is as wide as its current value. Nothing sizes it to its widest option, because no selector can measure options — the same wall segmented hit, where the answer was that the control writes the width itself (ADR-0099). An application gives it a width today, which is what a form does anyway. Also in TODO.

multiple, autocomplete and tree are not built, and two of the three are waiting on widgets rather than on decisions: autocomplete=#true makes the closed control an editable text-input, and tree=#true takes a tree’s model. Neither exists. multiple=#true renders the selection as badge chips, which do exist, and is deferred as scope rather than as a blocker.

The list is not clamped to the screen and does not scroll. Menus caps its own content by estimating a row height (ADR-0118); select does not, so a list longer than the display is clamped by Placement as every other popup is. It is the same gap, in one more place, and it will be fixed in one place when the popup facility can report what it measured.