@chromvoid/headless-ui 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +99 -0
- package/dist/a11y-contracts/index.d.ts +23 -0
- package/dist/a11y-contracts/index.js +1 -0
- package/dist/accordion/index.d.ts +78 -0
- package/dist/accordion/index.js +264 -0
- package/dist/adapters/index.d.ts +9 -0
- package/dist/adapters/index.js +1 -0
- package/dist/alert/index.d.ts +33 -0
- package/dist/alert/index.js +54 -0
- package/dist/alert-dialog/index.d.ts +69 -0
- package/dist/alert-dialog/index.js +94 -0
- package/dist/badge/index.d.ts +48 -0
- package/dist/badge/index.js +89 -0
- package/dist/breadcrumb/index.d.ts +55 -0
- package/dist/breadcrumb/index.js +77 -0
- package/dist/button/index.d.ts +46 -0
- package/dist/button/index.js +86 -0
- package/dist/callout/index.d.ts +41 -0
- package/dist/callout/index.js +63 -0
- package/dist/card/index.d.ts +54 -0
- package/dist/card/index.js +103 -0
- package/dist/carousel/index.d.ts +98 -0
- package/dist/carousel/index.js +243 -0
- package/dist/checkbox/index.d.ts +50 -0
- package/dist/checkbox/index.js +87 -0
- package/dist/combobox/index.d.ts +114 -0
- package/dist/combobox/index.js +431 -0
- package/dist/command-palette/index.d.ts +73 -0
- package/dist/command-palette/index.js +147 -0
- package/dist/context-menu/index.d.ts +111 -0
- package/dist/context-menu/index.js +372 -0
- package/dist/copy-button/index.d.ts +62 -0
- package/dist/copy-button/index.js +183 -0
- package/dist/core/index.d.ts +20 -0
- package/dist/core/index.js +2 -0
- package/dist/core/selection.d.ts +5 -0
- package/dist/core/selection.js +39 -0
- package/dist/core/value-range.d.ts +49 -0
- package/dist/core/value-range.js +134 -0
- package/dist/date-picker/index.d.ts +210 -0
- package/dist/date-picker/index.js +895 -0
- package/dist/dialog/index.d.ts +95 -0
- package/dist/dialog/index.js +153 -0
- package/dist/disclosure/index.d.ts +52 -0
- package/dist/disclosure/index.js +159 -0
- package/dist/drawer/index.d.ts +30 -0
- package/dist/drawer/index.js +39 -0
- package/dist/feed/index.d.ts +77 -0
- package/dist/feed/index.js +260 -0
- package/dist/grid/index.d.ts +103 -0
- package/dist/grid/index.js +415 -0
- package/dist/index.d.ts +51 -0
- package/dist/index.js +51 -0
- package/dist/input/index.d.ts +86 -0
- package/dist/input/index.js +156 -0
- package/dist/interactions/composite-navigation.d.ts +69 -0
- package/dist/interactions/composite-navigation.js +169 -0
- package/dist/interactions/index.d.ts +15 -0
- package/dist/interactions/index.js +4 -0
- package/dist/interactions/keyboard-intents.d.ts +16 -0
- package/dist/interactions/keyboard-intents.js +33 -0
- package/dist/interactions/overlay-focus.d.ts +40 -0
- package/dist/interactions/overlay-focus.js +93 -0
- package/dist/interactions/typeahead.d.ts +20 -0
- package/dist/interactions/typeahead.js +41 -0
- package/dist/landmarks/index.d.ts +39 -0
- package/dist/landmarks/index.js +58 -0
- package/dist/link/index.d.ts +34 -0
- package/dist/link/index.js +39 -0
- package/dist/listbox/index.d.ts +92 -0
- package/dist/listbox/index.js +337 -0
- package/dist/menu/index.d.ts +132 -0
- package/dist/menu/index.js +541 -0
- package/dist/menu-button/index.d.ts +71 -0
- package/dist/menu-button/index.js +121 -0
- package/dist/meter/index.d.ts +45 -0
- package/dist/meter/index.js +106 -0
- package/dist/number/index.d.ts +113 -0
- package/dist/number/index.js +252 -0
- package/dist/popover/index.d.ts +70 -0
- package/dist/popover/index.js +126 -0
- package/dist/progress/index.d.ts +49 -0
- package/dist/progress/index.js +79 -0
- package/dist/radio-group/index.d.ts +61 -0
- package/dist/radio-group/index.js +150 -0
- package/dist/select/index.d.ts +92 -0
- package/dist/select/index.js +239 -0
- package/dist/sidebar/index.d.ts +74 -0
- package/dist/sidebar/index.js +186 -0
- package/dist/slider/index.d.ts +61 -0
- package/dist/slider/index.js +150 -0
- package/dist/slider-multi-thumb/index.d.ts +70 -0
- package/dist/slider-multi-thumb/index.js +222 -0
- package/dist/spinbutton/index.d.ts +75 -0
- package/dist/spinbutton/index.js +214 -0
- package/dist/spinner/index.d.ts +1 -0
- package/dist/spinner/index.js +1 -0
- package/dist/spinner/spinner.d.ts +23 -0
- package/dist/spinner/spinner.js +25 -0
- package/dist/switch/index.d.ts +40 -0
- package/dist/switch/index.js +61 -0
- package/dist/table/index.d.ts +117 -0
- package/dist/table/index.js +377 -0
- package/dist/tabs/index.d.ts +63 -0
- package/dist/tabs/index.js +174 -0
- package/dist/textarea/index.d.ts +68 -0
- package/dist/textarea/index.js +137 -0
- package/dist/toast/index.d.ts +67 -0
- package/dist/toast/index.js +145 -0
- package/dist/toolbar/index.d.ts +59 -0
- package/dist/toolbar/index.js +139 -0
- package/dist/tooltip/index.d.ts +52 -0
- package/dist/tooltip/index.js +169 -0
- package/dist/treegrid/index.d.ts +101 -0
- package/dist/treegrid/index.js +463 -0
- package/dist/treeview/index.d.ts +68 -0
- package/dist/treeview/index.js +370 -0
- package/dist/window-splitter/index.d.ts +65 -0
- package/dist/window-splitter/index.js +204 -0
- package/package.json +92 -0
- package/specs/ADR-001-headless-architecture.md +461 -0
- package/specs/ADR-002-repo-release-model.md +108 -0
- package/specs/ADR-003-public-api-versioning.md +136 -0
- package/specs/ADR-004-focus-selection-policy.md +117 -0
- package/specs/IMPLEMENTATION-ROADMAP.md +237 -0
- package/specs/ISSUE-BACKLOG.md +681 -0
- package/specs/RELEASE-CANDIDATE.md +30 -0
- package/specs/components/accordion.md +130 -0
- package/specs/components/alert-dialog.md +72 -0
- package/specs/components/alert.md +65 -0
- package/specs/components/badge.md +220 -0
- package/specs/components/breadcrumb.md +74 -0
- package/specs/components/button.md +115 -0
- package/specs/components/callout.md +195 -0
- package/specs/components/card.md +280 -0
- package/specs/components/carousel.md +140 -0
- package/specs/components/checkbox.md +172 -0
- package/specs/components/combobox.md +423 -0
- package/specs/components/command-palette.md +92 -0
- package/specs/components/context-menu.md +556 -0
- package/specs/components/copy-button.md +293 -0
- package/specs/components/date-picker.md +400 -0
- package/specs/components/dialog.md +298 -0
- package/specs/components/disclosure.md +257 -0
- package/specs/components/drawer.md +353 -0
- package/specs/components/feed.md +265 -0
- package/specs/components/grid.md +186 -0
- package/specs/components/input.md +254 -0
- package/specs/components/landmarks.md +136 -0
- package/specs/components/link.md +134 -0
- package/specs/components/listbox.md +351 -0
- package/specs/components/menu-button.md +76 -0
- package/specs/components/menu.md +623 -0
- package/specs/components/meter.md +149 -0
- package/specs/components/number.md +393 -0
- package/specs/components/popover.md +252 -0
- package/specs/components/progress.md +188 -0
- package/specs/components/radio-group.md +151 -0
- package/specs/components/select.md +144 -0
- package/specs/components/sidebar.md +321 -0
- package/specs/components/slider-multi-thumb.md +78 -0
- package/specs/components/slider.md +84 -0
- package/specs/components/spinbutton.md +140 -0
- package/specs/components/spinner.md +132 -0
- package/specs/components/switch.md +175 -0
- package/specs/components/table.md +403 -0
- package/specs/components/tabs.md +265 -0
- package/specs/components/textarea.md +185 -0
- package/specs/components/toast.md +198 -0
- package/specs/components/toolbar.md +278 -0
- package/specs/components/tooltip.md +252 -0
- package/specs/components/treegrid.md +281 -0
- package/specs/components/treeview.md +91 -0
- package/specs/components/window-splitter.md +297 -0
- package/specs/ops/git-shard-sync.md +107 -0
- package/specs/ops/release-checklist.md +76 -0
- package/specs/release/GAP-TO-GREEN-ISSUES.md +88 -0
- package/specs/release/api-freeze-candidate.md +54 -0
- package/specs/release/changelog-automation.md +76 -0
- package/specs/release/changelog.generated.md +53 -0
- package/specs/release/changelog.patch.generated.md +46 -0
- package/specs/release/consumer-integration.md +53 -0
- package/specs/release/migration-notes-pre-v1.md +40 -0
- package/specs/release/mvp-changelog.md +57 -0
- package/specs/release/release-notes-template.md +61 -0
- package/specs/release/release-rehearsal.md +113 -0
- package/specs/release/semver-deprecation-dry-run.md +89 -0
- package/specs/release/shard-release-drill-report.md +50 -0
- package/specs/release/shard-release-follow-ups.md +31 -0
- package/specs/signals.md +208 -0
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
# Combobox Component Contract
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
`Combobox` provides a headless APG-aligned input + popup listbox model.
|
|
6
|
+
|
|
7
|
+
The component manages open/close, active option tracking, filtering,
|
|
8
|
+
and deterministic commit behavior with no visual layer.
|
|
9
|
+
|
|
10
|
+
Supports editable (autocomplete) and select-only modes, single and multi-select,
|
|
11
|
+
clearable behavior, and grouped options.
|
|
12
|
+
|
|
13
|
+
## Component Files
|
|
14
|
+
|
|
15
|
+
- `src/combobox/index.ts` - model and public `createCombobox` API
|
|
16
|
+
- `src/combobox/combobox.test.ts` - unit behavior tests
|
|
17
|
+
|
|
18
|
+
## Public API
|
|
19
|
+
|
|
20
|
+
- `createCombobox(options)`
|
|
21
|
+
- options:
|
|
22
|
+
- `options: readonly (ComboboxOption | ComboboxOptionGroup)[]` — flat options or grouped options (mixed allowed)
|
|
23
|
+
- `type?: "editable" | "select-only"` (default `"editable"`)
|
|
24
|
+
- `multiple?: boolean` (default `false`)
|
|
25
|
+
- `clearable?: boolean` (default `false`)
|
|
26
|
+
- `closeOnSelect?: boolean` (default `true` in single mode, `false` in multi mode)
|
|
27
|
+
- `matchMode?: "includes" | "startsWith"` (default `"includes"`)
|
|
28
|
+
- `filter?: (option: ComboboxOption, inputValue: string) => boolean`
|
|
29
|
+
- `typeahead?: boolean | { enabled?: boolean; timeoutMs?: number }`
|
|
30
|
+
- `idBase?: string`
|
|
31
|
+
- `ariaLabel?: string`
|
|
32
|
+
- `initialInputValue?: string`
|
|
33
|
+
- `initialSelectedId?: string | null`
|
|
34
|
+
- `initialSelectedIds?: string[]`
|
|
35
|
+
- `initialOpen?: boolean`
|
|
36
|
+
|
|
37
|
+
### Option Types
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
interface ComboboxOption {
|
|
41
|
+
id: string
|
|
42
|
+
label: string
|
|
43
|
+
disabled?: boolean
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface ComboboxOptionGroup {
|
|
47
|
+
id: string
|
|
48
|
+
label: string
|
|
49
|
+
options: ComboboxOption[]
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Groups and flat options can be mixed in the `options` array. A group is distinguished by the presence of a nested `options` array.
|
|
54
|
+
|
|
55
|
+
## State Signals
|
|
56
|
+
|
|
57
|
+
| Signal | Type | Description |
|
|
58
|
+
| ---------------- | ----------------------------- | ------------------------------------------------------------------------------------- |
|
|
59
|
+
| `inputValue()` | `string` | Current input field text |
|
|
60
|
+
| `isOpen()` | `boolean` | Popup visibility |
|
|
61
|
+
| `activeId()` | `string \| null` | Currently highlighted option id |
|
|
62
|
+
| `selectedId()` | `string \| null` | First (or only) selected option id. In multi mode, equals `selectedIds[0]` or `null`. |
|
|
63
|
+
| `selectedIds()` | `readonly string[]` | All selected option ids. In single mode, array of zero or one. |
|
|
64
|
+
| `hasSelection()` | `boolean` (computed) | `selectedIds.length > 0` |
|
|
65
|
+
| `type()` | `"editable" \| "select-only"` | Current combobox type (from config, not mutable at runtime) |
|
|
66
|
+
| `multiple()` | `boolean` | Whether multi-select is enabled (from config) |
|
|
67
|
+
|
|
68
|
+
### Backward Compatibility
|
|
69
|
+
|
|
70
|
+
`selectedId` remains the primary single-selection signal. In single mode it behaves identically to the pre-update API. In multi mode it is a computed alias for `selectedIds[0] ?? null`.
|
|
71
|
+
|
|
72
|
+
## Actions
|
|
73
|
+
|
|
74
|
+
| Action | Signature | Description |
|
|
75
|
+
| ---------------- | ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
76
|
+
| `open` | `() => void` | Opens popup, resets typeahead, ensures active option is valid |
|
|
77
|
+
| `close` | `() => void` | Closes popup, resets typeahead |
|
|
78
|
+
| `setInputValue` | `(value: string) => void` | Updates input text, opens popup, revalidates active option. **No-op in select-only mode.** |
|
|
79
|
+
| `setActive` | `(id: string \| null) => void` | Sets active option if visible and enabled |
|
|
80
|
+
| `moveNext` | `() => void` | Opens popup, moves active to next enabled visible option (wrapping) |
|
|
81
|
+
| `movePrev` | `() => void` | Opens popup, moves active to previous enabled visible option (wrapping) |
|
|
82
|
+
| `moveFirst` | `() => void` | Opens popup, sets active to first enabled visible option |
|
|
83
|
+
| `moveLast` | `() => void` | Opens popup, sets active to last enabled visible option |
|
|
84
|
+
| `commitActive` | `() => void` | Commits currently active option. In single mode: sets `selectedId`/`inputValue`, closes if `closeOnSelect`. In multi mode: calls `toggleOption(activeId)`. |
|
|
85
|
+
| `select` | `(id: string) => void` | Commits specific option by id. In single mode: replaces selection. In multi mode: calls `toggleOption(id)`. |
|
|
86
|
+
| `toggleOption` | `(id: string) => void` | **Multi-mode only.** Adds id to `selectedIds` if not present, removes if present. No-op in single mode. |
|
|
87
|
+
| `removeSelected` | `(id: string) => void` | Removes specific id from `selectedIds`. Works in both modes. |
|
|
88
|
+
| `clearSelection` | `() => void` | Resets `selectedIds` to empty, `selectedId` to `null`. Does not clear `inputValue`. |
|
|
89
|
+
| `clear` | `() => void` | Clears both selection and input value. Resets `selectedIds` to empty, `selectedId` to `null`, `inputValue` to `""`. |
|
|
90
|
+
| `handleKeyDown` | `(event: KeyboardEventLike) => void` | Delegates to navigation/commit/dismiss transitions based on key |
|
|
91
|
+
|
|
92
|
+
## Contracts
|
|
93
|
+
|
|
94
|
+
| Contract | Return Type | Description |
|
|
95
|
+
| ----------------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
|
|
96
|
+
| `getInputProps()` | `ComboboxInputProps` | ARIA attributes for the trigger/input element |
|
|
97
|
+
| `getListboxProps()` | `ComboboxListboxProps` | ARIA attributes for the popup listbox |
|
|
98
|
+
| `getOptionProps(id)` | `ComboboxOptionProps` | ARIA attributes for an individual option |
|
|
99
|
+
| `getGroupProps(groupId)` | `ComboboxGroupProps` | ARIA attributes for an option group container |
|
|
100
|
+
| `getGroupLabelProps(groupId)` | `ComboboxGroupLabelProps` | Attributes for the group label element |
|
|
101
|
+
| `getVisibleOptions()` | `readonly (ComboboxOption \| ComboboxVisibleGroup)[]` | Filtered visible options, preserving group structure when groups exist. Empty groups are omitted. |
|
|
102
|
+
| `getFlatVisibleOptions()` | `readonly ComboboxOption[]` | Flat list of all visible options (groups flattened), for navigation purposes |
|
|
103
|
+
|
|
104
|
+
### Contract Return Types
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
interface ComboboxInputProps {
|
|
108
|
+
id: string
|
|
109
|
+
role: 'combobox'
|
|
110
|
+
tabindex: '0'
|
|
111
|
+
'aria-haspopup': 'listbox'
|
|
112
|
+
'aria-expanded': 'true' | 'false'
|
|
113
|
+
'aria-controls': string
|
|
114
|
+
'aria-autocomplete'?: 'list' // present in editable mode, omitted in select-only
|
|
115
|
+
'aria-activedescendant'?: string // present when open and active option exists
|
|
116
|
+
'aria-label'?: string
|
|
117
|
+
'aria-multiselectable'?: undefined // never on combobox; lives on listbox
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
interface ComboboxListboxProps {
|
|
121
|
+
id: string
|
|
122
|
+
role: 'listbox'
|
|
123
|
+
tabindex: '-1'
|
|
124
|
+
'aria-label'?: string
|
|
125
|
+
'aria-multiselectable'?: 'true' // present only when multiple=true
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
interface ComboboxOptionProps {
|
|
129
|
+
id: string
|
|
130
|
+
role: 'option'
|
|
131
|
+
tabindex: '-1'
|
|
132
|
+
'aria-selected': 'true' | 'false' // true for ALL selected options in multi mode
|
|
133
|
+
'aria-disabled'?: 'true'
|
|
134
|
+
'data-active': 'true' | 'false'
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
interface ComboboxGroupProps {
|
|
138
|
+
id: string
|
|
139
|
+
role: 'group'
|
|
140
|
+
'aria-labelledby': string // references the group label element id
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
interface ComboboxGroupLabelProps {
|
|
144
|
+
id: string
|
|
145
|
+
role: 'presentation'
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
interface ComboboxVisibleGroup {
|
|
149
|
+
id: string
|
|
150
|
+
label: string
|
|
151
|
+
options: readonly ComboboxOption[] // filtered, non-empty
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## APG and A11y Contract
|
|
156
|
+
|
|
157
|
+
- input role: `combobox`
|
|
158
|
+
- popup role: `listbox`
|
|
159
|
+
- option role: `option`
|
|
160
|
+
- group role: `group` with `aria-labelledby` referencing group label element
|
|
161
|
+
- `aria-activedescendant` is the default active-option focus strategy
|
|
162
|
+
- input exposes:
|
|
163
|
+
- `aria-expanded`
|
|
164
|
+
- `aria-controls`
|
|
165
|
+
- `aria-autocomplete="list"` (editable mode only; omitted in select-only mode)
|
|
166
|
+
- `aria-activedescendant` when active option exists and popup is open
|
|
167
|
+
- listbox exposes:
|
|
168
|
+
- `aria-multiselectable="true"` when `multiple=true`
|
|
169
|
+
- option exposes:
|
|
170
|
+
- `aria-selected="true"` for every selected option (not just one in multi mode)
|
|
171
|
+
|
|
172
|
+
## Behavior Contract
|
|
173
|
+
|
|
174
|
+
### Editable Mode (default)
|
|
175
|
+
|
|
176
|
+
- `setInputValue` updates value, opens popup, and revalidates active option
|
|
177
|
+
- Arrow navigation moves active option across enabled visible options
|
|
178
|
+
- Enter commits active option (`selectedId`, `inputValue`, popup close only when `closeOnSelect=true`)
|
|
179
|
+
- Escape closes popup without clearing selected value
|
|
180
|
+
- disabled options are skipped during navigation and cannot be committed
|
|
181
|
+
|
|
182
|
+
### Select-Only Mode
|
|
183
|
+
|
|
184
|
+
- `setInputValue` is a no-op; the input value is not user-editable
|
|
185
|
+
- The trigger displays the selected option's label (or placeholder)
|
|
186
|
+
- `inputValue` is kept in sync with the selected option's label automatically
|
|
187
|
+
- Type-to-select via printable characters: printable key presses use typeahead to jump to the matching option by label prefix (same typeahead mechanism as editable mode)
|
|
188
|
+
- Keyboard when closed:
|
|
189
|
+
- `Space` / `Enter`: opens popup
|
|
190
|
+
- `ArrowDown` / `ArrowUp`: opens popup and activates first/last enabled option
|
|
191
|
+
- Keyboard when open:
|
|
192
|
+
- `ArrowDown` / `ArrowUp`: navigate active option
|
|
193
|
+
- `Enter`: commits active option and closes
|
|
194
|
+
- `Space`: commits active option and closes
|
|
195
|
+
- `Escape`: closes without changing selection
|
|
196
|
+
- `Home` / `End`: navigate to first/last enabled option
|
|
197
|
+
- Filtering is not applicable in select-only mode; all non-disabled options are always visible
|
|
198
|
+
|
|
199
|
+
### Multi-Select Mode
|
|
200
|
+
|
|
201
|
+
- `commitActive` calls `toggleOption(activeId)` instead of replacing selection
|
|
202
|
+
- `select(id)` calls `toggleOption(id)` instead of replacing selection
|
|
203
|
+
- `toggleOption(id)` adds the id to `selectedIds` if absent, removes if present
|
|
204
|
+
- `closeOnSelect` defaults to `false` (popup stays open after each selection)
|
|
205
|
+
- `inputValue` is NOT automatically set to a label on commit (since multiple items are selected)
|
|
206
|
+
- In editable multi mode, `inputValue` drives filtering but is not overwritten on commit
|
|
207
|
+
- In select-only multi mode, `inputValue` is always `""` (the trigger shows tags/chips instead)
|
|
208
|
+
- `getOptionProps(id)` returns `aria-selected: "true"` for every option in `selectedIds`
|
|
209
|
+
- `getListboxProps()` returns `aria-multiselectable: "true"`
|
|
210
|
+
|
|
211
|
+
### Clearable
|
|
212
|
+
|
|
213
|
+
- `clear()` resets both selection state and input value
|
|
214
|
+
- `clearSelection()` resets only selection state (preserves `inputValue`)
|
|
215
|
+
- Adapter uses `hasSelection` to determine clear button visibility
|
|
216
|
+
|
|
217
|
+
### Option Groups
|
|
218
|
+
|
|
219
|
+
- Config `options` accepts a mix of `ComboboxOption` and `ComboboxOptionGroup`
|
|
220
|
+
- Flat options provided at top level are treated as ungrouped
|
|
221
|
+
- `getVisibleOptions()` returns grouped structure: `ComboboxVisibleGroup` entries preserve nesting, with empty groups filtered out
|
|
222
|
+
- `getFlatVisibleOptions()` returns all visible options in document order, groups flattened — used for keyboard navigation
|
|
223
|
+
- Filtering operates within groups; if all options in a group are filtered out, the group is omitted from `getVisibleOptions()`
|
|
224
|
+
- `getGroupProps(groupId)` returns `{ id, role: "group", "aria-labelledby": "<label-id>" }`
|
|
225
|
+
- `getGroupLabelProps(groupId)` returns `{ id: "<label-id>", role: "presentation" }`
|
|
226
|
+
- Navigation (moveNext/movePrev/moveFirst/moveLast) operates on the flat enabled visible list; group boundaries do not affect navigation order
|
|
227
|
+
|
|
228
|
+
## Transition Model
|
|
229
|
+
|
|
230
|
+
State is signal-backed, transitions are explicit and testable, and invariants are machine-checkable.
|
|
231
|
+
|
|
232
|
+
### Core Transitions (all modes)
|
|
233
|
+
|
|
234
|
+
| Event / action | Preconditions | Next state |
|
|
235
|
+
| -------------------------------------------------------- | -------------------------------- | ---------------------------------------------------------------------------------------- |
|
|
236
|
+
| `open()` | none | `isOpen=true`; `activeId` is first enabled visible option or `null` |
|
|
237
|
+
| `close()` | none | `isOpen=false`; `selectedId` and `inputValue` unchanged |
|
|
238
|
+
| `moveNext()` / `movePrev()` | none | `isOpen=true`; `activeId` cycles within enabled visible options, or `null` when none |
|
|
239
|
+
| `moveFirst()` / `moveLast()` | none | `isOpen=true`; `activeId` becomes first/last enabled visible option, or `null` when none |
|
|
240
|
+
| `setActive(id)` | `id` must be visible and enabled | `activeId=id`; invalid/disabled/non-visible ids are ignored |
|
|
241
|
+
| `clearSelection()` | none | `selectedIds=[]`; `selectedId=null`; `inputValue` unchanged |
|
|
242
|
+
| `clear()` | none | `selectedIds=[]`; `selectedId=null`; `inputValue=""`; `isOpen` unchanged |
|
|
243
|
+
| `handleKeyDown(ArrowUp/ArrowDown/Home/End/Enter/Escape)` | keyboard intent recognized | delegates to explicit transitions above |
|
|
244
|
+
|
|
245
|
+
### Editable Mode Transitions
|
|
246
|
+
|
|
247
|
+
| Event / action | Preconditions | Next state |
|
|
248
|
+
| ------------------------- | ------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
|
|
249
|
+
| `setInputValue(value)` | `type="editable"` | `inputValue=value`; `isOpen=true`; `activeId` revalidated against enabled visible options |
|
|
250
|
+
| `commitActive()` (single) | `activeId!=null`, enabled, `multiple=false` | `selectedId=activeId`; `selectedIds=[activeId]`; `inputValue=selected label`; closes when `closeOnSelect=true` |
|
|
251
|
+
| `commitActive()` (multi) | `activeId!=null`, enabled, `multiple=true` | `toggleOption(activeId)`; `inputValue` unchanged; closes when `closeOnSelect=true` |
|
|
252
|
+
| `select(id)` (single) | `id` exists, enabled, `multiple=false` | same as single `commitActive` for that id |
|
|
253
|
+
| `select(id)` (multi) | `id` exists, enabled, `multiple=true` | `toggleOption(id)`; `inputValue` unchanged; closes when `closeOnSelect=true` |
|
|
254
|
+
|
|
255
|
+
### Select-Only Mode Transitions
|
|
256
|
+
|
|
257
|
+
| Event / action | Preconditions | Next state |
|
|
258
|
+
| ------------------------------- | ------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
|
|
259
|
+
| `setInputValue(value)` | `type="select-only"` | **no-op** |
|
|
260
|
+
| `commitActive()` (single) | `activeId!=null`, enabled, `multiple=false` | `selectedId=activeId`; `selectedIds=[activeId]`; `inputValue=selected label`; closes when `closeOnSelect=true` |
|
|
261
|
+
| `commitActive()` (multi) | `activeId!=null`, enabled, `multiple=true` | `toggleOption(activeId)`; `inputValue=""`; closes when `closeOnSelect=true` |
|
|
262
|
+
| `handleKeyDown(Space)` (closed) | `type="select-only"`, `isOpen=false` | `open()` |
|
|
263
|
+
| `handleKeyDown(Space)` (open) | `type="select-only"`, `isOpen=true` | `commitActive()` |
|
|
264
|
+
| `handleKeyDown(printable char)` | `type="select-only"` | typeahead navigation (jump to matching option by label prefix) |
|
|
265
|
+
|
|
266
|
+
### Multi-Select Transitions
|
|
267
|
+
|
|
268
|
+
| Event / action | Preconditions | Next state |
|
|
269
|
+
| -------------------- | ------------------------------------- | -------------------------------------------------------------------------- |
|
|
270
|
+
| `toggleOption(id)` | `id` exists, enabled, `multiple=true` | if `id` in `selectedIds`: remove it; else: add it. `activeId` set to `id`. |
|
|
271
|
+
| `toggleOption(id)` | `multiple=false` | **no-op** |
|
|
272
|
+
| `removeSelected(id)` | `id` in `selectedIds` | removes `id` from `selectedIds`; `selectedId` recomputed |
|
|
273
|
+
| `removeSelected(id)` | `id` not in `selectedIds` | **no-op** |
|
|
274
|
+
|
|
275
|
+
## Typeahead Contract
|
|
276
|
+
|
|
277
|
+
- optional typeahead support for active-option navigation
|
|
278
|
+
- configuration via `options.typeahead`:
|
|
279
|
+
- `boolean`
|
|
280
|
+
- `{ enabled?: boolean; timeoutMs?: number }`
|
|
281
|
+
- repeated same-character key cycling is supported
|
|
282
|
+
- buffered matching uses timeout-based reset
|
|
283
|
+
- disabled visible options are skipped during typeahead matching
|
|
284
|
+
- In select-only mode, typeahead is always active for printable characters (regardless of `typeahead` config), matching the APG select-only combobox pattern
|
|
285
|
+
- In editable mode, typeahead is used only when the popup is open and the key is not consumed by the input field
|
|
286
|
+
|
|
287
|
+
## Filtering Contract
|
|
288
|
+
|
|
289
|
+
- default filter:
|
|
290
|
+
- case-insensitive `includes` when `matchMode="includes"`
|
|
291
|
+
- case-insensitive `startsWith` when `matchMode="startsWith"`
|
|
292
|
+
- custom filter hook can be provided via `options.filter`
|
|
293
|
+
- active option must always be valid for current filtered visible options or `null`
|
|
294
|
+
- **Filtering is disabled in select-only mode**: all options are always visible regardless of `inputValue`
|
|
295
|
+
- When groups are used, filtering operates per-option within each group; empty groups are excluded from visible results
|
|
296
|
+
|
|
297
|
+
## Invariants
|
|
298
|
+
|
|
299
|
+
- `selectedId` is either a valid option id or `null`
|
|
300
|
+
- `selectedId` equals `selectedIds[0]` when `selectedIds` is non-empty, `null` otherwise
|
|
301
|
+
- In single mode (`multiple=false`), `selectedIds` has at most one element
|
|
302
|
+
- `activeId` is either a valid enabled visible option id or `null`
|
|
303
|
+
- commit behavior is deterministic for the same state and active option
|
|
304
|
+
- `getOptionProps(id)` returns `aria-selected: "true"` for every id in `selectedIds`
|
|
305
|
+
- `getListboxProps()` returns `aria-multiselectable: "true"` if and only if `multiple=true`
|
|
306
|
+
- `getInputProps()` includes `aria-autocomplete: "list"` if and only if `type="editable"`
|
|
307
|
+
- In select-only mode, `setInputValue` must be a no-op
|
|
308
|
+
- Every group referenced via `getGroupProps(groupId)` must have a corresponding `getGroupLabelProps(groupId)` with matching `aria-labelledby` linkage
|
|
309
|
+
- Group ids are unique and do not collide with option ids
|
|
310
|
+
|
|
311
|
+
These invariants are machine-checkable through deterministic assertions in
|
|
312
|
+
`src/combobox/combobox.test.ts`.
|
|
313
|
+
|
|
314
|
+
## Adapter / Consumer Mapping
|
|
315
|
+
|
|
316
|
+
- headless contracts are the single source of ARIA mapping truth:
|
|
317
|
+
- `getInputProps()` maps signal state to combobox input attributes
|
|
318
|
+
- `getListboxProps()` maps popup identity and role
|
|
319
|
+
- `getOptionProps(id)` maps option active/selected/disabled semantics
|
|
320
|
+
- `getGroupProps(groupId)` maps group container identity and role
|
|
321
|
+
- `getGroupLabelProps(groupId)` maps group label identity
|
|
322
|
+
- UIKit adapter (`cv-combobox`) must consume these contracts directly instead of
|
|
323
|
+
recomputing ARIA state.
|
|
324
|
+
- consumers that compose combobox behavior (for example command palette)
|
|
325
|
+
interact via `state`, `actions`, and `contracts` without bypassing invariants.
|
|
326
|
+
|
|
327
|
+
## Adapter Expectations
|
|
328
|
+
|
|
329
|
+
UIKit adapter will:
|
|
330
|
+
|
|
331
|
+
**Signals read (reactive, drive re-renders):**
|
|
332
|
+
|
|
333
|
+
- `state.inputValue()` — current input text
|
|
334
|
+
- `state.isOpen()` — popup visibility
|
|
335
|
+
- `state.activeId()` — highlighted option id
|
|
336
|
+
- `state.selectedId()` — first selected option id (single mode primary)
|
|
337
|
+
- `state.selectedIds()` — all selected ids (multi mode primary)
|
|
338
|
+
- `state.hasSelection()` — whether any option is selected (clearable button visibility)
|
|
339
|
+
- `state.type()` — determines trigger rendering (input vs button-like)
|
|
340
|
+
- `state.multiple()` — determines single vs multi rendering
|
|
341
|
+
|
|
342
|
+
**Actions called (event handlers, never mutate state directly):**
|
|
343
|
+
|
|
344
|
+
- `actions.open()` / `actions.close()` — popup toggle
|
|
345
|
+
- `actions.setInputValue(value)` — on input change (editable mode)
|
|
346
|
+
- `actions.setActive(id)` — on pointer hover over option
|
|
347
|
+
- `actions.moveNext()` / `actions.movePrev()` — arrow key navigation
|
|
348
|
+
- `actions.moveFirst()` / `actions.moveLast()` — Home/End navigation
|
|
349
|
+
- `actions.commitActive()` — Enter/Space commit
|
|
350
|
+
- `actions.select(id)` — direct option click
|
|
351
|
+
- `actions.toggleOption(id)` — multi-mode option click (adapter may use `select` which delegates)
|
|
352
|
+
- `actions.removeSelected(id)` — tag/chip dismiss in multi mode
|
|
353
|
+
- `actions.clear()` — clear button click (clearable mode)
|
|
354
|
+
- `actions.clearSelection()` — programmatic selection reset
|
|
355
|
+
- `actions.handleKeyDown(event)` — keyboard delegation
|
|
356
|
+
|
|
357
|
+
**Contracts spread (attribute maps applied directly to DOM elements):**
|
|
358
|
+
|
|
359
|
+
- `contracts.getInputProps()` — spread onto trigger/input element
|
|
360
|
+
- `contracts.getListboxProps()` — spread onto popup listbox container
|
|
361
|
+
- `contracts.getOptionProps(id)` — spread onto each option element
|
|
362
|
+
- `contracts.getGroupProps(groupId)` — spread onto group container element
|
|
363
|
+
- `contracts.getGroupLabelProps(groupId)` — spread onto group label element
|
|
364
|
+
- `contracts.getVisibleOptions()` — drives option rendering (supports grouped structure)
|
|
365
|
+
- `contracts.getFlatVisibleOptions()` — available for navigation index calculations
|
|
366
|
+
|
|
367
|
+
**UIKit-only concerns (NOT in headless):**
|
|
368
|
+
|
|
369
|
+
- Tag/chip rendering for multi-select selected items
|
|
370
|
+
- "+N more" overflow display for multi-select
|
|
371
|
+
- Clear button rendering and visibility (uses `hasSelection` + `clearable` config)
|
|
372
|
+
- Select-only trigger visual (button-like with selected label + expand icon)
|
|
373
|
+
- Option group visual styling (indentation, separator)
|
|
374
|
+
- Popup positioning and animation
|
|
375
|
+
|
|
376
|
+
## Minimum Test Matrix
|
|
377
|
+
|
|
378
|
+
- open/close transitions
|
|
379
|
+
- Arrow/Home/End navigation behavior
|
|
380
|
+
- Enter commit behavior
|
|
381
|
+
- Escape close behavior
|
|
382
|
+
- disabled option skip behavior
|
|
383
|
+
- active descendant id integrity
|
|
384
|
+
- custom filter hook behavior
|
|
385
|
+
- typeahead cycling and timeout reset behavior
|
|
386
|
+
- select-only: `setInputValue` is no-op
|
|
387
|
+
- select-only: Space opens popup when closed
|
|
388
|
+
- select-only: Space commits active option when open
|
|
389
|
+
- select-only: `aria-autocomplete` is absent from input props
|
|
390
|
+
- select-only: type-to-select via printable characters
|
|
391
|
+
- multi-select: `toggleOption` adds and removes from `selectedIds`
|
|
392
|
+
- multi-select: `commitActive` toggles instead of replacing
|
|
393
|
+
- multi-select: `selectedId` equals `selectedIds[0]`
|
|
394
|
+
- multi-select: `getOptionProps` returns `aria-selected: "true"` for all selected
|
|
395
|
+
- multi-select: `getListboxProps` returns `aria-multiselectable: "true"`
|
|
396
|
+
- multi-select: `closeOnSelect` defaults to `false`
|
|
397
|
+
- multi-select: `removeSelected` removes specific id
|
|
398
|
+
- clearable: `clear()` resets selection and input value
|
|
399
|
+
- clearable: `clearSelection()` resets selection but preserves input value
|
|
400
|
+
- option groups: `getVisibleOptions` returns grouped structure
|
|
401
|
+
- option groups: empty groups are filtered out
|
|
402
|
+
- option groups: `getGroupProps` returns correct ARIA
|
|
403
|
+
- option groups: navigation crosses group boundaries seamlessly
|
|
404
|
+
- option groups: `getFlatVisibleOptions` returns flat list
|
|
405
|
+
|
|
406
|
+
## ADR-001 Compliance
|
|
407
|
+
|
|
408
|
+
- **Runtime Policy**: Reatom v1000 only; no @statx/\* in headless core.
|
|
409
|
+
- **Layering**: core -> interactions -> a11y-contracts -> adapters; adapters remain thin mappings.
|
|
410
|
+
- **Independence**: No imports from @project/_, apps/_, or other out-of-package modules.
|
|
411
|
+
- **Verification**: Mandatory adapter integration tests and standalone package test execution.
|
|
412
|
+
|
|
413
|
+
## Out of Scope (Current)
|
|
414
|
+
|
|
415
|
+
- async option loading
|
|
416
|
+
- free-text custom value creation
|
|
417
|
+
- inline autocomplete completion rendering
|
|
418
|
+
|
|
419
|
+
## Optional Advanced Behaviors (Future Scope)
|
|
420
|
+
|
|
421
|
+
These are intentionally optional and not required for current compatibility:
|
|
422
|
+
|
|
423
|
+
- custom-value commit policy (`allowCustomValue`) when no option is active
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# Command Palette Component Contract
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
`Command Palette` provides a headless dialog + searchable command list model for quick command execution.
|
|
6
|
+
|
|
7
|
+
## Component Files
|
|
8
|
+
|
|
9
|
+
- `src/command-palette/index.ts` - model and public `createCommandPalette` API
|
|
10
|
+
- `src/command-palette/command-palette.test.ts` - unit behavior tests
|
|
11
|
+
|
|
12
|
+
## Public API
|
|
13
|
+
|
|
14
|
+
- `createCommandPalette(options)`
|
|
15
|
+
- `state` (signal-backed):
|
|
16
|
+
- `isOpen()` - dialog visibility
|
|
17
|
+
- `inputValue()` - current search input
|
|
18
|
+
- `activeId()` - active command id
|
|
19
|
+
- `selectedId()` - last selected command id from combobox
|
|
20
|
+
- `lastExecutedId()` - last executed command id
|
|
21
|
+
- `restoreTargetId()` - trigger restore target after close
|
|
22
|
+
- `actions`:
|
|
23
|
+
- `open`, `close`, `toggle`
|
|
24
|
+
- `execute(id)`
|
|
25
|
+
- `setInputValue(value)`
|
|
26
|
+
- `handleGlobalKeyDown`
|
|
27
|
+
- `handlePaletteKeyDown`
|
|
28
|
+
- `handleOutsidePointer`
|
|
29
|
+
- `contracts`:
|
|
30
|
+
- `getTriggerProps()`
|
|
31
|
+
- `getDialogProps()`
|
|
32
|
+
- `getInputProps()`
|
|
33
|
+
- `getListboxProps()`
|
|
34
|
+
- `getOptionProps(id)`
|
|
35
|
+
- `getVisibleCommands()`
|
|
36
|
+
|
|
37
|
+
## APG and A11y Contract
|
|
38
|
+
|
|
39
|
+
- trigger role: `button`
|
|
40
|
+
- trigger attributes:
|
|
41
|
+
- `aria-haspopup="dialog"`
|
|
42
|
+
- `aria-expanded`
|
|
43
|
+
- `aria-controls`
|
|
44
|
+
- dialog role: `dialog`
|
|
45
|
+
- dialog attributes:
|
|
46
|
+
- `aria-modal="true"`
|
|
47
|
+
- `aria-label`
|
|
48
|
+
- `hidden`
|
|
49
|
+
- command list and options inherit combobox/listbox/option contracts.
|
|
50
|
+
|
|
51
|
+
## Keyboard Contract
|
|
52
|
+
|
|
53
|
+
- global shortcut: `Ctrl/Cmd + <openShortcutKey>` toggles palette
|
|
54
|
+
- palette `Escape`: closes palette
|
|
55
|
+
- palette `Enter` / `Space`: executes active command (fallback to first enabled visible command)
|
|
56
|
+
- arrow/home/end/typeahead behaviors are delegated to combobox keyboard contract
|
|
57
|
+
|
|
58
|
+
## Behavior Contract
|
|
59
|
+
|
|
60
|
+
- `Command Palette` composes `createCombobox` as the searchable command list engine.
|
|
61
|
+
- `execute` validates command id, updates `lastExecutedId`, and calls `onExecute`.
|
|
62
|
+
- `closeOnExecute` controls close vs keep-open behavior after execution.
|
|
63
|
+
- outside pointer close behavior is configurable.
|
|
64
|
+
|
|
65
|
+
## Invariants
|
|
66
|
+
|
|
67
|
+
- `lastExecutedId` updates only for known command ids.
|
|
68
|
+
- `restoreTargetId` is set on close paths.
|
|
69
|
+
- dialog `aria-expanded` and hidden state remain synchronized with `isOpen`.
|
|
70
|
+
- contract option click and keyboard execute paths must call the same execute flow.
|
|
71
|
+
|
|
72
|
+
## Minimum Test Matrix
|
|
73
|
+
|
|
74
|
+
- global shortcut toggle behavior
|
|
75
|
+
- dialog role and escape close contract
|
|
76
|
+
- execute-on-enter behavior with callback and `lastExecutedId` state
|
|
77
|
+
- keep-open execution mode (`closeOnExecute=false`)
|
|
78
|
+
- outside pointer close policy behavior
|
|
79
|
+
|
|
80
|
+
## ADR-001 Compliance
|
|
81
|
+
|
|
82
|
+
- **Runtime Policy**: Reatom v1000 only; no @statx/\* in headless core.
|
|
83
|
+
- **Layering**: core -> interactions -> a11y-contracts -> adapters; adapters remain thin mappings.
|
|
84
|
+
- **Independence**: No imports from @project/_, apps/_, or other out-of-package modules.
|
|
85
|
+
- **Verification**: Mandatory adapter integration tests and standalone package test execution.
|
|
86
|
+
|
|
87
|
+
## Out of Scope (Current)
|
|
88
|
+
|
|
89
|
+
- async command providers
|
|
90
|
+
- fuzzy scoring/ranking customization
|
|
91
|
+
- command groups/sections and breadcrumbs
|
|
92
|
+
- history and recent-command persistence
|