From 77caf000452a929f860233c9a86ec143c0677b04 Mon Sep 17 00:00:00 2001 From: Gerard Oosterhof Date: Sat, 3 Oct 2026 20:44:15 +0200 Subject: [PATCH 01/14] fix(ui-inputs): typeahead on the select-only listboxes (WR-1991) `useListbox` had no character search, so a SingleSelect replacing a native parity: the typed string grows while keys arrive within 500 ms; a single or repeated character moves to the NEXT option starting with it, so repeating it cycles; a longer string matches by prefix from the current option, so a highlight that still matches stays put; the search wraps, compares case-insensitively, and moves nothing on no match. Space joins the string only once one is being typed, and is then prevented so the trigger is not clicked on keyup. Modified keys are not typeahead. Closing drops the string. Closed, a match opens the list on that option and commits nothing (the APG select-only combobox), never a native-style closed commit: that would toggle membership on a MultiSelect and commit on a stray keystroke. SingleSelect, MultiSelect and GroupSelect pass their rendered labels; the comboboxes do not, since their typed characters are the query (negative pin). Specs in happy-dom per control (34 cases) plus a real-keyboard arm in the browser suite. ui-inputs 0.15.0 -> 0.17.0. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016MHxNh28encKSSVY9rFM9u --- docs/packages/ui-inputs.md | 1 + package-lock.json | 2 +- packages/ui-inputs/CHANGELOG.md | 6 + packages/ui-inputs/README.md | 9 + packages/ui-inputs/package.json | 2 +- .../ui-inputs/src/components/GroupSelect.vue | 1 + .../ui-inputs/src/components/MultiSelect.vue | 1 + .../ui-inputs/src/components/SingleSelect.vue | 1 + .../ui-inputs/src/composables/useListbox.ts | 65 +++++- .../tests/browser/interaction.browser.spec.ts | 26 +++ packages/ui-inputs/tests/typeahead.spec.ts | 218 ++++++++++++++++++ 11 files changed, 329 insertions(+), 3 deletions(-) create mode 100644 packages/ui-inputs/tests/typeahead.spec.ts diff --git a/docs/packages/ui-inputs.md b/docs/packages/ui-inputs.md index d0e39573..327b9ffd 100644 --- a/docs/packages/ui-inputs.md +++ b/docs/packages/ui-inputs.md @@ -662,6 +662,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 Enter 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). - Home/End 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 `` 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. 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 diff --git a/packages/ui-inputs/package.json b/packages/ui-inputs/package.json index dac73c47..a10bce4c 100644 --- a/packages/ui-inputs/package.json +++ b/packages/ui-inputs/package.json @@ -1,6 +1,6 @@ { "name": "@script-development/ui-inputs", - "version": "0.15.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", diff --git a/packages/ui-inputs/src/components/GroupSelect.vue b/packages/ui-inputs/src/components/GroupSelect.vue index c97bf98c..a713b667 100644 --- a/packages/ui-inputs/src/components/GroupSelect.vue +++ b/packages/ui-inputs/src/components/GroupSelect.vue @@ -216,6 +216,7 @@ const { onCommit: commit, onDismiss: () => close(), onOutside: () => close(), + typeaheadLabels: () => flatOptions.value.map(labelOf), clearEntry: () => clearLabel !== undefined, onClearCommit: commitClear, }); diff --git a/packages/ui-inputs/src/components/MultiSelect.vue b/packages/ui-inputs/src/components/MultiSelect.vue index 7fe4763f..4c3259ec 100644 --- a/packages/ui-inputs/src/components/MultiSelect.vue +++ b/packages/ui-inputs/src/components/MultiSelect.vue @@ -237,6 +237,7 @@ const {open, pointer, listboxId, optionId, activeDescendant, floatingStyles, onK onCommit: commit, onDismiss: () => close(), onOutside: () => close(), + typeaheadLabels: () => optionLabels.value, }); const toggle = () => { diff --git a/packages/ui-inputs/src/components/SingleSelect.vue b/packages/ui-inputs/src/components/SingleSelect.vue index 330c3852..a4d7cbc5 100644 --- a/packages/ui-inputs/src/components/SingleSelect.vue +++ b/packages/ui-inputs/src/components/SingleSelect.vue @@ -216,6 +216,7 @@ const { onCommit: commit, onDismiss: () => close(), onOutside: () => close(), + typeaheadLabels: () => optionLabels.value, clearEntry: () => clearLabel !== undefined, onClearCommit: commitClear, }); diff --git a/packages/ui-inputs/src/composables/useListbox.ts b/packages/ui-inputs/src/composables/useListbox.ts index 217599c0..ed46d213 100644 --- a/packages/ui-inputs/src/composables/useListbox.ts +++ b/packages/ui-inputs/src/composables/useListbox.ts @@ -65,8 +65,17 @@ export interface UseListboxOptions { clearEntry?: () => boolean; /** Commit the clear entry; returns whether a commit happened (decides preventDefault). */ onClearCommit?: () => boolean; + /** + * The rendered options' display strings, index-aligned with the list — supplying it turns on + * typeahead. Select-only listboxes pass it; a combobox never does, because its typed + * characters belong to its text input. + */ + typeaheadLabels?: () => string[]; } +/** How long the typed string survives without a keystroke (the APG listbox examples' 500 ms). */ +const TYPEAHEAD_RESET_MS = 500; + /** * The behavioural core shared by every ui-inputs listbox control (SingleSelect, Combobox, and — * forthcoming — MultiSelect). Entirely position/index-based, so the option type `T` never crosses @@ -89,6 +98,7 @@ export const useListbox = (options: UseListboxOptions) => { floatingOptions = {}, clearEntry, onClearCommit, + typeaheadLabels, } = options; const open = ref(false); @@ -121,9 +131,53 @@ export const useListbox = (options: UseListboxOptions) => { // as a phantom highlight (and a phantom aria-activedescendant) on the next open. The // composable still never calls this on commit: closing after a commit stays the caller's // decision (`onCommit` — the MultiSelect toggle-and-stay-open contract). + let typed = ''; + let typedTimer: ReturnType | undefined; + const dropTyped = () => { + clearTimeout(typedTimer); + typed = ''; + }; + const close = () => { open.value = false; resetHighlight(); + dropTyped(); + }; + + // A key is typeahead when it prints one character with no command modifier. Space counts only + // inside a string already being typed — on its own it keeps its open/toggle meaning. + const isTypeahead = (event: KeyboardEvent): boolean => + event.key.length === 1 && + !event.ctrlKey && + !event.metaKey && + !event.altKey && + (event.key !== ' ' || typed !== ''); + + /** + * The WAI-ARIA listbox typeahead, with native searches from its value. MultiSelect has no single committed option and searches from the top (pinned). The last-toggled member is not visible on a closed control, and an invisible anchor is worse than none. Mutants: binding the model (the reviewed shape) and a frozen draft each red the re-render case; ignoring the committed index reds both Banana cases; starting AT the committed option reds them plus the cycling case. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/packages/ui-inputs.md | 2 +- packages/ui-inputs/CHANGELOG.md | 2 +- packages/ui-inputs/README.md | 8 ++- .../ui-inputs/src/components/GroupSelect.vue | 1 + .../ui-inputs/src/components/SingleSelect.vue | 1 + .../ui-inputs/src/components/TextInput.vue | 17 +++--- .../ui-inputs/src/components/Textarea.vue | 17 +++--- .../ui-inputs/src/composables/useListbox.ts | 12 +++- .../ui-inputs/src/internal/composition.ts | 23 +++++--- packages/ui-inputs/tests/composition.spec.ts | 21 +++++++ packages/ui-inputs/tests/typeahead.spec.ts | 58 +++++++++++++++++++ 11 files changed, 134 insertions(+), 28 deletions(-) diff --git a/docs/packages/ui-inputs.md b/docs/packages/ui-inputs.md index c3b49da1..d0a3d739 100644 --- a/docs/packages/ui-inputs.md +++ b/docs/packages/ui-inputs.md @@ -664,7 +664,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 Enter 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). - Home/End 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 `` 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 `