From 3c46669b258642e5bd9eec1b772ed6d19616549c Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Sun, 19 Jul 2026 01:16:59 +0200 Subject: [PATCH] feat: add the Combobox primitive Adds @dunky.dev/combobox (core state machine) and @dunky.dev/react-combobox (React binding). Anatomy: Combobox root with Input, Trigger, Listbox, Item, and ItemIndicator parts. An editable combobox with list autocomplete per the WAI-ARIA APG: focus stays in the input while aria-activedescendant tracks the highlighted item, the machine owns navigation/highlight/selection over consumer-filtered items, and the open listbox joins the shared overlay layer stack. Co-Authored-By: Claude Fable 5 --- .changeset/combobox.md | 46 ++ packages/core/combobox/README.md | 32 ++ packages/core/combobox/SPEC.md | 195 +++++++ packages/core/combobox/package.json | 40 ++ packages/core/combobox/src/connect.ts | 225 ++++++++ packages/core/combobox/src/index.ts | 18 + packages/core/combobox/src/machine.ts | 261 +++++++++ packages/core/combobox/src/types.ts | 107 ++++ packages/core/combobox/tests/machine.test.ts | 522 ++++++++++++++++++ packages/react/combobox/README.md | 45 ++ packages/react/combobox/SPEC.md | 141 +++++ packages/react/combobox/package.json | 53 ++ packages/react/combobox/src/combobox.tsx | 245 ++++++++ packages/react/combobox/src/context.ts | 43 ++ packages/react/combobox/src/effects.ts | 78 +++ packages/react/combobox/src/index.ts | 15 + packages/react/combobox/src/use-combobox.ts | 19 + .../combobox/src/utils/document-index.ts | 19 + .../combobox/stories/combobox.stories.tsx | 160 ++++++ .../react/combobox/tests/combobox.test.tsx | 459 +++++++++++++++ pnpm-lock.yaml | 43 ++ tsconfig.json | 2 + tsdown.config.ts | 2 + 23 files changed, 2770 insertions(+) create mode 100644 .changeset/combobox.md create mode 100644 packages/core/combobox/README.md create mode 100644 packages/core/combobox/SPEC.md create mode 100644 packages/core/combobox/package.json create mode 100644 packages/core/combobox/src/connect.ts create mode 100644 packages/core/combobox/src/index.ts create mode 100644 packages/core/combobox/src/machine.ts create mode 100644 packages/core/combobox/src/types.ts create mode 100644 packages/core/combobox/tests/machine.test.ts create mode 100644 packages/react/combobox/README.md create mode 100644 packages/react/combobox/SPEC.md create mode 100644 packages/react/combobox/package.json create mode 100644 packages/react/combobox/src/combobox.tsx create mode 100644 packages/react/combobox/src/context.ts create mode 100644 packages/react/combobox/src/effects.ts create mode 100644 packages/react/combobox/src/index.ts create mode 100644 packages/react/combobox/src/use-combobox.ts create mode 100644 packages/react/combobox/src/utils/document-index.ts create mode 100644 packages/react/combobox/stories/combobox.stories.tsx create mode 100644 packages/react/combobox/tests/combobox.test.tsx diff --git a/.changeset/combobox.md b/.changeset/combobox.md new file mode 100644 index 0000000..67b01c8 --- /dev/null +++ b/.changeset/combobox.md @@ -0,0 +1,46 @@ +--- +'@dunky.dev/combobox': minor +'@dunky.dev/react-combobox': minor +--- + +Add the Combobox primitive — an editable combobox with list autocomplete +following the WAI-ARIA APG pattern, shipped as an agnostic core +(`@dunky.dev/combobox`) plus a React binding (`@dunky.dev/react-combobox`). + +DOM focus stays in the input the whole time (`aria-activedescendant` tracks +the highlighted suggestion), items register themselves from the render tree, +and the machine owns navigation, highlighting, and selection. Filtering is +the consumer's: render the Items that match the input text — the machine +navigates whatever is rendered, keeping arrow-key order aligned with the +rendered order even as filtering unmounts, remounts, or re-sorts items in +place. Value, input +text, and open state are each controlled or uncontrolled; every change is +reported through `onValueChange` / `onInputValueChange` / `onOpenChange` / +`onHighlightChange`. The open listbox joins the shared overlay layer stack, +so Escape and outside interactions dismiss one layer at a time across +primitives. + +```tsx +import { useState } from 'react' +import { Combobox } from '@dunky.dev/react-combobox' + +const fruits = ['Apple', 'Banana', 'Cherry'] + +function App() { + const [query, setQuery] = useState('') + const matches = fruits.filter(fruit => fruit.toLowerCase().includes(query.toLowerCase())) + return ( + + + + + {matches.map(fruit => ( + + {fruit} + + ))} + + + ) +} +``` diff --git a/packages/core/combobox/README.md b/packages/core/combobox/README.md new file mode 100644 index 0000000..57d95be --- /dev/null +++ b/packages/core/combobox/README.md @@ -0,0 +1,32 @@ +# @dunky.dev/combobox + +The framework-agnostic combobox interaction, modeled as a state machine on +`@dunky.dev/state-machine`. Pure logic — no substrate, no framework. Consumers +pair it with a substrate driver rather than driving the machine directly. + +The behavior contract — scenarios, guarantees, driver obligations — lives in +[SPEC.md](./SPEC.md). + +## Install + +```sh +npm install @dunky.dev/combobox +``` + +## Usage + +```ts +import { machine, connector } from '@dunky.dev/state-machine' +import { comboboxMachine, comboboxConnect } from '@dunky.dev/combobox' + +// `id` is substrate-minted (SSR-safe); the connect derives the per-part ids. +const service = machine(comboboxMachine({ ...options, id: 'my-combobox' })) +connector(service, comboboxConnect, options) // wires the consumer callbacks +service.start() + +// The substrate reports the rendered suggestions; the machine navigates them. +service.send({ type: 'item.register', item: { value: 'apple', label: 'Apple', disabled: false } }) +service.send({ type: 'input', value: 'ap' }) // typing opens the list +service.send({ type: 'highlight.next' }) +service.send({ type: 'select' }) // commits: value 'apple', input text 'Apple' +``` diff --git a/packages/core/combobox/SPEC.md b/packages/core/combobox/SPEC.md new file mode 100644 index 0000000..bdb6272 --- /dev/null +++ b/packages/core/combobox/SPEC.md @@ -0,0 +1,195 @@ +# SPEC / Combobox + +## Reference + +- **W3C pattern**: [APG Combobox](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/), + the editable variant with list autocomplete and manual selection, over the + normative + [WAI-ARIA 1.2 `combobox` / `listbox` / `option`](https://www.w3.org/TR/wai-aria-1.2/#combobox) + definitions. +- **State machine**: built on `@dunky.dev/state-machine`. +- **Prior art**: API shape modeled on the Ark and Base UI comboboxes (Radix + has none). +- **Layering**: dismissal routing follows the shared overlay layer stack — + see `@dunky.dev/dom-layer-stack`. + +## Overview + +A combobox is a text input paired with a listbox of suggestions: the user +types freely, the list narrows to what matches, and picking a suggestion +commits it as the value. It is the home for search-and-pick interactions — +choosing a country, assigning a user, jumping to a file — where the option +set is too large to scan and the user knows (part of) what they're looking +for. The input text and the committed value are two different things: text is +free, the value only ever comes from a suggestion. + +## Anatomy + +``` + — root; owns value + input text + open state, renders + | nothing of its own + |_ — the text field; DOM focus lives here the whole time + |_ — optional disclosure button; not in the tab order + |_ — the popup list of suggestions + |_ — one suggestion; registers itself with the machine + |_ — the "this one is chosen" mark, rendered only + inside the selected item +``` + +## Behavior + +Using the combobox is a walkthrough of intent, not a prop list: + +- The **root** owns three pieces of state, each exposed controlled and + uncontrolled: the value (which suggestion is chosen), the input text, and + the open state. Every change to any of them — whatever caused it — is + reported back through its callback so a controlled consumer stays in sync. +- **Typing** updates the input text, opens the list, and clears the highlight + (the suggestion set is about to change). Filtering is the consumer's + responsibility: they decide which Items to render from the input text — the + machine never filters, it navigates whatever is rendered. +- **Arrow keys** open the list when it is closed — ArrowDown starting the + highlight from the selected suggestion (when rendered and enabled) else the + first enabled one, ArrowUp from the selection else the last. While open they + move the highlight across enabled suggestions: disabled ones are skipped, + and the `loop` option decides whether the ends wrap around. Moving the + pointer over an enabled suggestion moves the highlight there too — keyboard + and pointer drive the same highlight. +- **Selecting** — Enter on the highlighted suggestion, or pressing one — + commits: the value becomes the item's value, the input text becomes its + label, and the list closes. Enter with nothing highlighted just closes. + Free-typed text never commits itself: the value only ever comes from an + item. +- The **trigger** is an optional disclosure button that toggles the list. It + is not a tab stop — the input is the one place keyboard focus lives. +- **Escape** closes without selecting; so does any interaction outside the + combobox — a press, or focus moving out (Tab included). The consumer can + veto a single occurrence of either from its handler. +- **Items** are not configuration: each suggestion registers itself with the + machine (value, label, disabled) when it appears, re-registers when its + label, disabled state, or rendered position changes, and unregisters when + it goes away — so the suggestion list always mirrors what is actually + rendered, keystroke by keystroke. A disabled item is skipped by navigation, + cannot be highlighted, and pressing it does nothing. +- A **disabled** combobox never opens, by any input. + +The combobox participates in the shared overlay layer stack +(`@dunky.dev/dom-layer-stack`) while open: a combobox opened from within +another overlay — a dialog, a popover — stacks on top of it, Escape unwinds +one layer per press, and an interaction inside a nested layer never counts as +outside the one beneath. Being registered is also what keeps the open listbox +reachable when it floats above a modal layer. + +## States + +| State | Behavior | +| -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `closed` | Only the input (and trigger) are interactive. Open intents — typing, arrow keys, trigger press, imperative open — move to `open` unless the combobox is disabled. | +| `open` | The listbox shows and a highlight tracks the active suggestion. Navigation moves the highlight; selection commits and closes; dismissal closes without selecting. | + +### Highlight + +The highlight exists only while open, and unlike a select it is optional — +the resting state while the user types is "nothing highlighted". It is seeded +by the arrow key that opens the list, moved by navigation and pointer, cleared +by typing, and cleared on every close. The highlighted suggestion +unregistering (filtered out from under the highlight) clears it, as does +re-registering as disabled. + +### Input text + +The input text is free-form and belongs to the user until a selection +commits: typing and controlled updates are the only things that change it +besides selection (which replaces it with the chosen item's label). Closing +without selecting — Escape, outside interaction — leaves the text exactly as +typed; syncing a controlled value from outside doesn't touch it either. + +## Accessibility + +Per the APG editable combobox with list autocomplete: + +- **Roles**: the input is a `combobox` with `aria-autocomplete="list"`, + `aria-expanded`, and `aria-controls` naming the listbox. The popup is a + `listbox`; every item is an `option` carrying `aria-selected`, and + `aria-disabled` when disabled. The trigger carries + `aria-haspopup="listbox"` and mirrors `aria-expanded`. +- **Focus**: DOM focus stays in the input the whole time — the listbox is + never focused. While open, `aria-activedescendant` on the input names the + highlighted option's id, and is absent while nothing is highlighted. No + focus moves on open/close means closing never needs a focus restore. +- **Keyboard** (on the input, since focus never leaves it): + + | Key | Closed | Open | + | ------------------- | -------------------------------------------------- | -------------------------------------------- | + | Printable / editing | edits the text, opens the list | edits the text, clears the highlight | + | ArrowDown | opens, highlights the selection else first enabled | moves the highlight down (wraps when `loop`) | + | ArrowUp | opens, highlights the selection else last enabled | moves the highlight up (wraps when `loop`) | + | Enter | — (native: submits the form) | selects the highlighted suggestion, closes | + | Escape | — | closes without selecting | + | Home / End | native caret movement | native caret movement — never the highlight | + | Tab | moves focus on | focus out is an outside interaction — closes | + +- **Disabled**: a disabled combobox exposes `aria-disabled`; whether the + input also refuses text entry is the substrate's native concern. +- **Hidden while closed**: the listbox stays rendered (suggestions keep their + registration and ids stable) but is removed from the accessibility tree. + +## Constraints + +- The value only ever comes from a registered, enabled item — free-typed + input text never becomes the value. +- A disabled item can never be selected or highlighted, by any input. +- `aria-activedescendant` only ever references an option that is rendered and + highlighted; it is absent while closed or while nothing is highlighted. +- Item values must be unique within one combobox and contain no whitespace — + option ids are derived from them. +- Navigation order always matches the rendered order of the suggestions, even + as the consumer's filtering unmounts and remounts items between keystrokes — + or a keyed re-sort moves them in place without unmounting anything. +- Every value, input-text, open, and highlight change is reported: whatever + its cause, the matching callback fires — value, then input text, then + highlight, then open within one interaction. +- The machine never filters: which suggestions exist is decided entirely by + what the consumer renders. +- Out of scope for v0, on purpose: multiple selection; committing free-typed + values (the input text is free, the value is not); inline autocomplete + (`aria-autocomplete` is always `list`); virtualization; a positioning + engine or portal part (the consumer positions the popup — `data-state` is + the styling hook); scrolling the highlighted option into view; a Label part + (label the input via `aria-label`/`aria-labelledby` or a native `