@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,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`)
|