@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,298 @@
1
+ # Dialog Component Contract
2
+
3
+ ## Purpose
4
+
5
+ `Dialog` is a headless APG-aligned contract for modal and non-modal dialogs. It manages visibility, focus trapping (modal only), scroll locking (modal only), and dismissal behavior. Supports both `dialog` and `alertdialog` ARIA roles.
6
+
7
+ ## Component Files
8
+
9
+ - `src/dialog/index.ts` - model and public `createDialog` API
10
+ - `src/dialog/dialog.test.ts` - unit behavior tests
11
+
12
+ ## Public API
13
+
14
+ - `createDialog(options)`
15
+ - `state` (signal-backed):
16
+ - `isOpen()` — whether the dialog is currently visible
17
+ - `isModal()` — whether the dialog is in modal mode
18
+ - `type()` — `'dialog' | 'alertdialog'`
19
+ - `restoreTargetId()` — element id to return focus to on close
20
+ - `isFocusTrapped()` — computed: `true` when open AND modal
21
+ - `shouldLockScroll()` — computed: `true` when open AND modal
22
+ - `initialFocusTargetId()` — id of element to focus on open (or `null`)
23
+ - `actions`:
24
+ - `open(source?)`, `close(intent?)`, `toggle(source?)`
25
+ - `setTriggerId(id)`
26
+ - `handleTriggerClick()`
27
+ - `handleTriggerKeyDown(event)`
28
+ - `handleKeyDown(event)`
29
+ - `handleOutsidePointer()`
30
+ - `handleOutsideFocus()`
31
+ - `contracts`:
32
+ - `getTriggerProps()`
33
+ - `getOverlayProps()`
34
+ - `getContentProps()`
35
+ - `getTitleProps()`
36
+ - `getDescriptionProps()`
37
+ - `getCloseButtonProps()` — footer/generic close button
38
+ - `getHeaderCloseButtonProps()` — header close icon button
39
+
40
+ ## CreateDialogOptions
41
+
42
+ | Option | Type | Default | Description |
43
+ | ----------------------- | --------------------------- | ---------------------- | ---------------------------------------------- |
44
+ | `idBase` | `string` | `'dialog'` | Base id prefix for all generated ids |
45
+ | `type` | `'dialog' \| 'alertdialog'` | `'dialog'` | ARIA role for the content element |
46
+ | `initialOpen` | `boolean` | `false` | Whether the dialog starts open |
47
+ | `isModal` | `boolean` | `true` | Modal mode enables focus trap and scroll lock |
48
+ | `closeOnEscape` | `boolean` | `true` | Whether Escape key closes the dialog |
49
+ | `closeOnOutsidePointer` | `boolean` | `true` | Whether clicking outside closes the dialog |
50
+ | `closeOnOutsideFocus` | `boolean` | `true` | Whether focusing outside closes the dialog |
51
+ | `initialFocusId` | `string` | — | Id of element to receive initial focus on open |
52
+ | `ariaLabelledBy` | `string` | `{idBase}-title` | Custom id for `aria-labelledby` |
53
+ | `ariaDescribedBy` | `string` | `{idBase}-description` | Custom id for `aria-describedby` |
54
+
55
+ ## State Signal Surface
56
+
57
+ | Signal | Type | Derived? | Description |
58
+ | ---------------------- | --------------------------------- | -------- | -------------------------------------------------------- |
59
+ | `isOpen` | `Atom<boolean>` | No | Single source of truth for visibility |
60
+ | `isModal` | `Atom<boolean>` | No | Whether modal behaviors (focus trap, scroll lock) are on |
61
+ | `type` | `Atom<'dialog' \| 'alertdialog'>` | No | ARIA role type |
62
+ | `restoreTargetId` | `Atom<string \| null>` | No | Element id to return focus to after close |
63
+ | `isFocusTrapped` | `Computed<boolean>` | Yes | `isOpen() && isModal()` |
64
+ | `shouldLockScroll` | `Computed<boolean>` | Yes | `isOpen() && isModal()` |
65
+ | `initialFocusTargetId` | `Atom<string \| null>` | No | Id of element to receive focus when dialog opens |
66
+
67
+ ## APG and A11y Contract
68
+
69
+ - content role: `dialog` (default) or `alertdialog` (when `type: 'alertdialog'`)
70
+ - required attributes:
71
+ - content: `aria-modal`, `aria-labelledby`
72
+ - content: `aria-describedby` (required when `type: 'alertdialog'`, recommended for `dialog`)
73
+ - trigger: `aria-haspopup="dialog"`, `aria-expanded`, `aria-controls`
74
+ - focus management:
75
+ - **modal**: focus trap within the dialog; Tab/Shift+Tab cycle through focusable elements
76
+ - **non-modal**: no focus trap; focus can move freely to/from the dialog
77
+ - initial focus on a specific target (via `initialFocusId`) or the first focusable element
78
+ - return focus to the trigger upon closing (both modal and non-modal)
79
+ - alertdialog specifics:
80
+ - role `alertdialog` signals that the dialog contains an alert message requiring user response
81
+ - `aria-describedby` is required (per W3C APG) to point to the alert message content
82
+
83
+ ## Behavior Contract
84
+
85
+ ### Modal (`isModal: true`, default)
86
+
87
+ - `Escape` key closes the dialog (configurable via `closeOnEscape`)
88
+ - Outside pointer click closes the dialog (configurable via `closeOnOutsidePointer`)
89
+ - Outside focus closes the dialog (configurable via `closeOnOutsideFocus`)
90
+ - Scroll lock on the body while the dialog is open
91
+ - Focus trap: `Tab` and `Shift+Tab` cycle through focusable elements inside the dialog
92
+ - Initial focus: defaults to the first focusable element, can be overridden via `initialFocusId`
93
+
94
+ ### Non-modal (`isModal: false`)
95
+
96
+ - `Escape` key closes the dialog (configurable via `closeOnEscape`)
97
+ - Outside pointer click closes the dialog (configurable via `closeOnOutsidePointer`)
98
+ - Outside focus closes the dialog (configurable via `closeOnOutsideFocus`)
99
+ - No scroll lock — page remains scrollable
100
+ - No focus trap — user can Tab out of the dialog freely
101
+ - `aria-modal` is `'false'`
102
+ - Initial focus: same as modal (configurable via `initialFocusId`)
103
+
104
+ ## Contract Prop Shapes
105
+
106
+ ### `getTriggerProps()`
107
+
108
+ ```ts
109
+ {
110
+ id: string // trigger element id
111
+ role: 'button'
112
+ tabindex: '0'
113
+ 'aria-haspopup': 'dialog'
114
+ 'aria-expanded': 'true' | 'false' // reflects isOpen
115
+ 'aria-controls': string // points to content id
116
+ onClick: () => void
117
+ onKeyDown: (event) => void
118
+ }
119
+ ```
120
+
121
+ ### `getOverlayProps()`
122
+
123
+ ```ts
124
+ {
125
+ id: string // overlay element id
126
+ hidden: boolean // !isOpen
127
+ 'data-open': 'true' | 'false' // reflects isOpen
128
+ onPointerDownOutside: () => void
129
+ onFocusOutside: () => void
130
+ }
131
+ ```
132
+
133
+ ### `getContentProps()`
134
+
135
+ ```ts
136
+ {
137
+ id: string // content element id
138
+ role: 'dialog' | 'alertdialog' // based on type option
139
+ tabindex: '-1'
140
+ 'aria-modal': 'true' | 'false' // reflects isModal
141
+ 'aria-labelledby': string // points to title id
142
+ 'aria-describedby'?: string // points to description id (required for alertdialog)
143
+ 'data-initial-focus'?: string // initialFocusId if provided
144
+ onKeyDown: (event) => void
145
+ }
146
+ ```
147
+
148
+ ### `getTitleProps()`
149
+
150
+ ```ts
151
+ {
152
+ id: string // title element id
153
+ }
154
+ ```
155
+
156
+ ### `getDescriptionProps()`
157
+
158
+ ```ts
159
+ {
160
+ id: string // description element id
161
+ }
162
+ ```
163
+
164
+ ### `getCloseButtonProps()` (footer/generic close)
165
+
166
+ ```ts
167
+ {
168
+ id: string // '{idBase}-close'
169
+ role: 'button'
170
+ tabindex: '0'
171
+ onClick: () => void // calls close('programmatic')
172
+ }
173
+ ```
174
+
175
+ ### `getHeaderCloseButtonProps()` (header close icon)
176
+
177
+ ```ts
178
+ {
179
+ id: string // '{idBase}-header-close'
180
+ role: 'button'
181
+ tabindex: '0'
182
+ 'aria-label': 'Close'
183
+ onClick: () => void // calls close('programmatic')
184
+ }
185
+ ```
186
+
187
+ ## Transitions Table
188
+
189
+ | Event / Action | Current State | Next State / Effect |
190
+ | ----------------------------------- | ----------------------------------------------- | ---------------------------------------------------------------- |
191
+ | `open(source)` | `isOpen = false` | `isOpen = true`; restore target cleared; focus management begins |
192
+ | `close(intent)` | `isOpen = true` | `isOpen = false`; `restoreTargetId` set to trigger id |
193
+ | `toggle(source)` | `isOpen = false` | calls `open(source)` |
194
+ | `toggle(source)` | `isOpen = true` | calls `close('programmatic')` |
195
+ | `handleTriggerClick()` | any | calls `toggle('pointer')` |
196
+ | `handleTriggerKeyDown(Enter/Space)` | any | calls `toggle('keyboard')` |
197
+ | `handleKeyDown(Escape)` | `isOpen = true`, `closeOnEscape = true` | calls `close('escape')` |
198
+ | `handleKeyDown(Escape)` | `closeOnEscape = false` | no-op |
199
+ | `handleOutsidePointer()` | `isOpen = true`, `closeOnOutsidePointer = true` | calls `close('outside-pointer')` |
200
+ | `handleOutsidePointer()` | `closeOnOutsidePointer = false` | no-op |
201
+ | `handleOutsideFocus()` | `isOpen = true`, `closeOnOutsideFocus = true` | calls `close('outside-focus')` |
202
+ | `handleOutsideFocus()` | `closeOnOutsideFocus = false` | no-op |
203
+ | `setTriggerId(id)` | any | trigger id updated; affects future `restoreTargetId` |
204
+
205
+ ### Derived state reactions
206
+
207
+ | State Change | `isFocusTrapped` | `shouldLockScroll` |
208
+ | ---------------- | ---------------- | ------------------ |
209
+ | open + modal | `true` | `true` |
210
+ | open + non-modal | `false` | `false` |
211
+ | closed (any) | `false` | `false` |
212
+
213
+ ## Invariants
214
+
215
+ 1. Modal dialogs must trap focus and prevent interaction with the rest of the page.
216
+ 2. Non-modal dialogs must NOT trap focus and must NOT lock scroll.
217
+ 3. `isOpen` state is the single source of truth for visibility.
218
+ 4. Closing the dialog must set `restoreTargetId` to the trigger element id for focus restoration.
219
+ 5. `isFocusTrapped` === `isOpen && isModal` — always derived, never set directly.
220
+ 6. `shouldLockScroll` === `isOpen && isModal` — always derived, never set directly.
221
+ 7. When `type` is `'alertdialog'`, the content role must be `'alertdialog'` (not `'dialog'`).
222
+ 8. `aria-describedby` is always included for `alertdialog` (W3C APG requirement).
223
+ 9. Both `getCloseButtonProps()` and `getHeaderCloseButtonProps()` must call the same `close('programmatic')` action.
224
+ 10. `getHeaderCloseButtonProps()` must include `aria-label: 'Close'` for icon-only buttons.
225
+
226
+ ## Adapter Expectations
227
+
228
+ UIKit adapters MUST bind to the headless model as follows:
229
+
230
+ **Signals read (reactive, drive re-renders):**
231
+
232
+ - `state.isOpen()` — whether the dialog is visible
233
+ - `state.isModal()` — whether modal behaviors are active
234
+ - `state.type()` — dialog type for role assignment
235
+ - `state.isFocusTrapped()` — whether focus trap should be active
236
+ - `state.shouldLockScroll()` — whether body scroll lock should be active
237
+ - `state.restoreTargetId()` — element id to focus after close
238
+ - `state.initialFocusTargetId()` — element id to focus on open
239
+
240
+ **Actions called (event handlers, never mutate state directly):**
241
+
242
+ - `actions.open(source?)` / `actions.close(intent?)` — programmatic open/close
243
+ - `actions.toggle(source?)` — toggle open state
244
+ - `actions.setTriggerId(id)` — set custom trigger element id
245
+ - `actions.handleTriggerClick()` — on trigger click
246
+ - `actions.handleTriggerKeyDown(event)` — on trigger keydown
247
+ - `actions.handleKeyDown(event)` — on content keydown (Escape handling)
248
+ - `actions.handleOutsidePointer()` — on pointer outside the dialog
249
+ - `actions.handleOutsideFocus()` — on focus outside the dialog
250
+
251
+ **Contracts spread (attribute maps applied directly to DOM elements):**
252
+
253
+ - `contracts.getTriggerProps()` — spread onto the trigger button element
254
+ - `contracts.getOverlayProps()` — spread onto the overlay/backdrop element
255
+ - `contracts.getContentProps()` — spread onto the dialog content panel (returns `role: 'dialog' | 'alertdialog'` based on `type`)
256
+ - `contracts.getTitleProps()` — spread onto the dialog title element
257
+ - `contracts.getDescriptionProps()` — spread onto the dialog description element
258
+ - `contracts.getCloseButtonProps()` — spread onto a footer/generic close button
259
+ - `contracts.getHeaderCloseButtonProps()` — spread onto a header close icon button (includes `aria-label: 'Close'`)
260
+
261
+ **UIKit-only concerns (NOT in headless):**
262
+
263
+ - Lifecycle events (`cv-open`, `cv-close`, `cv-after-open`, `cv-after-close`)
264
+ - CSS transitions and animations
265
+ - Backdrop rendering and styling
266
+ - Scroll lock implementation (headless provides the signal, UIKit applies the side effect)
267
+ - Focus trap implementation (headless provides the signal, UIKit manages DOM focus)
268
+
269
+ ## Minimum Test Matrix
270
+
271
+ - open/close lifecycle via actions
272
+ - `Escape` key dismissal (with and without `closeOnEscape`)
273
+ - outside pointer dismissal (with and without `closeOnOutsidePointer`)
274
+ - outside focus dismissal (with and without `closeOnOutsideFocus`)
275
+ - focus trap behavior: `isFocusTrapped` is `true` for modal, `false` for non-modal
276
+ - scroll lock: `shouldLockScroll` is `true` for modal, `false` for non-modal
277
+ - return focus to trigger on close (`restoreTargetId`)
278
+ - initial focus placement (`initialFocusTargetId`, `data-initial-focus`)
279
+ - trigger/content/title/description aria linkage consistency
280
+ - `type: 'alertdialog'` produces `role: 'alertdialog'` in content props
281
+ - `type: 'dialog'` (default) produces `role: 'dialog'` in content props
282
+ - `aria-describedby` present for alertdialog
283
+ - `getHeaderCloseButtonProps()` returns `aria-label: 'Close'` and closes the dialog
284
+ - `getCloseButtonProps()` closes the dialog
285
+ - trigger click and keyboard (Enter, Space) handlers
286
+ - overlay props reflect open state
287
+
288
+ ## ADR-001 Compliance
289
+
290
+ - **Runtime Policy**: Reatom v1000 only; no `@statx/*` in headless core.
291
+ - **Layering**: `core -> interactions -> a11y-contracts -> adapters`; adapters remain thin mappings.
292
+ - **Independence**: No imports from `@project/*`, `apps/*`, or other out-of-package modules.
293
+ - **Verification**: Mandatory adapter integration tests and standalone package test execution.
294
+
295
+ ## Out of Scope (Current)
296
+
297
+ - Nested/stacked dialogs management
298
+ - Complex animations/transitions (CSS/JS animations are UIKit concerns)
@@ -0,0 +1,257 @@
1
+ # Disclosure Component Contract
2
+
3
+ ## Purpose
4
+
5
+ `Disclosure` is a headless APG-aligned contract for a simple interactive element that controls the visibility of a single content area. It manages the open/closed state, ensures correct ARIA linkage between the trigger and the panel, and supports name-based exclusive grouping for accordion-like behavior.
6
+
7
+ ## Component Files
8
+
9
+ - `src/disclosure/index.ts` - model, registry, and public `createDisclosure` API
10
+ - `src/disclosure/disclosure.test.ts` - unit behavior tests
11
+
12
+ ## Public API
13
+
14
+ - `createDisclosure(options)`
15
+ - `options`:
16
+ - `idBase?`: string — base id prefix for generated ids (default: `'disclosure'`)
17
+ - `isOpen?`: boolean — initial open state (default: `false`)
18
+ - `isDisabled?`: boolean — initial disabled state (default: `false`)
19
+ - `name?`: string — group name for exclusive accordion-like behavior; when set, opening this disclosure closes all others sharing the same `name`
20
+ - `onOpenChange?`: `(isOpen: boolean) => void` — callback fired on state change
21
+ - `state` (signal-backed):
22
+ - `isOpen()` — boolean indicating if the content is visible
23
+ - `isDisabled()` — boolean indicating if user interaction is blocked
24
+ - `name()` — current group name or `null` (reactive)
25
+ - `actions`:
26
+ - `open()` — shows the content; if `name` is set, closes all other disclosures in the same group
27
+ - `close()` — hides the content
28
+ - `toggle()` — toggles visibility (delegates to `open` or `close`)
29
+ - `setDisabled(value)` — sets the disabled state
30
+ - `setName(value)` — updates the group name; re-registers in the new group
31
+ - `handleClick()` — delegates to `toggle()`
32
+ - `handleKeyDown(event)` — processes keyboard input (see Keyboard Contract)
33
+ - `destroy()` — unregisters from the group registry; MUST be called on teardown
34
+ - `contracts`:
35
+ - `getTriggerProps()` — returns complete ARIA and event handler attribute map for the trigger element
36
+ - `getPanelProps()` — returns complete ARIA attribute map for the content panel
37
+
38
+ ## CreateDisclosureOptions
39
+
40
+ | Option | Type | Default | Description |
41
+ | -------------- | --------------------------- | -------------- | --------------------------------------------- |
42
+ | `idBase` | `string` | `'disclosure'` | Base id prefix for all generated ids |
43
+ | `isOpen` | `boolean` | `false` | Whether the disclosure starts open |
44
+ | `isDisabled` | `boolean` | `false` | Whether user interaction is initially blocked |
45
+ | `name` | `string` | — | Group name for exclusive behavior |
46
+ | `onOpenChange` | `(isOpen: boolean) => void` | — | Callback fired when `isOpen` changes |
47
+
48
+ ## State Signal Surface
49
+
50
+ | Signal | Type | Derived? | Description |
51
+ | ------------ | ---------------------- | -------- | -------------------------------------------- |
52
+ | `isOpen` | `Atom<boolean>` | No | Single source of truth for visibility |
53
+ | `isDisabled` | `Atom<boolean>` | No | Whether user interaction is blocked |
54
+ | `name` | `Atom<string \| null>` | No | Group name for exclusive behavior, or `null` |
55
+
56
+ ## APG and A11y Contract
57
+
58
+ - trigger role: `button`
59
+ - panel role: none (usually a `div`)
60
+ - required attributes:
61
+ - trigger: `aria-expanded`, `aria-controls`, `id`, `aria-disabled` (when disabled)
62
+ - panel: `id`, `aria-labelledby`, `hidden`
63
+ - focus management:
64
+ - the trigger is in the page tab sequence (`tabindex: '0'`); removed from tab order when disabled (`tabindex: '-1'`)
65
+ - focus remains on the trigger when toggled, unless the content contains focusable elements that the user chooses to move focus to
66
+
67
+ ## Keyboard Contract
68
+
69
+ | Key | Action |
70
+ | -------------------------- | ---------------------------------------------------------- |
71
+ | `Enter` | Toggle the `isOpen` state; calls `preventDefault` |
72
+ | `Space` | Toggle the `isOpen` state; calls `preventDefault` |
73
+ | `ArrowDown` / `ArrowRight` | Expand (open) if currently closed; calls `preventDefault` |
74
+ | `ArrowUp` / `ArrowLeft` | Collapse (close) if currently open; calls `preventDefault` |
75
+
76
+ All keyboard actions are no-ops when `isDisabled` is `true`.
77
+
78
+ ## Behavior Contract
79
+
80
+ - **Toggle Behavior**:
81
+ - `Enter` or `Space` on the trigger toggles the `isOpen` state
82
+ - clicking the trigger toggles the `isOpen` state
83
+ - **Directional Expand/Collapse**:
84
+ - `ArrowDown` or `ArrowRight` on the trigger opens the disclosure (no-op if already open)
85
+ - `ArrowUp` or `ArrowLeft` on the trigger closes the disclosure (no-op if already closed)
86
+ - **Name-Based Exclusive Grouping**:
87
+ - when `name` is set, the disclosure is registered in a shared group registry keyed by `name`
88
+ - when a named disclosure opens (via any action: `open`, `toggle`, `handleKeyDown`, `handleClick`), all other disclosures in the same `name` group are closed
89
+ - closing a disclosure does not open any other disclosure in the group
90
+ - the registry is a module-level `Map<string, Set<DisclosureModel>>` — not global, scoped to the headless package
91
+ - `destroy()` removes the disclosure from the registry; adapters MUST call this on `disconnectedCallback` or equivalent teardown
92
+ - `setName(value)` re-registers: unregisters from the old group, registers in the new group
93
+ - **Linkage**:
94
+ - `aria-controls` on the trigger must match the `id` of the panel
95
+ - `aria-expanded` reflects the `isOpen` state
96
+ - `aria-labelledby` on the panel must match the `id` of the trigger
97
+
98
+ ## Contract Prop Shapes
99
+
100
+ ### `getTriggerProps()`
101
+
102
+ ```ts
103
+ {
104
+ id: string // '{idBase}-trigger'
105
+ role: 'button'
106
+ tabindex: '0' | '-1' // '-1' when disabled
107
+ 'aria-expanded': 'true' | 'false' // reflects isOpen
108
+ 'aria-controls': string // points to panel id
109
+ 'aria-disabled'?: 'true' // present only when disabled
110
+ onClick: () => void // calls handleClick
111
+ onKeyDown: (event) => void // calls handleKeyDown
112
+ }
113
+ ```
114
+
115
+ ### `getPanelProps()`
116
+
117
+ ```ts
118
+ {
119
+ id: string // '{idBase}-panel'
120
+ 'aria-labelledby': string // points to trigger id
121
+ hidden: boolean // !isOpen
122
+ }
123
+ ```
124
+
125
+ ## Transitions Table
126
+
127
+ | Event / Action | Current State | Next State / Effect |
128
+ | --------------------------- | ------------------------------ | ---------------------------------------------------------------- |
129
+ | `open()` | `isOpen = false` | `isOpen = true`; if `name` set, close all other group members |
130
+ | `open()` | `isOpen = true` | no-op |
131
+ | `close()` | `isOpen = true` | `isOpen = false` |
132
+ | `close()` | `isOpen = false` | no-op |
133
+ | `toggle()` | `isOpen = false` | delegates to `open()` |
134
+ | `toggle()` | `isOpen = true` | delegates to `close()` |
135
+ | `handleClick()` | any | delegates to `toggle()` |
136
+ | `handleKeyDown(Enter)` | any, not disabled | delegates to `toggle()`; `preventDefault` |
137
+ | `handleKeyDown(Space)` | any, not disabled | delegates to `toggle()`; `preventDefault` |
138
+ | `handleKeyDown(ArrowDown)` | `isOpen = false`, not disabled | delegates to `open()`; `preventDefault` |
139
+ | `handleKeyDown(ArrowRight)` | `isOpen = false`, not disabled | delegates to `open()`; `preventDefault` |
140
+ | `handleKeyDown(ArrowDown)` | `isOpen = true`, not disabled | no-op (already open); `preventDefault` |
141
+ | `handleKeyDown(ArrowRight)` | `isOpen = true`, not disabled | no-op (already open); `preventDefault` |
142
+ | `handleKeyDown(ArrowUp)` | `isOpen = true`, not disabled | delegates to `close()`; `preventDefault` |
143
+ | `handleKeyDown(ArrowLeft)` | `isOpen = true`, not disabled | delegates to `close()`; `preventDefault` |
144
+ | `handleKeyDown(ArrowUp)` | `isOpen = false`, not disabled | no-op (already closed); `preventDefault` |
145
+ | `handleKeyDown(ArrowLeft)` | `isOpen = false`, not disabled | no-op (already closed); `preventDefault` |
146
+ | `handleKeyDown(other)` | any | no-op; no `preventDefault` |
147
+ | `handleKeyDown(any)` | `isDisabled = true` | no-op; no `preventDefault` |
148
+ | `setDisabled(value)` | any | `isDisabled = value` |
149
+ | `setName(value)` | any | unregister from old group; `name = value`; register in new group |
150
+ | `destroy()` | any | unregister from group registry |
151
+
152
+ ### Group Side Effects
153
+
154
+ | Trigger | Side Effect on Other Group Members |
155
+ | --------------------- | ------------------------------------------------------------ |
156
+ | `open()` with `name` | all other disclosures with the same `name` receive `close()` |
157
+ | `close()` with `name` | none |
158
+
159
+ ## Invariants
160
+
161
+ 1. `isOpen` is a boolean.
162
+ 2. `aria-expanded` is `"true"` when `isOpen` is `true`, and `"false"` otherwise.
163
+ 3. If the trigger is disabled, `toggle`, `open`, `close`, and `handleKeyDown` actions are no-ops for user interactions.
164
+ 4. `aria-controls` on the trigger always matches the `id` of the panel.
165
+ 5. `aria-labelledby` on the panel always matches the `id` of the trigger.
166
+ 6. When `name` is set, at most one disclosure in the group can be open at any time (exclusive constraint).
167
+ 7. Arrow keys (`ArrowDown`/`ArrowRight`) only open; they never close. Arrow keys (`ArrowUp`/`ArrowLeft`) only close; they never open.
168
+ 8. `destroy()` must remove the disclosure from the group registry; failure to call `destroy()` constitutes a memory leak.
169
+
170
+ ## Name-Based Group Registry
171
+
172
+ The registry is a module-level data structure within `src/disclosure/index.ts`:
173
+
174
+ ```ts
175
+ // Module-level registry — not exported, internal implementation detail
176
+ const groupRegistry = new Map<string, Set<DisclosureModel>>()
177
+ ```
178
+
179
+ **Registration lifecycle:**
180
+
181
+ - On `createDisclosure({ name })`: if `name` is provided, add the model to `groupRegistry.get(name)`
182
+ - On `setName(newName)`: remove from old group set, add to new group set
183
+ - On `destroy()`: remove from current group set; clean up empty sets from the map
184
+
185
+ **Exclusive open enforcement:**
186
+
187
+ - When `open()` is called on a model with a `name`, iterate `groupRegistry.get(name)` and call `close()` on every other model in the set
188
+ - This is an internal side effect of `open()`, not a separate action
189
+
190
+ ## Adapter Expectations
191
+
192
+ UIKit adapters MUST bind to the headless model as follows:
193
+
194
+ **Signals read (reactive, drive re-renders):**
195
+
196
+ - `state.isOpen()` — whether the disclosure content is visible
197
+ - `state.isDisabled()` — whether user interaction is blocked
198
+ - `state.name()` — current group name (used for registration lifecycle)
199
+
200
+ **Actions called (event handlers, never mutate state directly):**
201
+
202
+ - `actions.open()` / `actions.close()` — programmatic show/hide
203
+ - `actions.toggle()` — toggle visibility
204
+ - `actions.setDisabled(value)` — update disabled state
205
+ - `actions.setName(value)` — update group name
206
+ - `actions.handleClick()` — on trigger click
207
+ - `actions.handleKeyDown(event)` — on trigger keydown
208
+ - `actions.destroy()` — on `disconnectedCallback` or equivalent teardown
209
+
210
+ **Contracts spread (attribute maps applied directly to DOM elements):**
211
+
212
+ - `contracts.getTriggerProps()` — spread onto the trigger button element
213
+ - `contracts.getPanelProps()` — spread onto the content panel element
214
+
215
+ **UIKit-only concerns (NOT in headless):**
216
+
217
+ - CSS animations and transitions for open/close
218
+ - `show()` / `hide()` imperative methods (delegate to `actions.open()` / `actions.close()`)
219
+ - CSS custom properties and animation tokens
220
+ - Lifecycle events (`cv-open`, `cv-close`)
221
+
222
+ ## Minimum Test Matrix
223
+
224
+ - initialize in both open and closed states
225
+ - toggle state via `toggle()` action
226
+ - toggle state via `Enter` and `Space` keys on the trigger
227
+ - `ArrowDown` and `ArrowRight` open a closed disclosure
228
+ - `ArrowDown` and `ArrowRight` are no-ops on an already open disclosure
229
+ - `ArrowUp` and `ArrowLeft` close an open disclosure
230
+ - `ArrowUp` and `ArrowLeft` are no-ops on an already closed disclosure
231
+ - arrow keys call `preventDefault`
232
+ - arrow keys are no-ops when disabled (no `preventDefault`)
233
+ - verify `aria-expanded` updates correctly
234
+ - verify `aria-controls` matches panel `id`
235
+ - verify `aria-labelledby` matches trigger `id`
236
+ - ensure disabled trigger does not toggle state
237
+ - `onOpenChange` callback fires on state transitions
238
+ - named group: opening one disclosure closes the other in the same group
239
+ - named group: closing a disclosure does not affect others in the group
240
+ - named group: disclosures with different names are independent
241
+ - named group: `destroy()` removes from registry
242
+ - named group: `setName()` re-registers in new group
243
+ - named group: ungrouped disclosures (no `name`) are not affected by grouped ones
244
+
245
+ ## ADR-001 Compliance
246
+
247
+ - **Runtime Policy**: Reatom v1000 only; no @statx/\* in headless core.
248
+ - **Layering**: core -> interactions -> a11y-contracts -> adapters; adapters remain thin mappings.
249
+ - **Independence**: No imports from @project/_, apps/_, or other out-of-package modules.
250
+ - **Verification**: Mandatory adapter integration tests and standalone package test execution.
251
+
252
+ ## Out of Scope (Current)
253
+
254
+ - multiple panels controlled by one trigger
255
+ - hover-based disclosure (not APG compliant for this pattern)
256
+ - animation state management (UIKit concern)
257
+ - `allowZeroExpanded` constraint within a named group (all can be closed)