@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.
Files changed (191) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +99 -0
  3. package/dist/a11y-contracts/index.d.ts +23 -0
  4. package/dist/a11y-contracts/index.js +1 -0
  5. package/dist/accordion/index.d.ts +78 -0
  6. package/dist/accordion/index.js +264 -0
  7. package/dist/adapters/index.d.ts +9 -0
  8. package/dist/adapters/index.js +1 -0
  9. package/dist/alert/index.d.ts +33 -0
  10. package/dist/alert/index.js +54 -0
  11. package/dist/alert-dialog/index.d.ts +69 -0
  12. package/dist/alert-dialog/index.js +94 -0
  13. package/dist/badge/index.d.ts +48 -0
  14. package/dist/badge/index.js +89 -0
  15. package/dist/breadcrumb/index.d.ts +55 -0
  16. package/dist/breadcrumb/index.js +77 -0
  17. package/dist/button/index.d.ts +46 -0
  18. package/dist/button/index.js +86 -0
  19. package/dist/callout/index.d.ts +41 -0
  20. package/dist/callout/index.js +63 -0
  21. package/dist/card/index.d.ts +54 -0
  22. package/dist/card/index.js +103 -0
  23. package/dist/carousel/index.d.ts +98 -0
  24. package/dist/carousel/index.js +243 -0
  25. package/dist/checkbox/index.d.ts +50 -0
  26. package/dist/checkbox/index.js +87 -0
  27. package/dist/combobox/index.d.ts +114 -0
  28. package/dist/combobox/index.js +431 -0
  29. package/dist/command-palette/index.d.ts +73 -0
  30. package/dist/command-palette/index.js +147 -0
  31. package/dist/context-menu/index.d.ts +111 -0
  32. package/dist/context-menu/index.js +372 -0
  33. package/dist/copy-button/index.d.ts +62 -0
  34. package/dist/copy-button/index.js +183 -0
  35. package/dist/core/index.d.ts +20 -0
  36. package/dist/core/index.js +2 -0
  37. package/dist/core/selection.d.ts +5 -0
  38. package/dist/core/selection.js +39 -0
  39. package/dist/core/value-range.d.ts +49 -0
  40. package/dist/core/value-range.js +134 -0
  41. package/dist/date-picker/index.d.ts +210 -0
  42. package/dist/date-picker/index.js +895 -0
  43. package/dist/dialog/index.d.ts +95 -0
  44. package/dist/dialog/index.js +153 -0
  45. package/dist/disclosure/index.d.ts +52 -0
  46. package/dist/disclosure/index.js +159 -0
  47. package/dist/drawer/index.d.ts +30 -0
  48. package/dist/drawer/index.js +39 -0
  49. package/dist/feed/index.d.ts +77 -0
  50. package/dist/feed/index.js +260 -0
  51. package/dist/grid/index.d.ts +103 -0
  52. package/dist/grid/index.js +415 -0
  53. package/dist/index.d.ts +51 -0
  54. package/dist/index.js +51 -0
  55. package/dist/input/index.d.ts +86 -0
  56. package/dist/input/index.js +156 -0
  57. package/dist/interactions/composite-navigation.d.ts +69 -0
  58. package/dist/interactions/composite-navigation.js +169 -0
  59. package/dist/interactions/index.d.ts +15 -0
  60. package/dist/interactions/index.js +4 -0
  61. package/dist/interactions/keyboard-intents.d.ts +16 -0
  62. package/dist/interactions/keyboard-intents.js +33 -0
  63. package/dist/interactions/overlay-focus.d.ts +40 -0
  64. package/dist/interactions/overlay-focus.js +93 -0
  65. package/dist/interactions/typeahead.d.ts +20 -0
  66. package/dist/interactions/typeahead.js +41 -0
  67. package/dist/landmarks/index.d.ts +39 -0
  68. package/dist/landmarks/index.js +58 -0
  69. package/dist/link/index.d.ts +34 -0
  70. package/dist/link/index.js +39 -0
  71. package/dist/listbox/index.d.ts +92 -0
  72. package/dist/listbox/index.js +337 -0
  73. package/dist/menu/index.d.ts +132 -0
  74. package/dist/menu/index.js +541 -0
  75. package/dist/menu-button/index.d.ts +71 -0
  76. package/dist/menu-button/index.js +121 -0
  77. package/dist/meter/index.d.ts +45 -0
  78. package/dist/meter/index.js +106 -0
  79. package/dist/number/index.d.ts +113 -0
  80. package/dist/number/index.js +252 -0
  81. package/dist/popover/index.d.ts +70 -0
  82. package/dist/popover/index.js +126 -0
  83. package/dist/progress/index.d.ts +49 -0
  84. package/dist/progress/index.js +79 -0
  85. package/dist/radio-group/index.d.ts +61 -0
  86. package/dist/radio-group/index.js +150 -0
  87. package/dist/select/index.d.ts +92 -0
  88. package/dist/select/index.js +239 -0
  89. package/dist/sidebar/index.d.ts +74 -0
  90. package/dist/sidebar/index.js +186 -0
  91. package/dist/slider/index.d.ts +61 -0
  92. package/dist/slider/index.js +150 -0
  93. package/dist/slider-multi-thumb/index.d.ts +70 -0
  94. package/dist/slider-multi-thumb/index.js +222 -0
  95. package/dist/spinbutton/index.d.ts +75 -0
  96. package/dist/spinbutton/index.js +214 -0
  97. package/dist/spinner/index.d.ts +1 -0
  98. package/dist/spinner/index.js +1 -0
  99. package/dist/spinner/spinner.d.ts +23 -0
  100. package/dist/spinner/spinner.js +25 -0
  101. package/dist/switch/index.d.ts +40 -0
  102. package/dist/switch/index.js +61 -0
  103. package/dist/table/index.d.ts +117 -0
  104. package/dist/table/index.js +377 -0
  105. package/dist/tabs/index.d.ts +63 -0
  106. package/dist/tabs/index.js +174 -0
  107. package/dist/textarea/index.d.ts +68 -0
  108. package/dist/textarea/index.js +137 -0
  109. package/dist/toast/index.d.ts +67 -0
  110. package/dist/toast/index.js +145 -0
  111. package/dist/toolbar/index.d.ts +59 -0
  112. package/dist/toolbar/index.js +139 -0
  113. package/dist/tooltip/index.d.ts +52 -0
  114. package/dist/tooltip/index.js +169 -0
  115. package/dist/treegrid/index.d.ts +101 -0
  116. package/dist/treegrid/index.js +463 -0
  117. package/dist/treeview/index.d.ts +68 -0
  118. package/dist/treeview/index.js +370 -0
  119. package/dist/window-splitter/index.d.ts +65 -0
  120. package/dist/window-splitter/index.js +204 -0
  121. package/package.json +92 -0
  122. package/specs/ADR-001-headless-architecture.md +461 -0
  123. package/specs/ADR-002-repo-release-model.md +108 -0
  124. package/specs/ADR-003-public-api-versioning.md +136 -0
  125. package/specs/ADR-004-focus-selection-policy.md +117 -0
  126. package/specs/IMPLEMENTATION-ROADMAP.md +237 -0
  127. package/specs/ISSUE-BACKLOG.md +681 -0
  128. package/specs/RELEASE-CANDIDATE.md +30 -0
  129. package/specs/components/accordion.md +130 -0
  130. package/specs/components/alert-dialog.md +72 -0
  131. package/specs/components/alert.md +65 -0
  132. package/specs/components/badge.md +220 -0
  133. package/specs/components/breadcrumb.md +74 -0
  134. package/specs/components/button.md +115 -0
  135. package/specs/components/callout.md +195 -0
  136. package/specs/components/card.md +280 -0
  137. package/specs/components/carousel.md +140 -0
  138. package/specs/components/checkbox.md +172 -0
  139. package/specs/components/combobox.md +423 -0
  140. package/specs/components/command-palette.md +92 -0
  141. package/specs/components/context-menu.md +556 -0
  142. package/specs/components/copy-button.md +293 -0
  143. package/specs/components/date-picker.md +400 -0
  144. package/specs/components/dialog.md +298 -0
  145. package/specs/components/disclosure.md +257 -0
  146. package/specs/components/drawer.md +353 -0
  147. package/specs/components/feed.md +265 -0
  148. package/specs/components/grid.md +186 -0
  149. package/specs/components/input.md +254 -0
  150. package/specs/components/landmarks.md +136 -0
  151. package/specs/components/link.md +134 -0
  152. package/specs/components/listbox.md +351 -0
  153. package/specs/components/menu-button.md +76 -0
  154. package/specs/components/menu.md +623 -0
  155. package/specs/components/meter.md +149 -0
  156. package/specs/components/number.md +393 -0
  157. package/specs/components/popover.md +252 -0
  158. package/specs/components/progress.md +188 -0
  159. package/specs/components/radio-group.md +151 -0
  160. package/specs/components/select.md +144 -0
  161. package/specs/components/sidebar.md +321 -0
  162. package/specs/components/slider-multi-thumb.md +78 -0
  163. package/specs/components/slider.md +84 -0
  164. package/specs/components/spinbutton.md +140 -0
  165. package/specs/components/spinner.md +132 -0
  166. package/specs/components/switch.md +175 -0
  167. package/specs/components/table.md +403 -0
  168. package/specs/components/tabs.md +265 -0
  169. package/specs/components/textarea.md +185 -0
  170. package/specs/components/toast.md +198 -0
  171. package/specs/components/toolbar.md +278 -0
  172. package/specs/components/tooltip.md +252 -0
  173. package/specs/components/treegrid.md +281 -0
  174. package/specs/components/treeview.md +91 -0
  175. package/specs/components/window-splitter.md +297 -0
  176. package/specs/ops/git-shard-sync.md +107 -0
  177. package/specs/ops/release-checklist.md +76 -0
  178. package/specs/release/GAP-TO-GREEN-ISSUES.md +88 -0
  179. package/specs/release/api-freeze-candidate.md +54 -0
  180. package/specs/release/changelog-automation.md +76 -0
  181. package/specs/release/changelog.generated.md +53 -0
  182. package/specs/release/changelog.patch.generated.md +46 -0
  183. package/specs/release/consumer-integration.md +53 -0
  184. package/specs/release/migration-notes-pre-v1.md +40 -0
  185. package/specs/release/mvp-changelog.md +57 -0
  186. package/specs/release/release-notes-template.md +61 -0
  187. package/specs/release/release-rehearsal.md +113 -0
  188. package/specs/release/semver-deprecation-dry-run.md +89 -0
  189. package/specs/release/shard-release-drill-report.md +50 -0
  190. package/specs/release/shard-release-follow-ups.md +31 -0
  191. 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