On this page
Choices

Select released

A choice list with a panel of its own, holding one answer or many.

  • Import@pacit/components/select
  • Selectorspct-select, pct-multi-select

Implements the W3C ARIA APG Combobox pattern ↗

Shipping to: pl

select.demo.ts — the same file runs here and is highlighted under Code

Start here

Usage

One import per component, and the shortest possible use — the package itself is installed once, on the start page.

Import this component
import { PctSelect, PctMultiSelect } from '@pacit/components/select';
ExportedWhat it is
PctMultiSelectcomponentpct-multi-selectA many-choice select — the same panel, the same walk and the same file as pct-select, with a value that is a list.
PctSelectcomponentpct-selectA single-choice select with a panel of its own (not a native <select>).
PctSelectOptionContextinterfaceWhat the select hands an option template on every row it draws.
PctSelectOptionTemplatedirectiveng-template[pctSelectOption]The template a consumer writes instead of the select's own option row (req-api-templates).
PctSelectOptioninterfaceAn option of a select. The value is of any type T — business forms bind numeric ids, union members and whole entities, and narrowing it to a string forced every application into mapping by hand in both directions. T is a string by default, so string lists are written exactly as before.
PctSelectPanelWidthtype'field' | 'auto' | (string & {})Width of the dropdown panel: - 'field' (the default) — exactly that of the visible control: the field border inside the chrome, the trigger alone outside it. The panel is then an extension of the field rather than a separate object, - 'auto' — as wide as the longest option, but never narrower than the control. For lists where an option's full text matters more than a flush edge, - a CSS length ('320px', '24rem') — the width outright.
PctSelectPanelAligntype'start' | 'center' | 'end'Alignment of the panel to the control when the panel does not have the control's width ('auto' or an explicit width). With panelWidth="field" every variant gives the same result.
PctSelectOptionGroupinterfaceA named section of a list — what a native <optgroup> is, and what the ARIA listbox pattern draws as a role="group" with its label referenced by aria-labelledby.
PctSelectItemtypePctSelectOption<T> | PctSelectOptionGroup<T>What options accepts: a flat list, a list of groups, or the two mixed — options standing before the first heading are exactly what a native <select> draws above its first <optgroup>.
PctSelectFiltertype( option: PctSelectOption<T>, query: string, ) => booleanWhat decides whether an option stays in the panel while a filter is on: the option, and what the user has typed. A predicate rather than a mode name ('contains', 'startsWith') — the moment a list wants matching on a second field, on a code beside the label or on what a server said, a name in an enum has to grow another member and a function does not.
pctFilterByLabelconstThe default filter: the label, case-folded, contains what was typed.
pctKeepAllconstThe predicate that decides nothing: for a list somebody else has already narrowed. An application filtering on a server answers the question with another options list, and the control would otherwise narrow that answer a second time — a server that matched a city by its old name, by a code or by a misspelling would watch the row it found be taken out again by a client-side includes, in a library that never said it would (0037).
<pct-select ariaLabel="Country" [options]="countries" [(value)]="country" />
Examples

Every state, running

Each example is the component itself, not a screenshot. Open the code under any of them.

Groups, and a filter

An item can be a group — a label with options under it — and filterable turns the trigger into an input that narrows the list as you type, the filter matching labels.

Region

Deploying to: nowhere yet

Many answers

pct-multi-select holds an array: each pick toggles a value, the trigger names how many are chosen, and the same keyboard walks the same panel.

Notify

Via: email

API

What it reads, and what it writes back

Read from the source at build time — the types, the defaults and the lines of documentation are the library's own.

unreleased marks 2 of them: on main, and not in @pacit/components@0.2.0 — the next release names the version.

PctSelectcomponent · pct-select

A single-choice select with a panel of its own (not a native <select>).

Inputs

NameTypeWhat it does
disableddefault falsebooleanBlocks the control and greys it — the native disabled, so it leaves the tab order as well. With [formField] the directive writes it, as it writes every input of the FormUiControl contract.
readonlydefault falsebooleanHolds the value and keeps the panel closed while the control stays focusable; aria-readonly says so.
invaliddefault falsebooleanThe form's verdict; shown only once touched, so an empty form does not open red.
toucheddefault falsebooleanWhether the user has left the field once; with invalid it gates the error face.
requireddefault falsebooleanMarks the label with the required sign; with [formField] it follows the schema's required().
errorsdefault []readonly ValidationError.WithOptionalFieldTree[]The form's validation errors; the first one's message takes the hint's place once the field is touched.
warningsunreleaseddefault undefinedreadonly ValidationError.WithOptionalFieldTree[] | undefinedA verdict without a veto (0087): shown after the error and before the hint once the field is touched, in the warning tone, with aria-invalid untouched. Left unbound, the control reads what pctWarn() wrote for its own [formField]; bound — [] included — the list given stands.
namedefault ''stringThe native name — what a form submission calls the value.
optionsdefault []readonly PctSelectItem<T>[]The option list. The values have to be unique by compareWith: a value is what maps back to an option (the first match wins), so of two options sharing one value only the earlier is ever reachable — picking the later one shows the earlier one's label and leaves aria-selected on it. Which option an equal value denotes is not the component's to decide, so it does not repair the list quietly: in dev mode it says so.
labeldefault ''stringThe visible label, rendered by the control itself when it stands outside a pct-field; inside one, the field's label is the name.
hintdefault ''stringA line of help under the control, outside a pct-field; the first error message takes its place while the field is invalid and touched.
ariaLabeldefault ''stringThe accessible name of a control with no visible label — an INPUT rather than an aria-label written on the tag, because the tag cannot carry one: role="combobox" sits on the trigger inside, the host has no role at all, and an ARIA name on a roleless element is ignored (aria-label is prohibited for the generic role). Without this a standalone select is an unnamed combobox and the consumer has no way in (req-a11y-built-in).
ariaLabelledbydefault ''stringAs ariaLabel, for a name that already stands somewhere on the page — a heading, the header of the column the control sits in. It wins over ariaLabel and over label, in ARIA's order and not ours.
placeholderstringThe placeholder. With no value it comes from PCT_TEXTS — and it comes at render time, not at construction: an input's default value is produced once, so an application switching language at runtime would be left with the string from before the change (0014). placeholder="" stays an empty placeholder rather than a return to the default — absent and empty mean different things.
sizedefault config.defaultSizePctSizeHeight 28 / 36 / 44 px — the axis every field shares; from providePctConfig by default (req-api-config).
compareWithdefault pctSameValuePctCompareWith<T>Value equality. Identity by default, which for strings and numbers is the same as ===. Entities need comparison by key — an instance from the server is not the same reference as an option on the list, so without this the selected item would not highlight after the form loads.
filterabledefault falsebooleanWhether the trigger is a text field and the list narrows to what is typed into it.
filterTexttwo-waydefault ''stringWhat has been typed into the trigger. A model, so an application filtering on a server can read the question and answer it with another options list — and the control still owns the clearing, because the question belongs to the panel: it is set to '' when the panel closes and when a pick answers it. It is not the value and never becomes one.
filterWithdefault pctFilterByLabelPctSelectFilter<T>What counts as a match. The default folds case on the label and asks for includes; anything more than that — a second field, a code beside the label, an idea of which letters are the same letter — is the application's, because it is the application that knows the language (pctFilterByLabel, and lesson-101 for why).
clearabledefault falsebooleanWhether the control draws a cross that takes back what the trigger is showing — the answer, or the question being typed while the panel is up (0036).
loadingdefault falsebooleanThe list is on its way. A fact about the list, not about the control (0037): it takes nothing away — not the focus, not the answer already given, not the rows still on the screen — and says two things instead. The panel carries aria-busy, so a reader knows that what it is reading may not be the last word, and an empty one says "loading" where it would otherwise have said "no options": that sentence is a conclusion, and a list still coming has reached none.
virtualdefault falsebooleanDraw only the rows the panel can show. It is a promise about how many rows exist, and that is why it is the control's to make: the panel builds its own rows, so it is the one thing here that can decide not to (0033).
panelWidthdefault 'field'PctSelectPanelWidthWidth of the dropdown panel — equal to the control by default ('field'). The panel then comes out exactly from its edge, so the list reads as an extension of the field. 'auto' fits the width to the longest option (without narrowing the panel below the control), and a CSS length sets it outright.
panelAligndefault 'start'PctSelectPanelAlignAlignment of the panel to the control when it is wider or narrower than it.
valuetwo-waydefault nullNoInfer<T> | nullThe selected value — a required field of the FormValueControl contract. The type is T | null, because "nothing selected" is a state reachable for every T: the select starts empty and the cross returns to it.
emptyValuedefault nullNoInfer<T> | nullThe value standing for no choice — what the cross (clearable) writes. null by default, but an application with a non-nullable field (plan: string) supplies its own (emptyValue=""), so that a clear does not write null into the model against its type. A form's reset writes nothing here: the value it resets to is the form's (0092).

Outputs

NamePayloadWhen it fires
touchvoidEmitted on blur — lets the form mark the field as touched.

What it writes on the element

The hooks a stylesheet or a test can hold. Stable by contract — the parts inventory gate fails the build when one disappears.

AttributeBound toNote
classpct-select
data-pct-sizesize()
data-pct-openopen() ? "" : null
data-pct-invalidshowInvalid() ? "" : null
data-pct-warningwarningLit() ? "" : null
data-pct-disableddisabled() ? "" : null
data-pct-multiplemultiple ? "" : nullThe one state a consumer's stylesheet cannot derive from the parts: both controls draw the same DOM, so the tag is what tells them apart, and a tag is not a selector the parts contract exposes (req-api-attributes).
data-pct-clearableclearable() ? "" : nullNot "there is a cross standing" but "this control has one": the space it takes is reserved by the input alone, so a trigger's text does not reflow the moment an answer appears under it.
data-pct-in-fieldinField ? "" : nullInside the chrome pct-field draws the border and the label — the control hands them over.

PctMultiSelectcomponent · pct-multi-select

A many-choice select — the same panel, the same walk and the same file as pct-select, with a value that is a list.

Inputs

NameTypeWhat it does
disableddefault falsebooleanBlocks the control and greys it — the native disabled, so it leaves the tab order as well. With [formField] the directive writes it, as it writes every input of the FormUiControl contract.
readonlydefault falsebooleanHolds the value and keeps the panel closed while the control stays focusable; aria-readonly says so.
invaliddefault falsebooleanThe form's verdict; shown only once touched, so an empty form does not open red.
toucheddefault falsebooleanWhether the user has left the field once; with invalid it gates the error face.
requireddefault falsebooleanMarks the label with the required sign; with [formField] it follows the schema's required().
errorsdefault []readonly ValidationError.WithOptionalFieldTree[]The form's validation errors; the first one's message takes the hint's place once the field is touched.
warningsunreleaseddefault undefinedreadonly ValidationError.WithOptionalFieldTree[] | undefinedA verdict without a veto (0087): shown after the error and before the hint once the field is touched, in the warning tone, with aria-invalid untouched. Left unbound, the control reads what pctWarn() wrote for its own [formField]; bound — [] included — the list given stands.
namedefault ''stringThe native name — what a form submission calls the value.
optionsdefault []readonly PctSelectItem<T>[]The option list. The values have to be unique by compareWith: a value is what maps back to an option (the first match wins), so of two options sharing one value only the earlier is ever reachable — picking the later one shows the earlier one's label and leaves aria-selected on it. Which option an equal value denotes is not the component's to decide, so it does not repair the list quietly: in dev mode it says so.
labeldefault ''stringThe visible label, rendered by the control itself when it stands outside a pct-field; inside one, the field's label is the name.
hintdefault ''stringA line of help under the control, outside a pct-field; the first error message takes its place while the field is invalid and touched.
ariaLabeldefault ''stringThe accessible name of a control with no visible label — an INPUT rather than an aria-label written on the tag, because the tag cannot carry one: role="combobox" sits on the trigger inside, the host has no role at all, and an ARIA name on a roleless element is ignored (aria-label is prohibited for the generic role). Without this a standalone select is an unnamed combobox and the consumer has no way in (req-a11y-built-in).
ariaLabelledbydefault ''stringAs ariaLabel, for a name that already stands somewhere on the page — a heading, the header of the column the control sits in. It wins over ariaLabel and over label, in ARIA's order and not ours.
placeholderstringThe placeholder. With no value it comes from PCT_TEXTS — and it comes at render time, not at construction: an input's default value is produced once, so an application switching language at runtime would be left with the string from before the change (0014). placeholder="" stays an empty placeholder rather than a return to the default — absent and empty mean different things.
sizedefault config.defaultSizePctSizeHeight 28 / 36 / 44 px — the axis every field shares; from providePctConfig by default (req-api-config).
compareWithdefault pctSameValuePctCompareWith<T>Value equality. Identity by default, which for strings and numbers is the same as ===. Entities need comparison by key — an instance from the server is not the same reference as an option on the list, so without this the selected item would not highlight after the form loads.
filterabledefault falsebooleanWhether the trigger is a text field and the list narrows to what is typed into it.
filterTexttwo-waydefault ''stringWhat has been typed into the trigger. A model, so an application filtering on a server can read the question and answer it with another options list — and the control still owns the clearing, because the question belongs to the panel: it is set to '' when the panel closes and when a pick answers it. It is not the value and never becomes one.
filterWithdefault pctFilterByLabelPctSelectFilter<T>What counts as a match. The default folds case on the label and asks for includes; anything more than that — a second field, a code beside the label, an idea of which letters are the same letter — is the application's, because it is the application that knows the language (pctFilterByLabel, and lesson-101 for why).
clearabledefault falsebooleanWhether the control draws a cross that takes back what the trigger is showing — the answer, or the question being typed while the panel is up (0036).
loadingdefault falsebooleanThe list is on its way. A fact about the list, not about the control (0037): it takes nothing away — not the focus, not the answer already given, not the rows still on the screen — and says two things instead. The panel carries aria-busy, so a reader knows that what it is reading may not be the last word, and an empty one says "loading" where it would otherwise have said "no options": that sentence is a conclusion, and a list still coming has reached none.
virtualdefault falsebooleanDraw only the rows the panel can show. It is a promise about how many rows exist, and that is why it is the control's to make: the panel builds its own rows, so it is the one thing here that can decide not to (0033).
panelWidthdefault 'field'PctSelectPanelWidthWidth of the dropdown panel — equal to the control by default ('field'). The panel then comes out exactly from its edge, so the list reads as an extension of the field. 'auto' fits the width to the longest option (without narrowing the panel below the control), and a CSS length sets it outright.
panelAligndefault 'start'PctSelectPanelAlignAlignment of the panel to the control when it is wider or narrower than it.
valuetwo-waydefault []NoInfer<T>[]The chosen values — the FormValueControl contract's one field, here a list. Empty is the empty list, so there is no emptyValue to declare: [] is reachable for every T and means the same thing in every application, which null did not.

Outputs

NamePayloadWhen it fires
touchvoidEmitted on blur — lets the form mark the field as touched.

What it writes on the element

The hooks a stylesheet or a test can hold. Stable by contract — the parts inventory gate fails the build when one disappears.

AttributeBound toNote
classpct-select
data-pct-sizesize()
data-pct-openopen() ? "" : null
data-pct-invalidshowInvalid() ? "" : null
data-pct-warningwarningLit() ? "" : null
data-pct-disableddisabled() ? "" : null
data-pct-multiplemultiple ? "" : nullThe one state a consumer's stylesheet cannot derive from the parts: both controls draw the same DOM, so the tag is what tells them apart, and a tag is not a selector the parts contract exposes (req-api-attributes).
data-pct-clearableclearable() ? "" : nullNot "there is a cross standing" but "this control has one": the space it takes is reserved by the input alone, so a trigger's text does not reflow the moment an answer appears under it.
data-pct-in-fieldinField ? "" : nullInside the chrome pct-field draws the border and the label — the control hands them over.
Styling

Parts to select, tokens to override

Two ways in, both stable. No class names to depend on.

Parts

In a test, PctSelectHarness, PctMultiSelectHarness from @pacit/components/testing read a part by the name below and a state by its attribute — the harness gate holds the names to the package.

PartSelectorWhat it is
arrow[data-pct-part="arrow"]the chevron at the end
clear[data-pct-part="clear"]the button that empties the value
empty[data-pct-part="empty"]the sentence shown when the list has nothing to show — beside the list, not in it (0069)
error[data-pct-part="error"]the message when invalid
group[data-pct-part="group"]a group of options
group-label[data-pct-part="group-label"]the heading of a group
hint[data-pct-part="hint"]the hint under the control
label[data-pct-part="label"]the label of the control
list[data-pct-part="list"]the listbox inside the panel — the element that scrolls, and the one the trigger controls
option[data-pct-part="option"]one option
option-check[data-pct-part="option-check"]the mark on a chosen option
panel[data-pct-part="panel"]the list box
placeholder[data-pct-part="placeholder"]the text while nothing is chosen
trigger[data-pct-part="trigger"]the combobox element the panel opens from
value[data-pct-part="value"]the chosen value as text
warning[data-pct-part="warning"]the message when the value is allowed and suspect — after the error, before the hint (0087)

Tokens

Every value the component reads, with the light and the dark default. Override a token at any scope and it cascades like any custom property.

TokenStyles
--pct-select-arrow-fg{pct.text-muted}#334155 · #e2e8f0Text colour of the arrow.
--pct-select-bg{pct.surface}#ffffff · #0f172aBackground of the select.
--pct-select-bg-disabled{pct.surface-disabled}#f1f5f9 · #1e293bBackground of the select while disabled.
--pct-select-border{pct.border-strong}#64748bEdge colour of the select.
--pct-select-border-disabled{pct.border}#e2e8f0 · #334155Edge colour of the select while disabled.
--pct-select-border-focus{pct.primary}#2563eb · #60a5faEdge colour of the select when focused.
--pct-select-border-hover{pct.text-muted}#334155 · #e2e8f0Edge colour of the select on hover.
--pct-select-border-invalid{pct.danger}#b91c1c · #f87171Edge colour of the select when invalid.
--pct-select-border-warning{pct.warning}#b45309 · #fbbf24Edge colour of the select under a warning (0087).
--pct-select-fg{pct.text}#0f172a · #ffffffText colour of the select.
--pct-select-fg-disabled{pct.text-disabled}#64748bText colour of the select while disabled.
--pct-select-fg-invalid{pct.danger}#b91c1c · #f87171Text colour of the select when invalid.
--pct-select-fg-warning{pct.warning}#b45309 · #fbbf24Text colour of the warning line (0087).
--pct-select-font-size{pct.font.size.md}14pxFont size of the select.
--pct-select-group-label-fg{pct.text-muted}#334155 · #e2e8f0Text colour of the group label.
--pct-select-group-label-font-size{pct.font.size.sm}13pxFont size of the group label.
--pct-select-group-label-font-weight{pct.font.weight.medium}500Font weight of the group label.
--pct-select-group-label-padding-x{pct.space.4}12pxHorizontal padding of the group label.
--pct-select-group-label-padding-y{pct.space.2}4pxVertical padding of the group label.
--pct-select-hint-fg{pct.text-muted}#334155 · #e2e8f0Text colour of the hint.
--pct-select-label-fg{pct.text}#0f172a · #ffffffText colour of the label.
--pct-select-option-bg{pct.surface}#ffffff · #0f172aBackground of an option.
--pct-select-option-bg-active{pct.surface-100}#f1f5f9 · #1e293bBackground of the option the keyboard is on.
--pct-select-option-bg-selected{pct.primary}#2563eb · #60a5faBackground of an option when selected.
--pct-select-option-fg{pct.text}#0f172a · #ffffffText colour of an option.
--pct-select-option-fg-active{pct.text}#0f172a · #ffffffText colour of the option the keyboard is on.
--pct-select-option-fg-disabled{pct.text-disabled}#64748bText colour of an option while disabled.
--pct-select-option-fg-selected{pct.on-primary}#ffffff · #0f172aText colour of an option when selected.
--pct-select-option-padding-x{pct.space.4}12pxHorizontal padding of an option.
--pct-select-option-padding-y{pct.space.3}8pxVertical padding of an option.
--pct-select-padding-x{pct.space.4}12pxHorizontal padding of the select.
--pct-select-padding-y{pct.space.3}8pxVertical padding of the select.
--pct-select-panel-bg{pct.surface}#ffffff · #0f172aBackground of the panel.
--pct-select-panel-border{pct.border-strong}#64748bEdge colour of the panel.
--pct-select-panel-max-height240pxMaximum height of the panel.
--pct-select-panel-radius{pct.radius.md}8pxCorner radius of the panel.
--pct-select-panel-shadow{pct.shadow-panel}0 8px 24px rgba(15, 23, 42, 0.14) · 0 8px 24px rgba(0, 0, 0, 0.55)Shadow of the panel.
--pct-select-placeholder-fg{pct.text-muted}#334155 · #e2e8f0Text colour of the placeholder.
--pct-select-radius{pct.radius.md}8pxCorner radius of the select.

Theming

A brand override in a few declarations. Scope it to a theme attribute, a page or one container — the component never notices.

[data-theme='brand'] {
  --pct-select-border-focus: #0f766e;
  --pct-select-option-bg-selected: #0f766e;
  --pct-select-option-fg-selected: #ffffff;
}

Shipping to: pl

Accessibility

Measured, not assumed

Pattern: Combobox — the

Keyboard

keyeffecttest
↑ / ↓open the panel / change the active optionapps/sandbox-e2e/src/select.spec.ts
Home / Endfirst / last optionapps/sandbox-e2e/src/select.spec.ts
Enterpick the active option, closeapps/sandbox-e2e/src/select.spec.ts
Enter (many)toggle the active option, stay openapps/sandbox-e2e/src/select.spec.ts
Escapeclose with no changeapps/sandbox-e2e/src/select.spec.ts
Escape (shut, clearable)take the answer back — the keyboard's crossapps/sandbox-e2e/src/select.spec.ts
characterstypeahead, skipping disabled optionslibs/components/select/src/select.spec.ts

With filterable the map splits with the element the trigger is — the list keeps what it owns, the caret takes back what a text field answers on its own (req-api-platform):

keyon a filtering triggertest
↑ / ↓open the panel / change the active optionapps/sandbox-e2e/src/select.spec.ts
charactersnarrow the list — and open the panel doing itapps/sandbox-e2e/src/select.spec.ts
Home / Endthe caret's: the ends of the textapps/sandbox-e2e/src/select.spec.ts
spacea character, not a pickapps/sandbox-e2e/src/select.spec.ts
Enterpick the active option (closed: the form's)apps/sandbox-e2e/src/select.spec.ts
Escape / Tabclose, and the question goes with the panellibs/components/select/src/select.spec.ts
a click on the triggeropens, and never closes — a caret is not a switchlibs/components/select/src/select.spec.ts

The map is unchanged by a custom option row — the keys sit on the trigger, not on the option (apps/sandbox-e2e/src/select.spec.ts, "the keyboard is unchanged by a custom row").

What is measured, and what is not yet

Every line is a test in three engines or a gate on every commit. The gaps are listed the same way, each with the requirement that will close it.

GapARIA APG pattern in the JSDocnone — gap
MeasuredKeyboard map testedapps/sandbox-e2e/src/select.spec.ts, libs/components/select/src/select.spec.ts
Measuredaxe auditapps/sandbox-e2e/src/a11y.spec.ts — including the panel with a scoped theme, the panel waiting for its list and the empty panel, whose sentence stands beside the listbox (0069)
MeasuredVisual screenshotapps/sandbox-e2e/src/visual.spec.ts — separate select-panel-open and select-panel-loading shots
Measuredforced-colors: activeapps/sandbox-e2e/src/forced-colors.spec.ts — selection is carried by the background (SelectedItem) and the keyboard cursor by the outline (Highlight), so an option that is both selected and active shows both states at once. tools/check-styles.mjs (point 7) adds what no browser here shows: the active option's surface goes back to Canvas (the cursor is the outline), and inside the chrome the ring belongs to the field's row in this mode as well (lesson-70)
Measuredprefers-reduced-motionapps/sandbox-e2e/src/preferences.spec.ts
MeasuredTouch target ≥ 24×24 pxapps/sandbox-e2e/src/field-hitarea.spec.ts — this is where the 19.6 px regression surfaced (lesson-25)
MeasuredSize axisapps/sandbox-e2e/src/size.spec.ts — inside a wrapper the size is handed to the field; the panel takes its font size from the trigger
GapDensity axisnone — gap
MeasuredRTLapps/sandbox-e2e/src/rtl.spec.ts — the panel's COMPUTED direction compared with the trigger's, with the panel proved to sit outside app-root so the assertion is not ordinary inheritance (lesson-35); plus five -rtl baselines, four of them of the open panel, in apps/sandbox-e2e/src/visual.spec.ts, and tools/check-styles.mjs (point 5, req-token-logical) over the stylesheet
MeasuredSSR + hydrationapps/sandbox-e2e/src/hydration.spec.ts
MeasuredFormslibs/components/select/src/select.spec.ts, apps/sandbox-e2e/src/forms.spec.ts; a form's reset — reset() keeping the value, reset(value) showing it, the form pristine: libs/components/select/src/select.spec.ts › signal forms and multi-select.spec.ts
MeasuredOption row through a templatelibs/components/select/src/select.spec.ts, apps/sandbox-e2e/src/select.spec.ts — the row rendered in three engines, with the listbox pattern and the keyboard intact
MeasuredThe arrow through PCT_ICONStools/check-icons.mjs (target check-icons), libs/components/select/src/select.spec.ts, apps/sandbox-e2e/src/select.spec.ts — the registered drawing in three engines, in the same box and turning with it on opening
MeasuredHeadings in the list (groups)tools/check-aria.mjs point 9 — the option's owner read off the template's tree, with axe's requiredContext as the rule, before any page renders the panel (lesson-65); libs/components/select/src/select.spec.ts, apps/sandbox-e2e/src/select.spec.ts — the group named by the heading it draws and owning its options, in three engines; the walk crossing the headings; a disabled group out of reach. apps/sandbox-e2e/src/a11y.spec.ts — the open grouped panel audited, which is what says an option is still owned through a role="group". apps/sandbox-e2e/src/visual.spec.ts — a select-panel-groups baseline, a heading being the one thing here that is drawn and never touched
MeasuredMore than one answerlibs/components/select/src/multi-select.spec.ts — the list-valued contract: the panel that stays open, the value in list order, a value the list cannot name, the marks, the walk opening on the first chosen row, and the input parity of the two tags read off ɵcmp.inputs. apps/sandbox-e2e/src/select.spec.ts — the two comboboxes side by side in three engines, where aria-multiselectable is measured as a difference rather than as an attribute somebody wrote. apps/sandbox-e2e/src/a11y.spec.ts — the open many-choice panel audited, which is what says every option carries aria-selected, the false half included. apps/sandbox-e2e/src/visual.spec.ts — select-panel-multiple and its RTL twin, the mark being drawn and never touched
MeasuredA question typed into the triggerlibs/components/select/src/select.spec.ts and multi-select.spec.ts — 26 cases: the trigger that is a text field, the list narrowing under the letters, the cursor on the first row still standing, the chosen label surviving a question that hides it, the caret's keys, the query that dies with the panel, and a many-choice pick that keeps what the question hid. apps/sandbox-e2e/src/select.spec.ts — 7 cases × 3 engines, where the letters really reach a field. apps/sandbox-e2e/src/a11y.spec.ts — the narrowed panel audited whole-page, because aria-controls and aria-activedescendant cross from the page into the overlay and a reference to an id that is not in the document is exactly what axe reports. apps/sandbox-e2e/src/visual.spec.ts — select-filter-trigger and its RTL twin: an <input> wearing the button's border with the arrow over its end padding, which no behavioural case touches
MeasuredA cross beside the triggerlibs/components/select/src/select.spec.ts and multi-select.spec.ts — 22 cases: what the cross takes back on each of the four states a trigger can be in, the value a list cannot name, the control nobody may change, Escape spent and Escape left alone, the press that keeps focus on the trigger, and the press that is not a click outside the panel. apps/sandbox-e2e/src/select.spec.ts — 8 cases × 3 engines, where the cross really is a sibling in a page that was parsed rather than built (lesson-104), Tab really walks past it, and the target really measures 24 px. apps/sandbox-e2e/src/a11y.spec.ts — the cross audited where it stands, its name coming from PCT_TEXTS. apps/sandbox-e2e/src/visual.spec.ts — select-clear-trigger and its RTL twin: two ways of ending a trigger that have to come out as one pair of icons
MeasuredParts in the inventorylibs/components/parts.snapshot.md, tools/check-parts.mjs (target check-parts)
MeasuredTokens + contrast.policy.jsonlibs/tokens/src/contrast.policy.json
MeasuredStrings through PCT_TEXTStools/check-texts.mjs + select.spec.ts — a partial override leaves the rest at the defaults, and a runtime language change reaches the strings
MeasuredSize budgetlibs/components/size.snapshot.md — the ./select row, and in the second block what one of its two tags costs alone
MeasuredScreen-reader logRead 2026-09-28 from docs/acr/at/ — three readers, whole sandbox, and the act that opens the list. ON OPEN, two of the three announce the listbox, and differently: Orca leaving main content. · List with 6 items · Poland · not selected. — the list, its size, the row the cursor points at and that row's state — and NVDA list, nothing more; VoiceOver was silent at the opening — focus stays on the trigger by design, so whether the list opened under it the record cannot say — and its one reading of a trigger here is , Sélectionn Country A list with a panel of its own (CDK Overlay) list box pop up collapsed combo box — a truncated value read before the label, as on 2026-09-16, and worth a look. On Escape Orca says collapsed. ON ARRIVING at a trigger the earlier reading stands: label, combobox role and that it opens a list from all three, the VALUE from two — NVDA Country, combo box, Poland, collapsed, opens list, Orca field (the default) · combo box. · opens listbox with no word of the value. A value change is a second act, not this one
Measureddocs page/components/select on the published site — prerendered, the demo's own source is the code tab
Evidence

What the repository holds for this component

Read at build time from the tracked snapshots, not typed here.

92.96%mutation score66 of 71 mutants killed
60 × 3e2e cases × enginesplus the shared audits: axe, forced colors, preferences, size, hydration
21colour pairs measuredlight and dark, at every token build
13visual baselinesChromium, the sandbox's own pictures
8elements in the preview4 listeners · 1 render to settle · 4589 µs in jsdom, held exactly but for the clock

Why it is built this way

Not a native <select>, because the native one gives no panel whose look and content can be controlled — a deliberate exception to req-api-platform.

Decisions behind it

  • 0006The anchor and inheritance in an overlay
  • 0024The overlay layer carries what an overlay severs, and borrows the closing stack
  • 0010A generic value and NoInfer
  • 0003Wrapper and control
  • 0007Configuration apart from texts
  • 0025A panel says whether it takes focus, and the rest of the focus layer waits for a consumer
  • 0026One channel per politeness, and a message with a home announces from it
  • 0028An icon set is a component, and an icon's box is the contract
  • 0033An option is a row of data, not a component the consumer projects
  • 0034Multiplicity is a type, so it is a tag
  • 0035A filter is a question, not a value
  • 0036A clear takes back what the trigger shows
  • 0037Loading is a fact about the list, and a cursor names an option
  • 0038A window is measured, and its spacer is not an element
  • 0069A message about the list is not an item in it
  • lesson-66When the only witness of a defect is somebody else's diagnostic, the cheap fix silences it
  • lesson-101Whether an accent is a letter is a question of language, and the trick that pretends otherwise folds half the alphabet
  • lesson-104A <button> inside a <button> is two buttons, and only the parser says so
  • lesson-106An empty listbox is a critical violation, and aria-busy is the specification's way of saying "not yet"
  • lesson-107A cursor kept as a number survives only a list that changes on a keystroke
  • lesson-108offsetHeight is an integer, and a list is arithmetic over a fraction
  • lesson-84A template's name and a template's context are two different checks, and no one shape gives both
  • lesson-176The template that has no parent: a report that had to stop asking DI
  • lesson-70A media query adds no specificity, and the browser it is written for hides that
  • lesson-25A wrapped select had a 19.6 px touch target
  • lesson-65An audit of the rendered page measures the pages you wrote, not the component you shipped
  • lesson-97Two elements of one template are two instantiations, and neither is evidence about the other
  • lesson-98Projected rows belong to the consumer's view, so the panel never owns how many exist

Known limitations

  • A windowed list asks for rows of one height, and cannot check it. The window is arithmetic over one measured row, so a label that wraps onto a second line or a pctSelectOption template drawing two lines moves every row below it. Reported in dev mode and not repaired — which of two heights is the right one is the application's question. A window and panelWidth="auto" are a poor pair for the same reason: "as wide as the longest option" is measured from the rows that exist, so the panel would change width as the user scrolls.
  • Taking the answer back is not announced. Focus stays on the trigger and its accessible name changes underneath, which no screen reader promises to speak. The honest fix is a sentence on the polite channel, and it waits on the measurement this card already records as missing — the screen-reader log below.
  • A filtering panel closes by keyboard or by a click outside it, and not by the arrow. A press on a text field is a caret being placed, so the trigger opens and never toggles; the arrow is drawn pointer-events: none and is a sign rather than a button. Escape, Tab, a pick and an outside click all close it.
  • The matched letters are not highlighted in a row, and there is no debounce. The row is a slot, so a consumer who wants the first has filterText and their own template; the second belongs to whoever calls a server, because a predicate over an array in memory needs none.
  • A many-choice trigger reads every chosen label, and nothing shortens it. Long selections are cut by the ellipsis the trigger has always had; there is no "3 selected" summary, because a count needs a plural rule and a plural rule is not a string — it would be the first thing in PCT_TEXTS that an application could not translate by handing over a word.
  • A projected <pct-option> is not coming. Measured rather than declined: <pct-option [value]="1"> beside [value]="'pl'" compiles silently, the parent's own query hands back any, and a thousand projected options are a thousand component instances on a page whose panel has never opened (0033, lesson-97, lesson-98).
  • Duplicate option values are reported, not repaired. Which option an equal value denotes is the application's call, so the component says so in dev mode and leaves the list alone (lesson-66). What is deliberately not measured: that the message ever reaches a browser console — no page renders a list with a duplicate, and the unit cases carry the proof instead.
  • A row outside the window is outside find-in-page. That is why virtual is opt-in rather than a length the library decides for itself: a list that quietly stopped being searchable at the four hundredth option would be a defect nobody could report against a promise nobody made.