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