@chromvoid/headless-ui 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +99 -0
- package/dist/a11y-contracts/index.d.ts +23 -0
- package/dist/a11y-contracts/index.js +1 -0
- package/dist/accordion/index.d.ts +78 -0
- package/dist/accordion/index.js +264 -0
- package/dist/adapters/index.d.ts +9 -0
- package/dist/adapters/index.js +1 -0
- package/dist/alert/index.d.ts +33 -0
- package/dist/alert/index.js +54 -0
- package/dist/alert-dialog/index.d.ts +69 -0
- package/dist/alert-dialog/index.js +94 -0
- package/dist/badge/index.d.ts +48 -0
- package/dist/badge/index.js +89 -0
- package/dist/breadcrumb/index.d.ts +55 -0
- package/dist/breadcrumb/index.js +77 -0
- package/dist/button/index.d.ts +46 -0
- package/dist/button/index.js +86 -0
- package/dist/callout/index.d.ts +41 -0
- package/dist/callout/index.js +63 -0
- package/dist/card/index.d.ts +54 -0
- package/dist/card/index.js +103 -0
- package/dist/carousel/index.d.ts +98 -0
- package/dist/carousel/index.js +243 -0
- package/dist/checkbox/index.d.ts +50 -0
- package/dist/checkbox/index.js +87 -0
- package/dist/combobox/index.d.ts +114 -0
- package/dist/combobox/index.js +431 -0
- package/dist/command-palette/index.d.ts +73 -0
- package/dist/command-palette/index.js +147 -0
- package/dist/context-menu/index.d.ts +111 -0
- package/dist/context-menu/index.js +372 -0
- package/dist/copy-button/index.d.ts +62 -0
- package/dist/copy-button/index.js +183 -0
- package/dist/core/index.d.ts +20 -0
- package/dist/core/index.js +2 -0
- package/dist/core/selection.d.ts +5 -0
- package/dist/core/selection.js +39 -0
- package/dist/core/value-range.d.ts +49 -0
- package/dist/core/value-range.js +134 -0
- package/dist/date-picker/index.d.ts +210 -0
- package/dist/date-picker/index.js +895 -0
- package/dist/dialog/index.d.ts +95 -0
- package/dist/dialog/index.js +153 -0
- package/dist/disclosure/index.d.ts +52 -0
- package/dist/disclosure/index.js +159 -0
- package/dist/drawer/index.d.ts +30 -0
- package/dist/drawer/index.js +39 -0
- package/dist/feed/index.d.ts +77 -0
- package/dist/feed/index.js +260 -0
- package/dist/grid/index.d.ts +103 -0
- package/dist/grid/index.js +415 -0
- package/dist/index.d.ts +51 -0
- package/dist/index.js +51 -0
- package/dist/input/index.d.ts +86 -0
- package/dist/input/index.js +156 -0
- package/dist/interactions/composite-navigation.d.ts +69 -0
- package/dist/interactions/composite-navigation.js +169 -0
- package/dist/interactions/index.d.ts +15 -0
- package/dist/interactions/index.js +4 -0
- package/dist/interactions/keyboard-intents.d.ts +16 -0
- package/dist/interactions/keyboard-intents.js +33 -0
- package/dist/interactions/overlay-focus.d.ts +40 -0
- package/dist/interactions/overlay-focus.js +93 -0
- package/dist/interactions/typeahead.d.ts +20 -0
- package/dist/interactions/typeahead.js +41 -0
- package/dist/landmarks/index.d.ts +39 -0
- package/dist/landmarks/index.js +58 -0
- package/dist/link/index.d.ts +34 -0
- package/dist/link/index.js +39 -0
- package/dist/listbox/index.d.ts +92 -0
- package/dist/listbox/index.js +337 -0
- package/dist/menu/index.d.ts +132 -0
- package/dist/menu/index.js +541 -0
- package/dist/menu-button/index.d.ts +71 -0
- package/dist/menu-button/index.js +121 -0
- package/dist/meter/index.d.ts +45 -0
- package/dist/meter/index.js +106 -0
- package/dist/number/index.d.ts +113 -0
- package/dist/number/index.js +252 -0
- package/dist/popover/index.d.ts +70 -0
- package/dist/popover/index.js +126 -0
- package/dist/progress/index.d.ts +49 -0
- package/dist/progress/index.js +79 -0
- package/dist/radio-group/index.d.ts +61 -0
- package/dist/radio-group/index.js +150 -0
- package/dist/select/index.d.ts +92 -0
- package/dist/select/index.js +239 -0
- package/dist/sidebar/index.d.ts +74 -0
- package/dist/sidebar/index.js +186 -0
- package/dist/slider/index.d.ts +61 -0
- package/dist/slider/index.js +150 -0
- package/dist/slider-multi-thumb/index.d.ts +70 -0
- package/dist/slider-multi-thumb/index.js +222 -0
- package/dist/spinbutton/index.d.ts +75 -0
- package/dist/spinbutton/index.js +214 -0
- package/dist/spinner/index.d.ts +1 -0
- package/dist/spinner/index.js +1 -0
- package/dist/spinner/spinner.d.ts +23 -0
- package/dist/spinner/spinner.js +25 -0
- package/dist/switch/index.d.ts +40 -0
- package/dist/switch/index.js +61 -0
- package/dist/table/index.d.ts +117 -0
- package/dist/table/index.js +377 -0
- package/dist/tabs/index.d.ts +63 -0
- package/dist/tabs/index.js +174 -0
- package/dist/textarea/index.d.ts +68 -0
- package/dist/textarea/index.js +137 -0
- package/dist/toast/index.d.ts +67 -0
- package/dist/toast/index.js +145 -0
- package/dist/toolbar/index.d.ts +59 -0
- package/dist/toolbar/index.js +139 -0
- package/dist/tooltip/index.d.ts +52 -0
- package/dist/tooltip/index.js +169 -0
- package/dist/treegrid/index.d.ts +101 -0
- package/dist/treegrid/index.js +463 -0
- package/dist/treeview/index.d.ts +68 -0
- package/dist/treeview/index.js +370 -0
- package/dist/window-splitter/index.d.ts +65 -0
- package/dist/window-splitter/index.js +204 -0
- package/package.json +92 -0
- package/specs/ADR-001-headless-architecture.md +461 -0
- package/specs/ADR-002-repo-release-model.md +108 -0
- package/specs/ADR-003-public-api-versioning.md +136 -0
- package/specs/ADR-004-focus-selection-policy.md +117 -0
- package/specs/IMPLEMENTATION-ROADMAP.md +237 -0
- package/specs/ISSUE-BACKLOG.md +681 -0
- package/specs/RELEASE-CANDIDATE.md +30 -0
- package/specs/components/accordion.md +130 -0
- package/specs/components/alert-dialog.md +72 -0
- package/specs/components/alert.md +65 -0
- package/specs/components/badge.md +220 -0
- package/specs/components/breadcrumb.md +74 -0
- package/specs/components/button.md +115 -0
- package/specs/components/callout.md +195 -0
- package/specs/components/card.md +280 -0
- package/specs/components/carousel.md +140 -0
- package/specs/components/checkbox.md +172 -0
- package/specs/components/combobox.md +423 -0
- package/specs/components/command-palette.md +92 -0
- package/specs/components/context-menu.md +556 -0
- package/specs/components/copy-button.md +293 -0
- package/specs/components/date-picker.md +400 -0
- package/specs/components/dialog.md +298 -0
- package/specs/components/disclosure.md +257 -0
- package/specs/components/drawer.md +353 -0
- package/specs/components/feed.md +265 -0
- package/specs/components/grid.md +186 -0
- package/specs/components/input.md +254 -0
- package/specs/components/landmarks.md +136 -0
- package/specs/components/link.md +134 -0
- package/specs/components/listbox.md +351 -0
- package/specs/components/menu-button.md +76 -0
- package/specs/components/menu.md +623 -0
- package/specs/components/meter.md +149 -0
- package/specs/components/number.md +393 -0
- package/specs/components/popover.md +252 -0
- package/specs/components/progress.md +188 -0
- package/specs/components/radio-group.md +151 -0
- package/specs/components/select.md +144 -0
- package/specs/components/sidebar.md +321 -0
- package/specs/components/slider-multi-thumb.md +78 -0
- package/specs/components/slider.md +84 -0
- package/specs/components/spinbutton.md +140 -0
- package/specs/components/spinner.md +132 -0
- package/specs/components/switch.md +175 -0
- package/specs/components/table.md +403 -0
- package/specs/components/tabs.md +265 -0
- package/specs/components/textarea.md +185 -0
- package/specs/components/toast.md +198 -0
- package/specs/components/toolbar.md +278 -0
- package/specs/components/tooltip.md +252 -0
- package/specs/components/treegrid.md +281 -0
- package/specs/components/treeview.md +91 -0
- package/specs/components/window-splitter.md +297 -0
- package/specs/ops/git-shard-sync.md +107 -0
- package/specs/ops/release-checklist.md +76 -0
- package/specs/release/GAP-TO-GREEN-ISSUES.md +88 -0
- package/specs/release/api-freeze-candidate.md +54 -0
- package/specs/release/changelog-automation.md +76 -0
- package/specs/release/changelog.generated.md +53 -0
- package/specs/release/changelog.patch.generated.md +46 -0
- package/specs/release/consumer-integration.md +53 -0
- package/specs/release/migration-notes-pre-v1.md +40 -0
- package/specs/release/mvp-changelog.md +57 -0
- package/specs/release/release-notes-template.md +61 -0
- package/specs/release/release-rehearsal.md +113 -0
- package/specs/release/semver-deprecation-dry-run.md +89 -0
- package/specs/release/shard-release-drill-report.md +50 -0
- package/specs/release/shard-release-follow-ups.md +31 -0
- package/specs/signals.md +208 -0
|
@@ -0,0 +1,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
|