@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,252 @@
|
|
|
1
|
+
# Popover Component Contract
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
`Popover` provides a headless APG-aligned non-modal overlay model for contextual content linked to a trigger. Supports progressive enhancement via the native HTML Popover API when available, with automatic fallback to manual visibility management.
|
|
6
|
+
|
|
7
|
+
## Component Files
|
|
8
|
+
|
|
9
|
+
- `src/popover/index.ts` - model and public `createPopover` API
|
|
10
|
+
- `src/popover/popover.test.ts` - unit behavior tests
|
|
11
|
+
|
|
12
|
+
## Public API
|
|
13
|
+
|
|
14
|
+
- `createPopover(options)`
|
|
15
|
+
- `state` (signal-backed):
|
|
16
|
+
- `isOpen()` — current visibility state
|
|
17
|
+
- `triggerId()` — active trigger identifier
|
|
18
|
+
- `openedBy()` — open source (`keyboard`, `pointer`, `programmatic`)
|
|
19
|
+
- `restoreTargetId()` — focus restore target after close
|
|
20
|
+
- `lastDismissIntent()` — latest close intent
|
|
21
|
+
- `isInteractive()` — computed: mirrors `isOpen`
|
|
22
|
+
- `useNativePopover()` — whether native Popover API is active
|
|
23
|
+
- `actions`:
|
|
24
|
+
- `setTriggerId(id)`
|
|
25
|
+
- `open(source)`
|
|
26
|
+
- `close(intent)`
|
|
27
|
+
- `toggle(source)`
|
|
28
|
+
- `handleTriggerKeyDown(event)`
|
|
29
|
+
- `handleContentKeyDown(event)`
|
|
30
|
+
- `handleOutsidePointer()`
|
|
31
|
+
- `handleOutsideFocus()`
|
|
32
|
+
- `handleNativeToggle(newState)` — sync headless state when native popover fires `toggle` event
|
|
33
|
+
- `contracts`:
|
|
34
|
+
- `getTriggerProps()`
|
|
35
|
+
- `getContentProps()`
|
|
36
|
+
|
|
37
|
+
## CreatePopoverOptions
|
|
38
|
+
|
|
39
|
+
| Option | Type | Default | Description |
|
|
40
|
+
| ----------------------- | ---------------- | -------------------- | ------------------------------------------- |
|
|
41
|
+
| `idBase` | `string` | `'popover'` | Base id prefix for all generated ids |
|
|
42
|
+
| `initialOpen` | `boolean` | `false` | Whether the popover starts open |
|
|
43
|
+
| `initialTriggerId` | `string \| null` | `'{idBase}-trigger'` | Trigger element id |
|
|
44
|
+
| `ariaLabel` | `string` | — | Content `aria-label` |
|
|
45
|
+
| `ariaLabelledBy` | `string` | — | Content `aria-labelledby` |
|
|
46
|
+
| `closeOnEscape` | `boolean` | `true` | Whether Escape key closes the popover |
|
|
47
|
+
| `closeOnOutsidePointer` | `boolean` | `true` | Whether clicking outside closes the popover |
|
|
48
|
+
| `closeOnOutsideFocus` | `boolean` | `true` | Whether focusing outside closes the popover |
|
|
49
|
+
| `useNativePopover` | `boolean` | `false` | Enable native HTML Popover API integration |
|
|
50
|
+
|
|
51
|
+
## State Signal Surface
|
|
52
|
+
|
|
53
|
+
| Signal | Type | Derived? | Description |
|
|
54
|
+
| ------------------- | ------------------------------------ | -------- | ----------------------------------------- |
|
|
55
|
+
| `isOpen` | `Atom<boolean>` | No | Single source of truth for visibility |
|
|
56
|
+
| `triggerId` | `Atom<string \| null>` | No | Active trigger element id |
|
|
57
|
+
| `openedBy` | `Atom<PopoverOpenSource \| null>` | No | How the popover was opened |
|
|
58
|
+
| `restoreTargetId` | `Atom<string \| null>` | No | Element id to return focus to after close |
|
|
59
|
+
| `lastDismissIntent` | `Atom<PopoverDismissIntent \| null>` | No | Most recent dismiss intent |
|
|
60
|
+
| `isInteractive` | `Computed<boolean>` | Yes | `isOpen()` — always mirrors open state |
|
|
61
|
+
| `useNativePopover` | `Atom<boolean>` | No | Whether native Popover API mode is active |
|
|
62
|
+
|
|
63
|
+
## APG and A11y Contract
|
|
64
|
+
|
|
65
|
+
- trigger role: `button`
|
|
66
|
+
- trigger attributes:
|
|
67
|
+
- `aria-haspopup="dialog"`
|
|
68
|
+
- `aria-expanded` — reflects `isOpen`
|
|
69
|
+
- `aria-controls` — points to content id
|
|
70
|
+
- when native popover is active: `popovertarget` — points to content id
|
|
71
|
+
- content role: `dialog`
|
|
72
|
+
- content attributes:
|
|
73
|
+
- `aria-modal="false"`
|
|
74
|
+
- `aria-label` or `aria-labelledby`
|
|
75
|
+
- `hidden` — reflects `!isOpen` (manual mode only)
|
|
76
|
+
- when native popover is active: `popover="manual"` — native popover attribute
|
|
77
|
+
|
|
78
|
+
## Keyboard Contract
|
|
79
|
+
|
|
80
|
+
- trigger keys `Enter`, `Space`, `ArrowDown`: toggle popover
|
|
81
|
+
- content key `Escape`: dismiss popover (unless `closeOnEscape` is disabled)
|
|
82
|
+
|
|
83
|
+
## Behavior Contract
|
|
84
|
+
|
|
85
|
+
- `Popover` composes `overlay-focus` in non-trapping mode (`trapFocus=false`).
|
|
86
|
+
- dismiss intents and open sources are captured as part of model state.
|
|
87
|
+
- outside pointer and outside focus closing are independently configurable.
|
|
88
|
+
- focus restore target is synchronized through overlay state.
|
|
89
|
+
|
|
90
|
+
### Native Popover API Integration
|
|
91
|
+
|
|
92
|
+
When `useNativePopover` is `true`:
|
|
93
|
+
|
|
94
|
+
- **Content contract** includes `popover: 'manual'` attribute. The `manual` type is used because the headless layer manages all open/close logic; `auto` type is not used because it would cause the browser to close the popover independently from the headless state.
|
|
95
|
+
- **Content contract** omits `hidden` attribute — native popover manages visibility via the popover attribute.
|
|
96
|
+
- **Trigger contract** includes `popovertarget` attribute pointing to content id, and `popovertargetaction: 'toggle'`.
|
|
97
|
+
- **Adapter hook**: The adapter (UIKit) is responsible for calling `showPopover()` / `hidePopover()` on the content DOM element when `isOpen` changes. The headless layer provides the state; the adapter applies the DOM side effect.
|
|
98
|
+
- **`handleNativeToggle(newState)`**: When the native popover fires a `toggle` event (e.g., browser-initiated close), the adapter calls this action to synchronize headless state. `newState` is `'open'` or `'closed'`.
|
|
99
|
+
- **Light-dismiss**: With `popover="manual"`, light-dismiss is NOT handled by the browser. The headless layer's `handleOutsidePointer` / `handleOutsideFocus` / `handleContentKeyDown` actions handle all dismiss behavior, keeping behavior consistent between native and manual modes.
|
|
100
|
+
|
|
101
|
+
When `useNativePopover` is `false` (default):
|
|
102
|
+
|
|
103
|
+
- Content uses `hidden` attribute for visibility.
|
|
104
|
+
- No `popover` or `popovertarget` attributes emitted.
|
|
105
|
+
- Outside dismiss is handled via document-level listeners in the adapter (same as current behavior).
|
|
106
|
+
|
|
107
|
+
## Contract Prop Shapes
|
|
108
|
+
|
|
109
|
+
### `getTriggerProps()`
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
{
|
|
113
|
+
id: string // trigger element id
|
|
114
|
+
role: 'button'
|
|
115
|
+
tabindex: '0'
|
|
116
|
+
'aria-haspopup': 'dialog'
|
|
117
|
+
'aria-expanded': 'true' | 'false' // reflects isOpen
|
|
118
|
+
'aria-controls': string // points to content id
|
|
119
|
+
popovertarget?: string // content id (only when useNativePopover)
|
|
120
|
+
popovertargetaction?: 'toggle' // (only when useNativePopover)
|
|
121
|
+
onClick: () => void
|
|
122
|
+
onKeyDown: (event) => void
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### `getContentProps()`
|
|
127
|
+
|
|
128
|
+
```ts
|
|
129
|
+
{
|
|
130
|
+
id: string // content element id
|
|
131
|
+
role: 'dialog'
|
|
132
|
+
tabindex: '-1'
|
|
133
|
+
'aria-modal': 'false'
|
|
134
|
+
'aria-label'?: string // from options
|
|
135
|
+
'aria-labelledby'?: string // from options
|
|
136
|
+
hidden?: boolean // !isOpen (only when NOT useNativePopover)
|
|
137
|
+
popover?: 'manual' // (only when useNativePopover)
|
|
138
|
+
onKeyDown: (event) => void
|
|
139
|
+
onPointerDownOutside: () => void
|
|
140
|
+
onFocusOutside: () => void
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## Transitions Table
|
|
145
|
+
|
|
146
|
+
| Event / Action | Current State | Next State / Effect |
|
|
147
|
+
| --------------------------------------------- | ----------------------------------------------- | ------------------------------------------------------- |
|
|
148
|
+
| `open(source)` | `isOpen = false` | `isOpen = true`; restore target cleared; `openedBy` set |
|
|
149
|
+
| `close(intent)` | `isOpen = true` | `isOpen = false`; `restoreTargetId` set to trigger id |
|
|
150
|
+
| `close(intent)` | `isOpen = false` | `lastDismissIntent` updated; no visibility change |
|
|
151
|
+
| `toggle(source)` | `isOpen = false` | calls `open(source)` |
|
|
152
|
+
| `toggle(source)` | `isOpen = true` | calls `close('programmatic')` |
|
|
153
|
+
| `handleTriggerKeyDown(Enter/Space/ArrowDown)` | any | calls `toggle('keyboard')` |
|
|
154
|
+
| `handleTriggerKeyDown(other key)` | any | no-op |
|
|
155
|
+
| `handleContentKeyDown(Escape)` | `isOpen = true`, `closeOnEscape = true` | calls `close('escape')` |
|
|
156
|
+
| `handleContentKeyDown(Escape)` | `closeOnEscape = false` | no-op |
|
|
157
|
+
| `handleOutsidePointer()` | `isOpen = true`, `closeOnOutsidePointer = true` | calls `close('outside-pointer')` |
|
|
158
|
+
| `handleOutsidePointer()` | `closeOnOutsidePointer = false` | no-op |
|
|
159
|
+
| `handleOutsideFocus()` | `isOpen = true`, `closeOnOutsideFocus = true` | calls `close('outside-focus')` |
|
|
160
|
+
| `handleOutsideFocus()` | `closeOnOutsideFocus = false` | no-op |
|
|
161
|
+
| `setTriggerId(id)` | any | trigger id updated; affects future `restoreTargetId` |
|
|
162
|
+
| `handleNativeToggle('closed')` | `isOpen = true` | calls `close('programmatic')` to sync state |
|
|
163
|
+
| `handleNativeToggle('open')` | `isOpen = false` | calls `open('programmatic')` to sync state |
|
|
164
|
+
| `handleNativeToggle(same state)` | any | no-op (already in sync) |
|
|
165
|
+
|
|
166
|
+
### Derived state reactions
|
|
167
|
+
|
|
168
|
+
| State Change | `isInteractive` |
|
|
169
|
+
| ------------ | --------------- |
|
|
170
|
+
| open | `true` |
|
|
171
|
+
| closed | `false` |
|
|
172
|
+
|
|
173
|
+
## Invariants
|
|
174
|
+
|
|
175
|
+
1. `isInteractive` must mirror `isOpen` — always derived, never set directly.
|
|
176
|
+
2. `aria-expanded` must always reflect `isOpen`.
|
|
177
|
+
3. Trigger `aria-controls` must match content `id`.
|
|
178
|
+
4. `lastDismissIntent` and `restoreTargetId` are updated on dismiss paths.
|
|
179
|
+
5. `isOpen` is the single source of truth for visibility.
|
|
180
|
+
6. Closing the popover must set `restoreTargetId` to the trigger element id for focus restoration.
|
|
181
|
+
7. When `useNativePopover` is `true`, content props must include `popover: 'manual'` and must NOT include `hidden`.
|
|
182
|
+
8. When `useNativePopover` is `false`, content props must include `hidden` and must NOT include `popover`.
|
|
183
|
+
9. `handleNativeToggle` must be idempotent — calling it with the current state must be a no-op.
|
|
184
|
+
|
|
185
|
+
## Adapter Expectations
|
|
186
|
+
|
|
187
|
+
UIKit adapters MUST bind to the headless model as follows:
|
|
188
|
+
|
|
189
|
+
**Signals read (reactive, drive re-renders):**
|
|
190
|
+
|
|
191
|
+
- `state.isOpen()` — whether the popover is visible
|
|
192
|
+
- `state.triggerId()` — trigger element id
|
|
193
|
+
- `state.openedBy()` — how the popover was opened
|
|
194
|
+
- `state.restoreTargetId()` — element id to focus after close
|
|
195
|
+
- `state.lastDismissIntent()` — most recent dismiss intent
|
|
196
|
+
- `state.isInteractive()` — whether the popover content is interactive
|
|
197
|
+
- `state.useNativePopover()` — whether native Popover API mode is active
|
|
198
|
+
|
|
199
|
+
**Actions called (event handlers, never mutate state directly):**
|
|
200
|
+
|
|
201
|
+
- `actions.open(source?)` / `actions.close(intent?)` — programmatic open/close
|
|
202
|
+
- `actions.toggle(source?)` — toggle open state
|
|
203
|
+
- `actions.setTriggerId(id)` — set custom trigger element id
|
|
204
|
+
- `actions.handleTriggerKeyDown(event)` — on trigger keydown
|
|
205
|
+
- `actions.handleContentKeyDown(event)` — on content keydown (Escape handling)
|
|
206
|
+
- `actions.handleOutsidePointer()` — on pointer outside the popover
|
|
207
|
+
- `actions.handleOutsideFocus()` — on focus outside the popover
|
|
208
|
+
- `actions.handleNativeToggle(newState)` — sync state from native `toggle` event
|
|
209
|
+
|
|
210
|
+
**Contracts spread (attribute maps applied directly to DOM elements):**
|
|
211
|
+
|
|
212
|
+
- `contracts.getTriggerProps()` — spread onto the trigger button element (includes `popovertarget` when native)
|
|
213
|
+
- `contracts.getContentProps()` — spread onto the popover content panel (includes `popover="manual"` when native, `hidden` when manual)
|
|
214
|
+
|
|
215
|
+
**UIKit-only concerns (NOT in headless):**
|
|
216
|
+
|
|
217
|
+
- Calling `showPopover()` / `hidePopover()` on the content DOM element (when `useNativePopover` is active)
|
|
218
|
+
- Listening for native `toggle` / `beforetoggle` events on the content element and calling `handleNativeToggle`
|
|
219
|
+
- Document-level `pointerdown` and `focusin` listeners for outside dismiss
|
|
220
|
+
- CSS transitions, animations, placement, arrow rendering
|
|
221
|
+
- Focus restoration DOM side effect (reading `restoreTargetId` and calling `element.focus()`)
|
|
222
|
+
- Lifecycle events (`toggle`, `beforetoggle` naming per design decision #4)
|
|
223
|
+
|
|
224
|
+
## Minimum Test Matrix
|
|
225
|
+
|
|
226
|
+
- trigger keyboard open (`Enter`, `Space`, `ArrowDown`) and escape close
|
|
227
|
+
- role and aria linkage (`aria-controls` matches content `id`)
|
|
228
|
+
- `aria-expanded` reflects `isOpen`
|
|
229
|
+
- outside pointer close policy enable/disable
|
|
230
|
+
- outside focus close policy enable/disable
|
|
231
|
+
- dismiss intent and restore-target synchronization
|
|
232
|
+
- `useNativePopover = true`: content props include `popover: 'manual'`, no `hidden`
|
|
233
|
+
- `useNativePopover = true`: trigger props include `popovertarget`
|
|
234
|
+
- `useNativePopover = false`: content props include `hidden`, no `popover`
|
|
235
|
+
- `handleNativeToggle('closed')` syncs state from open to closed
|
|
236
|
+
- `handleNativeToggle('open')` syncs state from closed to open
|
|
237
|
+
- `handleNativeToggle` is idempotent when state is already in sync
|
|
238
|
+
|
|
239
|
+
## ADR-001 Compliance
|
|
240
|
+
|
|
241
|
+
- **Runtime Policy**: Reatom v1000 only; no `@statx/*` in headless core.
|
|
242
|
+
- **Layering**: `core -> interactions -> a11y-contracts -> adapters`; adapters remain thin mappings.
|
|
243
|
+
- **Independence**: No imports from `@project/*`, `apps/*`, or other out-of-package modules.
|
|
244
|
+
- **Verification**: Mandatory adapter integration tests and standalone package test execution.
|
|
245
|
+
|
|
246
|
+
## Out of Scope (Current)
|
|
247
|
+
|
|
248
|
+
- collision/placement engine
|
|
249
|
+
- nested popover orchestration
|
|
250
|
+
- modal focus trapping behavior
|
|
251
|
+
- portal/layer manager concerns (adapter-level)
|
|
252
|
+
- `popover="auto"` type (headless manages all dismiss logic; `manual` is used exclusively)
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# Progress Component Contract
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
`Progress` provides a headless progressbar model for determinate and indeterminate loading states.
|
|
6
|
+
|
|
7
|
+
## Component Files
|
|
8
|
+
|
|
9
|
+
- `src/progress/index.ts` - model and public `createProgress` API
|
|
10
|
+
- `src/progress/progress.test.ts` - unit behavior tests
|
|
11
|
+
|
|
12
|
+
## Options (`CreateProgressOptions`)
|
|
13
|
+
|
|
14
|
+
| Option | Type | Default | Description |
|
|
15
|
+
| ----------------- | ---------------------------------------- | ------------ | --------------------------------------------------------------------------------------------------------- |
|
|
16
|
+
| `idBase` | `string` | `'progress'` | Prefix for atom debug names and generated element IDs |
|
|
17
|
+
| `value` | `number` | `min` | Initial value; clamped and snapped to range |
|
|
18
|
+
| `min` | `number` | `0` | Minimum value |
|
|
19
|
+
| `max` | `number` | `100` | Maximum value |
|
|
20
|
+
| `step` | `number` | `1` | Step size for `increment()`/`decrement()` (delegated to `createValueRange`) |
|
|
21
|
+
| `isIndeterminate` | `boolean` | `false` | Whether to start in indeterminate mode |
|
|
22
|
+
| `valueText` | `string \| undefined` | `undefined` | Static override for `aria-valuetext`; takes precedence over `formatValueText` and the percentage fallback |
|
|
23
|
+
| `ariaLabel` | `string \| undefined` | `undefined` | Passed through to `aria-label` |
|
|
24
|
+
| `ariaLabelledBy` | `string \| undefined` | `undefined` | Passed through to `aria-labelledby` |
|
|
25
|
+
| `ariaDescribedBy` | `string \| undefined` | `undefined` | Passed through to `aria-describedby` |
|
|
26
|
+
| `formatValueText` | `(value: number) => string \| undefined` | `undefined` | Custom formatter for `aria-valuetext`; used when `valueText` is not set |
|
|
27
|
+
| `onValueChange` | `(value: number) => void \| undefined` | `undefined` | Called when `value` actually changes (after clamping) |
|
|
28
|
+
|
|
29
|
+
## Public API
|
|
30
|
+
|
|
31
|
+
### `createProgress(options): ProgressModel`
|
|
32
|
+
|
|
33
|
+
### State (signal-backed)
|
|
34
|
+
|
|
35
|
+
| Signal | Type | Description |
|
|
36
|
+
| ------------------- | ------------------- | ---------------------------------------------- |
|
|
37
|
+
| `value()` | `Atom<number>` | Current value, clamped to `[min, max]` |
|
|
38
|
+
| `min()` | `Atom<number>` | Minimum boundary |
|
|
39
|
+
| `max()` | `Atom<number>` | Maximum boundary |
|
|
40
|
+
| `percentage()` | `Computed<number>` | Derived: `((value - min) / (max - min)) * 100` |
|
|
41
|
+
| `isIndeterminate()` | `Atom<boolean>` | Whether progress is in indeterminate mode |
|
|
42
|
+
| `isComplete()` | `Computed<boolean>` | Derived: `!isIndeterminate && value >= max` |
|
|
43
|
+
|
|
44
|
+
### Actions
|
|
45
|
+
|
|
46
|
+
| Action | Signature | Description |
|
|
47
|
+
| ------------------ | -------------------------- | ------------------------------------------------------------------------- |
|
|
48
|
+
| `setValue` | `(value: number) => void` | Set value; clamped to range, fires `onValueChange` on actual change |
|
|
49
|
+
| `increment` | `() => void` | Increase value by `step`; clamped, fires `onValueChange` on actual change |
|
|
50
|
+
| `decrement` | `() => void` | Decrease value by `step`; clamped, fires `onValueChange` on actual change |
|
|
51
|
+
| `setIndeterminate` | `(value: boolean) => void` | Switch between determinate and indeterminate modes |
|
|
52
|
+
|
|
53
|
+
### Contracts
|
|
54
|
+
|
|
55
|
+
| Contract | Return type | Description |
|
|
56
|
+
| -------------------- | --------------- | -------------------------------------------------------------- |
|
|
57
|
+
| `getProgressProps()` | `ProgressProps` | Ready-to-spread ARIA attribute map for the progressbar element |
|
|
58
|
+
|
|
59
|
+
#### `ProgressProps` shape
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
{
|
|
63
|
+
id: string // `${idBase}-root`
|
|
64
|
+
role: 'progressbar'
|
|
65
|
+
'aria-valuenow'?: string // present only in determinate mode
|
|
66
|
+
'aria-valuemin'?: string // present only in determinate mode
|
|
67
|
+
'aria-valuemax'?: string // present only in determinate mode
|
|
68
|
+
'aria-valuetext'?: string // present only in determinate mode (see resolution order below)
|
|
69
|
+
'aria-label'?: string // from options.ariaLabel
|
|
70
|
+
'aria-labelledby'?: string // from options.ariaLabelledBy
|
|
71
|
+
'aria-describedby'?: string // from options.ariaDescribedBy
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`aria-valuetext` resolution order (determinate mode only):
|
|
76
|
+
|
|
77
|
+
1. `options.valueText` if set (static string override)
|
|
78
|
+
2. `options.formatValueText(value)` if provided
|
|
79
|
+
3. Rounded percentage fallback: `"${Math.round(percentage)}%"`
|
|
80
|
+
|
|
81
|
+
## APG and A11y Contract
|
|
82
|
+
|
|
83
|
+
- role: `progressbar`
|
|
84
|
+
- determinate mode attributes:
|
|
85
|
+
- `aria-valuenow`
|
|
86
|
+
- `aria-valuemin`
|
|
87
|
+
- `aria-valuemax`
|
|
88
|
+
- `aria-valuetext` (static override, custom formatter, or percentage fallback)
|
|
89
|
+
- indeterminate mode:
|
|
90
|
+
- omit `aria-valuenow`, `aria-valuemin`, `aria-valuemax`, `aria-valuetext`
|
|
91
|
+
- labeling:
|
|
92
|
+
- `aria-label` or `aria-labelledby`
|
|
93
|
+
- optional `aria-describedby`
|
|
94
|
+
|
|
95
|
+
## Keyboard Contract
|
|
96
|
+
|
|
97
|
+
- `Progress` is not keyboard-interactive as a widget role.
|
|
98
|
+
- `increment`/`decrement` actions are programmatic state APIs.
|
|
99
|
+
|
|
100
|
+
## Behavior Contract
|
|
101
|
+
|
|
102
|
+
- Value handling is delegated to `createValueRange` for clamping and step behavior.
|
|
103
|
+
- `onValueChange` fires only when value actually changes (after clamping).
|
|
104
|
+
- Completion state is computed as `!isIndeterminate && value >= max`.
|
|
105
|
+
|
|
106
|
+
## Transitions Table
|
|
107
|
+
|
|
108
|
+
| Trigger | Precondition | State change | Side effect |
|
|
109
|
+
| --------------------------------- | ------------- | --------------------------------------- | ------------------------------------------ |
|
|
110
|
+
| `actions.setValue(v)` | any | `value` = clamp(v, min, max) | `onValueChange` if value changed |
|
|
111
|
+
| `actions.increment()` | any | `value` = clamp(value + step, min, max) | `onValueChange` if value changed |
|
|
112
|
+
| `actions.decrement()` | any | `value` = clamp(value - step, min, max) | `onValueChange` if value changed |
|
|
113
|
+
| `actions.setIndeterminate(true)` | determinate | `isIndeterminate` = true | `isComplete` recomputes to false |
|
|
114
|
+
| `actions.setIndeterminate(false)` | indeterminate | `isIndeterminate` = false | `isComplete` recomputes based on value/max |
|
|
115
|
+
|
|
116
|
+
## Invariants
|
|
117
|
+
|
|
118
|
+
- `value` must remain clamped to `[min, max]`.
|
|
119
|
+
- `isComplete` must be `false` whenever `isIndeterminate` is `true`.
|
|
120
|
+
- ARIA value attributes (`aria-valuenow`, `aria-valuemin`, `aria-valuemax`, `aria-valuetext`) must be present only in determinate mode.
|
|
121
|
+
- `aria-valuetext` resolution order: `valueText` > `formatValueText(value)` > rounded percentage string.
|
|
122
|
+
- `percentage` must equal `0` when `min === max`.
|
|
123
|
+
|
|
124
|
+
## Adapter Expectations
|
|
125
|
+
|
|
126
|
+
This section defines what UIKit (`cv-progress`) binds to from the headless model.
|
|
127
|
+
|
|
128
|
+
### Signals read by adapter
|
|
129
|
+
|
|
130
|
+
| Signal | UIKit usage |
|
|
131
|
+
| ------------------------- | ------------------------------------------------------------------ |
|
|
132
|
+
| `state.value()` | Not read directly; consumed via contracts |
|
|
133
|
+
| `state.percentage()` | Sets `--cv-progress-value` CSS custom property for indicator width |
|
|
134
|
+
| `state.isIndeterminate()` | Sets `indeterminate` attribute on host for CSS animation switching |
|
|
135
|
+
| `state.isComplete()` | Sets `complete` attribute on host for visual feedback |
|
|
136
|
+
|
|
137
|
+
### Actions called by adapter
|
|
138
|
+
|
|
139
|
+
| Action | UIKit trigger |
|
|
140
|
+
| ----------------------------- | ------------------------------------------------------------------- |
|
|
141
|
+
| `actions.setValue(v)` | When `value` attribute/property changes on the host element |
|
|
142
|
+
| `actions.setIndeterminate(v)` | When `indeterminate` attribute/property changes on the host element |
|
|
143
|
+
|
|
144
|
+
Note: `increment()` and `decrement()` are available for programmatic use but have no direct DOM event trigger in UIKit.
|
|
145
|
+
|
|
146
|
+
### Contracts spread by adapter
|
|
147
|
+
|
|
148
|
+
| Contract | Target element | Notes |
|
|
149
|
+
| -------------------- | ---------------------------------------- | --------------------------------------------------------- |
|
|
150
|
+
| `getProgressProps()` | Root progressbar element (`part="base"`) | Spread as attributes; provides `role`, `aria-*`, and `id` |
|
|
151
|
+
|
|
152
|
+
### Options passed through from UIKit attributes
|
|
153
|
+
|
|
154
|
+
| UIKit attribute | Headless option | Notes |
|
|
155
|
+
| ------------------ | ----------------- | ------------------------------------------- |
|
|
156
|
+
| `value` | `value` | Numeric, synced via `setValue` action |
|
|
157
|
+
| `min` | `min` | Numeric |
|
|
158
|
+
| `max` | `max` | Numeric |
|
|
159
|
+
| `indeterminate` | `isIndeterminate` | Boolean attribute |
|
|
160
|
+
| `value-text` | `valueText` | Static string override for `aria-valuetext` |
|
|
161
|
+
| `aria-label` | `ariaLabel` | Labeling |
|
|
162
|
+
| `aria-labelledby` | `ariaLabelledBy` | Labeling |
|
|
163
|
+
| `aria-describedby` | `ariaDescribedBy` | Labeling |
|
|
164
|
+
|
|
165
|
+
## Minimum Test Matrix
|
|
166
|
+
|
|
167
|
+
- determinate aria value contract
|
|
168
|
+
- indeterminate aria omission contract
|
|
169
|
+
- `valueText` static override takes precedence over formatter and percentage
|
|
170
|
+
- `formatValueText` custom formatter produces expected `aria-valuetext`
|
|
171
|
+
- increment/decrement with clamping
|
|
172
|
+
- completion-state transitions
|
|
173
|
+
- `onValueChange` callback behavior
|
|
174
|
+
- `setIndeterminate` toggles aria attribute presence
|
|
175
|
+
|
|
176
|
+
## ADR-001 Compliance
|
|
177
|
+
|
|
178
|
+
- **Runtime Policy**: Reatom v1000 only; no `@statx/*` in headless core.
|
|
179
|
+
- **Layering**: `core -> interactions -> a11y-contracts -> adapters`; adapters remain thin mappings.
|
|
180
|
+
- **Independence**: No imports from `@project/*`, `apps/*`, or other out-of-package modules.
|
|
181
|
+
- **Verification**: Mandatory adapter integration tests and standalone package test execution.
|
|
182
|
+
|
|
183
|
+
## Out of Scope (Current)
|
|
184
|
+
|
|
185
|
+
- buffer/secondary-progress tracks
|
|
186
|
+
- animated interpolation and transitions
|
|
187
|
+
- cancellation/error state orchestration
|
|
188
|
+
- adapter-level rendering and styling concerns
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# Radio Group Component Contract
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
`RadioGroup` provides a headless APG-aligned model for a set of checkable buttons where only one can be checked at a time.
|
|
6
|
+
|
|
7
|
+
It handles roving tabindex, arrow-key navigation with automatic selection, and group-level state management.
|
|
8
|
+
|
|
9
|
+
## Component Files
|
|
10
|
+
|
|
11
|
+
- `src/radio-group/index.ts` - model and public `createRadioGroup` API
|
|
12
|
+
- `src/radio-group/radio-group.test.ts` - unit behavior tests
|
|
13
|
+
|
|
14
|
+
## Public API
|
|
15
|
+
|
|
16
|
+
- `createRadioGroup(options)`
|
|
17
|
+
- `items`: `readonly RadioGroupItem[]` — radio definitions
|
|
18
|
+
- `idBase?`: `string` — prefix for generated ids
|
|
19
|
+
- `orientation?`: `'horizontal' | 'vertical'` — navigation axis (default `'horizontal'`)
|
|
20
|
+
- `isDisabled?`: `boolean` — initial group-level disabled state
|
|
21
|
+
- `ariaLabel?`: `string` — accessible label for the group
|
|
22
|
+
- `ariaLabelledBy?`: `string` — id reference for external label
|
|
23
|
+
- `initialValue?`: `string | null` — initially checked radio id
|
|
24
|
+
- `initialActiveId?`: `string | null` — initially focused radio id
|
|
25
|
+
- `RadioGroupItem`:
|
|
26
|
+
- `id`: `string` — unique identifier
|
|
27
|
+
- `disabled?`: `boolean` — per-item disabled state
|
|
28
|
+
- `describedBy?`: `string` — id reference for description element (`aria-describedby`)
|
|
29
|
+
|
|
30
|
+
### State (signal-backed)
|
|
31
|
+
|
|
32
|
+
- `value()`: `string | null` — id of the checked radio
|
|
33
|
+
- `activeId()`: `string | null` — id of the focused radio (roving tabindex target)
|
|
34
|
+
- `isDisabled()`: `boolean` — group-level disabled state
|
|
35
|
+
- `orientation`: `'horizontal' | 'vertical'` — navigation orientation (static after creation)
|
|
36
|
+
|
|
37
|
+
### Actions
|
|
38
|
+
|
|
39
|
+
- `select(id)`: checks the radio with the given id (no-op if disabled or id is not enabled)
|
|
40
|
+
- `moveNext()`: moves focus and selection to the next enabled radio
|
|
41
|
+
- `movePrev()`: moves focus and selection to the previous enabled radio
|
|
42
|
+
- `moveFirst()`: moves focus and selection to the first enabled radio
|
|
43
|
+
- `moveLast()`: moves focus and selection to the last enabled radio
|
|
44
|
+
- `handleKeyDown(event)`: delegates to navigation/selection based on key
|
|
45
|
+
- `setDisabled(value)`: updates group-level disabled state
|
|
46
|
+
|
|
47
|
+
### Contracts
|
|
48
|
+
|
|
49
|
+
- `getRootProps()` — returns `RadioGroupRootProps`:
|
|
50
|
+
- `role`: `'radiogroup'`
|
|
51
|
+
- `aria-label?`: `string`
|
|
52
|
+
- `aria-labelledby?`: `string`
|
|
53
|
+
- `aria-disabled?`: `'true'` (present only when disabled)
|
|
54
|
+
- `aria-orientation`: `'horizontal' | 'vertical'`
|
|
55
|
+
- `onKeyDown`: keyboard handler
|
|
56
|
+
|
|
57
|
+
- `getRadioProps(id)` — returns `RadioProps`:
|
|
58
|
+
- `id`: `string` (generated: `{idBase}-radio-{id}`)
|
|
59
|
+
- `role`: `'radio'`
|
|
60
|
+
- `tabindex`: `'0' | '-1'` (roving tabindex)
|
|
61
|
+
- `aria-checked`: `'true' | 'false'`
|
|
62
|
+
- `aria-disabled?`: `'true'` (present when group or item is disabled)
|
|
63
|
+
- `aria-describedby?`: `string` (present when item has `describedBy`)
|
|
64
|
+
- `data-active`: `'true' | 'false'` (tracks focus, not selection)
|
|
65
|
+
- `onClick`: click handler
|
|
66
|
+
- `onKeyDown`: keyboard handler
|
|
67
|
+
|
|
68
|
+
## APG and A11y Contract
|
|
69
|
+
|
|
70
|
+
- root role: `radiogroup`
|
|
71
|
+
- item role: `radio`
|
|
72
|
+
- `aria-checked`: `"true" | "false"` on each radio
|
|
73
|
+
- `aria-disabled`: on root (when entire group disabled) or individual radios
|
|
74
|
+
- `aria-describedby`: on individual radios when description content is associated
|
|
75
|
+
- `aria-orientation`: on root, reflects navigation axis
|
|
76
|
+
- focus strategy: `roving-tabindex`
|
|
77
|
+
- only the active radio (checked, or first enabled when none checked) is in the tab sequence.
|
|
78
|
+
- if no radio is checked, the first enabled radio is in the tab sequence.
|
|
79
|
+
- linkage: root supports `aria-label` and `aria-labelledby`
|
|
80
|
+
|
|
81
|
+
## Behavior Contract
|
|
82
|
+
|
|
83
|
+
- `ArrowDown` / `ArrowRight` moves focus to the next radio and checks it.
|
|
84
|
+
- `ArrowUp` / `ArrowLeft` moves focus to the previous radio and checks it.
|
|
85
|
+
- Navigation wraps around from last to first and vice versa.
|
|
86
|
+
- `Home` moves focus and selection to the first enabled radio.
|
|
87
|
+
- `End` moves focus and selection to the last enabled radio.
|
|
88
|
+
- `Space` checks the focused radio if not already checked.
|
|
89
|
+
- Disabled radios are skipped during arrow navigation.
|
|
90
|
+
- If a radio is disabled, it cannot be checked.
|
|
91
|
+
- When the entire group is disabled, all keyboard and click actions are no-ops.
|
|
92
|
+
|
|
93
|
+
## Transitions
|
|
94
|
+
|
|
95
|
+
| Event / Action | Current State | Next State | Notes |
|
|
96
|
+
| -------------------------------- | --------------------- | ----------------------------- | ---------------------------------------------- |
|
|
97
|
+
| `select(id)` | `value=X, activeId=Y` | `value=id, activeId=id` | No-op if `isDisabled` or item disabled |
|
|
98
|
+
| `moveNext()` | `activeId=current` | `value=next, activeId=next` | Wraps; skips disabled; no-op if group disabled |
|
|
99
|
+
| `movePrev()` | `activeId=current` | `value=prev, activeId=prev` | Wraps; skips disabled; no-op if group disabled |
|
|
100
|
+
| `moveFirst()` | any | `value=first, activeId=first` | First enabled item; no-op if group disabled |
|
|
101
|
+
| `moveLast()` | any | `value=last, activeId=last` | Last enabled item; no-op if group disabled |
|
|
102
|
+
| `handleKeyDown(ArrowRight/Down)` | any | delegates to `moveNext()` | |
|
|
103
|
+
| `handleKeyDown(ArrowLeft/Up)` | any | delegates to `movePrev()` | |
|
|
104
|
+
| `handleKeyDown(Home)` | any | delegates to `moveFirst()` | |
|
|
105
|
+
| `handleKeyDown(End)` | any | delegates to `moveLast()` | |
|
|
106
|
+
| `handleKeyDown(Space)` | `activeId=id` | `value=id` | Selects focused radio; no-op if group disabled |
|
|
107
|
+
| `setDisabled(v)` | `isDisabled=old` | `isDisabled=v` | Does not change value or activeId |
|
|
108
|
+
|
|
109
|
+
## Invariants
|
|
110
|
+
|
|
111
|
+
- At most one radio can be checked at any time.
|
|
112
|
+
- `activeId` must always be an enabled radio id (unless no enabled radios exist).
|
|
113
|
+
- `value` and `activeId` are synchronized during arrow navigation (selection follows focus).
|
|
114
|
+
- `value` is `null` or an enabled radio id.
|
|
115
|
+
- `getRadioProps(id)` throws for unknown ids.
|
|
116
|
+
- When `isDisabled` is `true`, `getRootProps()` includes `aria-disabled="true"`.
|
|
117
|
+
- `tabindex="0"` is assigned to exactly one radio (the active one) when it is enabled; all others get `"-1"`.
|
|
118
|
+
|
|
119
|
+
## Minimum Test Matrix
|
|
120
|
+
|
|
121
|
+
- single selection behavior (checking one unchecks the previous)
|
|
122
|
+
- arrow navigation with automatic selection and wrapping
|
|
123
|
+
- skipping disabled radios during navigation
|
|
124
|
+
- Home/End key behavior
|
|
125
|
+
- initial state with no selection (first radio is tabbable)
|
|
126
|
+
- initial state with selection (selected radio is tabbable)
|
|
127
|
+
- group-level disabled blocks all interactions
|
|
128
|
+
- `aria-describedby` present when item has `describedBy`
|
|
129
|
+
|
|
130
|
+
## Adapter Expectations
|
|
131
|
+
|
|
132
|
+
UIKit adapter (`cv-radio-group` + `cv-radio`) will:
|
|
133
|
+
|
|
134
|
+
- **Read**: `state.value`, `state.activeId`, `state.isDisabled`, `state.orientation`
|
|
135
|
+
- **Call**: `actions.select`, `actions.moveNext`, `actions.movePrev`, `actions.moveFirst`, `actions.moveLast`, `actions.handleKeyDown`, `actions.setDisabled`
|
|
136
|
+
- **Spread**: `contracts.getRootProps()` onto the radio-group host/container, `contracts.getRadioProps(id)` onto each radio element
|
|
137
|
+
- **Size** (`small | medium | large`): UIKit-only visual concern; does not affect headless contracts
|
|
138
|
+
- **Description slot**: UIKit renders a secondary text slot; headless provides `aria-describedby` linkage via `RadioGroupItem.describedBy`
|
|
139
|
+
|
|
140
|
+
## ADR-001 Compliance
|
|
141
|
+
|
|
142
|
+
- **Runtime Policy**: Reatom v1000 only; no @statx/\* in headless core.
|
|
143
|
+
- **Layering**: core -> interactions -> a11y-contracts -> adapters; adapters remain thin mappings.
|
|
144
|
+
- **Independence**: No imports from @project/_, apps/_, or other out-of-package modules.
|
|
145
|
+
- **Verification**: Mandatory adapter integration tests and standalone package test execution.
|
|
146
|
+
|
|
147
|
+
## Out of Scope (Current)
|
|
148
|
+
|
|
149
|
+
- nested radio groups
|
|
150
|
+
- manual activation mode (where arrows move focus but not selection)
|
|
151
|
+
- native form submission integration (handled by adapters/wrappers)
|