@chromvoid/headless-ui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (191) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +99 -0
  3. package/dist/a11y-contracts/index.d.ts +23 -0
  4. package/dist/a11y-contracts/index.js +1 -0
  5. package/dist/accordion/index.d.ts +78 -0
  6. package/dist/accordion/index.js +264 -0
  7. package/dist/adapters/index.d.ts +9 -0
  8. package/dist/adapters/index.js +1 -0
  9. package/dist/alert/index.d.ts +33 -0
  10. package/dist/alert/index.js +54 -0
  11. package/dist/alert-dialog/index.d.ts +69 -0
  12. package/dist/alert-dialog/index.js +94 -0
  13. package/dist/badge/index.d.ts +48 -0
  14. package/dist/badge/index.js +89 -0
  15. package/dist/breadcrumb/index.d.ts +55 -0
  16. package/dist/breadcrumb/index.js +77 -0
  17. package/dist/button/index.d.ts +46 -0
  18. package/dist/button/index.js +86 -0
  19. package/dist/callout/index.d.ts +41 -0
  20. package/dist/callout/index.js +63 -0
  21. package/dist/card/index.d.ts +54 -0
  22. package/dist/card/index.js +103 -0
  23. package/dist/carousel/index.d.ts +98 -0
  24. package/dist/carousel/index.js +243 -0
  25. package/dist/checkbox/index.d.ts +50 -0
  26. package/dist/checkbox/index.js +87 -0
  27. package/dist/combobox/index.d.ts +114 -0
  28. package/dist/combobox/index.js +431 -0
  29. package/dist/command-palette/index.d.ts +73 -0
  30. package/dist/command-palette/index.js +147 -0
  31. package/dist/context-menu/index.d.ts +111 -0
  32. package/dist/context-menu/index.js +372 -0
  33. package/dist/copy-button/index.d.ts +62 -0
  34. package/dist/copy-button/index.js +183 -0
  35. package/dist/core/index.d.ts +20 -0
  36. package/dist/core/index.js +2 -0
  37. package/dist/core/selection.d.ts +5 -0
  38. package/dist/core/selection.js +39 -0
  39. package/dist/core/value-range.d.ts +49 -0
  40. package/dist/core/value-range.js +134 -0
  41. package/dist/date-picker/index.d.ts +210 -0
  42. package/dist/date-picker/index.js +895 -0
  43. package/dist/dialog/index.d.ts +95 -0
  44. package/dist/dialog/index.js +153 -0
  45. package/dist/disclosure/index.d.ts +52 -0
  46. package/dist/disclosure/index.js +159 -0
  47. package/dist/drawer/index.d.ts +30 -0
  48. package/dist/drawer/index.js +39 -0
  49. package/dist/feed/index.d.ts +77 -0
  50. package/dist/feed/index.js +260 -0
  51. package/dist/grid/index.d.ts +103 -0
  52. package/dist/grid/index.js +415 -0
  53. package/dist/index.d.ts +51 -0
  54. package/dist/index.js +51 -0
  55. package/dist/input/index.d.ts +86 -0
  56. package/dist/input/index.js +156 -0
  57. package/dist/interactions/composite-navigation.d.ts +69 -0
  58. package/dist/interactions/composite-navigation.js +169 -0
  59. package/dist/interactions/index.d.ts +15 -0
  60. package/dist/interactions/index.js +4 -0
  61. package/dist/interactions/keyboard-intents.d.ts +16 -0
  62. package/dist/interactions/keyboard-intents.js +33 -0
  63. package/dist/interactions/overlay-focus.d.ts +40 -0
  64. package/dist/interactions/overlay-focus.js +93 -0
  65. package/dist/interactions/typeahead.d.ts +20 -0
  66. package/dist/interactions/typeahead.js +41 -0
  67. package/dist/landmarks/index.d.ts +39 -0
  68. package/dist/landmarks/index.js +58 -0
  69. package/dist/link/index.d.ts +34 -0
  70. package/dist/link/index.js +39 -0
  71. package/dist/listbox/index.d.ts +92 -0
  72. package/dist/listbox/index.js +337 -0
  73. package/dist/menu/index.d.ts +132 -0
  74. package/dist/menu/index.js +541 -0
  75. package/dist/menu-button/index.d.ts +71 -0
  76. package/dist/menu-button/index.js +121 -0
  77. package/dist/meter/index.d.ts +45 -0
  78. package/dist/meter/index.js +106 -0
  79. package/dist/number/index.d.ts +113 -0
  80. package/dist/number/index.js +252 -0
  81. package/dist/popover/index.d.ts +70 -0
  82. package/dist/popover/index.js +126 -0
  83. package/dist/progress/index.d.ts +49 -0
  84. package/dist/progress/index.js +79 -0
  85. package/dist/radio-group/index.d.ts +61 -0
  86. package/dist/radio-group/index.js +150 -0
  87. package/dist/select/index.d.ts +92 -0
  88. package/dist/select/index.js +239 -0
  89. package/dist/sidebar/index.d.ts +74 -0
  90. package/dist/sidebar/index.js +186 -0
  91. package/dist/slider/index.d.ts +61 -0
  92. package/dist/slider/index.js +150 -0
  93. package/dist/slider-multi-thumb/index.d.ts +70 -0
  94. package/dist/slider-multi-thumb/index.js +222 -0
  95. package/dist/spinbutton/index.d.ts +75 -0
  96. package/dist/spinbutton/index.js +214 -0
  97. package/dist/spinner/index.d.ts +1 -0
  98. package/dist/spinner/index.js +1 -0
  99. package/dist/spinner/spinner.d.ts +23 -0
  100. package/dist/spinner/spinner.js +25 -0
  101. package/dist/switch/index.d.ts +40 -0
  102. package/dist/switch/index.js +61 -0
  103. package/dist/table/index.d.ts +117 -0
  104. package/dist/table/index.js +377 -0
  105. package/dist/tabs/index.d.ts +63 -0
  106. package/dist/tabs/index.js +174 -0
  107. package/dist/textarea/index.d.ts +68 -0
  108. package/dist/textarea/index.js +137 -0
  109. package/dist/toast/index.d.ts +67 -0
  110. package/dist/toast/index.js +145 -0
  111. package/dist/toolbar/index.d.ts +59 -0
  112. package/dist/toolbar/index.js +139 -0
  113. package/dist/tooltip/index.d.ts +52 -0
  114. package/dist/tooltip/index.js +169 -0
  115. package/dist/treegrid/index.d.ts +101 -0
  116. package/dist/treegrid/index.js +463 -0
  117. package/dist/treeview/index.d.ts +68 -0
  118. package/dist/treeview/index.js +370 -0
  119. package/dist/window-splitter/index.d.ts +65 -0
  120. package/dist/window-splitter/index.js +204 -0
  121. package/package.json +92 -0
  122. package/specs/ADR-001-headless-architecture.md +461 -0
  123. package/specs/ADR-002-repo-release-model.md +108 -0
  124. package/specs/ADR-003-public-api-versioning.md +136 -0
  125. package/specs/ADR-004-focus-selection-policy.md +117 -0
  126. package/specs/IMPLEMENTATION-ROADMAP.md +237 -0
  127. package/specs/ISSUE-BACKLOG.md +681 -0
  128. package/specs/RELEASE-CANDIDATE.md +30 -0
  129. package/specs/components/accordion.md +130 -0
  130. package/specs/components/alert-dialog.md +72 -0
  131. package/specs/components/alert.md +65 -0
  132. package/specs/components/badge.md +220 -0
  133. package/specs/components/breadcrumb.md +74 -0
  134. package/specs/components/button.md +115 -0
  135. package/specs/components/callout.md +195 -0
  136. package/specs/components/card.md +280 -0
  137. package/specs/components/carousel.md +140 -0
  138. package/specs/components/checkbox.md +172 -0
  139. package/specs/components/combobox.md +423 -0
  140. package/specs/components/command-palette.md +92 -0
  141. package/specs/components/context-menu.md +556 -0
  142. package/specs/components/copy-button.md +293 -0
  143. package/specs/components/date-picker.md +400 -0
  144. package/specs/components/dialog.md +298 -0
  145. package/specs/components/disclosure.md +257 -0
  146. package/specs/components/drawer.md +353 -0
  147. package/specs/components/feed.md +265 -0
  148. package/specs/components/grid.md +186 -0
  149. package/specs/components/input.md +254 -0
  150. package/specs/components/landmarks.md +136 -0
  151. package/specs/components/link.md +134 -0
  152. package/specs/components/listbox.md +351 -0
  153. package/specs/components/menu-button.md +76 -0
  154. package/specs/components/menu.md +623 -0
  155. package/specs/components/meter.md +149 -0
  156. package/specs/components/number.md +393 -0
  157. package/specs/components/popover.md +252 -0
  158. package/specs/components/progress.md +188 -0
  159. package/specs/components/radio-group.md +151 -0
  160. package/specs/components/select.md +144 -0
  161. package/specs/components/sidebar.md +321 -0
  162. package/specs/components/slider-multi-thumb.md +78 -0
  163. package/specs/components/slider.md +84 -0
  164. package/specs/components/spinbutton.md +140 -0
  165. package/specs/components/spinner.md +132 -0
  166. package/specs/components/switch.md +175 -0
  167. package/specs/components/table.md +403 -0
  168. package/specs/components/tabs.md +265 -0
  169. package/specs/components/textarea.md +185 -0
  170. package/specs/components/toast.md +198 -0
  171. package/specs/components/toolbar.md +278 -0
  172. package/specs/components/tooltip.md +252 -0
  173. package/specs/components/treegrid.md +281 -0
  174. package/specs/components/treeview.md +91 -0
  175. package/specs/components/window-splitter.md +297 -0
  176. package/specs/ops/git-shard-sync.md +107 -0
  177. package/specs/ops/release-checklist.md +76 -0
  178. package/specs/release/GAP-TO-GREEN-ISSUES.md +88 -0
  179. package/specs/release/api-freeze-candidate.md +54 -0
  180. package/specs/release/changelog-automation.md +76 -0
  181. package/specs/release/changelog.generated.md +53 -0
  182. package/specs/release/changelog.patch.generated.md +46 -0
  183. package/specs/release/consumer-integration.md +53 -0
  184. package/specs/release/migration-notes-pre-v1.md +40 -0
  185. package/specs/release/mvp-changelog.md +57 -0
  186. package/specs/release/release-notes-template.md +61 -0
  187. package/specs/release/release-rehearsal.md +113 -0
  188. package/specs/release/semver-deprecation-dry-run.md +89 -0
  189. package/specs/release/shard-release-drill-report.md +50 -0
  190. package/specs/release/shard-release-follow-ups.md +31 -0
  191. package/specs/signals.md +208 -0
@@ -0,0 +1,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)