@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,134 @@
1
+ # Link Component Contract
2
+
3
+ ## Purpose
4
+
5
+ `Link` is a headless APG-aligned contract for a hyperlink that allows users to navigate to another page or a different portion of the current page. It handles activation, focus management, and keyboard interaction.
6
+
7
+ Per strict APG guidance, links do not support a disabled state. If a link destination is unavailable, the link should be removed from the UI or replaced with static text by the consuming application.
8
+
9
+ ## Component Files
10
+
11
+ - `src/link/index.ts` - model and public `createLink` API
12
+ - `src/link/link.test.ts` - unit behavior tests
13
+
14
+ ## Public API
15
+
16
+ - `createLink(options)`
17
+ - `options`:
18
+ - `idBase?`: string - prefix for generated element IDs (default: `"link"`)
19
+ - `href?`: string - target URL for navigation
20
+ - `isSemanticHost?`: boolean - set `true` when the host element is a native `<a>` tag (omits `role` and `tabindex` from contract)
21
+ - `onPress?`: callback function - invoked on activation (click or `Enter` keydown)
22
+ - `state` (signal-backed):
23
+ - _(none)_ - link has no mutable headless state; all configuration is immutable from options
24
+ - `actions`:
25
+ - `press()` - manually triggers the link's `onPress` callback
26
+ - `handleClick(event?)` - handles click events; delegates to `press()`
27
+ - `handleKeyDown(event)` - processes activation keys (`Enter`); delegates to `press()`
28
+ - `contracts`:
29
+ - `getLinkProps()` - returns a ready-to-spread ARIA prop object for the link element
30
+
31
+ ## State Signal Surface
32
+
33
+ | Signal | Type | Description |
34
+ | -------- | ---- | ------------------------------------------------------------------------------------ |
35
+ | _(none)_ | — | Link is stateless at the headless level; all behavior derives from immutable options |
36
+
37
+ ## Actions
38
+
39
+ | Action | Signature | Description |
40
+ | --------------- | ------------------------------------------------------------- | ---------------------------------------------------------------- |
41
+ | `press` | `() => void` | Invokes `onPress` callback if provided |
42
+ | `handleClick` | `(event?) => void` | Click handler; delegates to `press()` |
43
+ | `handleKeyDown` | `(event: {key: string; preventDefault?: () => void}) => void` | Keyboard handler; calls `press()` on `Enter`, ignores other keys |
44
+
45
+ ## Contracts
46
+
47
+ ### `getLinkProps() -> LinkProps`
48
+
49
+ Returns a complete attribute map to spread onto the link host element.
50
+
51
+ | Property | Type | Value |
52
+ | ----------- | --------------------- | ---------------------------------------------------------------------------- |
53
+ | `id` | `string` | `"{idBase}-root"` |
54
+ | `role` | `"link" \| undefined` | `"link"` for non-semantic hosts; `undefined` when `isSemanticHost` is `true` |
55
+ | `href` | `string \| undefined` | Passthrough of `options.href` |
56
+ | `tabindex` | `"0" \| undefined` | `"0"` for non-semantic hosts; `undefined` when `isSemanticHost` is `true` |
57
+ | `onClick` | `(event?) => void` | Bound to `handleClick` action |
58
+ | `onKeyDown` | `(event) => void` | Bound to `handleKeyDown` action |
59
+
60
+ ## APG and A11y Contract
61
+
62
+ - role: `link`
63
+ - required attributes:
64
+ - `role="link"` (only for non-semantic elements; omitted for native `<a>`)
65
+ - `tabindex="0"` (only for non-semantic elements; omitted for native `<a>`)
66
+ - focus management:
67
+ - links are always in the page tab sequence
68
+ - keyboard interaction:
69
+ - `Enter`: triggers the link action
70
+
71
+ ## Behavior Contract
72
+
73
+ - **Activation**:
74
+ - clicking the link triggers the `onPress` callback
75
+ - pressing `Enter` triggers the `onPress` callback
76
+ - non-`Enter` keys are ignored by `handleKeyDown`
77
+ - **Semantic vs Non-Semantic Host**:
78
+ - when `isSemanticHost` is `true`, `role` and `tabindex` are omitted from `getLinkProps()` because the native `<a>` element provides them
79
+ - when `isSemanticHost` is `false` (default), `role="link"` and `tabindex="0"` are included
80
+ - **No Disabled State**:
81
+ - per APG guidance, links must not be disabled
82
+ - if a destination is unavailable, the link should be removed or replaced with static text at the application level
83
+
84
+ ## Transitions Table
85
+
86
+ | Event | Guard | Action | Next State |
87
+ | ----------------- | ----------------- | ------------------------------- | --------------- |
88
+ | click | — | `handleClick(e)` -> `press()` | calls `onPress` |
89
+ | `keydown Enter` | `key === "Enter"` | `handleKeyDown(e)` -> `press()` | calls `onPress` |
90
+ | `keydown` (other) | `key !== "Enter"` | — | no change |
91
+
92
+ ## Invariants
93
+
94
+ - a link must have an accessible name (enforced by UIKit adapter or consumer)
95
+ - `role="link"` must be present when the host is not a native `<a>` element
96
+ - `tabindex="0"` must be present when the host is not a native `<a>` element
97
+ - `href` is always passed through from options (never conditionally removed)
98
+ - `onPress` is always invoked on activation (no gating — there is no disabled state)
99
+
100
+ ## Adapter Expectations
101
+
102
+ UIKit (`cv-link`) binds to the headless contract as follows:
103
+
104
+ - **Signals read**: _(none)_ — link has no mutable headless state
105
+ - **Actions called**: _(none directly)_ — activation is handled via event handlers wired through the contract
106
+ - **Contracts spread**: `contracts.getLinkProps()` — spread onto the inner `<a>` element (or `[part="base"]`) to apply `id`, `role`, `href`, `tabindex`, and keyboard/click handlers
107
+ - **Slots**: UIKit provides `prefix` and `suffix` slots for icon placement (visual-layer concern, not headless)
108
+ - **Attribute reflection**: `href` attribute on the host is forwarded to `createLink` options; `isSemanticHost` is determined by the adapter based on whether the inner element is a native `<a>`
109
+
110
+ ## Minimum Test Matrix
111
+
112
+ - trigger `onPress` on click
113
+ - trigger `onPress` on `Enter` keydown
114
+ - trigger `onPress` via direct `press()` action
115
+ - verify non-`Enter` keys are ignored by `handleKeyDown`
116
+ - verify `getLinkProps()` returns `role="link"` and `tabindex="0"` for non-semantic host
117
+ - verify `getLinkProps()` omits `role` and `tabindex` for semantic host
118
+ - verify `getLinkProps()` includes `href` from options
119
+ - verify deterministic `id` based on `idBase`
120
+ - verify no error when `onPress` is not provided
121
+
122
+ ## ADR-001 Compliance
123
+
124
+ - **Runtime Policy**: Reatom v1000 only; no @statx/\* in headless core.
125
+ - **Layering**: core -> interactions -> a11y-contracts -> adapters; adapters remain thin mappings.
126
+ - **Independence**: No imports from @project/_, apps/_, or other out-of-package modules.
127
+ - **Verification**: Mandatory adapter integration tests and standalone package test execution.
128
+
129
+ ## Out of Scope (Current)
130
+
131
+ - link previews/tooltips
132
+ - download link specific attributes (`download` attribute)
133
+ - security attributes (`rel="noopener noreferrer"`) - handled by visual layer or adapter
134
+ - disabled state (removed per APG guidance — links must not be disabled)
@@ -0,0 +1,351 @@
1
+ # Listbox Component Contract
2
+
3
+ ## Purpose
4
+
5
+ `Listbox` is a headless APG-aligned contract for focus, selection, and keyboard behavior,
6
+ with no visual layer. Supports flat and grouped options, two focus strategies, single and
7
+ multiple selection modes, typeahead, range selection, and virtual scroll attributes.
8
+
9
+ ## Component Files
10
+
11
+ - `src/listbox/index.ts` - model and public `createListbox` API
12
+ - `src/listbox/listbox.test.ts` - unit behavior tests
13
+
14
+ ## Public API
15
+
16
+ ### `createListbox(options: CreateListboxOptions): ListboxModel`
17
+
18
+ ### Option Types
19
+
20
+ ```ts
21
+ interface ListboxOption {
22
+ id: string
23
+ label?: string
24
+ disabled?: boolean
25
+ groupId?: string // references a ListboxGroup.id; ungrouped when omitted
26
+ }
27
+
28
+ interface ListboxGroup {
29
+ id: string
30
+ label: string
31
+ }
32
+ ```
33
+
34
+ Groups and flat options coexist. An option references a group via `groupId`. Options without `groupId` are ungrouped and rendered before any groups (or interleaved per declaration order — see rendering section).
35
+
36
+ ### CreateListboxOptions
37
+
38
+ ```ts
39
+ interface CreateListboxOptions {
40
+ options: readonly ListboxOption[]
41
+ groups?: readonly ListboxGroup[]
42
+ selectionMode?: 'single' | 'multiple' // default 'single'
43
+ focusStrategy?: FocusStrategy // default 'aria-activedescendant'
44
+ selectionFollowsFocus?: boolean // default false
45
+ rangeSelection?: boolean | {enabled?: boolean} // default false
46
+ orientation?: 'vertical' | 'horizontal' // default 'vertical'
47
+ typeahead?: boolean | {enabled?: boolean; timeoutMs?: number}
48
+ ariaLabel?: string
49
+ idBase?: string
50
+ initialActiveId?: string | null
51
+ initialSelectedIds?: readonly string[]
52
+ }
53
+ ```
54
+
55
+ **Default focus strategy change**: `focusStrategy` defaults to `'aria-activedescendant'` (previously `'roving-tabindex'`). Both strategies remain fully supported via configuration.
56
+
57
+ ## State Signals
58
+
59
+ | Signal | Type | Description |
60
+ | ---------------- | ---------------------------- | --------------------------------------------------------------------------- |
61
+ | `activeId()` | `Atom<string \| null>` | Currently focused option id |
62
+ | `selectedIds()` | `Atom<string[]>` | Selected option ids |
63
+ | `isOpen()` | `Atom<boolean>` | Popup visibility (for composite patterns like select) |
64
+ | `hasSelection()` | `Computed<boolean>` | `selectedIds.length > 0` |
65
+ | `selectionMode` | `'single' \| 'multiple'` | Current selection mode (from config) |
66
+ | `focusStrategy` | `FocusStrategy` | Current focus strategy (from config) |
67
+ | `orientation` | `'vertical' \| 'horizontal'` | Current orientation (from config) |
68
+ | `optionCount` | `number` | Total number of options (flat count across all groups). For `aria-setsize`. |
69
+ | `groups` | `readonly ListboxGroup[]` | Configured groups (from config, empty array when no groups) |
70
+
71
+ All reactive state is signal-backed via Reatom atoms. `selectionMode`, `focusStrategy`, `orientation`, `optionCount`, and `groups` are static config values exposed on the state object for adapter convenience.
72
+
73
+ ## Actions
74
+
75
+ | Action | Signature | Description |
76
+ | ---------------- | ------------------------------------ | ------------------------------------------------------------------------ |
77
+ | `open` | `() => void` | Sets `isOpen=true`, resets typeahead |
78
+ | `close` | `() => void` | Sets `isOpen=false`, resets typeahead |
79
+ | `setActive` | `(id: string \| null) => void` | Sets active option if enabled; no-op for disabled/unknown ids |
80
+ | `moveNext` | `() => void` | Moves active to next enabled option (linear across groups) |
81
+ | `movePrev` | `() => void` | Moves active to previous enabled option (linear across groups) |
82
+ | `moveFirst` | `() => void` | Sets active to first enabled option |
83
+ | `moveLast` | `() => void` | Sets active to last enabled option |
84
+ | `toggleSelected` | `(id: string) => void` | Toggles selection for the given id. Respects selection mode constraints. |
85
+ | `selectOnly` | `(id: string) => void` | Selects only the given id, clearing previous selection |
86
+ | `clearSelected` | `() => void` | Clears all selection |
87
+ | `handleKeyDown` | `(event: KeyboardEventLike) => void` | Delegates keyboard events to navigation/selection/typeahead transitions |
88
+
89
+ UIKit must only call actions, never mutate state atoms directly.
90
+
91
+ ## Contracts
92
+
93
+ | Contract | Return Type | Description |
94
+ | ----------------------------- | -------------------------- | -------------------------------------------------- |
95
+ | `getRootProps()` | `ListboxRootProps` | ARIA attributes for the listbox root element |
96
+ | `getOptionProps(id)` | `ListboxOptionProps` | ARIA attributes for an individual option |
97
+ | `getGroupProps(groupId)` | `ListboxGroupProps` | ARIA attributes for a group container |
98
+ | `getGroupLabelProps(groupId)` | `ListboxGroupLabelProps` | Attributes for the group label element |
99
+ | `getGroupOptions(groupId)` | `readonly ListboxOption[]` | Options belonging to a group, in declaration order |
100
+ | `getUngroupedOptions()` | `readonly ListboxOption[]` | Options not assigned to any group |
101
+
102
+ ### Contract Return Types
103
+
104
+ ```ts
105
+ interface ListboxRootProps {
106
+ role: 'listbox'
107
+ tabindex: '0' | '-1'
108
+ 'aria-label'?: string
109
+ 'aria-orientation': 'vertical' | 'horizontal'
110
+ 'aria-multiselectable'?: 'true'
111
+ 'aria-activedescendant'?: string // present when focusStrategy='aria-activedescendant' and activeId != null
112
+ }
113
+
114
+ interface ListboxOptionProps {
115
+ id: string
116
+ role: 'option'
117
+ tabindex: '0' | '-1'
118
+ 'aria-disabled'?: 'true'
119
+ 'aria-selected': 'true' | 'false'
120
+ 'aria-setsize': string // total option count (flat, across all groups)
121
+ 'aria-posinset': string // 1-based position in the flat option list
122
+ 'data-active': 'true' | 'false'
123
+ }
124
+
125
+ interface ListboxGroupProps {
126
+ id: string
127
+ role: 'group'
128
+ 'aria-labelledby': string // references the group label element id
129
+ }
130
+
131
+ interface ListboxGroupLabelProps {
132
+ id: string
133
+ role: 'presentation'
134
+ }
135
+ ```
136
+
137
+ ### `getRootProps()` behavior
138
+
139
+ - `role` is always `'listbox'`
140
+ - `tabindex` is `'0'` when `focusStrategy='aria-activedescendant'`, `'-1'` otherwise
141
+ - `aria-multiselectable` is `'true'` only when `selectionMode='multiple'`
142
+ - `aria-activedescendant` is present only when `focusStrategy='aria-activedescendant'` and `activeId` is not `null`
143
+
144
+ ### `getOptionProps(id)` behavior
145
+
146
+ - `tabindex` is `'0'` for the active option when `focusStrategy='roving-tabindex'`, `'-1'` for all others
147
+ - `aria-selected` reflects whether the option is in `selectedIds`
148
+ - `aria-setsize` is the total number of options across all groups (flat count)
149
+ - `aria-posinset` is the 1-based position of this option in the flat option list (declaration order)
150
+ - `data-active` indicates whether this option is the currently active option
151
+
152
+ ### `getGroupProps(groupId)` behavior
153
+
154
+ - Returns `{ id, role: "group", "aria-labelledby": "<label-element-id>" }`
155
+ - The `aria-labelledby` value references the DOM id returned by `getGroupLabelProps(groupId).id`
156
+ - Throws if `groupId` is unknown
157
+
158
+ ### `getGroupLabelProps(groupId)` behavior
159
+
160
+ - Returns `{ id: "<label-element-id>", role: "presentation" }`
161
+ - The id is deterministic: `${idBase}-group-${groupId}-label`
162
+ - Throws if `groupId` is unknown
163
+
164
+ ## APG and A11y Contract
165
+
166
+ - root role: `listbox`
167
+ - item role: `option`
168
+ - group role: `group` with `aria-labelledby` referencing group label element
169
+ - focus strategies:
170
+ - `aria-activedescendant` (default)
171
+ - `roving-tabindex`
172
+ - selection modes:
173
+ - `single`
174
+ - `multiple`
175
+ - required attributes:
176
+ - root: `aria-label`, `aria-multiselectable`, `aria-orientation`, `aria-activedescendant`
177
+ - option: `aria-selected`, `aria-disabled`, `tabindex`, `aria-setsize`, `aria-posinset`
178
+ - group: `role="group"`, `aria-labelledby`
179
+
180
+ ## Option Group Behavior
181
+
182
+ - `groups` is an optional array of `ListboxGroup` objects on `CreateListboxOptions`
183
+ - Each option may reference a group via its `groupId` field
184
+ - Options without `groupId` are ungrouped
185
+ - `getGroupOptions(groupId)` returns options belonging to that group in declaration order
186
+ - `getUngroupedOptions()` returns options not assigned to any group
187
+ - Navigation is group-unaware (linear): arrow keys traverse ALL options in flat declaration order, crossing group boundaries seamlessly
188
+ - Typeahead operates on the flat option list across all groups
189
+ - Range selection operates on the flat option list across all groups
190
+ - Group ids must not collide with option ids
191
+
192
+ ## Virtual Scroll Support
193
+
194
+ `aria-setsize` and `aria-posinset` are returned by `getOptionProps(id)` to support virtual scrolling.
195
+
196
+ - `aria-setsize` = total number of options in the listbox (flat count across all groups)
197
+ - `aria-posinset` = 1-based index of the option in the flat declaration-order list
198
+ - When options are grouped, setsize/posinset reflect the FULL flat list count, not per-group counts
199
+ - These values are stable for a given option set. When using virtual scrolling, the adapter renders only a subset of options but each option still carries the correct setsize/posinset from the full list
200
+ - For dynamic option lists (e.g., async loading), the adapter should recreate the listbox model with the updated options array; setsize/posinset will be recomputed accordingly
201
+
202
+ ## Typeahead Behavior
203
+
204
+ - supports single-character typeahead navigation
205
+ - supports buffered typeahead queries within configurable timeout window
206
+ - supports repeated same-character cycling across matching options
207
+ - skips disabled options in typeahead matching
208
+ - operates on the flat option list across all groups
209
+ - configuration: `typeahead` option (`boolean` or `{ enabled?: boolean; timeoutMs?: number }`)
210
+
211
+ ## Range Selection Behavior
212
+
213
+ - optional, available only in `multiple` mode
214
+ - configuration: `rangeSelection` option (`boolean` or `{ enabled?: boolean }`)
215
+ - supports `Shift+Arrow` contiguous range selection
216
+ - supports `Shift+Space` range selection from anchor to active option
217
+ - skips disabled options while building selected range
218
+ - operates on the flat option list across all groups
219
+ - keeps behavior unchanged when range selection is disabled
220
+
221
+ ## Transition Model
222
+
223
+ ### Core Transitions
224
+
225
+ | Event / Action | Preconditions | Next State |
226
+ | -------------------- | -------------------- | ------------------------------------------------------------------------------------------- |
227
+ | `open()` | none | `isOpen=true`; typeahead reset |
228
+ | `close()` | none | `isOpen=false`; typeahead reset |
229
+ | `setActive(id)` | `id` must be enabled | `activeId=id`; if `selectionFollowsFocus` and single mode: `selectedIds=[id]` |
230
+ | `setActive(null)` | none | `activeId=null` |
231
+ | `moveNext()` | none | `activeId` = next enabled option in flat order, or unchanged at end |
232
+ | `movePrev()` | none | `activeId` = previous enabled option in flat order, or unchanged at start |
233
+ | `moveFirst()` | none | `activeId` = first enabled option, or `null` |
234
+ | `moveLast()` | none | `activeId` = last enabled option, or `null` |
235
+ | `toggleSelected(id)` | `id` must be enabled | In single mode: toggles id in/out. In multiple mode: adds/removes id. Updates range anchor. |
236
+ | `selectOnly(id)` | `id` must be enabled | `selectedIds=[id]`; updates range anchor |
237
+ | `clearSelected()` | none | `selectedIds=[]`; range anchor reset |
238
+
239
+ ### Keyboard Transitions (via `handleKeyDown`)
240
+
241
+ | Key | Modifiers | Context | Action |
242
+ | ---------------------------- | --------- | ------------------------- | --------------------------------------------- |
243
+ | `ArrowDown` / `ArrowRight`\* | none | any | `moveNext()` |
244
+ | `ArrowUp` / `ArrowLeft`\* | none | any | `movePrev()` |
245
+ | `Home` | none | any | `moveFirst()` |
246
+ | `End` | none | any | `moveLast()` |
247
+ | `Space` | none | single mode | `selectOnly(activeId)` |
248
+ | `Space` | none | multiple mode | `toggleSelected(activeId)` |
249
+ | `Enter` | none | single mode | `selectOnly(activeId)` |
250
+ | `Enter` | none | multiple mode | `toggleSelected(activeId)` |
251
+ | `Escape` | none | any | `close()` |
252
+ | `Ctrl/Cmd + A` | none | multiple mode | select all enabled options |
253
+ | `Shift + ArrowDown/ArrowUp` | shift | multiple + rangeSelection | range extend: move + select range from anchor |
254
+ | `Shift + Space` | shift | multiple + rangeSelection | select range from anchor to active |
255
+ | printable char | none | typeahead enabled | typeahead navigation |
256
+
257
+ \*Arrow key mapping depends on orientation: vertical uses Up/Down, horizontal uses Left/Right.
258
+
259
+ ## Invariants
260
+
261
+ - `focus` and `selection` are independent (except when `selectionFollowsFocus=true`)
262
+ - a disabled option cannot become active or selected
263
+ - in `single` mode, `selectedIds` contains at most one id
264
+ - `Ctrl/Cmd + A` is supported only in `multiple` mode
265
+ - `aria-setsize` on every option equals total option count
266
+ - `aria-posinset` on every option is unique and in range `[1, optionCount]`
267
+ - every group referenced via `getGroupProps(groupId)` must have a corresponding `getGroupLabelProps(groupId)` with matching `aria-labelledby` linkage
268
+ - group ids are unique and do not collide with option ids
269
+ - navigation order is the flat declaration order of options, regardless of grouping
270
+ - when `focusStrategy='aria-activedescendant'`, root `tabindex` is `'0'` and all options have `tabindex='-1'`
271
+ - when `focusStrategy='roving-tabindex'`, root `tabindex` is `'-1'` and active option has `tabindex='0'`
272
+
273
+ ## Adapter Expectations
274
+
275
+ UIKit adapter will:
276
+
277
+ **Signals read (reactive, drive re-renders):**
278
+
279
+ - `state.activeId()` — currently focused option id
280
+ - `state.selectedIds()` — selected option ids
281
+ - `state.isOpen()` — popup visibility
282
+ - `state.hasSelection()` — whether any option is selected
283
+ - `state.selectionMode` — single vs multiple (static, for conditional rendering)
284
+ - `state.focusStrategy` — focus management mode (static, for conditional rendering)
285
+ - `state.orientation` — layout orientation (static)
286
+ - `state.optionCount` — total flat option count (static, for virtual scroll)
287
+ - `state.groups` — group definitions (static, for rendering group structure)
288
+
289
+ **Actions called (event handlers, never mutate state directly):**
290
+
291
+ - `actions.open()` / `actions.close()` — popup lifecycle
292
+ - `actions.setActive(id)` — pointer hover / programmatic focus
293
+ - `actions.moveNext()` / `actions.movePrev()` — arrow key navigation
294
+ - `actions.moveFirst()` / `actions.moveLast()` — Home/End navigation
295
+ - `actions.toggleSelected(id)` — selection toggle
296
+ - `actions.selectOnly(id)` — exclusive selection
297
+ - `actions.clearSelected()` — clear all selection
298
+ - `actions.handleKeyDown(event)` — keyboard delegation
299
+
300
+ **Contracts spread (attribute maps applied directly to DOM elements):**
301
+
302
+ - `contracts.getRootProps()` — spread onto listbox root element
303
+ - `contracts.getOptionProps(id)` — spread onto each option element
304
+ - `contracts.getGroupProps(groupId)` — spread onto group container element
305
+ - `contracts.getGroupLabelProps(groupId)` — spread onto group label element
306
+
307
+ **Contracts called for rendering:**
308
+
309
+ - `contracts.getGroupOptions(groupId)` — options to render within a group
310
+ - `contracts.getUngroupedOptions()` — options to render outside any group
311
+
312
+ **UIKit-only concerns (NOT in headless):**
313
+
314
+ - Group visual styling (indentation, separators, headers)
315
+ - Virtual scroll viewport management and option recycling
316
+ - Popup positioning and animation
317
+ - Visual active/selected indicators
318
+
319
+ ## Minimum Test Matrix
320
+
321
+ - initialize `activeId` while skipping disabled options
322
+ - arrow navigation while skipping disabled options
323
+ - `selectionFollowsFocus` in single-select mode
324
+ - toggle behavior in multi-select mode
325
+ - `Ctrl/Cmd + A` in multi-select mode
326
+ - range selection behavior (`Shift+Arrow`, `Shift+Space`) when enabled
327
+ - horizontal orientation parity for navigation and range selection
328
+ - correct `getRootProps/getOptionProps` behavior for both focus strategies
329
+ - default focus strategy is `aria-activedescendant`
330
+ - `getOptionProps` returns correct `aria-setsize` and `aria-posinset`
331
+ - `aria-setsize` equals total flat option count when groups are used
332
+ - `aria-posinset` is 1-based and reflects flat declaration order
333
+ - option groups: `getGroupProps` returns correct `role` and `aria-labelledby`
334
+ - option groups: `getGroupLabelProps` returns matching label id
335
+ - option groups: navigation crosses group boundaries seamlessly
336
+ - option groups: `getGroupOptions` returns correct subset
337
+ - option groups: `getUngroupedOptions` returns correct subset
338
+ - option groups: options with unknown `groupId` are treated as ungrouped
339
+ - typeahead operates across group boundaries
340
+
341
+ ## ADR-001 Compliance
342
+
343
+ - **Runtime Policy**: Reatom v1000 only; no @statx/\* in headless core.
344
+ - **Layering**: core -> interactions -> a11y-contracts -> adapters; adapters remain thin mappings.
345
+ - **Independence**: No imports from @project/_, apps/_, or other out-of-package modules.
346
+ - **Verification**: Mandatory adapter integration tests and standalone package test execution.
347
+
348
+ ## Next Steps
349
+
350
+ - add optional APG extended shortcuts (`Ctrl+Shift+Home`, `Ctrl+Shift+End`)
351
+ - split keyboard-heavy tests into dedicated `listbox.keyboard.test.ts`
@@ -0,0 +1,76 @@
1
+ # Menu Button Component Contract
2
+
3
+ ## Purpose
4
+
5
+ `MenuButton` is a headless APG-aligned contract for a button that toggles the visibility of a menu. It manages the trigger state, expanded state, and keyboard shortcuts to open the menu.
6
+
7
+ ## Component Files
8
+
9
+ - `src/menu-button/index.ts` - model and public `createMenuButton` API
10
+ - `src/menu-button/menu-button.test.ts` - unit behavior tests
11
+
12
+ ## Public API
13
+
14
+ - `createMenuButton(options)`
15
+ - `state` (signal-backed):
16
+ - `isOpen()`
17
+ - `activeId()` (of the menu item)
18
+ - `actions`:
19
+ - `open`, `close`, `toggle`
20
+ - `handleKeyDown`
21
+ - `contracts`:
22
+ - `getTriggerProps()`
23
+ - `getMenuProps()`
24
+ - `getItemProps(id)`
25
+
26
+ ## APG and A11y Contract
27
+
28
+ - trigger role: `button`
29
+ - trigger attributes:
30
+ - `aria-haspopup="menu"`
31
+ - `aria-expanded` (reflects `isOpen`)
32
+ - `aria-controls` (links to menu ID)
33
+ - menu role: `menu`
34
+ - item role: `menuitem` (or `menuitemcheckbox`, `menuitemradio`)
35
+
36
+ ## Behavior Contract
37
+
38
+ - trigger keyboard support:
39
+ - `Enter`, `Space`, or `ArrowDown` opens the menu and moves focus to the first item
40
+ - `ArrowUp` opens the menu and moves focus to the last item
41
+ - menu keyboard support:
42
+ - `Escape` closes the menu and returns focus to the trigger
43
+ - `Tab` closes the menu and moves focus to the next focusable element
44
+ - `ArrowDown/ArrowUp` cycles focus through menu items
45
+ - dismissal:
46
+ - clicking outside the menu or trigger closes the menu
47
+ - selecting an item closes the menu (configurable)
48
+
49
+ ## Invariants
50
+
51
+ - `isOpen` is the single source of truth for the menu's visibility
52
+ - focus must be returned to the trigger when the menu is closed via `Escape` or item selection
53
+ - disabled menu items are skipped during keyboard navigation
54
+
55
+ ## Minimum Test Matrix
56
+
57
+ - toggle menu visibility via trigger click
58
+ - open menu via `ArrowDown` (focus first item)
59
+ - open menu via `ArrowUp` (focus last item)
60
+ - close menu via `Escape` (return focus to trigger)
61
+ - close menu via outside click
62
+ - close menu via item selection
63
+ - verify ARIA attributes on trigger and menu
64
+
65
+ ## ADR-001 Compliance
66
+
67
+ - **Runtime Policy**: Reatom v1000 only; no @statx/\* in headless core.
68
+ - **Layering**: core -> interactions -> a11y-contracts -> adapters; adapters remain thin mappings.
69
+ - **Independence**: No imports from @project/_, apps/_, or other out-of-package modules.
70
+ - **Verification**: Mandatory adapter integration tests and standalone package test execution.
71
+
72
+ ## Out of Scope (Current)
73
+
74
+ - submenus (nested menus)
75
+ - context menus (right-click)
76
+ - complex positioning (handled by consumer)