@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,623 @@
1
+ # Menu Component Contract
2
+
3
+ ## Purpose
4
+
5
+ `Menu` provides a headless model for menu button and menu interactions.
6
+
7
+ It handles trigger-open-close lifecycle, active-item navigation,
8
+ dismiss behavior, deterministic item selection, typeahead character navigation,
9
+ checkable items (checkbox and radio), submenu management, and split button
10
+ trigger support — all without visual rendering.
11
+
12
+ ## Component Files
13
+
14
+ - `src/menu/index.ts` - model and public `createMenu` API
15
+ - `src/menu/menu.test.ts` - unit behavior tests
16
+
17
+ ## Public API
18
+
19
+ ### `createMenu(options)`
20
+
21
+ ```ts
22
+ interface MenuItem {
23
+ id: string
24
+ label?: string
25
+ disabled?: boolean
26
+ type?: 'normal' | 'checkbox' | 'radio' // NEW — default: 'normal'
27
+ group?: string // NEW — radio group name (for type='radio')
28
+ checked?: boolean // NEW — initial checked state (for checkbox/radio)
29
+ hasSubmenu?: boolean // NEW — whether this item opens a submenu
30
+ }
31
+
32
+ interface MenuGroup {
33
+ // NEW
34
+ id: string
35
+ type: 'checkbox' | 'radio'
36
+ label?: string
37
+ }
38
+
39
+ interface CreateMenuOptions {
40
+ items: readonly MenuItem[]
41
+ idBase?: string
42
+ ariaLabel?: string
43
+ initialOpen?: boolean
44
+ initialActiveId?: string | null
45
+ closeOnSelect?: boolean // default: true
46
+ typeahead?: boolean // NEW — default: true
47
+ typeaheadTimeout?: number // NEW — default: 500 (ms)
48
+ groups?: readonly MenuGroup[] // NEW — group definitions
49
+ splitButton?: boolean // NEW — enable split button pattern (default: false)
50
+ }
51
+ ```
52
+
53
+ ### Return: `MenuModel`
54
+
55
+ ```ts
56
+ interface MenuModel {
57
+ readonly state: MenuState
58
+ readonly actions: MenuActions
59
+ readonly contracts: MenuContracts
60
+ }
61
+ ```
62
+
63
+ ## State Signal Surface
64
+
65
+ All state is signal-backed (Reatom atoms). UIKit reads these reactively to drive re-renders.
66
+
67
+ | Signal | Type | Description | Status |
68
+ | ------------------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------- | -------- |
69
+ | `isOpen()` | `boolean` | Menu visibility | existing |
70
+ | `activeId()` | `string \| null` | Currently highlighted item id | existing |
71
+ | `selectedId()` | `string \| null` | Last selected item id | existing |
72
+ | `openedBy()` | `MenuOpenSource \| null` | Source that triggered the current open: `'keyboard'`, `'pointer'`, or `'programmatic'`. Resets to `null` on close. | existing |
73
+ | `hasSelection` | `Computed<boolean>` | Whether any item has been selected (`selectedId != null`) | existing |
74
+ | `checkedIds()` | `ReadonlySet<string>` | Set of currently checked item ids. Initialized from items with `checked: true`. Updated on checkbox toggle and radio group selection. | **NEW** |
75
+ | `openSubmenuId()` | `string \| null` | Id of the currently open submenu parent item, or `null` if no submenu is open. Reset to `null` on close. | **NEW** |
76
+ | `submenuActiveId()` | `string \| null` | Id of the currently highlighted item within the open submenu, or `null`. Reset to `null` on close or submenu close. | **NEW** |
77
+
78
+ ```ts
79
+ type MenuOpenSource = 'keyboard' | 'pointer' | 'programmatic'
80
+ ```
81
+
82
+ ## Actions
83
+
84
+ All state transitions go through these actions. UIKit must never mutate state directly.
85
+
86
+ | Action | Signature | Description | Status |
87
+ | ------------------------ | -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ |
88
+ | `open` | `(source?: MenuOpenSource) => void` | Opens the menu. Sets `openedBy`. If no `activeId` is set, sets it to the first enabled item. Resets submenu state. | existing |
89
+ | `close` | `() => void` | Closes the menu. Resets `activeId`, `openedBy`, `openSubmenuId`, `submenuActiveId` to `null`. | existing (updated) |
90
+ | `toggle` | `(source?: MenuOpenSource) => void` | Toggles open/close. | existing |
91
+ | `setActive` | `(id: string \| null) => void` | Sets the active (highlighted) item. No-op for disabled items. | existing |
92
+ | `moveNext` | `() => void` | Moves active to next enabled item (wrapping). | existing |
93
+ | `movePrev` | `() => void` | Moves active to previous enabled item (wrapping). | existing |
94
+ | `moveFirst` | `() => void` | Moves active to first enabled item. | existing |
95
+ | `moveLast` | `() => void` | Moves active to last enabled item. | existing |
96
+ | `select` | `(id: string) => void` | Selects an item. For `type='checkbox'`: toggles the item in `checkedIds`. For `type='radio'`: sets item as only checked in its group. For `type='normal'`: sets `selectedId`. If `closeOnSelect` is true, closes the menu. No-op for disabled or unknown items. | existing (updated) |
97
+ | `toggleCheck` | `(id: string) => void` | Toggles checked state for a checkbox item. For radio items, sets item as only checked in its group. No-op for normal items, disabled items, or unknown ids. | **NEW** |
98
+ | `openSubmenu` | `(id: string) => void` | Opens the submenu for the given parent item id. Sets `openSubmenuId` to `id`. Sets `submenuActiveId` to first enabled child. No-op if item does not have `hasSubmenu: true`. | **NEW** |
99
+ | `closeSubmenu` | `() => void` | Closes the currently open submenu. Resets `openSubmenuId` and `submenuActiveId` to `null`. | **NEW** |
100
+ | `handleTypeahead` | `(char: string) => void` | Handles a single printable character for typeahead navigation. Advances the character buffer, matches items by label prefix, and moves `activeId` to the matched item. If a submenu is open, searches submenu children instead. No-op if `typeahead` option is `false`. | **NEW** |
101
+ | `handleTriggerKeyDown` | `(event: Pick<KeyboardEvent, 'key'>) => void` | Handles keyboard on the trigger element. `ArrowDown` opens and focuses first item. `ArrowUp` opens and focuses last item. `Enter`/`Space` toggles. | existing |
102
+ | `handleMenuKeyDown` | `(event: MenuKeyboardEventLike) => void` | Handles keyboard inside the open menu. When a submenu is open, delegates navigation to submenu. `ArrowRight` on a submenu item opens the submenu. `ArrowLeft` closes the submenu. Printable characters trigger typeahead. All other keys handled as before. | existing (updated) |
103
+ | `handleItemPointerEnter` | `(id: string) => void` | Handles pointer enter on a menu item. Sets `activeId` immediately. If item has submenu, starts ~200ms hover intent timer. If item does not have submenu, cancels pending timer and closes open submenu. | **NEW** |
104
+ | `handleItemPointerLeave` | `(id: string) => void` | Handles pointer leave on a menu item. Cancels pending hover intent timer if it was for this item. | **NEW** |
105
+ | `setSubmenuItems` | `(parentId: string, items: readonly MenuItem[]) => void` | Provides submenu child items for a parent item. Must be called before `openSubmenu` for the parent to have navigable children. | **NEW** |
106
+
107
+ ```ts
108
+ interface MenuKeyboardEventLike {
109
+ key: string
110
+ shiftKey?: boolean
111
+ ctrlKey?: boolean
112
+ metaKey?: boolean
113
+ altKey?: boolean
114
+ }
115
+ ```
116
+
117
+ ## Contracts
118
+
119
+ Contracts return complete ARIA prop objects ready to spread onto DOM elements.
120
+
121
+ ### `getTriggerProps(): MenuTriggerProps`
122
+
123
+ ```ts
124
+ interface MenuTriggerProps {
125
+ id: string // '{idBase}-trigger'
126
+ tabindex: '0'
127
+ 'aria-haspopup': 'menu'
128
+ 'aria-expanded': 'true' | 'false'
129
+ 'aria-controls': string // '{idBase}-menu'
130
+ 'aria-label'?: string
131
+ }
132
+ ```
133
+
134
+ Existing, unchanged.
135
+
136
+ ### `getMenuProps(): MenuProps`
137
+
138
+ ```ts
139
+ interface MenuProps {
140
+ id: string // '{idBase}-menu'
141
+ role: 'menu'
142
+ tabindex: '-1'
143
+ 'aria-label'?: string
144
+ 'aria-activedescendant'?: string // NEW — DOM id of active item when open
145
+ }
146
+ ```
147
+
148
+ **Updated**: Added `aria-activedescendant` which references the active item's DOM id when the menu is open and an item is active.
149
+
150
+ ### `getItemProps(id: string): MenuItemProps`
151
+
152
+ ```ts
153
+ interface MenuItemProps {
154
+ id: string // '{idBase}-item-{id}'
155
+ role: 'menuitem' | 'menuitemcheckbox' | 'menuitemradio' // UPDATED
156
+ tabindex: '-1'
157
+ 'aria-disabled'?: 'true' // present only for disabled items
158
+ 'data-active': 'true' | 'false' // reflects activeId === id
159
+ 'aria-checked'?: 'true' | 'false' // NEW — present for checkbox and radio items
160
+ 'aria-haspopup'?: 'menu' // NEW — present for submenu items
161
+ 'aria-expanded'?: 'true' | 'false' // NEW — present for submenu items
162
+ }
163
+ ```
164
+
165
+ **Updated**: Role assignment per item type:
166
+
167
+ - `type='normal'` (default) — `role: 'menuitem'`
168
+ - `type='checkbox'` — `role: 'menuitemcheckbox'`
169
+ - `type='radio'` — `role: 'menuitemradio'`
170
+
171
+ For items with `hasSubmenu: true`, `aria-haspopup` is set to `'menu'` and `aria-expanded` reflects whether the submenu is currently open (`openSubmenuId === id`).
172
+
173
+ For checkbox and radio items, `aria-checked` reflects whether the item id is present in `checkedIds`.
174
+
175
+ ### `getSubmenuProps(parentItemId: string): MenuSubmenuProps` — NEW
176
+
177
+ ```ts
178
+ interface MenuSubmenuProps {
179
+ id: string // '{idBase}-submenu-{parentItemId}'
180
+ role: 'menu'
181
+ tabindex: '-1'
182
+ hidden: boolean // openSubmenuId !== parentItemId
183
+ 'aria-label'?: string // from parent item label
184
+ }
185
+ ```
186
+
187
+ Returns props for the submenu container associated with a parent item. `hidden` reflects whether the submenu is currently open.
188
+
189
+ ### `getSubmenuItemProps(parentItemId: string, childId: string): MenuItemProps` — NEW
190
+
191
+ ```ts
192
+ // Returns MenuItemProps (same interface as getItemProps)
193
+ ```
194
+
195
+ Returns props for an item within a submenu. `data-active` reflects `submenuActiveId === childId` instead of `activeId`. Role, `aria-checked`, `aria-disabled` follow the same rules as `getItemProps`.
196
+
197
+ ### `getSplitTriggerProps(): MenuSplitTriggerProps` — NEW
198
+
199
+ ```ts
200
+ interface MenuSplitTriggerProps {
201
+ id: string // '{idBase}-split-action'
202
+ tabindex: '0'
203
+ role: 'button'
204
+ }
205
+ ```
206
+
207
+ Returns props for the action portion of a split button. This is the primary action area that does NOT open the menu. Only returned when `splitButton: true`. Throws if `splitButton` is not enabled.
208
+
209
+ ### `getSplitDropdownProps(): MenuSplitDropdownProps` — NEW
210
+
211
+ ```ts
212
+ interface MenuSplitDropdownProps {
213
+ id: string // '{idBase}-split-dropdown'
214
+ tabindex: '0'
215
+ role: 'button'
216
+ 'aria-haspopup': 'menu'
217
+ 'aria-expanded': 'true' | 'false'
218
+ 'aria-controls': string // '{idBase}-menu'
219
+ 'aria-label': string // 'More options' or from ariaLabel
220
+ }
221
+ ```
222
+
223
+ Returns props for the dropdown arrow portion of a split button. This is the trigger that opens the menu. Only returned when `splitButton: true`. Throws if `splitButton` is not enabled.
224
+
225
+ When `splitButton: true`, `getTriggerProps()` is still available and returns the same props as `getSplitDropdownProps()` (they are equivalent). This ensures backward compatibility for adapters that use `getTriggerProps()`.
226
+
227
+ ### `getGroupProps(groupId: string): MenuGroupProps` — NEW
228
+
229
+ ```ts
230
+ interface MenuGroupProps {
231
+ id: string // '{idBase}-group-{groupId}'
232
+ role: 'group'
233
+ 'aria-label'?: string // from group.label
234
+ }
235
+ ```
236
+
237
+ Returns props for a group container element. Groups provide semantic grouping for related checkbox or radio items.
238
+
239
+ ## APG and A11y Contract
240
+
241
+ - trigger exposes `aria-haspopup="menu"`, `aria-expanded`, `aria-controls`
242
+ - popup role: `menu`
243
+ - item role: `menuitem` (default), `menuitemcheckbox` (for `type='checkbox'`), or `menuitemradio` (for `type='radio'`) — **UPDATED**
244
+ - disabled items expose `aria-disabled="true"`
245
+ - checkable items expose `aria-checked="true"` or `aria-checked="false"` — **NEW**
246
+ - submenu items expose `aria-haspopup="menu"` and `aria-expanded` — **NEW**
247
+ - submenu container role: `menu` with `tabindex="-1"` and `hidden` — **NEW**
248
+ - group container role: `group` with optional `aria-label` — **NEW**
249
+ - split button: action area has `role="button"`, dropdown area has `role="button"` with `aria-haspopup="menu"` — **NEW**
250
+
251
+ ## Keyboard Contract
252
+
253
+ ### Trigger Element
254
+
255
+ | Key | Action | Status |
256
+ | ----------- | ----------------------------------- | -------- |
257
+ | `ArrowDown` | Open menu, focus first enabled item | existing |
258
+ | `ArrowUp` | Open menu, focus last enabled item | existing |
259
+ | `Enter` | Toggle open state | existing |
260
+ | `Space` | Toggle open state | existing |
261
+
262
+ ### Menu Element (when open, no submenu)
263
+
264
+ | Key | Action | Status |
265
+ | ------------------- | ---------------------------------------------------------------------- | ------------------ |
266
+ | `ArrowDown` | Move active to next enabled item (wrapping) | existing |
267
+ | `ArrowUp` | Move active to previous enabled item (wrapping) | existing |
268
+ | `Home` | Move active to first enabled item | existing |
269
+ | `End` | Move active to last enabled item | existing |
270
+ | `Enter` | Select active item | existing |
271
+ | `Space` | Select active item (for checkable items: toggle check state) | existing (updated) |
272
+ | `Escape` | Close menu | existing |
273
+ | `ArrowRight` | If active item has submenu: open submenu, focus first child | **NEW** |
274
+ | `ArrowLeft` | No-op (at top-level menu) | **NEW** |
275
+ | Printable character | Typeahead: advance query, move active to matching item by label prefix | **NEW** |
276
+
277
+ ### Submenu (when open) — NEW
278
+
279
+ | Key | Action |
280
+ | ------------------- | -------------------------------------------------------------------------------------- |
281
+ | `Escape` | Close submenu, return focus to parent menu item |
282
+ | `ArrowLeft` | Close submenu, return focus to parent menu item |
283
+ | `ArrowDown` | Move `submenuActiveId` to next enabled submenu item (wrapping) |
284
+ | `ArrowUp` | Move `submenuActiveId` to previous enabled submenu item (wrapping) |
285
+ | `Home` | Move `submenuActiveId` to first enabled submenu item |
286
+ | `End` | Move `submenuActiveId` to last enabled submenu item |
287
+ | `Enter` | Select active submenu item |
288
+ | `Space` | Select active submenu item |
289
+ | `ArrowRight` | If active submenu item has nested submenu: open it (future — currently one level only) |
290
+ | Printable character | Typeahead within submenu items |
291
+
292
+ ## Behavior Contract
293
+
294
+ - trigger keyboard support:
295
+ - `ArrowDown` opens and focuses first enabled item
296
+ - `ArrowUp` opens and focuses last enabled item
297
+ - `Enter` and `Space` toggle open state
298
+ - menu keyboard support:
299
+ - `ArrowUp/ArrowDown` navigation (with disabled-item skip)
300
+ - `Home/End` first/last navigation
301
+ - `Enter` activates selected item
302
+ - `Escape` closes menu
303
+ - `ArrowRight` opens submenu on submenu items — **NEW**
304
+ - `ArrowLeft` closes submenu — **NEW**
305
+ - Printable characters trigger typeahead navigation — **NEW**
306
+ - selection closes menu by default (`closeOnSelect: true`)
307
+ - pointer path is tracked via `openedBy: 'pointer'`
308
+ - **NEW**: For checkbox items, `select()` toggles checked state in `checkedIds` without closing the menu (unless `closeOnSelect` is explicitly true)
309
+ - **NEW**: For radio items, `select()` sets the item as checked and unchecks all other items in the same group
310
+ - **NEW**: Submenu open uses hover intent with ~200ms delay timer when triggered by pointer
311
+ - **NEW**: Submenu items that have children receive `aria-haspopup="menu"` and `aria-expanded`
312
+ - **NEW**: Split button mode provides separate action and dropdown trigger areas
313
+
314
+ ### Typeahead Behavior — NEW
315
+
316
+ - Enabled by default (`typeahead: true`)
317
+ - Uses `interactions/typeahead` utility for character buffering
318
+ - Buffer timeout: configurable via `typeaheadTimeout` (default 500ms)
319
+ - On each printable character key:
320
+ 1. Advance the typeahead buffer
321
+ 2. Build a query from the buffer (handles repeated character sequences)
322
+ 3. Search items by normalized label prefix, starting from the item after `activeId`
323
+ 4. If a match is found, set `activeId` to the matching item
324
+ 5. If no match, do not change `activeId`
325
+ - When a submenu is open, typeahead searches submenu children instead
326
+ - Space is excluded from typeahead (used for selection)
327
+ - Modifier keys (Ctrl, Meta, Alt) exclude the key from typeahead
328
+
329
+ ### Checkable Item Behavior — NEW
330
+
331
+ - Items with `type='checkbox'`:
332
+ - Toggle their presence in `checkedIds` when selected
333
+ - `aria-checked` reflects their state
334
+ - Default: do NOT close menu on selection (override `closeOnSelect` for checkable items)
335
+ - Items with `type='radio'`:
336
+ - Must have a `group` property
337
+ - On selection: uncheck all other items in the same `group`, check the selected item
338
+ - `aria-checked` reflects their state
339
+ - Default: do NOT close menu on selection (override `closeOnSelect` for checkable items)
340
+ - `checkedIds` is initialized from items that have `checked: true`
341
+ - Radio group invariant: at most one item per group is checked at any time
342
+
343
+ ### Submenu Behavior — NEW
344
+
345
+ - Items with `hasSubmenu: true` can have an associated submenu
346
+ - Submenu items are not provided as children in the `items` array; instead, the submenu is a separate menu model or the adapter provides children via `getSubmenuProps()`
347
+ - The headless model manages submenu open/close state and focus transitions
348
+ - **Opening**: `ArrowRight` on a submenu item, or hover intent (~200ms delay)
349
+ - **Closing**: `ArrowLeft` or `Escape` while submenu is open
350
+ - Only one submenu can be open at a time
351
+ - When a submenu opens, `submenuActiveId` is set to the first enabled child
352
+ - When a submenu closes, focus returns to the parent item (`activeId` is unchanged)
353
+ - Submenu items for navigation must be provided via `setSubmenuItems(parentId, items)` action or as part of the initial configuration
354
+
355
+ #### Hover Intent
356
+
357
+ - When the pointer enters a submenu item, start a ~200ms delay timer
358
+ - If the pointer leaves the item before the timer fires, cancel the timer
359
+ - If the timer fires, open the submenu
360
+ - If the pointer enters a different submenu item, close the current submenu and start a new timer
361
+ - Actions for hover:
362
+ - `handleItemPointerEnter(id: string)` — start hover intent timer if item has submenu; immediately set `activeId`
363
+ - `handleItemPointerLeave(id: string)` — cancel hover intent timer
364
+
365
+ ### Split Button Behavior — NEW
366
+
367
+ - When `splitButton: true`, the trigger is conceptually split into two areas:
368
+ 1. **Action area** (`getSplitTriggerProps()`): performs the primary action (e.g., "Save")
369
+ 2. **Dropdown area** (`getSplitDropdownProps()`): opens the menu with alternative actions
370
+ - The dropdown area behaves identically to a regular menu trigger
371
+ - The action area does NOT interact with the menu model (it is the adapter's responsibility to wire the action)
372
+ - `getTriggerProps()` returns the same result as `getSplitDropdownProps()` in split button mode for backward compatibility
373
+
374
+ ## Transition Model
375
+
376
+ ### Core Transitions
377
+
378
+ | Event / Action | Preconditions | Next State | Status |
379
+ | ----------------------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------- | ------------------ |
380
+ | `open(source)` | none | `isOpen=true`; `openedBy=source`; `activeId=first enabled item`; `openSubmenuId=null`; `submenuActiveId=null` | existing (updated) |
381
+ | `close()` | none | `isOpen=false`; `activeId=null`; `openedBy=null`; `openSubmenuId=null`; `submenuActiveId=null` | existing (updated) |
382
+ | `toggle(source)` | `isOpen=false` | same as `open(source)` | existing |
383
+ | `toggle(source)` | `isOpen=true` | same as `close()` | existing |
384
+ | `select(id)` (normal) | item exists, not disabled | `selectedId=id`; `activeId=id`; if `closeOnSelect`: close | existing |
385
+ | `select(id)` (checkbox) | item exists, not disabled, type=checkbox | toggles `id` in `checkedIds`; does NOT close menu by default | **NEW** |
386
+ | `select(id)` (radio) | item exists, not disabled, type=radio, has group | unchecks all items in same group, adds `id` to `checkedIds`; does NOT close menu by default | **NEW** |
387
+ | `select(id)` | item disabled or unknown | no-op | existing |
388
+ | `toggleCheck(id)` (checkbox) | item exists, type=checkbox | toggles `id` in `checkedIds` | **NEW** |
389
+ | `toggleCheck(id)` (radio) | item exists, type=radio, has group | unchecks all in group, adds `id` | **NEW** |
390
+ | `toggleCheck(id)` | item is normal, disabled, or unknown | no-op | **NEW** |
391
+ | `openSubmenu(id)` | item has `hasSubmenu: true` | `openSubmenuId=id`; `submenuActiveId=first enabled child` | **NEW** |
392
+ | `openSubmenu(id)` | item does not have submenu | no-op | **NEW** |
393
+ | `closeSubmenu()` | `openSubmenuId != null` | `openSubmenuId=null`; `submenuActiveId=null` | **NEW** |
394
+ | `closeSubmenu()` | `openSubmenuId == null` | no-op | **NEW** |
395
+ | `handleTypeahead(char)` | `isOpen=true`, `typeahead=true` | buffer advanced; `activeId` or `submenuActiveId` moves to match | **NEW** |
396
+ | `handleTypeahead(char)` | `isOpen=false` or `typeahead=false` | no-op | **NEW** |
397
+ | `handleMenuKeyDown(ArrowRight)` | `isOpen=true`, `activeId` has submenu | `openSubmenu(activeId)` | **NEW** |
398
+ | `handleMenuKeyDown(ArrowLeft)` | `isOpen=true`, submenu open | `closeSubmenu()` | **NEW** |
399
+ | `handleMenuKeyDown(printable)` | `isOpen=true`, `typeahead=true` | `handleTypeahead(key)` | **NEW** |
400
+ | `handleMenuKeyDown(ArrowDown)` | submenu open | `submenuActiveId` moves to next enabled child (wrapping) | **NEW** |
401
+ | `handleMenuKeyDown(ArrowUp)` | submenu open | `submenuActiveId` moves to previous enabled child (wrapping) | **NEW** |
402
+ | `handleMenuKeyDown(Home)` | submenu open | `submenuActiveId` moves to first enabled child | **NEW** |
403
+ | `handleMenuKeyDown(End)` | submenu open | `submenuActiveId` moves to last enabled child | **NEW** |
404
+ | `handleMenuKeyDown(Enter/Space)` | submenu open, `submenuActiveId!=null` | `select(submenuActiveId)` | **NEW** |
405
+ | `handleMenuKeyDown(Escape)` | submenu open | `closeSubmenu()` | **NEW** |
406
+ | `handleMenuKeyDown(Escape)` | no submenu open | `close()` | existing |
407
+ | `handleMenuKeyDown(ArrowDown)` | no submenu | `activeId` moves to next enabled item (wrapping) | existing |
408
+ | `handleMenuKeyDown(ArrowUp)` | no submenu | `activeId` moves to previous enabled item (wrapping) | existing |
409
+ | `handleMenuKeyDown(Home)` | no submenu | `activeId` moves to first enabled item | existing |
410
+ | `handleMenuKeyDown(End)` | no submenu | `activeId` moves to last enabled item | existing |
411
+ | `handleMenuKeyDown(Enter)` | no submenu, `activeId!=null` | `select(activeId)` | existing |
412
+ | `handleMenuKeyDown(Space)` | no submenu, `activeId!=null` | `select(activeId)` | existing |
413
+ | `handleMenuKeyDown(*)` | `isOpen=false` | no-op | existing |
414
+ | `handleItemPointerEnter(id)` | item has submenu | start ~200ms hover timer; on fire: `openSubmenu(id)`. Set `activeId=id`. | **NEW** |
415
+ | `handleItemPointerEnter(id)` | item does not have submenu | cancel any pending hover timer; close open submenu; set `activeId=id`. | **NEW** |
416
+ | `handleItemPointerLeave(id)` | timer pending for `id` | cancel timer | **NEW** |
417
+ | `handleTriggerKeyDown(ArrowDown)` | none | `open('keyboard')`; `activeId=first enabled item` | existing |
418
+ | `handleTriggerKeyDown(ArrowUp)` | none | `open('keyboard')`; `activeId=last enabled item` | existing |
419
+ | `handleTriggerKeyDown(Enter/Space)` | none | `toggle('keyboard')` | existing |
420
+
421
+ ### Submenu Open/Close Flow — NEW
422
+
423
+ ```
424
+ ArrowRight on submenu item
425
+ -> openSubmenuId=activeId
426
+ -> submenuActiveId=first enabled child
427
+
428
+ Escape or ArrowLeft while submenu is open
429
+ -> openSubmenuId=null
430
+ -> submenuActiveId=null
431
+ -> focus returns to parent menu item
432
+
433
+ Hover enter on submenu item
434
+ -> start 200ms timer
435
+ -> on timer fire: openSubmenuId=id, submenuActiveId=first enabled child
436
+
437
+ Hover enter on non-submenu item
438
+ -> cancel pending timer
439
+ -> close open submenu
440
+ ```
441
+
442
+ ### Checkbox Toggle Flow — NEW
443
+
444
+ ```
445
+ select(id) where item.type='checkbox'
446
+ -> toggle id in checkedIds
447
+ -> do NOT close menu (checkable items override closeOnSelect)
448
+ ```
449
+
450
+ ### Radio Selection Flow — NEW
451
+
452
+ ```
453
+ select(id) where item.type='radio', item.group='groupName'
454
+ -> remove all items with group='groupName' from checkedIds
455
+ -> add id to checkedIds
456
+ -> do NOT close menu (checkable items override closeOnSelect)
457
+ ```
458
+
459
+ ### Typeahead Flow — NEW
460
+
461
+ ```
462
+ printable character key in open menu (typeahead=true)
463
+ -> advance typeahead buffer
464
+ -> build query (handle repeated chars)
465
+ -> search items by label prefix from activeId+1 (wrap around)
466
+ -> if match: set activeId to matched item
467
+ -> if no match: no change
468
+ -> if submenu open: search submenu children, set submenuActiveId
469
+ ```
470
+
471
+ ## Invariants
472
+
473
+ 1. `activeId` is always `null` or an enabled item id (existing)
474
+ 2. Disabled items cannot become selected (existing)
475
+ 3. Close action always resets `openedBy` to `null` (existing)
476
+ 4. **NEW**: `checkedIds` is only modified through `select()` or `toggleCheck()` on checkbox or radio items; never externally
477
+ 5. **NEW**: In a radio group, at most one item is checked at a time. Selecting a radio item unchecks all others in the same group.
478
+ 6. **NEW**: `openSubmenuId` and `submenuActiveId` must be `null` whenever `isOpen` is `false`
479
+ 7. **NEW**: Only one submenu can be open at a time
480
+ 8. **NEW**: `submenuActiveId` must be `null` whenever `openSubmenuId` is `null`
481
+ 9. **NEW**: `getItemProps` role must match item type: `menuitem` for normal, `menuitemcheckbox` for checkbox, `menuitemradio` for radio
482
+ 10. **NEW**: `aria-checked` is only present on checkbox and radio items
483
+ 11. **NEW**: `aria-haspopup` and `aria-expanded` are only present on items with `hasSubmenu: true`
484
+ 12. **NEW**: Split button contracts (`getSplitTriggerProps`, `getSplitDropdownProps`) throw if `splitButton` option is not `true`
485
+ 13. **NEW**: Typeahead buffer resets after `typeaheadTimeout` ms of inactivity
486
+
487
+ ## Adapter Expectations — NEW
488
+
489
+ UIKit adapter (`cv-menu`) will:
490
+
491
+ **Signals read (reactive, drive re-renders):**
492
+
493
+ - `state.isOpen()` — menu visibility, controls `hidden` attribute and positioning
494
+ - `state.activeId()` — highlighted item id, used to sync `data-active` and visual focus
495
+ - `state.selectedId()` — last selected item id, included in event detail
496
+ - `state.openedBy()` — open source, may affect focus management strategy
497
+ - `state.hasSelection()` — whether any item has been selected
498
+ - `state.checkedIds()` — set of checked item ids, used to sync `aria-checked` on checkbox/radio items
499
+ - `state.openSubmenuId()` — id of open submenu parent, used to show/hide submenu containers
500
+ - `state.submenuActiveId()` — active item within open submenu, used to sync `data-active` and focus
501
+
502
+ **Actions called (event handlers, never mutate state directly):**
503
+
504
+ - `actions.open(source)` — on trigger click or programmatic open
505
+ - `actions.close()` — on dismiss or programmatic close
506
+ - `actions.toggle(source)` — on trigger click
507
+ - `actions.select(id)` — on item click or Enter/Space
508
+ - `actions.toggleCheck(id)` — explicit check toggle (alternative to select for checkable items)
509
+ - `actions.openSubmenu(id)` — on ArrowRight or hover intent
510
+ - `actions.closeSubmenu()` — on ArrowLeft or Escape in submenu
511
+ - `actions.handleTypeahead(char)` — on printable character keypress in open menu
512
+ - `actions.handleTriggerKeyDown(event)` — on keydown in trigger
513
+ - `actions.handleMenuKeyDown(event)` — on keydown inside menu
514
+ - `actions.handleItemPointerEnter(id)` — on pointerenter on menu item (for submenu hover intent)
515
+ - `actions.handleItemPointerLeave(id)` — on pointerleave on menu item (cancel hover intent)
516
+ - `actions.setActive(id)` — on pointer hover over item (non-submenu active tracking)
517
+ - `actions.moveNext()` / `actions.movePrev()` / `actions.moveFirst()` / `actions.moveLast()` — programmatic navigation
518
+
519
+ **Contracts spread (attribute maps applied directly to DOM elements):**
520
+
521
+ - `contracts.getTriggerProps()` — applied to trigger element (or split dropdown in split-button mode)
522
+ - `contracts.getMenuProps()` — applied to menu container element
523
+ - `contracts.getItemProps(id)` — applied to each menu item element
524
+ - `contracts.getSubmenuProps(parentItemId)` — applied to submenu container elements
525
+ - `contracts.getSubmenuItemProps(parentItemId, childId)` — applied to submenu item elements
526
+ - `contracts.getSplitTriggerProps()` — applied to split button action area (only when `splitButton: true`)
527
+ - `contracts.getSplitDropdownProps()` — applied to split button dropdown area (only when `splitButton: true`)
528
+ - `contracts.getGroupProps(groupId)` — applied to group container elements
529
+
530
+ **UIKit-only concerns (NOT in headless):**
531
+
532
+ - Popup positioning and viewport collision detection
533
+ - Slotted item element discovery and `slotchange` observation
534
+ - Item element attribute synchronization (imperative DOM updates)
535
+ - Focus management (focusing active item, restoring focus to trigger on close)
536
+ - `input` and `change` custom event dispatch for checkable items
537
+ - `value` / `open` property reflection and attribute sync
538
+ - `preventDefault()` on keyboard events that should not propagate
539
+ - Submenu positioning relative to parent item
540
+ - Hover intent pointer tracking within menu regions
541
+ - Model rebuild on slot content changes or config property changes
542
+
543
+ ## Minimum Test Matrix
544
+
545
+ **Existing tests:**
546
+
547
+ - keyboard and pointer open paths
548
+ - trigger toggle behavior
549
+ - navigation and disabled skip behavior
550
+ - Enter activation behavior
551
+ - Escape dismissal behavior
552
+ - roles/aria props contract checks
553
+
554
+ **New tests — Typeahead:**
555
+
556
+ - printable character moves active to matching item by label prefix
557
+ - typeahead buffer accumulates characters within timeout
558
+ - typeahead buffer resets after timeout
559
+ - typeahead wraps around to beginning of list
560
+ - repeated same character cycles through matching items
561
+ - space is not treated as typeahead character
562
+ - modifier keys exclude character from typeahead
563
+ - typeahead disabled when `typeahead: false`
564
+ - typeahead within open submenu searches submenu children
565
+
566
+ **New tests — Checkable items:**
567
+
568
+ - checkbox item has `role=menuitemcheckbox` and `aria-checked`
569
+ - checkbox toggle updates `checkedIds`
570
+ - checkbox select does not close menu by default
571
+ - radio item has `role=menuitemradio` and `aria-checked`
572
+ - radio selection updates `checkedIds` (only one in group)
573
+ - radio select does not close menu by default
574
+ - initial `checked: true` items appear in `checkedIds`
575
+ - `toggleCheck` toggles checkbox item
576
+ - `toggleCheck` on radio sets only that item in group
577
+ - `toggleCheck` is no-op for normal items
578
+ - disabled checkable items cannot be toggled
579
+
580
+ **New tests — Submenu:**
581
+
582
+ - submenu item has `aria-haspopup=menu` and `aria-expanded`
583
+ - ArrowRight opens submenu on submenu item
584
+ - ArrowRight is no-op on non-submenu item
585
+ - ArrowLeft closes submenu
586
+ - Escape closes submenu (not entire menu)
587
+ - submenu navigation (ArrowDown/ArrowUp/Home/End on `submenuActiveId`)
588
+ - submenu item selection (Enter/Space)
589
+ - `getSubmenuProps` returns correct `hidden` state
590
+ - `getSubmenuItemProps` returns correct `data-active` based on `submenuActiveId`
591
+ - only one submenu open at a time
592
+ - opening another submenu closes the current one
593
+ - closing menu resets submenu state
594
+ - hover intent opens submenu after ~200ms delay
595
+ - hover leave cancels pending submenu open
596
+ - hover on non-submenu item closes open submenu
597
+
598
+ **New tests — Split button:**
599
+
600
+ - `getSplitTriggerProps` returns action button props
601
+ - `getSplitDropdownProps` returns dropdown trigger props with `aria-haspopup`
602
+ - split contracts throw when `splitButton` is not enabled
603
+ - `getTriggerProps` in split mode returns same as `getSplitDropdownProps`
604
+ - dropdown area opens/closes menu normally
605
+
606
+ **New tests — Group props:**
607
+
608
+ - `getGroupProps` returns `role=group` with optional `aria-label`
609
+
610
+ ## ADR-001 Compliance
611
+
612
+ - **Runtime Policy**: Reatom v1000 only; no @statx/\* in headless core.
613
+ - **Layering**: core -> interactions -> a11y-contracts -> adapters; adapters remain thin mappings.
614
+ - **Independence**: No imports from @project/_, apps/_, or other out-of-package modules.
615
+ - **Verification**: Mandatory adapter integration tests and standalone package test execution.
616
+
617
+ ## Out of Scope (Current)
618
+
619
+ - Nested submenus beyond one level (future extension)
620
+ - Async item loading and virtualization
621
+ - Viewport collision/placement logic (UIKit concern)
622
+ - Animation and transition effects
623
+ - Context menu behavior (handled by `createContextMenu` which composes `createMenu`)