@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,556 @@
1
+ # Context Menu Component Contract
2
+
3
+ ## Purpose
4
+
5
+ `Context Menu` provides a headless model for pointer and keyboard-invoked contextual menu interactions. It composes the existing `createMenu` primitive for navigation and selection, adding right-click trigger behavior, coordinate-based positioning, keyboard invocation (`Shift+F10`, `ContextMenu` key), checkable items (checkbox/radio), sub-menus, separators, group labels, long-press touch support, and type-ahead character navigation on top.
6
+
7
+ ## Component Files
8
+
9
+ - `src/context-menu/index.ts` - model and public `createContextMenu` API
10
+ - `src/context-menu/context-menu.test.ts` - unit behavior tests
11
+
12
+ ## Public API
13
+
14
+ ### `createContextMenu(options)`
15
+
16
+ ```ts
17
+ interface CreateContextMenuOptions {
18
+ items: readonly ContextMenuItem[]
19
+ idBase?: string
20
+ ariaLabel?: string
21
+ closeOnSelect?: boolean // default: true (via composed menu)
22
+ closeOnOutsidePointer?: boolean // default: true
23
+ longPressDuration?: number // default: 500 (ms, for touch devices)
24
+ }
25
+ ```
26
+
27
+ ### `ContextMenuItem`
28
+
29
+ Context-menu uses its own item type that extends beyond the composed menu's `MenuItem` to support checkable items, separators, group labels, and sub-menus:
30
+
31
+ ```ts
32
+ type ContextMenuItemType = 'item' | 'separator' | 'group-label' | 'checkbox' | 'radio' | 'submenu'
33
+
34
+ interface ContextMenuItem {
35
+ id: string
36
+ label?: string
37
+ disabled?: boolean
38
+ type?: ContextMenuItemType // default: 'item'
39
+ checked?: boolean // initial checked state for checkbox/radio items
40
+ group?: string // radio group name for radio items
41
+ children?: readonly ContextMenuItem[] // children for submenu items
42
+ }
43
+ ```
44
+
45
+ Item types and their behavior:
46
+
47
+ - `'item'` (default) — standard actionable menu item
48
+ - `'separator'` — visual divider, not actionable, skipped during navigation
49
+ - `'group-label'` — label for a group of items, not actionable, skipped during navigation
50
+ - `'checkbox'` — toggleable item with `aria-checked` state
51
+ - `'radio'` — mutually exclusive item within a `group`, with `aria-checked` state
52
+ - `'submenu'` — item that opens a nested sub-menu via `children`
53
+
54
+ ### Return: `ContextMenuModel`
55
+
56
+ ```ts
57
+ interface ContextMenuModel {
58
+ readonly state: ContextMenuState
59
+ readonly actions: ContextMenuActions
60
+ readonly contracts: ContextMenuContracts
61
+ }
62
+ ```
63
+
64
+ ## State Signal Surface
65
+
66
+ All state is signal-backed (Reatom atoms). UIKit reads these reactively to drive re-renders.
67
+
68
+ | Signal | Type | Description |
69
+ | ------------------- | ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
70
+ | `isOpen()` | `boolean` | Menu visibility. Delegated to composed `createMenu`. |
71
+ | `activeId()` | `string \| null` | Currently highlighted item id. Delegated to composed `createMenu`. |
72
+ | `anchorX()` | `number` | X coordinate of the context menu anchor point (from right-click or imperative call). Initial: `0`. |
73
+ | `anchorY()` | `number` | Y coordinate of the context menu anchor point. Initial: `0`. |
74
+ | `openedBy()` | `ContextMenuOpenSource \| null` | Source that triggered the current open: `'pointer'`, `'keyboard'`, or `'programmatic'`. Resets to `null` on close. |
75
+ | `restoreTargetId()` | `string \| null` | DOM id of the element that should receive focus after menu close. Set to `'{idBase}-target'` on close/select-close. `null` while open. |
76
+ | `checkedIds()` | `ReadonlySet<string>` | Set of currently checked item ids. Initialized from items with `checked: true`. Updated on checkbox toggle and radio group selection. |
77
+ | `openSubmenuId()` | `string \| null` | Id of the currently open sub-menu parent item, or `null` if no sub-menu is open. Reset to `null` on close. |
78
+ | `submenuActiveId()` | `string \| null` | Id of the currently highlighted item within the open sub-menu, or `null`. Reset to `null` on close or sub-menu close. |
79
+
80
+ ```ts
81
+ type ContextMenuOpenSource = 'pointer' | 'keyboard' | 'programmatic'
82
+ ```
83
+
84
+ ### Composed State (from `createMenu`)
85
+
86
+ The following state is held internally by the composed `createMenu` instance and exposed through the context-menu model's `state`:
87
+
88
+ - `isOpen` -- re-exported directly from `menu.state.isOpen`
89
+ - `activeId` -- re-exported directly from `menu.state.activeId`
90
+
91
+ The composed menu also holds `selectedId`, `openedBy`, and `hasSelection`, but these are internal to the menu and not re-exported on the context-menu surface.
92
+
93
+ ## Actions
94
+
95
+ All state transitions go through these actions. UIKit must never mutate state directly.
96
+
97
+ | Action | Signature | Description |
98
+ | ---------------------- | ---------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
99
+ | `openAt` | `(x: number, y: number, source?: ContextMenuOpenSource) => void` | Opens the menu at coordinates `(x, y)`. Sets `anchorX`, `anchorY`, `openedBy`. Default `source` is `'programmatic'`. Clears `restoreTargetId`. Resets `openSubmenuId` and `submenuActiveId` to `null`. Delegates open to composed menu. |
100
+ | `close` | `() => void` | Closes the menu. Resets `activeId` to `null`. Sets `openedBy` to `null`. Sets `restoreTargetId` to `'{idBase}-target'`. Resets `openSubmenuId` and `submenuActiveId` to `null`. |
101
+ | `select` | `(id: string) => void` | Selects an item. For checkbox items, toggles the item's presence in `checkedIds`. For radio items, sets the item as the only checked item in its group. For submenu children, closes the entire menu if `closeOnSelect` is true. For regular items, delegates to composed menu's `select`. Skips separators and group-labels. No-op for disabled or unknown items. If menu closes as a result (when `closeOnSelect` is true), resets `openedBy` to `null`, sets `restoreTargetId`, and resets sub-menu state. |
102
+ | `handleTargetKeyDown` | `(event: ContextMenuKeyboardEventLike) => void` | Handles keyboard on the target element. Opens the menu on `ContextMenu` key or `Shift+F10`. Uses `'keyboard'` as open source. Coordinates stay at their current values (last known position). |
103
+ | `handleKeyDown` | `(event: ContextMenuKeyboardEventLike) => void` | Handles keyboard inside the open menu. When a sub-menu is open, delegates to sub-menu keyboard handler first (Escape/ArrowLeft close sub-menu, ArrowDown/ArrowUp/Home/End navigate sub-menu items, Enter/Space select sub-menu item). `Escape` and `Tab` close the menu. `ArrowRight` on a submenu item opens the sub-menu. Printable characters trigger type-ahead navigation. All other keys are delegated to the composed menu's `handleMenuKeyDown` (arrow navigation, Home/End, Enter/Space activation). No-op when menu is closed. |
104
+ | `handleOutsidePointer` | `() => void` | Closes the menu on outside pointer interaction. No-op when `closeOnOutsidePointer` is `false` or menu is already closed. |
105
+ | `handleTouchStart` | `(point: {clientX: number; clientY: number}) => void` | Starts a long-press timer. After `longPressDuration` ms, opens the menu at the touch coordinates with source `'pointer'`. |
106
+ | `handleTouchMove` | `() => void` | Cancels the long-press timer (touch moved, not a long-press). |
107
+ | `handleTouchEnd` | `() => void` | Cancels the long-press timer (touch ended before threshold). |
108
+
109
+ ```ts
110
+ interface ContextMenuKeyboardEventLike {
111
+ key: string
112
+ shiftKey?: boolean
113
+ ctrlKey?: boolean
114
+ metaKey?: boolean
115
+ altKey?: boolean
116
+ }
117
+ ```
118
+
119
+ ## Contracts
120
+
121
+ Contracts return complete ARIA prop objects ready to spread onto DOM elements.
122
+
123
+ ### `getTargetProps(): ContextMenuTargetProps`
124
+
125
+ ```ts
126
+ interface ContextMenuTargetProps {
127
+ id: string // '{idBase}-target'
128
+ onContextMenu: (event: {clientX: number; clientY: number; preventDefault?: () => void}) => void
129
+ onKeyDown: (event: ContextMenuKeyboardEventLike) => void
130
+ }
131
+ ```
132
+
133
+ The `onContextMenu` handler:
134
+
135
+ 1. Calls `event.preventDefault()` if available (suppresses native browser context menu)
136
+ 2. Opens the menu at `(event.clientX, event.clientY)` with source `'pointer'`
137
+
138
+ The `onKeyDown` handler delegates to `handleTargetKeyDown`.
139
+
140
+ ### `getMenuProps(): ContextMenuProps`
141
+
142
+ ```ts
143
+ interface ContextMenuProps {
144
+ id: string // '{idBase}-menu' (from composed menu)
145
+ role: 'menu'
146
+ tabindex: '-1'
147
+ hidden: boolean // !isOpen
148
+ 'aria-label'?: string // from options.ariaLabel
149
+ 'data-anchor-x': string // String(anchorX)
150
+ 'data-anchor-y': string // String(anchorY)
151
+ onKeyDown: (event: ContextMenuKeyboardEventLike) => void
152
+ }
153
+ ```
154
+
155
+ Spreads the composed menu's `getMenuProps()` result and augments it with:
156
+
157
+ - `hidden` reflecting open state
158
+ - `data-anchor-x` / `data-anchor-y` reflecting anchor coordinates as string data attributes
159
+ - `onKeyDown` delegating to `handleKeyDown`
160
+
161
+ ### `getItemProps(id: string): ContextMenuItemProps`
162
+
163
+ ```ts
164
+ interface ContextMenuItemProps {
165
+ id: string // '{idBase}-item-{id}' (from composed menu or manual)
166
+ role: 'menuitem' | 'menuitemcheckbox' | 'menuitemradio'
167
+ tabindex: '-1'
168
+ 'aria-disabled'?: 'true' // present only for disabled items
169
+ 'data-active': 'true' | 'false' // reflects activeId === id (or submenuActiveId for sub-menu children)
170
+ 'aria-checked'?: 'true' | 'false' // present for checkbox and radio items, reflects checkedIds
171
+ 'aria-haspopup'?: 'menu' // present for submenu items
172
+ 'aria-expanded'?: 'true' | 'false' // present for submenu items, reflects openSubmenuId
173
+ onClick: () => void // calls select(id)
174
+ }
175
+ ```
176
+
177
+ Role assignment per item type:
178
+
179
+ - `'item'` (default) and `'submenu'` — `role: 'menuitem'`
180
+ - `'checkbox'` — `role: 'menuitemcheckbox'`
181
+ - `'radio'` — `role: 'menuitemradio'`
182
+
183
+ For submenu items, `aria-haspopup` is set to `'menu'` and `aria-expanded` reflects whether the sub-menu is currently open.
184
+
185
+ For sub-menu child items (not in the top-level actionable items list), props are built manually with `data-active` reflecting `submenuActiveId`.
186
+
187
+ ### `getSeparatorProps(id: string): ContextMenuSeparatorProps`
188
+
189
+ ```ts
190
+ interface ContextMenuSeparatorProps {
191
+ id: string // '{idBase}-separator-{id}'
192
+ role: 'separator'
193
+ }
194
+ ```
195
+
196
+ ### `getGroupLabelProps(id: string): ContextMenuGroupLabelProps`
197
+
198
+ ```ts
199
+ interface ContextMenuGroupLabelProps {
200
+ id: string // '{idBase}-group-{id}'
201
+ role: 'presentation'
202
+ 'aria-label'?: string // from item.label
203
+ }
204
+ ```
205
+
206
+ ### `getSubmenuProps(id: string): ContextMenuSubmenuProps`
207
+
208
+ ```ts
209
+ interface ContextMenuSubmenuProps {
210
+ id: string // '{idBase}-submenu-{id}'
211
+ role: 'menu'
212
+ tabindex: '-1'
213
+ hidden: boolean // openSubmenuId !== id
214
+ }
215
+ ```
216
+
217
+ ## Transition Model
218
+
219
+ ### Core Transitions
220
+
221
+ | Event / Action | Preconditions | Next State |
222
+ | ---------------------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
223
+ | `openAt(x, y, source)` | none | `isOpen=true`; `anchorX=x`; `anchorY=y`; `openedBy=source`; `restoreTargetId=null`; `openSubmenuId=null`; `submenuActiveId=null`; `activeId` set to first enabled item (via composed menu) |
224
+ | `close()` | none | `isOpen=false`; `activeId=null`; `openedBy=null`; `restoreTargetId='{idBase}-target'`; `openSubmenuId=null`; `submenuActiveId=null` |
225
+ | `select(id)` (checkbox) | item exists, not disabled, type=checkbox | toggles `id` in `checkedIds`; delegates to composed menu `select` |
226
+ | `select(id)` (radio) | item exists, not disabled, type=radio, has group | unchecks all items in same group in `checkedIds`, adds `id`; delegates to composed menu `select` |
227
+ | `select(id)` (submenu child) | item exists, not disabled | handles checkable state if applicable; if `closeOnSelect=true`: closes entire menu |
228
+ | `select(id)` (regular) | item exists, not disabled | delegates to composed menu's `select`; if `closeOnSelect=true`: `isOpen=false`, `activeId=null`, `openedBy=null`, `restoreTargetId='{idBase}-target'`, sub-menu state reset |
229
+ | `select(id)` | item disabled, unknown, separator, or group-label | no-op |
230
+ | `handleTargetKeyDown(ContextMenu)` | none | same as `openAt(currentAnchorX, currentAnchorY, 'keyboard')` |
231
+ | `handleTargetKeyDown(Shift+F10)` | none | same as `openAt(currentAnchorX, currentAnchorY, 'keyboard')` |
232
+ | `handleTargetKeyDown(other key)` | none | no-op |
233
+ | `handleKeyDown(Escape)` | `isOpen=true`, sub-menu open | closes sub-menu only (`openSubmenuId=null`, `submenuActiveId=null`) |
234
+ | `handleKeyDown(Escape)` | `isOpen=true`, no sub-menu open | `close()` |
235
+ | `handleKeyDown(Tab)` | `isOpen=true` | `close()` |
236
+ | `handleKeyDown(ArrowDown)` | `isOpen=true`, sub-menu open | `submenuActiveId` moves to next enabled sub-menu item (wrapping) |
237
+ | `handleKeyDown(ArrowDown)` | `isOpen=true`, no sub-menu | `activeId` moves to next enabled item (wrapping) |
238
+ | `handleKeyDown(ArrowUp)` | `isOpen=true`, sub-menu open | `submenuActiveId` moves to previous enabled sub-menu item (wrapping) |
239
+ | `handleKeyDown(ArrowUp)` | `isOpen=true`, no sub-menu | `activeId` moves to previous enabled item (wrapping) |
240
+ | `handleKeyDown(ArrowRight)` | `isOpen=true`, `activeId` is a submenu item | opens sub-menu: `openSubmenuId=activeId`, `submenuActiveId=first enabled child` |
241
+ | `handleKeyDown(ArrowLeft)` | `isOpen=true`, sub-menu open | closes sub-menu (`openSubmenuId=null`, `submenuActiveId=null`) |
242
+ | `handleKeyDown(Home)` | `isOpen=true`, sub-menu open | `submenuActiveId` moves to first enabled sub-menu item |
243
+ | `handleKeyDown(Home)` | `isOpen=true`, no sub-menu | `activeId` moves to first enabled item |
244
+ | `handleKeyDown(End)` | `isOpen=true`, sub-menu open | `submenuActiveId` moves to last enabled sub-menu item |
245
+ | `handleKeyDown(End)` | `isOpen=true`, no sub-menu | `activeId` moves to last enabled item |
246
+ | `handleKeyDown(Enter/Space)` | `isOpen=true`, sub-menu open, `submenuActiveId!=null` | `select(submenuActiveId)` |
247
+ | `handleKeyDown(Enter)` | `isOpen=true`, `activeId!=null` | `select(activeId)` via composed menu |
248
+ | `handleKeyDown(Space)` | `isOpen=true`, `activeId!=null` | `select(activeId)` via composed menu |
249
+ | `handleKeyDown(printable char)` | `isOpen=true` | type-ahead: advances query, moves `activeId` to matching item by label prefix |
250
+ | `handleKeyDown(*)` | `isOpen=false` | no-op |
251
+ | `handleOutsidePointer()` | `isOpen=true`, `closeOnOutsidePointer!=false` | `close()` |
252
+ | `handleOutsidePointer()` | `isOpen=false` or `closeOnOutsidePointer=false` | no-op |
253
+ | `handleTouchStart(point)` | none | starts long-press timer; after `longPressDuration` ms: `openAt(clientX, clientY, 'pointer')` |
254
+ | `handleTouchMove()` | timer running | cancels long-press timer |
255
+ | `handleTouchEnd()` | timer running | cancels long-press timer |
256
+
257
+ ### Pointer Open Flow
258
+
259
+ ```
260
+ contextmenu event on target
261
+ -> preventDefault()
262
+ -> openAt(clientX, clientY, 'pointer')
263
+ -> anchorX=clientX, anchorY=clientY
264
+ -> openedBy='pointer'
265
+ -> menu opens, activeId set to first enabled item
266
+ ```
267
+
268
+ ### Long-Press Open Flow
269
+
270
+ ```
271
+ touchstart on target
272
+ -> start timer(longPressDuration)
273
+ -> if no touchmove/touchend before threshold:
274
+ -> openAt(clientX, clientY, 'pointer')
275
+ -> anchorX=clientX, anchorY=clientY
276
+ -> openedBy='pointer'
277
+ -> menu opens, activeId set to first enabled item
278
+ -> if touchmove or touchend: cancel timer
279
+ ```
280
+
281
+ ### Keyboard Open Flow
282
+
283
+ ```
284
+ ContextMenu or Shift+F10 on target
285
+ -> openAt(currentAnchorX, currentAnchorY, 'keyboard')
286
+ -> openedBy='keyboard'
287
+ -> menu opens, activeId set to first enabled item
288
+ ```
289
+
290
+ ### Close Flows
291
+
292
+ ```
293
+ Escape/Tab in menu OR outside pointer click OR close()
294
+ -> isOpen=false
295
+ -> activeId=null
296
+ -> openedBy=null
297
+ -> restoreTargetId='{idBase}-target'
298
+ -> openSubmenuId=null
299
+ -> submenuActiveId=null
300
+ ```
301
+
302
+ ```
303
+ Item selected (closeOnSelect=true)
304
+ -> select(id) -> composed menu closes
305
+ -> openedBy=null
306
+ -> restoreTargetId='{idBase}-target'
307
+ -> openSubmenuId=null
308
+ -> submenuActiveId=null
309
+ ```
310
+
311
+ ### Sub-menu Open/Close Flow
312
+
313
+ ```
314
+ ArrowRight on submenu item
315
+ -> openSubmenuId=activeId
316
+ -> submenuActiveId=first enabled child
317
+
318
+ Escape or ArrowLeft while sub-menu is open
319
+ -> openSubmenuId=null
320
+ -> submenuActiveId=null
321
+ -> focus returns to parent menu item
322
+ ```
323
+
324
+ ### Checkbox Toggle Flow
325
+
326
+ ```
327
+ select(id) where item.type='checkbox'
328
+ -> toggle id in checkedIds
329
+ -> delegates to composed menu select (may close if closeOnSelect)
330
+ ```
331
+
332
+ ### Radio Selection Flow
333
+
334
+ ```
335
+ select(id) where item.type='radio', item.group='groupName'
336
+ -> remove all items with group='groupName' from checkedIds
337
+ -> add id to checkedIds
338
+ -> delegates to composed menu select (may close if closeOnSelect)
339
+ ```
340
+
341
+ ## Invariants
342
+
343
+ 1. `isOpen` is the single source of truth for menu visibility, delegated to composed `createMenu`.
344
+ 2. `openedBy` must be `null` whenever `isOpen` is `false`.
345
+ 3. `restoreTargetId` must be `null` while the menu is open (cleared on `openAt`).
346
+ 4. `restoreTargetId` must be set to `'{idBase}-target'` on every close path (explicit close, Escape, Tab, select-close, outside pointer).
347
+ 5. `data-anchor-x` and `data-anchor-y` in menu props always reflect the current `anchorX` and `anchorY` atom values as strings.
348
+ 6. `activeId` is always `null` or an enabled item id (enforced by composed menu).
349
+ 7. Disabled items cannot become active or selected.
350
+ 8. `getTargetProps().onContextMenu` must call `preventDefault()` when available.
351
+ 9. `handleKeyDown` is a complete no-op when the menu is closed.
352
+ 10. Menu and item contracts remain structurally compatible with the underlying `createMenu` contracts.
353
+ 11. `openSubmenuId` and `submenuActiveId` must be `null` whenever `isOpen` is `false`.
354
+ 12. Only one sub-menu can be open at a time.
355
+ 13. `checkedIds` is only modified through `select()` on checkbox or radio items; never modified externally.
356
+ 14. In a radio group, exactly one item (the most recently selected) is checked at a time.
357
+ 15. Separators and group-labels are never included in keyboard navigation or selection.
358
+
359
+ ## Composition Detail
360
+
361
+ `createContextMenu` internally creates a `createMenu` instance and delegates to it:
362
+
363
+ | Context-menu concern | Delegated to composed `createMenu` |
364
+ | ---------------------------------------------------- | ------------------------------------------------------- |
365
+ | `state.isOpen` | `menu.state.isOpen` (re-exported) |
366
+ | `state.activeId` | `menu.state.activeId` (re-exported) |
367
+ | `openAt` open logic | `menu.actions.open(source)` |
368
+ | `close` close logic | `menu.actions.close()` + `menu.actions.setActive(null)` |
369
+ | `select` (regular items) | `menu.actions.select(id)` |
370
+ | Arrow/Home/End navigation | `menu.actions.handleMenuKeyDown(event)` |
371
+ | `getMenuProps` base | `menu.contracts.getMenuProps()` |
372
+ | `getItemProps` base (for top-level actionable items) | `menu.contracts.getItemProps(id)` |
373
+
374
+ Only actionable items (`item`, `checkbox`, `radio`, `submenu`) are passed to the composed `createMenu`; separators and group-labels are filtered out.
375
+
376
+ Context-menu adds its own layers:
377
+
378
+ - Anchor coordinate atoms (`anchorX`, `anchorY`)
379
+ - Open source tracking (`openedBy`)
380
+ - Focus restoration (`restoreTargetId`)
381
+ - Checkable item state (`checkedIds`) with checkbox toggle and radio group management
382
+ - Sub-menu state (`openSubmenuId`, `submenuActiveId`) with ArrowRight/ArrowLeft/Escape navigation
383
+ - Type-ahead character navigation via `interactions/typeahead`
384
+ - Long-press touch support (`handleTouchStart`, `handleTouchMove`, `handleTouchEnd`)
385
+ - Separator and group-label contracts (`getSeparatorProps`, `getGroupLabelProps`)
386
+ - Sub-menu container contract (`getSubmenuProps`)
387
+ - Target contract with `onContextMenu` and `onKeyDown`
388
+ - Escape/Tab interception before delegation to menu
389
+
390
+ ## Adapter Expectations
391
+
392
+ UIKit adapter (`cv-context-menu`) will:
393
+
394
+ **Signals read (reactive, drive re-renders):**
395
+
396
+ - `state.isOpen()` -- menu visibility, controls `hidden` attribute and outside-pointer listener registration
397
+ - `state.activeId()` -- highlighted item id, used to sync `data-active` and focus on item elements
398
+ - `state.anchorX()` / `state.anchorY()` -- positioning coordinates, applied as CSS custom properties for fixed positioning
399
+ - `state.openedBy()` -- open source, included in event detail
400
+ - `state.restoreTargetId()` -- focus restoration target id, used to return focus to target element on close
401
+ - `state.checkedIds()` -- set of checked item ids, used to sync `aria-checked` on checkbox/radio items
402
+ - `state.openSubmenuId()` -- id of open sub-menu parent, used to show/hide sub-menu containers
403
+ - `state.submenuActiveId()` -- active item within open sub-menu, used to sync `data-active` and focus
404
+
405
+ **Actions called (event handlers, never mutate state directly):**
406
+
407
+ - `actions.openAt(x, y, source)` -- on `contextmenu` event (via target contract), or imperative `openAt()` method on element
408
+ - `actions.close()` -- imperative `close()` method on element, or driven by property sync
409
+ - `actions.select(id)` -- on item click (via item contract `onClick`), or on Enter/Space in menu (via `handleKeyDown`)
410
+ - `actions.handleTargetKeyDown(event)` -- on keydown in target area (ContextMenu key, Shift+F10)
411
+ - `actions.handleKeyDown(event)` -- on keydown inside menu (Escape, Tab, arrows, Home, End, Enter, Space, printable chars for typeahead)
412
+ - `actions.handleOutsidePointer()` -- on document `pointerdown` outside component bounds
413
+ - `actions.handleTouchStart(point)` -- on `touchstart` event on the target element
414
+ - `actions.handleTouchMove()` -- on `touchmove` event on the target element
415
+ - `actions.handleTouchEnd()` -- on `touchend` event on the target element
416
+
417
+ **Contracts spread (attribute maps applied directly to DOM elements):**
418
+
419
+ - `contracts.getTargetProps()` -- applied to the target wrapper element (provides `id`, `onContextMenu`, `onKeyDown`)
420
+ - `contracts.getMenuProps()` -- applied to the menu container element (provides `id`, `role`, `tabindex`, `hidden`, `aria-label`, `data-anchor-x`, `data-anchor-y`, `onKeyDown`)
421
+ - `contracts.getItemProps(id)` -- applied to each menu item element (provides `id`, `role`, `tabindex`, `aria-disabled`, `data-active`, `aria-checked`, `aria-haspopup`, `aria-expanded`, `onClick`)
422
+ - `contracts.getSeparatorProps(id)` -- applied to separator elements (provides `id`, `role`)
423
+ - `contracts.getGroupLabelProps(id)` -- applied to group label elements (provides `id`, `role`, `aria-label`)
424
+ - `contracts.getSubmenuProps(id)` -- applied to sub-menu container elements (provides `id`, `role`, `tabindex`, `hidden`)
425
+
426
+ **UIKit-only concerns (NOT in headless):**
427
+
428
+ - Fixed positioning via CSS custom properties (`--cv-context-menu-x`, `--cv-context-menu-y`)
429
+ - Document-level `pointerdown` listener registration/cleanup for outside-click detection
430
+ - Slotted item element discovery and `slotchange` observation
431
+ - Item element attribute synchronization (imperative DOM updates)
432
+ - Focus management (focusing active item, restoring focus to target)
433
+ - `input` and `change` custom event dispatch
434
+ - `value` / `open` / `anchorX` / `anchorY` property reflection and attribute sync
435
+ - `preventDefault()` on keyboard events that should not propagate
436
+ - Model rebuild on slot content changes or config property changes
437
+
438
+ ## APG and A11y Contract
439
+
440
+ - menu role: `menu`
441
+ - item roles: `menuitem`, `menuitemcheckbox`, `menuitemradio`
442
+ - separator role: `separator`
443
+ - group label role: `presentation` with `aria-label`
444
+ - sub-menu role: `menu` with `tabindex="-1"` and `hidden`
445
+ - menu attributes:
446
+ - `aria-label` (optional, from config)
447
+ - `hidden` (reflects `!isOpen`)
448
+ - `tabindex="-1"` (focus programmatically managed)
449
+ - item attributes:
450
+ - `aria-disabled="true"` (present only when item is disabled)
451
+ - `data-active="true"|"false"` (reflects `activeId === id` or `submenuActiveId === id`)
452
+ - `tabindex="-1"` (all items)
453
+ - `aria-checked="true"|"false"` (present on checkbox and radio items)
454
+ - `aria-haspopup="menu"` (present on submenu items)
455
+ - `aria-expanded="true"|"false"` (present on submenu items)
456
+ - target attributes:
457
+ - `id="{idBase}-target"` (used as focus restore target)
458
+
459
+ ## Keyboard Contract
460
+
461
+ ### Target Element
462
+
463
+ | Key | Action |
464
+ | ------------- | ---------------------------------------------------------------- |
465
+ | `ContextMenu` | Open menu at current anchor coordinates with `source='keyboard'` |
466
+ | `Shift+F10` | Open menu at current anchor coordinates with `source='keyboard'` |
467
+
468
+ ### Menu Element (when open, no sub-menu)
469
+
470
+ | Key | Action |
471
+ | ------------------- | ----------------------------------------------------------------------- |
472
+ | `Escape` | Close menu, restore focus to target |
473
+ | `Tab` | Close menu, restore focus to target |
474
+ | `ArrowDown` | Move active to next enabled item (wrapping) |
475
+ | `ArrowUp` | Move active to previous enabled item (wrapping) |
476
+ | `ArrowRight` | If active item is a submenu: open sub-menu, focus first child |
477
+ | `Home` | Move active to first enabled item |
478
+ | `End` | Move active to last enabled item |
479
+ | `Enter` | Select active item |
480
+ | `Space` | Select active item |
481
+ | Printable character | Type-ahead: advance query, move active to matching item by label prefix |
482
+
483
+ ### Sub-menu (when open)
484
+
485
+ | Key | Action |
486
+ | ----------- | ----------------------------------------------------------------- |
487
+ | `Escape` | Close sub-menu, return to parent menu |
488
+ | `ArrowLeft` | Close sub-menu, return to parent menu |
489
+ | `ArrowDown` | Move submenuActiveId to next enabled sub-menu item (wrapping) |
490
+ | `ArrowUp` | Move submenuActiveId to previous enabled sub-menu item (wrapping) |
491
+ | `Home` | Move submenuActiveId to first enabled sub-menu item |
492
+ | `End` | Move submenuActiveId to last enabled sub-menu item |
493
+ | `Enter` | Select active sub-menu item |
494
+ | `Space` | Select active sub-menu item |
495
+
496
+ All menu keyboard handling is no-op when the menu is closed.
497
+
498
+ ## Minimum Test Matrix
499
+
500
+ - pointer context menu open with coordinate capture
501
+ - pointer context menu calls `preventDefault` when available
502
+ - pointer context menu works without `preventDefault` (graceful handling)
503
+ - keyboard invocation via `Shift+F10`
504
+ - keyboard invocation via `ContextMenu` key
505
+ - `openedBy` set to `'pointer'` on context menu event
506
+ - `openedBy` set to `'keyboard'` on keyboard invocation
507
+ - `openedBy` defaults to `'programmatic'` when source not specified
508
+ - `openedBy` resets to `null` on close
509
+ - `openedBy` resets to `null` on select-close
510
+ - menu role contract (role=`menu`, tabindex=`-1`)
511
+ - Escape closes menu
512
+ - Tab closes menu
513
+ - `restoreTargetId` set to target id on close
514
+ - `restoreTargetId` set to target id on select-close
515
+ - `restoreTargetId` null while menu is open
516
+ - outside pointer closes menu by default
517
+ - outside pointer does not close when `closeOnOutsidePointer` is false
518
+ - `data-anchor-x` / `data-anchor-y` reflect coordinates
519
+ - `hidden` attribute reflects open state
520
+ - item role contract (role=`menuitem`, tabindex=`-1`)
521
+ - item `aria-disabled` present for disabled items, absent for enabled
522
+ - item `data-active` reflects active state
523
+ - item `onClick` triggers select
524
+ - target contract includes id and event handlers
525
+ - arrow key navigation delegates to composed menu
526
+ - `handleKeyDown` is no-op when menu is closed
527
+ - `aria-label` forwarded to menu props
528
+ - checkbox item has `role=menuitemcheckbox` and `aria-checked`
529
+ - checkbox toggle updates `checkedIds`
530
+ - radio item has `role=menuitemradio` and `aria-checked`
531
+ - radio selection updates `checkedIds` (only one in group)
532
+ - separator has `role=separator` via `getSeparatorProps`
533
+ - group label has `role=presentation` and `aria-label` via `getGroupLabelProps`
534
+ - submenu item has `aria-haspopup=menu` and `aria-expanded`
535
+ - ArrowRight opens sub-menu on submenu item
536
+ - ArrowLeft/Escape closes sub-menu
537
+ - sub-menu navigation (ArrowDown/ArrowUp/Home/End)
538
+ - sub-menu item selection (Enter/Space)
539
+ - `getSubmenuProps` returns correct hidden state
540
+ - long-press touch opens menu after `longPressDuration`
541
+ - touch move cancels long-press
542
+ - touch end cancels long-press
543
+ - type-ahead navigates to matching item by label prefix
544
+
545
+ ## ADR-001 Compliance
546
+
547
+ - **Runtime Policy**: Reatom v1000 only; no @statx/\* in headless core.
548
+ - **Layering**: core -> interactions -> a11y-contracts -> adapters; adapters remain thin mappings.
549
+ - **Independence**: No imports from @project/_, apps/_, or other out-of-package modules.
550
+ - **Verification**: Mandatory adapter integration tests and standalone package test execution.
551
+
552
+ ## Out of Scope (Current)
553
+
554
+ - Viewport collision/placement logic (UIKit concern)
555
+ - Adapter rendering/layering strategies
556
+ - Animation and transition effects