@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,353 @@
1
+ # Drawer Component Contract
2
+
3
+ ## Purpose
4
+
5
+ `Drawer` is a headless contract for slide-out panel dialogs. It wraps `createDialog` internally, delegating all dialog behavior (visibility, focus trapping, scroll locking, dismissal), and adds a `placement` dimension that determines which edge the panel slides from. Supports all dialog features including modal/non-modal modes and `alertdialog` role.
6
+
7
+ ## Component Files
8
+
9
+ - `src/drawer/index.ts` - model and public `createDrawer` API
10
+ - `src/drawer/drawer.test.ts` - unit behavior tests
11
+
12
+ ## Public API
13
+
14
+ - `createDrawer(options)`
15
+ - `state` (signal-backed):
16
+ - `isOpen()` — whether the drawer is currently visible (delegated from dialog)
17
+ - `isModal()` — whether the drawer is in modal mode (delegated from dialog)
18
+ - `type()` — `'dialog' | 'alertdialog'` (delegated from dialog)
19
+ - `restoreTargetId()` — element id to return focus to on close (delegated from dialog)
20
+ - `isFocusTrapped()` — computed: `true` when open AND modal (delegated from dialog)
21
+ - `shouldLockScroll()` — computed: `true` when open AND modal (delegated from dialog)
22
+ - `initialFocusTargetId()` — id of element to focus on open (delegated from dialog)
23
+ - `placement()` — current placement edge: `'start' | 'end' | 'top' | 'bottom'`
24
+ - `actions`:
25
+ - `open(source?)`, `close(intent?)`, `toggle(source?)` (delegated from dialog)
26
+ - `setTriggerId(id)` (delegated from dialog)
27
+ - `setPlacement(placement)` — update the placement edge at runtime
28
+ - `handleTriggerClick()` (delegated from dialog)
29
+ - `handleTriggerKeyDown(event)` (delegated from dialog)
30
+ - `handleKeyDown(event)` (delegated from dialog)
31
+ - `handleOutsidePointer()` (delegated from dialog)
32
+ - `handleOutsideFocus()` (delegated from dialog)
33
+ - `contracts`:
34
+ - `getTriggerProps()` (delegated from dialog)
35
+ - `getOverlayProps()` (delegated from dialog)
36
+ - `getPanelProps()` — drawer-specific: extends dialog content props with `data-placement`
37
+ - `getTitleProps()` (delegated from dialog)
38
+ - `getDescriptionProps()` (delegated from dialog)
39
+ - `getCloseButtonProps()` (delegated from dialog)
40
+ - `getHeaderCloseButtonProps()` (delegated from dialog)
41
+
42
+ ## CreateDrawerOptions
43
+
44
+ Extends `CreateDialogOptions` with:
45
+
46
+ | Option | Type | Default | Description |
47
+ | ----------- | --------------------------------------- | ------- | --------------------------------------- |
48
+ | `placement` | `'start' \| 'end' \| 'top' \| 'bottom'` | `'end'` | Which edge the drawer panel slides from |
49
+
50
+ All options from `CreateDialogOptions` are supported and forwarded to the internal dialog:
51
+
52
+ | Option | Type | Default | Description |
53
+ | ----------------------- | --------------------------- | ---------------------- | ---------------------------------------------- |
54
+ | `idBase` | `string` | `'drawer'` | Base id prefix for all generated ids |
55
+ | `type` | `'dialog' \| 'alertdialog'` | `'dialog'` | ARIA role for the content element |
56
+ | `initialOpen` | `boolean` | `false` | Whether the drawer starts open |
57
+ | `isModal` | `boolean` | `true` | Modal mode enables focus trap and scroll lock |
58
+ | `closeOnEscape` | `boolean` | `true` | Whether Escape key closes the drawer |
59
+ | `closeOnOutsidePointer` | `boolean` | `true` | Whether clicking outside closes the drawer |
60
+ | `closeOnOutsideFocus` | `boolean` | `true` | Whether focusing outside closes the drawer |
61
+ | `initialFocusId` | `string` | --- | Id of element to receive initial focus on open |
62
+ | `ariaLabelledBy` | `string` | `{idBase}-title` | Custom id for `aria-labelledby` |
63
+ | `ariaDescribedBy` | `string` | `{idBase}-description` | Custom id for `aria-describedby` |
64
+
65
+ ## State Signal Surface
66
+
67
+ | Signal | Type | Derived? | Source | Description |
68
+ | ---------------------- | --------------------------------- | -------- | ------ | -------------------------------------------------------- |
69
+ | `isOpen` | `Atom<boolean>` | No | dialog | Single source of truth for visibility |
70
+ | `isModal` | `Atom<boolean>` | No | dialog | Whether modal behaviors (focus trap, scroll lock) are on |
71
+ | `type` | `Atom<'dialog' \| 'alertdialog'>` | No | dialog | ARIA role type |
72
+ | `restoreTargetId` | `Atom<string \| null>` | No | dialog | Element id to return focus to after close |
73
+ | `isFocusTrapped` | `Computed<boolean>` | Yes | dialog | `isOpen() && isModal()` |
74
+ | `shouldLockScroll` | `Computed<boolean>` | Yes | dialog | `isOpen() && isModal()` |
75
+ | `initialFocusTargetId` | `Atom<string \| null>` | No | dialog | Id of element to receive focus when drawer opens |
76
+ | `placement` | `Atom<DrawerPlacement>` | No | drawer | Current placement edge |
77
+
78
+ ### DrawerPlacement type
79
+
80
+ ```ts
81
+ type DrawerPlacement = 'start' | 'end' | 'top' | 'bottom'
82
+ ```
83
+
84
+ Logical values (`start`/`end`) follow the CSS inline direction, meaning `start` is left in LTR and right in RTL. Physical values (`top`/`bottom`) are always relative to the viewport block axis.
85
+
86
+ ## APG and A11y Contract
87
+
88
+ Inherits all dialog APG requirements:
89
+
90
+ - content role: `dialog` (default) or `alertdialog` (when `type: 'alertdialog'`)
91
+ - required attributes:
92
+ - panel (content): `aria-modal`, `aria-labelledby`
93
+ - panel (content): `aria-describedby` (required when `type: 'alertdialog'`, recommended for `dialog`)
94
+ - trigger: `aria-haspopup="dialog"`, `aria-expanded`, `aria-controls`
95
+ - focus management:
96
+ - **modal**: focus trap within the drawer; Tab/Shift+Tab cycle through focusable elements
97
+ - **non-modal**: no focus trap; focus can move freely to/from the drawer
98
+ - initial focus on a specific target (via `initialFocusId`) or the first focusable element
99
+ - return focus to the trigger upon closing (both modal and non-modal)
100
+ - alertdialog specifics:
101
+ - role `alertdialog` signals that the drawer contains an alert message requiring user response
102
+ - `aria-describedby` is required (per W3C APG) to point to the alert message content
103
+
104
+ Drawer additions:
105
+
106
+ - `data-placement` attribute on the panel element reflecting the current placement value
107
+ - No additional ARIA roles or attributes are required for placement; it is a visual/layout concern
108
+
109
+ ## Behavior Contract
110
+
111
+ All dialog behaviors are inherited. See Dialog spec for full details.
112
+
113
+ ### Modal (`isModal: true`, default)
114
+
115
+ - `Escape` key closes the drawer (configurable via `closeOnEscape`)
116
+ - Outside pointer click closes the drawer (configurable via `closeOnOutsidePointer`)
117
+ - Outside focus closes the drawer (configurable via `closeOnOutsideFocus`)
118
+ - Scroll lock on the body while the drawer is open
119
+ - Focus trap: `Tab` and `Shift+Tab` cycle through focusable elements inside the drawer
120
+ - Initial focus: defaults to the first focusable element, can be overridden via `initialFocusId`
121
+
122
+ ### Non-modal (`isModal: false`)
123
+
124
+ - `Escape` key closes the drawer (configurable via `closeOnEscape`)
125
+ - Outside pointer click closes the drawer (configurable via `closeOnOutsidePointer`)
126
+ - Outside focus closes the drawer (configurable via `closeOnOutsideFocus`)
127
+ - No scroll lock
128
+ - No focus trap
129
+ - `aria-modal` is `'false'`
130
+ - Initial focus: same as modal (configurable via `initialFocusId`)
131
+
132
+ ### Placement
133
+
134
+ - `placement` determines the edge from which the drawer visually appears
135
+ - Changing placement at runtime (via `setPlacement`) updates the `data-placement` attribute
136
+ - Placement has no effect on ARIA attributes or focus behavior; it is purely a layout/animation hint
137
+
138
+ ## Contract Prop Shapes
139
+
140
+ ### `getTriggerProps()`
141
+
142
+ Delegated from dialog. Same shape as `DialogTriggerProps`.
143
+
144
+ ```ts
145
+ {
146
+ id: string
147
+ role: 'button'
148
+ tabindex: '0'
149
+ 'aria-haspopup': 'dialog'
150
+ 'aria-expanded': 'true' | 'false'
151
+ 'aria-controls': string
152
+ onClick: () => void
153
+ onKeyDown: (event) => void
154
+ }
155
+ ```
156
+
157
+ ### `getOverlayProps()`
158
+
159
+ Delegated from dialog. Same shape as `DialogOverlayProps`.
160
+
161
+ ```ts
162
+ {
163
+ id: string
164
+ hidden: boolean
165
+ 'data-open': 'true' | 'false'
166
+ onPointerDownOutside: () => void
167
+ onFocusOutside: () => void
168
+ }
169
+ ```
170
+
171
+ ### `getPanelProps()`
172
+
173
+ Extends dialog content props with `data-placement`.
174
+
175
+ ```ts
176
+ {
177
+ id: string
178
+ role: 'dialog' | 'alertdialog'
179
+ tabindex: '-1'
180
+ 'aria-modal': 'true' | 'false'
181
+ 'aria-labelledby'?: string
182
+ 'aria-describedby'?: string
183
+ 'data-initial-focus'?: string
184
+ 'data-placement': DrawerPlacement
185
+ onKeyDown: (event) => void
186
+ }
187
+ ```
188
+
189
+ ### `getTitleProps()`
190
+
191
+ Delegated from dialog. Same shape as `DialogTitleProps`.
192
+
193
+ ```ts
194
+ {
195
+ id: string
196
+ }
197
+ ```
198
+
199
+ ### `getDescriptionProps()`
200
+
201
+ Delegated from dialog. Same shape as `DialogDescriptionProps`.
202
+
203
+ ```ts
204
+ {
205
+ id: string
206
+ }
207
+ ```
208
+
209
+ ### `getCloseButtonProps()` (footer/generic close)
210
+
211
+ Delegated from dialog. Same shape as `DialogCloseButtonProps`.
212
+
213
+ ```ts
214
+ {
215
+ id: string
216
+ role: 'button'
217
+ tabindex: '0'
218
+ onClick: () => void
219
+ }
220
+ ```
221
+
222
+ ### `getHeaderCloseButtonProps()` (header close icon)
223
+
224
+ Delegated from dialog. Same shape as `DialogHeaderCloseButtonProps`.
225
+
226
+ ```ts
227
+ {
228
+ id: string
229
+ role: 'button'
230
+ tabindex: '0'
231
+ 'aria-label': 'Close'
232
+ onClick: () => void
233
+ }
234
+ ```
235
+
236
+ ## Transitions Table
237
+
238
+ All dialog transitions apply. See Dialog spec for the full table.
239
+
240
+ Drawer-specific additions:
241
+
242
+ | Event / Action | Current State | Next State / Effect |
243
+ | ------------------------- | ------------- | ------------------------------------- |
244
+ | `setPlacement(placement)` | any | `placement` atom updated to new value |
245
+
246
+ ### Inherited transitions (from dialog)
247
+
248
+ | Event / Action | Current State | Next State / Effect |
249
+ | ----------------------------------- | ----------------------------------------------- | ---------------------------------------------------------------- |
250
+ | `open(source)` | `isOpen = false` | `isOpen = true`; restore target cleared; focus management begins |
251
+ | `close(intent)` | `isOpen = true` | `isOpen = false`; `restoreTargetId` set to trigger id |
252
+ | `toggle(source)` | `isOpen = false` | calls `open(source)` |
253
+ | `toggle(source)` | `isOpen = true` | calls `close('programmatic')` |
254
+ | `handleTriggerClick()` | any | calls `toggle('pointer')` |
255
+ | `handleTriggerKeyDown(Enter/Space)` | any | calls `toggle('keyboard')` |
256
+ | `handleKeyDown(Escape)` | `isOpen = true`, `closeOnEscape = true` | calls `close('escape')` |
257
+ | `handleKeyDown(Escape)` | `closeOnEscape = false` | no-op |
258
+ | `handleOutsidePointer()` | `isOpen = true`, `closeOnOutsidePointer = true` | calls `close('outside-pointer')` |
259
+ | `handleOutsidePointer()` | `closeOnOutsidePointer = false` | no-op |
260
+ | `handleOutsideFocus()` | `isOpen = true`, `closeOnOutsideFocus = true` | calls `close('outside-focus')` |
261
+ | `handleOutsideFocus()` | `closeOnOutsideFocus = false` | no-op |
262
+ | `setTriggerId(id)` | any | trigger id updated; affects future `restoreTargetId` |
263
+
264
+ ### Derived state reactions
265
+
266
+ | State Change | `isFocusTrapped` | `shouldLockScroll` |
267
+ | ---------------- | ---------------- | ------------------ |
268
+ | open + modal | `true` | `true` |
269
+ | open + non-modal | `false` | `false` |
270
+ | closed (any) | `false` | `false` |
271
+
272
+ ## Invariants
273
+
274
+ 1. All dialog invariants apply (see Dialog spec invariants 1-10).
275
+ 2. `placement` must always be one of `'start' | 'end' | 'top' | 'bottom'`.
276
+ 3. `placement` defaults to `'end'` when not specified.
277
+ 4. `getPanelProps()` must always include `data-placement` reflecting the current `placement` value.
278
+ 5. `getPanelProps()` must include all attributes from dialog's `getContentProps()` (role, aria-modal, aria-labelledby, etc.).
279
+ 6. The drawer must not duplicate any dialog logic; all dialog behavior is delegated to the internal `createDialog` instance.
280
+ 7. Changing `placement` must not affect open/close state, focus behavior, or ARIA attributes.
281
+
282
+ ## Adapter Expectations
283
+
284
+ UIKit adapters MUST bind to the headless model as follows:
285
+
286
+ **Signals read (reactive, drive re-renders):**
287
+
288
+ - `state.isOpen()` — whether the drawer is visible
289
+ - `state.isModal()` — whether modal behaviors are active
290
+ - `state.type()` — dialog type for role assignment
291
+ - `state.isFocusTrapped()` — whether focus trap should be active
292
+ - `state.shouldLockScroll()` — whether body scroll lock should be active
293
+ - `state.restoreTargetId()` — element id to focus after close
294
+ - `state.initialFocusTargetId()` — element id to focus on open
295
+ - `state.placement()` — current placement edge for layout/animation
296
+
297
+ **Actions called (event handlers, never mutate state directly):**
298
+
299
+ - `actions.open(source?)` / `actions.close(intent?)` — programmatic open/close
300
+ - `actions.toggle(source?)` — toggle open state
301
+ - `actions.setTriggerId(id)` — set custom trigger element id
302
+ - `actions.setPlacement(placement)` — update placement edge at runtime
303
+ - `actions.handleTriggerClick()` — on trigger click
304
+ - `actions.handleTriggerKeyDown(event)` — on trigger keydown
305
+ - `actions.handleKeyDown(event)` — on panel keydown (Escape handling)
306
+ - `actions.handleOutsidePointer()` — on pointer outside the drawer
307
+ - `actions.handleOutsideFocus()` — on focus outside the drawer
308
+
309
+ **Contracts spread (attribute maps applied directly to DOM elements):**
310
+
311
+ - `contracts.getTriggerProps()` — spread onto the trigger button element
312
+ - `contracts.getOverlayProps()` — spread onto the overlay/backdrop element
313
+ - `contracts.getPanelProps()` — spread onto the drawer panel element (includes all dialog content attrs plus `data-placement`)
314
+ - `contracts.getTitleProps()` — spread onto the drawer title element
315
+ - `contracts.getDescriptionProps()` — spread onto the drawer description element
316
+ - `contracts.getCloseButtonProps()` — spread onto a footer/generic close button
317
+ - `contracts.getHeaderCloseButtonProps()` — spread onto a header close icon button (includes `aria-label: 'Close'`)
318
+
319
+ **UIKit-only concerns (NOT in headless):**
320
+
321
+ - Lifecycle events (`cv-open`, `cv-close`, `cv-after-open`, `cv-after-close`)
322
+ - CSS transitions and slide animations (direction determined by `data-placement`)
323
+ - Backdrop rendering and styling
324
+ - Scroll lock implementation (headless provides the signal, UIKit applies the side effect)
325
+ - Focus trap implementation (headless provides the signal, UIKit manages DOM focus)
326
+ - Contained mode (deferred to v2)
327
+
328
+ ## Minimum Test Matrix
329
+
330
+ - All dialog tests apply (open/close, Escape, outside pointer, outside focus, focus trap, scroll lock, return focus, initial focus, ARIA linkage, alertdialog, close buttons, trigger handlers, overlay state)
331
+ - Default placement is `'end'`
332
+ - `getPanelProps()` returns `data-placement` matching current placement
333
+ - `setPlacement()` updates placement and `getPanelProps()` reflects the change
334
+ - All four placement values (`start`, `end`, `top`, `bottom`) are accepted
335
+ - Placement change does not affect `isOpen` state
336
+ - Placement change does not affect ARIA attributes (role, aria-modal, aria-labelledby, aria-describedby)
337
+ - `getPanelProps()` includes all dialog content attributes (role, tabindex, aria-modal, etc.)
338
+ - Custom `idBase` propagates through to all generated ids (uses `'drawer'` as default, not `'dialog'`)
339
+
340
+ ## ADR-001 Compliance
341
+
342
+ - **Runtime Policy**: Reatom v1000 only; no `@statx/*` in headless core.
343
+ - **Layering**: `core -> interactions -> a11y-contracts -> adapters`; adapters remain thin mappings.
344
+ - **Independence**: No imports from `@project/*`, `apps/*`, or other out-of-package modules.
345
+ - **Composition**: `createDrawer` wraps `createDialog`; no duplication of dialog internals.
346
+ - **Verification**: Mandatory adapter integration tests and standalone package test execution.
347
+
348
+ ## Out of Scope (Current)
349
+
350
+ - Contained mode (drawer within a parent container instead of viewport) - deferred to v2
351
+ - Swipe-to-dismiss gesture handling
352
+ - Nested/stacked drawers management
353
+ - Complex animations/transitions (CSS/JS animations are UIKit concerns)
@@ -0,0 +1,265 @@
1
+ # Feed Component Contract
2
+
3
+ ## Purpose
4
+
5
+ `Feed` provides a headless APG-aligned model for bidirectional infinite scrolling content, where sections of content (articles) are loaded dynamically as the user scrolls or triggers load actions. Supports both appending (bottom) and prepending (top) of articles.
6
+
7
+ ## Component Files
8
+
9
+ - `src/feed/index.ts` - model and public `createFeed` API
10
+ - `src/feed/feed.test.ts` - unit behavior tests
11
+
12
+ ## Public API
13
+
14
+ - `createFeed(options)`
15
+ - `state` (signal-backed):
16
+ - `articleIds()` - ordered list of loaded article identifiers
17
+ - `activeArticleId()` - identifier of the article currently focused or "active"
18
+ - `isLoading()` - boolean indicating if content is being fetched
19
+ - `isBusy()` - boolean for `aria-busy`
20
+ - `totalCount()` - total number of articles if known, or -1 if unknown/infinite
21
+ - `isEmpty()` - derived: `articleIds.length === 0`
22
+ - `hasError()` - derived: whether an error is present
23
+ - `error()` - current error value/message, or `null`
24
+ - `canLoadMore()` - derived: whether more bottom-loading is possible
25
+ - `canLoadNewer()` - derived: whether more top-loading is possible
26
+ - `actions`:
27
+ - navigation: `focusNextArticle`, `focusPrevArticle`
28
+ - lifecycle: `loadMore` (append), `loadNewer` (prepend), `setArticles`, `appendArticles`, `prependArticles`, `removeArticle`
29
+ - state: `setBusy`, `setError`, `clearError`, `setTotalCount`
30
+ - keyboard: `handleKeyDown`
31
+ - `contracts`:
32
+ - `getFeedProps()`
33
+ - `getArticleProps(articleId)`
34
+
35
+ ## APG and A11y Contract
36
+
37
+ - root role: `feed`
38
+ - item role: `article`
39
+ - required attributes:
40
+ - root: `aria-label` or `aria-labelledby`, `aria-busy`
41
+ - article: `aria-posinset`, `aria-setsize`, `tabindex`
42
+ - focus management:
43
+ - the feed container itself is not focusable
44
+ - articles are focusable and managed via `roving-tabindex`
45
+
46
+ ## Keyboard Contract
47
+
48
+ Per W3C APG Feed Pattern:
49
+
50
+ - `PageDown`: move focus to the next article (`focusNextArticle`)
51
+ - `PageUp`: move focus to the previous article (`focusPrevArticle`)
52
+ - `Ctrl + End`: move focus to the first focusable element AFTER the feed (not to the last article). The headless `handleKeyDown` signals this intent via a return value or flag; the adapter is responsible for actual DOM focus movement.
53
+ - `Ctrl + Home`: move focus to the first focusable element BEFORE the feed (not to the first article). Same adapter delegation as above.
54
+
55
+ The `handleKeyDown` action returns a `FeedKeyboardResult` indicating the action taken:
56
+
57
+ - `'next'` - moved to next article
58
+ - `'prev'` - moved to previous article
59
+ - `'exit-after'` - adapter should move focus after the feed
60
+ - `'exit-before'` - adapter should move focus before the feed
61
+ - `null` - key not handled
62
+
63
+ ## Behavior Contract
64
+
65
+ ### Bidirectional Loading
66
+
67
+ - `loadMore` triggers when the user/adapter requests more content at the bottom (append direction). The adapter (UIKit) uses IntersectionObserver on a bottom sentinel to call this action.
68
+ - `loadNewer` triggers when the user/adapter requests newer content at the top (prepend direction). The adapter (UIKit) uses IntersectionObserver on a top sentinel to call this action.
69
+ - Both `loadMore` and `loadNewer` are action-only: headless exposes the actions, UIKit decides when to call them.
70
+
71
+ ### Aria Busy
72
+
73
+ - `aria-busy` is set to `true` during both `loadMore` and `loadNewer` operations.
74
+ - `setBusy(true)` is called at the start of loading; `setBusy(false)` at completion.
75
+
76
+ ### Focus Preservation
77
+
78
+ - When articles are prepended, the currently focused article must retain its focus. The `activeArticleId` remains stable; `aria-posinset` values shift for all articles.
79
+ - When the active article is removed, focus moves to the nearest enabled article (prefer next, fallback to prev).
80
+
81
+ ### Position Recalculation
82
+
83
+ - `aria-posinset` and `aria-setsize` are recalculated on any article list change (`setArticles`, `appendArticles`, `prependArticles`, `removeArticle`).
84
+ - `aria-setsize` equals `totalCount` if known, or `-1` if unknown/infinite.
85
+ - `aria-posinset` is 1-based and reflects the article's position in the full ordered list.
86
+
87
+ ## State Signal Surface
88
+
89
+ | Signal | Type | Description |
90
+ | ----------------- | ---------------------- | ---------------------------------------------------------------------- |
91
+ | `articleIds` | `Computed<string[]>` | Ordered list of loaded article IDs |
92
+ | `activeArticleId` | `Atom<string \| null>` | Currently focused article ID |
93
+ | `isLoading` | `Atom<boolean>` | Whether a load operation is in progress |
94
+ | `isBusy` | `Atom<boolean>` | Maps to `aria-busy` |
95
+ | `totalCount` | `Atom<number>` | Total articles count, or `-1` if unknown |
96
+ | `isEmpty` | `Computed<boolean>` | `articleIds.length === 0` |
97
+ | `hasError` | `Computed<boolean>` | `error !== null` |
98
+ | `error` | `Atom<string \| null>` | Current error message, or `null` |
99
+ | `canLoadMore` | `Computed<boolean>` | Whether bottom-loading is possible (not loading AND not all loaded) |
100
+ | `canLoadNewer` | `Computed<boolean>` | Whether top-loading is possible (not loading AND newer content exists) |
101
+
102
+ ## Actions
103
+
104
+ | Action | Signature | Description |
105
+ | ------------------ | ------------------------------------------------------ | ------------------------------------------------------------ |
106
+ | `focusNextArticle` | `() => void` | Move active to next enabled article |
107
+ | `focusPrevArticle` | `() => void` | Move active to previous enabled article |
108
+ | `loadMore` | `() => Promise<void>` | Append articles at bottom; sets busy, calls adapter callback |
109
+ | `loadNewer` | `() => Promise<void>` | Prepend articles at top; sets busy, calls adapter callback |
110
+ | `setArticles` | `(articles: FeedArticle[]) => void` | Replace entire article list |
111
+ | `appendArticles` | `(articles: FeedArticle[]) => void` | Add articles to the end |
112
+ | `prependArticles` | `(articles: FeedArticle[]) => void` | Add articles to the beginning |
113
+ | `removeArticle` | `(articleId: string) => void` | Remove a single article by ID |
114
+ | `setBusy` | `(value: boolean) => void` | Set `aria-busy` state |
115
+ | `setError` | `(message: string) => void` | Set error state with message |
116
+ | `clearError` | `() => void` | Clear error state |
117
+ | `setTotalCount` | `(count: number) => void` | Set total article count (`-1` for unknown) |
118
+ | `handleKeyDown` | `(event: FeedKeyboardEventLike) => FeedKeyboardResult` | Process keyboard event per APG |
119
+
120
+ ## Contracts
121
+
122
+ ### `getFeedProps()`
123
+
124
+ Returns a complete ARIA prop object ready to spread on the feed root element:
125
+
126
+ ```ts
127
+ {
128
+ id: string
129
+ role: 'feed'
130
+ 'aria-label'?: string
131
+ 'aria-labelledby'?: string
132
+ 'aria-busy': 'true' | 'false'
133
+ }
134
+ ```
135
+
136
+ ### `getArticleProps(articleId)`
137
+
138
+ Returns a complete ARIA prop object ready to spread on each article element:
139
+
140
+ ```ts
141
+ {
142
+ id: string
143
+ role: 'article'
144
+ tabindex: '0' | '-1'
145
+ 'aria-posinset': number
146
+ 'aria-setsize': number
147
+ 'aria-labelledby'?: string
148
+ 'aria-describedby'?: string
149
+ 'aria-disabled'?: 'true'
150
+ 'data-active': 'true' | 'false'
151
+ onFocus: () => void
152
+ }
153
+ ```
154
+
155
+ ## Transitions Table
156
+
157
+ | Event / Action | Current State | Next State / Effect |
158
+ | -------------------------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
159
+ | `focusNextArticle()` | any | `activeArticleId` = next enabled article ID; clamps at last |
160
+ | `focusPrevArticle()` | any | `activeArticleId` = previous enabled article ID; clamps at first |
161
+ | `loadMore()` | `isLoading = false` | `isLoading` = `true`; `isBusy` = `true`; invoke callback; on resolve: append articles, `isLoading` = `false`, `isBusy` = `false`; on reject: set error, `isLoading` = `false`, `isBusy` = `false` |
162
+ | `loadMore()` | `isLoading = true` | no-op (guard against concurrent loads) |
163
+ | `loadNewer()` | `isLoading = false` | `isLoading` = `true`; `isBusy` = `true`; invoke callback; on resolve: prepend articles, `isLoading` = `false`, `isBusy` = `false`; on reject: set error, `isLoading` = `false`, `isBusy` = `false` |
164
+ | `loadNewer()` | `isLoading = true` | no-op (guard against concurrent loads) |
165
+ | `setArticles(list)` | any | `articles` = deduplicated list; recalculate all derived signals; ensure `activeArticleId` invariant |
166
+ | `appendArticles(list)` | any | `articles` = current + new (deduplicated); recalculate positions |
167
+ | `prependArticles(list)` | any | `articles` = new + current (deduplicated); recalculate positions; `activeArticleId` preserved |
168
+ | `removeArticle(id)` | `activeArticleId = id` | remove article; `activeArticleId` = nearest enabled (prefer next, fallback prev) |
169
+ | `removeArticle(id)` | `activeArticleId != id` | remove article; `activeArticleId` unchanged |
170
+ | `setBusy(value)` | any | `isBusy` = value |
171
+ | `setError(message)` | any | `error` = message; `hasError` = `true` |
172
+ | `clearError()` | any | `error` = `null`; `hasError` = `false` |
173
+ | `setTotalCount(count)` | any | `totalCount` = count; `canLoadMore` / `canLoadNewer` recalculated |
174
+ | `handleKeyDown(PageDown)` | any | calls `focusNextArticle()`; returns `'next'` |
175
+ | `handleKeyDown(PageUp)` | any | calls `focusPrevArticle()`; returns `'prev'` |
176
+ | `handleKeyDown(Ctrl+End)` | any | returns `'exit-after'` (adapter handles DOM focus) |
177
+ | `handleKeyDown(Ctrl+Home)` | any | returns `'exit-before'` (adapter handles DOM focus) |
178
+ | `handleKeyDown(other)` | any | returns `null` (not handled) |
179
+
180
+ ## Invariants
181
+
182
+ 1. `activeArticleId` must always be `null` or one of the currently loaded enabled `articleIds`.
183
+ 2. `aria-setsize` equals `totalCount` if known (>= 0), or `-1` if unknown/infinite.
184
+ 3. `aria-posinset` is 1-based and sequential across the ordered `articleIds` list.
185
+ 4. Only the active article has `tabindex="0"`; all others have `tabindex="-1"`.
186
+ 5. Disabled articles are skipped during keyboard navigation.
187
+ 6. Focus must be preserved or logically moved when articles are prepended, appended, or removed.
188
+ 7. `isEmpty` is always equivalent to `articleIds.length === 0`.
189
+ 8. `hasError` is always equivalent to `error !== null`.
190
+ 9. Concurrent `loadMore`/`loadNewer` calls are guarded — only one load operation at a time.
191
+ 10. After any article list mutation, `activeArticleId` is validated against the new list and corrected if needed.
192
+
193
+ ## Adapter Expectations
194
+
195
+ UIKit adapters MUST bind to the headless model as follows:
196
+
197
+ **Signals read (reactive, drive re-renders):**
198
+
199
+ - `state.articleIds()` — ordered article IDs for rendering the list
200
+ - `state.activeArticleId()` — for focus management
201
+ - `state.isLoading()` — for rendering loading indicators
202
+ - `state.isBusy()` — reflected in `aria-busy` via `getFeedProps()`
203
+ - `state.isEmpty()` — for rendering empty state slot
204
+ - `state.hasError()` — for rendering error state slot
205
+ - `state.error()` — for rendering error message
206
+ - `state.canLoadMore()` — for showing/hiding bottom sentinel or load-more button
207
+ - `state.canLoadNewer()` — for showing/hiding top sentinel or load-newer button
208
+ - `state.totalCount()` — reflected in `aria-setsize` via `getArticleProps()`
209
+
210
+ **Actions called (event handlers, never mutate state directly):**
211
+
212
+ - `actions.focusNextArticle()` / `actions.focusPrevArticle()` — article navigation
213
+ - `actions.loadMore()` — called by IntersectionObserver on bottom sentinel
214
+ - `actions.loadNewer()` — called by IntersectionObserver on top sentinel
215
+ - `actions.setArticles(list)` — to replace the full article list
216
+ - `actions.appendArticles(list)` / `actions.prependArticles(list)` — for manual batch additions
217
+ - `actions.removeArticle(id)` — to remove a single article
218
+ - `actions.setBusy(value)` — for external busy state control
219
+ - `actions.setError(message)` / `actions.clearError()` — for error state management
220
+ - `actions.setTotalCount(count)` — when total becomes known or changes
221
+ - `actions.handleKeyDown(event)` — on keydown within feed root; adapter inspects return value for `'exit-after'`/`'exit-before'` to handle DOM focus transfer
222
+
223
+ **Contracts spread (attribute maps applied directly to DOM elements):**
224
+
225
+ - `contracts.getFeedProps()` — spread onto the feed root element
226
+ - `contracts.getArticleProps(articleId)` — spread onto each article element
227
+
228
+ **UIKit-only concerns (NOT in headless):**
229
+
230
+ - IntersectionObserver setup for top/bottom sentinels
231
+ - DOM focus transfer for `Ctrl+End` / `Ctrl+Home` (moving focus outside the feed)
232
+ - Empty state and error state slot rendering
233
+ - Scroll position management
234
+ - Touch/gesture handling
235
+
236
+ ## Minimum Test Matrix
237
+
238
+ - article navigation via `focusNextArticle` / `focusPrevArticle`
239
+ - correct `aria-posinset` and `aria-setsize` calculation during dynamic loading
240
+ - `aria-busy` state transitions during `loadMore` and `loadNewer`
241
+ - focus preservation when articles are prepended
242
+ - focus recovery when active article is removed
243
+ - boundary handling (first/last article clamping)
244
+ - disabled article skip behavior
245
+ - `setArticles` replaces list and validates active
246
+ - `appendArticles` / `prependArticles` deduplication
247
+ - `handleKeyDown` returns correct `FeedKeyboardResult` values
248
+ - `isEmpty`, `hasError`, `canLoadMore`, `canLoadNewer` derived state accuracy
249
+ - error state transitions (`setError`, `clearError`)
250
+ - concurrent load guard (second `loadMore` during active load is no-op)
251
+
252
+ ## ADR-001 Compliance
253
+
254
+ - **Runtime Policy**: Reatom v1000 only; no @statx/\* in headless core.
255
+ - **Layering**: core -> interactions -> a11y-contracts -> adapters; adapters remain thin mappings.
256
+ - **Independence**: No imports from @project/_, apps/_, or other out-of-package modules.
257
+ - **Verification**: Mandatory adapter integration tests and standalone package test execution.
258
+
259
+ ## Out of Scope (Current)
260
+
261
+ - scroll position restoration
262
+ - complex filtering or sorting of the feed
263
+ - nested feeds
264
+ - automatic scroll-to-load logic (handled by IntersectionObserver in the adapter)
265
+ - virtualization of off-screen articles