Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
77caf00
fix(ui-inputs): typeahead on the select-only listboxes (WR-1991)
Goosterhof Oct 3, 2026
30d878d
fix(ui-inputs): the select family's attrs reach the combobox element …
Goosterhof Oct 3, 2026
798f1ed
fix(ui-inputs): alphabeticalSort defaults to false (WR-1991)
Goosterhof Oct 3, 2026
2281663
fix(ui-inputs): TextInput and Textarea do not emit while an IME compo…
Goosterhof Oct 3, 2026
540580f
Merge remote-tracking branch 'origin/main' into fix/wr-1991-single-se…
Goosterhof Oct 3, 2026
8276c74
fix(ui-inputs): keep the IME candidate on a re-render; typeahead sear…
Goosterhof Oct 3, 2026
7044cc0
Merge remote-tracking branch 'origin/main' into fix/wr-1991-single-se…
Goosterhof Oct 3, 2026
0814456
Merge branch 'main' into fix/wr-1991-single-select-typeahead
Goosterhof Oct 3, 2026
d626b4b
fix(ui-inputs): a held key costs typeahead the same on every repeat (…
Goosterhof Oct 3, 2026
ba6de0f
refactor(ui-inputs): useListbox is the one writer of a listbox's open…
Goosterhof Oct 3, 2026
645a884
test(ui-inputs): closing a select through its trigger drops the typed…
Goosterhof Oct 3, 2026
020c957
fix(ui-inputs): the typeahead string stops growing at 64 characters (…
Goosterhof Oct 3, 2026
936bbac
Merge remote-tracking branch 'origin/main' into fix/wr-1991-single-se…
Goosterhof Oct 5, 2026
401d7eb
fix(ui-inputs): typeahead folds keys and labels one code point at a t…
Goosterhof Oct 5, 2026
e6220f9
fix(ui-inputs): typeahead takes emoji keys and folds labels once (WR-…
Goosterhof Oct 5, 2026
cff5e6c
refactor(ui-inputs): foldedLabels above the typeahead docblock, not b…
Goosterhof Oct 5, 2026
dc7cf20
fix(ui-inputs): composition ends on change, typeahead caps keys, list…
Goosterhof Oct 5, 2026
37c7879
docs(ui-inputs): DECISIONS.md — D1 typeahead has no large-list budget…
Goosterhof Oct 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions docs/packages/ui-inputs.md
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,8 @@ The default slot receives `{controlId, errorId, required, invalid, describedby}`
| `DateInput` | `min`, `max` (ISO `YYYY-MM-DD`) | `string \| null` | `''` |
| `Textarea` | `rows`, `placeholder` | `string \| null` | `''` |

**IME input.** `TextInput` and `Textarea` follow Vue's own `v-model` on a text field: while an input method (Japanese, Chinese, Korean, …) is composing, the candidate `input` events emit nothing, and the composed value is emitted once on `compositionend`. Swapping a native `v-model` for one of these controls does not hand you every candidate.

See [Nullable values](#nullable-values) for why the models are nullable and what each input emits when cleared.

### The select family
Expand All @@ -305,7 +307,7 @@ See [Nullable values](#nullable-values) for why the models are nullable and what
| `id` | `string` | — | Required. Pairs the trigger with a label/error |
| `placeholder` | `string` | `'Select…'` | |
| `disabled` | `boolean` | `false` | |
| `alphabeticalSort` | `boolean` | `true` | Sorts rendered options by display string |
| `alphabeticalSort` | `boolean` | `false` | Sorts rendered options by display string; off keeps caller order |
| `required` | `boolean` | `false` | Conveyed via `aria-required` |
| `invalid` | `boolean` | `false` | Invalid styling + `aria-invalid` |
| `describedby` | `string` | — | Id of the paired error element |
Expand Down Expand Up @@ -437,7 +439,7 @@ The **`as` escape hatch is discouraged**. Where a button genuinely cannot be use

### Attribute fall-through

Props the components do not declare — `name`, `autocomplete`, `inputmode`, `data-*`, … — fall through to the underlying native control via Vue's attribute inheritance. You do not need a declared prop to make a field participate in autofill or a native form post. (`Checkbox` and `Switch` re-aim attrs at the native **input** — their root is the wrapping `<label>`.)
Props the components do not declare — `name`, `autocomplete`, `inputmode`, `data-*`, … — fall through to the underlying native control via Vue's attribute inheritance. You do not need a declared prop to make a field participate in autofill or a native form post. (`Checkbox` and `Switch` re-aim attrs at the native **input** — their root is the wrapping `<label>`.) The six selects and comboboxes split them: `class` and `style` stay on the root `<div>`, where you style the whole control, and every other attr — `aria-label`, `aria-labelledby`, `data-*`, listeners — lands on the element carrying `role="combobox"` (the trigger button, or the combobox's text input), the only place an accessible name can name the control.

## Theming — the `--ui-*` contract

Expand Down Expand Up @@ -666,6 +668,7 @@ The select family keeps DOM focus on the trigger and conveys the keyboard-focuse
- `aria-selected` marks the **committed** value, never the option under the keyboard pointer — keyboard/hover focus stays visual (`.is-active`) plus `aria-activedescendant`; selection only moves on <kbd>Enter</kbd> or click.
- `MultiSelect`'s and `MultiCombobox`'s listboxes are `aria-multiselectable="true"`; `aria-selected` marks membership, and every chip's remove button carries an accessible name (`"${removeLabel} ${label}"`). `MultiCombobox` additionally conveys the committed selection through an `aria-describedby` summary (its input's accessible value is the query).
- <kbd>Home</kbd>/<kbd>End</kbd> jump the keyboard highlight to the first/last option while the listbox is open, and the empty state (`emptyText`) is announced through a persistent, visually-hidden `aria-live="polite"` region — a filtered list draining to nothing is never silent.
- **Typeahead** on `SingleSelect`, `MultiSelect` and `GroupSelect`: Typing on the focused trigger moves the keyboard highlight the way a native `<select>` does: a character moves to the next option starting with it (so repeating it cycles through those options), a string typed quickly — spaces included — matches by prefix, and the string resets after 500 ms without a keystroke. Matching ignores case and wraps past the last option, and a character no option starts with moves nothing. With nothing highlighted, a search starts from the chosen option on `SingleSelect` and `GroupSelect` (so with Banana chosen, `b` moves on to the next b-option); `MultiSelect` has no single chosen option and searches from the top. On a closed control a match **opens** the list on that option and commits nothing; Enter commits, as always. The comboboxes have no typeahead: what you type there is the query.
- `required` and `invalid` are conveyed via `aria-required` / `aria-invalid`; pair `describedby` with the error element's id — `FormField` does all of this for you.

`Pressable` and `Disclosure` follow the same rule from the other end: rather than describing a control to assistive tech, they _are_ the control — a real `<button>`, whose role, focusability and activation the platform supplies. The only ARIA either sets by hand is the state a button has no native equivalent for (`aria-pressed`, `aria-expanded` + `aria-controls`).
Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 13 additions & 0 deletions packages/ui-inputs/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# @script-development/ui-inputs

## 0.17.0 — 2026-10-03

### Added

- **Typeahead on `SingleSelect`, `MultiSelect` and `GroupSelect` (WR-1991).** A typed character moves the highlight to the next option starting with it, a quickly typed string matches by prefix, and the string resets after 500 ms or when the list closes, however it closes. The string keeps its first 64 characters. With nothing highlighted, SingleSelect and GroupSelect search from the chosen option, as a native `<select>` does; MultiSelect searches from the top. On a closed control a match opens the list on that option; nothing commits until Enter. Case is ignored one character at a time, so typed `ΟΣ` finds both `ΟΣ` and `ΟΣΑ` (a whole-word lowercase would read the first as `ος`), and `İ` finds and cycles the labels starting with `İ`. An emoji key such as `🍎` counts as one character. The comboboxes are unchanged: their typed characters are the query.

### Changed

- **Closing `SingleSelect` or `MultiSelect` with a click on the trigger now clears the highlight (WR-1991).** Before, the highlighted option survived a trigger close and reappeared, with its `aria-activedescendant`, when the list reopened. Every other way of closing already cleared it, and `GroupSelect` cleared it on a trigger close too.
- **`TextInput` and `Textarea` no longer emit while an IME is composing (WR-1990).** Like Vue's own `v-model` on a text field, the candidate `input` events of a Japanese/Chinese/Korean input method emit nothing and the composed value is emitted once on `compositionend`, or on a `change` that ends the composition without one, as Vue does. Plain typing still emits on every keystroke. A consumer that reacted to each candidate (lokalekeuze: a request per candidate) now sees one value.
- **`alphabeticalSort` now defaults to `false` (WR-1991, Commander ruling 2026-10-03).** `SingleSelect`, `MultiSelect`, `Combobox` and `MultiCombobox` render options in the order you pass them unless you set `alphabeticalSort`. Uses that relied on the old default, found on 2026-10-03 at each repo's checked-out HEAD (direct uses with no `alphabeticalSort` binding; every other fleet use already passes `:alphabetical-sort="false"`): entreezuil `frontend/src/shared/ui/BaseSelect.vue` (reached by `apps/admin/domains/users/components/UserForm.vue`); brick-inventory-orchestrator `frontend/src/apps/families/domains/sets/pages/AddSetPage.vue`, `…/sets/pages/EditSetPage.vue`, `frontend/src/apps/families/modals/PlacePartModal.vue`, `frontend/src/apps/showcase/components/ComponentGallery.vue`, `frontend/src/apps/showcase/components/FormValidationWorkbench.vue`. Add `alphabetical-sort` to keep the sorted order.
- **The select family's attrs now reach the combobox element (WR-1991).** On `SingleSelect`, `MultiSelect`, `GroupSelect`, `Combobox`, `MultiCombobox` and `GroupCombobox`, every attr except `class` and `style` now lands on the element carrying `role="combobox"` (the trigger button or the text input) instead of the root `<div>`, so `aria-label`/`aria-labelledby` finally name the control. `class` and `style` stay on the root. A `data-*` selector or a listener you bound on one of these components now finds the control rather than the wrapper: `@focus`/`@blur` start firing (they never bubbled to the wrapper), and an `@keydown` runs on the control before the component's own handler. Fleet census on 2026-10-03: of 33 direct uses in 8 repos, 4 pass a fall-through attr, all of them `class` (ublgenie `editorial/Select.vue` ×2, `editorial/Multiselect.vue` ×2), which stays put.

## 0.16.0 — 2026-10-03

### Minor Changes
Expand Down
47 changes: 47 additions & 0 deletions packages/ui-inputs/DECISIONS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# ui-inputs — decisions

Why this package is shaped the way it is. Every entry names a cost it accepts or
a limitation it lives with, so the argument sits here rather than in a comment
nobody dates.

## D1 — Select typeahead scans the whole option list; there is no large-list budget

_2026-10-05, #285. Crit finding on head `dc7cf20` ("Select typeahead folds and
scans unbounded option lists on the browser main thread"); follow-up row WR-2035._

The typeahead in SingleSelect, MultiSelect and GroupSelect folds every option
label once per change of the options (`foldedLabels`, a computed in
`useListbox`), and each printable key runs a prefix search over the folded
labels. Both are O(n) in the number of options, and both run on the main thread.

**Why this is accepted.**

- **The scan is the matching.** A prefix search has to look at the labels to
find one.
- **Each key folds nothing.** Labels are folded once per options change, not on
every key, so a held key that matches nothing costs one scan per key.
- **It adds no new cost class to these controls.** Every options change already
maps every label (`optionLabels`) and, with `alphabeticalSort` on, sorts the
options with `localeCompare`. Opening the list renders every row; there is no
virtualization. One extra O(n) fold per options change sits under that work.

**What is not decided.** No supported ceiling on the number of options exists,
and none of the three costs has been measured at large n. WR-2035 asks for that
measurement first. Typeahead is the smallest of the three, so it is the wrong
place to start.

## D2 — AltGr and macOS Option characters do not start typeahead

_2026-10-05, #285. Crit finding `30ba9376644d`; follow-up row WR-2031._

`isTypeahead` refuses any keydown that carries `ctrlKey` or `altKey`, so that
command chords such as Ctrl+A or Alt+F never type. A character printed with
AltGr (which Windows reports as Ctrl+Alt) or with macOS Option carries the same
flags, so a label starting with such a character cannot be reached by typing it.

**Why it is not fixed here.** In the browser test lane (Chromium 153 via
Playwright), a real AltGr press reports `getModifierState('AltGraph')` as
`false`; only a synthetic event reports `true`. So an AltGr branch could only be
proven against synthetic events. On macOS, Option plus a key looks the same as
Alt plus a letter elsewhere; telling them apart needs layout or platform
detection. WR-2031 measures what native `<select>` does on each platform first.
19 changes: 19 additions & 0 deletions packages/ui-inputs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,12 @@ shape at that fixed breakpoint (a custom property cannot drive a media query, so

### The select family's shared extras

**Attributes.** On all six selects and comboboxes, `class` and `style` stay on the root `<div>`,
where you style the whole control; every other attr — `aria-label`, `aria-labelledby`, `data-*`,
listeners — lands on the element carrying `role="combobox"` (the trigger button, or the text
input), the only place an accessible name can name the control. A consumer attr never overrides
the component's own `id`, `role` or ARIA wiring.

**Per-option content — the `#option` scoped slot.** All four selects render each option's
plain display string by default; the `option` slot replaces that content with your own
(colour swatches, icons, rich labels). The payload is `{option, index, selected, active}` —
Expand Down Expand Up @@ -115,6 +121,17 @@ persistent, visually-hidden `aria-live="polite"` region that speaks the empty te
the (filtered) list drains to nothing — which matters most on the filterable components,
where typing can drain the list silently.

**Typeahead (`SingleSelect`, `MultiSelect`, `GroupSelect`).** Typing on the focused trigger
moves the keyboard highlight the way a native `<select>` does: a character moves to the next
option starting with it (so repeating it cycles through those options), a string typed quickly —
spaces included — matches by prefix, and the string resets after 500 ms without a keystroke.
Matching ignores case and wraps past the last option, and a character no option starts with
moves nothing. With nothing highlighted, a search starts from the chosen option on
`SingleSelect` and `GroupSelect` (so with Banana chosen, `b` moves on to the next b-option);
`MultiSelect` has no single chosen option and searches from the top. On a closed control a match
**opens** the list on that option and commits nothing; Enter commits, as always. The comboboxes
have no typeahead: what you type there is the query.

**The committing clear entry (`SingleSelect` / `Combobox`).** `clearLabel` renders a
committing entry **above** the options — choosing it commits `null` and closes, exactly like
choosing an option. It lives outside the option index space: its own keyboard slot between
Expand Down Expand Up @@ -522,6 +539,8 @@ option — the anchor grows with it, so the popup stays on-screen.

Every text-like input (`TextInput`, `DateInput`, `Textarea`) models `string | null`, and `NumberInput` models `number | null`. A `null` from a nullable backend column binds directly — the control renders empty, no `?? ''` at the call site. When the user clears the field, the string inputs emit `''` (the raw native value); a Laravel backend's `ConvertEmptyStringsToNull` middleware maps that back to `null` on submit. `NumberInput` is the one exception: an empty number input emits `null` (not `NaN`, not `''`), since a `number` model can never hold `''` honestly — so it round-trips to `null` without relying on the middleware.

**IME input.** `TextInput` and `Textarea` follow Vue's own `v-model` on a text field: while an input method (Japanese, Chinese, Korean, …) is composing, the candidate `input` events emit nothing, and the composed value is emitted once on `compositionend`. Swapping a native `v-model` for one of these controls does not hand you every candidate.

## SingleSelect and assistive tech

The listbox keeps DOM focus on the trigger and conveys the keyboard-focused option with
Expand Down
2 changes: 1 addition & 1 deletion packages/ui-inputs/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@script-development/ui-inputs",
"version": "0.16.0",
"version": "0.17.0",
"description": "Headless, themeable Vue 3 UI components — form inputs (field, label, error, text/number/date input, textarea, select, checkbox/radio/switch) plus keyboard-correct interactive controls (pressable, disclosure) — styled entirely through --ui-* CSS custom properties.",
"homepage": "https://packages.script.nl/packages/ui-inputs",
"license": "MIT",
Expand Down
17 changes: 11 additions & 6 deletions packages/ui-inputs/src/components/Combobox.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<template>
<div ref="root" class="ui-combobox" @keydown="onKey">
<div ref="root" v-bind="rootAttrs($attrs)" class="ui-combobox" @keydown="onKey">
<input
v-bind="controlAttrs($attrs)"
:id="id"
ref="input"
type="text"
Expand Down Expand Up @@ -82,15 +83,19 @@ import type {LabelKey, SelectItem} from '../types';

import {useListbox} from '../composables/useListbox';
import {ensureRefValueExists} from '../internal/reactivity';
import {controlAttrs, rootAttrs} from '../internal/split-attrs';
import OptionList from './OptionList.vue';

// `class`/`style` → root, every other attr → the combobox element (see split-attrs).
defineOptions({inheritAttrs: false});

const {
options,
label,
id,
placeholder = 'Select…',
disabled = false,
alphabeticalSort = true,
alphabeticalSort = false,
required = false,
invalid = false,
describedby,
Expand All @@ -107,6 +112,7 @@ const {
id: string;
placeholder?: string;
disabled?: boolean;
/** sort the rendered options by display string — off by default, so the caller's order is kept. */
alphabeticalSort?: boolean;
/** conveys the required state to assistive tech via `aria-required`. */
required?: boolean;
Expand Down Expand Up @@ -222,6 +228,7 @@ const {
activeDescendant,
floatingStyles,
onKey,
openList,
close,
clearHighlighted,
clearId,
Expand Down Expand Up @@ -285,13 +292,11 @@ const choose = (option: T): void => {
// `resetHighlight` (not a bare pointer write) so a hovered clear entry drops too.
const onInput = (event: Event) => {
query.value = (event.target as HTMLInputElement).value;
open.value = true;
openList();
resetHighlight();
};
// Clicking the (enabled) input opens the list. A disabled input never dispatches click.
const onClick = () => {
open.value = true;
};
const onClick = openList;

// The one sanctioned defineExpose: a PUBLIC imperative handle (isms WR-0448 focus trap).
// The input is non-null by lifetime; the loud accessor names the assumption if it ever breaks.
Expand Down
14 changes: 9 additions & 5 deletions packages/ui-inputs/src/components/GroupCombobox.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<template>
<div ref="root" class="ui-groupcombobox" @keydown="onKey">
<div ref="root" v-bind="rootAttrs($attrs)" class="ui-groupcombobox" @keydown="onKey">
<input
v-bind="controlAttrs($attrs)"
:id="id"
ref="input"
type="text"
Expand Down Expand Up @@ -82,8 +83,12 @@ import type {LabelKey, SelectItem} from '../types';
import {useListbox} from '../composables/useListbox';
import {buildGroupRows} from '../internal/group-rows';
import {ensureRefValueExists} from '../internal/reactivity';
import {controlAttrs, rootAttrs} from '../internal/split-attrs';
import OptionList from './OptionList.vue';

// `class`/`style` → root, every other attr → the combobox element (see split-attrs).
defineOptions({inheritAttrs: false});

const {
groups,
label,
Expand Down Expand Up @@ -224,6 +229,7 @@ const {
activeDescendant,
floatingStyles,
onKey,
openList,
close,
clearHighlighted,
clearId,
Expand Down Expand Up @@ -277,13 +283,11 @@ const dismiss = (): void => {
// `resetHighlight` (not a bare pointer write) so a hovered clear entry drops too.
const onInput = (event: Event) => {
query.value = (event.target as HTMLInputElement).value;
open.value = true;
openList();
resetHighlight();
};
// Clicking the (enabled) input opens the list. A disabled input never dispatches click.
const onClick = () => {
open.value = true;
};
const onClick = openList;

// The one sanctioned defineExpose: a PUBLIC imperative handle (isms WR-0448 focus trap).
defineExpose({focus: () => ensureRefValueExists(input).focus()});
Expand Down
Loading
Loading