@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,265 @@
1
+ # Tabs Component Contract
2
+
3
+ ## Purpose
4
+
5
+ `Tabs` provides a headless APG-aligned tablist/tab/tabpanel model.
6
+
7
+ It handles active tab focus, selected tab activation,
8
+ orientation-aware keyboard navigation, and panel linkage contracts.
9
+
10
+ ## Component Files
11
+
12
+ - `src/tabs/index.ts` - model and public `createTabs` API
13
+ - `src/tabs/tabs.test.ts` - unit behavior tests
14
+
15
+ ## Public API
16
+
17
+ - `createTabs(options): TabsModel`
18
+ - `state` (signal-backed): `activeTabId()`, `selectedTabId()`
19
+ - `actions`:
20
+ - `setActive`, `select`
21
+ - `moveNext`, `movePrev`, `moveFirst`, `moveLast`
22
+ - `handleKeyDown`
23
+ - `contracts`:
24
+ - `getTabListProps()`
25
+ - `getTabProps(id)`
26
+ - `getPanelProps(id)`
27
+
28
+ ## Options (`CreateTabsOptions`)
29
+
30
+ | Option | Type | Default | Description |
31
+ | ---------------------- | ---------------------------- | ------------------------------------ | ---------------------------------------------------------------------------------------------- |
32
+ | `tabs` | `readonly TabItem[]` | required | Tab definitions. Each `TabItem` has `id: string` and optional `disabled?: boolean`. |
33
+ | `idBase` | `string` | `'tabs'` | Prefix for generated DOM ids (`{idBase}-tablist`, `{idBase}-tab-{id}`, `{idBase}-panel-{id}`). |
34
+ | `ariaLabel` | `string \| undefined` | `undefined` | Optional `aria-label` for the tablist element. |
35
+ | `orientation` | `'horizontal' \| 'vertical'` | `'horizontal'` | Determines keyboard navigation axis and `aria-orientation` value. |
36
+ | `activationMode` | `'automatic' \| 'manual'` | `'automatic'` | Whether navigation also selects (`automatic`) or only Enter/Space selects (`manual`). |
37
+ | `initialActiveTabId` | `string \| null` | falls back to `initialSelectedTabId` | Initial roving-focus tab. Normalized to first enabled tab if invalid or disabled. |
38
+ | `initialSelectedTabId` | `string \| null` | first enabled tab | Initial selected tab. Normalized to first enabled tab if invalid or disabled. |
39
+
40
+ ### Initial State Resolution
41
+
42
+ 1. `initialSelectedTabId` is resolved: if the candidate is a valid enabled tab id, use it; otherwise fall back to the first enabled tab id, or `null` if no enabled tabs exist.
43
+ 2. `initialActiveTabId` is resolved: if provided, apply the same validation; if not provided, default to the resolved `initialSelectedTabId`.
44
+ 3. If `selectedTabId` resolves to `null` but `activeTabId` is non-null, `selectedTabId` is set to `activeTabId`.
45
+
46
+ ## Reactive State Contract
47
+
48
+ Headless Tabs exposes state as reactive signal-backed getters.
49
+
50
+ ### State Surface
51
+
52
+ - `state.activeTabId(): string | null`
53
+ - Current roving-focus tab id.
54
+ - Changes on directional navigation, `setActive`, and `select`.
55
+ - `state.selectedTabId(): string | null`
56
+ - Current selected/visible panel tab id.
57
+ - Changes on `select`, on `setActive` in `automatic` mode, and on navigation in `automatic` mode.
58
+
59
+ ### Reactivity Guarantees
60
+
61
+ - `state` values are read via getter calls (`Atom<string | null>`) and are suitable as reactive dependencies in adapters.
62
+ - Any state change MUST be observable synchronously by adapters after action execution.
63
+ - Adapters MUST treat `state` as source of truth; DOM flags are derived outputs.
64
+
65
+ ## Actions
66
+
67
+ ### `setActive(id: string | null)`
68
+
69
+ - If `id` is `null`, sets `activeTabId` to `null`.
70
+ - If `id` is a valid enabled tab, sets `activeTabId` to `id`.
71
+ - In `automatic` mode, also updates `selectedTabId` to match `activeTabId`.
72
+ - If `id` is disabled or unknown, no state change.
73
+
74
+ ### `select(id: string)`
75
+
76
+ - If `id` is a valid enabled tab, sets both `activeTabId` and `selectedTabId` to `id`.
77
+ - If `id` is disabled or unknown, no state change.
78
+
79
+ ### `moveNext()` / `movePrev()`
80
+
81
+ - Moves `activeTabId` to the next/previous enabled tab in circular (wrapping) order.
82
+ - If no enabled tabs exist, sets `activeTabId` to `null`.
83
+ - If `activeTabId` is currently `null` or invalid, resets to the first enabled tab.
84
+ - In `automatic` mode, also updates `selectedTabId`.
85
+
86
+ ### `moveFirst()` / `moveLast()`
87
+
88
+ - Sets `activeTabId` to the first/last enabled tab, or `null` if none exist.
89
+ - In `automatic` mode, also updates `selectedTabId`.
90
+
91
+ ### `handleKeyDown(event)`
92
+
93
+ - Accepts `Pick<KeyboardEvent, 'key' | 'shiftKey' | 'ctrlKey' | 'metaKey' | 'altKey'>`.
94
+ - Maps keys through `mapListboxKeyboardIntent` with `orientation`, `selectionMode: 'single'`, `rangeSelectionEnabled: false`.
95
+ - Intent mapping:
96
+ - `NAV_NEXT` -> `moveNext()`
97
+ - `NAV_PREV` -> `movePrev()`
98
+ - `NAV_FIRST` -> `moveFirst()`
99
+ - `NAV_LAST` -> `moveLast()`
100
+ - `ACTIVATE` / `TOGGLE_SELECTION` -> `select(activeTabId)` (if `activeTabId` is non-null)
101
+ - Unrecognized keys produce no state change.
102
+
103
+ ## Transitions Table
104
+
105
+ | Event / Action | `activeTabId` | `selectedTabId` |
106
+ | -------------------------------------------- | ----------------------------------- | ----------------------------------------------------------- |
107
+ | `setActive(id)` where id is enabled | set to `id` | set to `id` if `automatic`; unchanged if `manual` |
108
+ | `setActive(null)` | set to `null` | unchanged |
109
+ | `setActive(id)` where id is disabled/unknown | unchanged | unchanged |
110
+ | `select(id)` where id is enabled | set to `id` | set to `id` |
111
+ | `select(id)` where id is disabled/unknown | unchanged | unchanged |
112
+ | `moveNext()` / `movePrev()` | next/prev enabled (wrapping) | follows `activeTabId` if `automatic`; unchanged if `manual` |
113
+ | `moveFirst()` / `moveLast()` | first/last enabled | follows `activeTabId` if `automatic`; unchanged if `manual` |
114
+ | `handleKeyDown` (arrow key) | delegates to `moveNext`/`movePrev` | per activation mode |
115
+ | `handleKeyDown` (Home/End) | delegates to `moveFirst`/`moveLast` | per activation mode |
116
+ | `handleKeyDown` (Enter/Space) | unchanged | set to `activeTabId` (via `select`) |
117
+ | `handleKeyDown` (unrecognized key) | unchanged | unchanged |
118
+
119
+ ## Contracts
120
+
121
+ Contracts return ready-to-spread ARIA attribute maps.
122
+
123
+ ### `getTabListProps(): TabListProps`
124
+
125
+ ```ts
126
+ interface TabListProps {
127
+ id: string // '{idBase}-tablist'
128
+ role: 'tablist'
129
+ 'aria-orientation': 'horizontal' | 'vertical'
130
+ 'aria-label'?: string // from options.ariaLabel
131
+ }
132
+ ```
133
+
134
+ ### `getTabProps(id: string): TabProps`
135
+
136
+ Throws `Error` if `id` is not a known tab.
137
+
138
+ ```ts
139
+ interface TabProps {
140
+ id: string // '{idBase}-tab-{id}'
141
+ role: 'tab'
142
+ tabindex: '0' | '-1' // '0' if active, '-1' otherwise
143
+ 'aria-selected': 'true' | 'false' // 'true' if selected
144
+ 'aria-controls': string // '{idBase}-panel-{id}'
145
+ 'aria-disabled'?: 'true' // present only when tab is disabled
146
+ 'data-active': 'true' | 'false' // matches activeTabId
147
+ 'data-selected': 'true' | 'false' // matches selectedTabId
148
+ }
149
+ ```
150
+
151
+ ### `getPanelProps(id: string): TabPanelProps`
152
+
153
+ Throws `Error` if `id` is not a known tab.
154
+
155
+ ```ts
156
+ interface TabPanelProps {
157
+ id: string // '{idBase}-panel-{id}'
158
+ role: 'tabpanel'
159
+ tabindex: '0' | '-1' // '0' if selected, '-1' otherwise
160
+ 'aria-labelledby': string // '{idBase}-tab-{id}'
161
+ hidden: boolean // true if not selected
162
+ }
163
+ ```
164
+
165
+ ## APG and A11y Contract
166
+
167
+ - tablist role: `tablist`
168
+ - tab role: `tab`
169
+ - panel role: `tabpanel`
170
+ - tablist exposes `aria-orientation`
171
+ - tablist optionally exposes `aria-label`
172
+ - each tab exposes `aria-controls` pointing to its panel id
173
+ - each panel exposes `aria-labelledby` pointing to its tab id
174
+ - roving tabindex: active tab has `tabindex="0"`, all others `tabindex="-1"`
175
+ - selected tab has `aria-selected="true"`, all others `aria-selected="false"`
176
+ - disabled tabs expose `aria-disabled="true"`
177
+
178
+ ## Activation Modes
179
+
180
+ - `automatic`:
181
+ - moving active tab (via navigation or `setActive`) also updates selected tab
182
+ - `manual`:
183
+ - active tab changes on navigation and `setActive`
184
+ - selected tab changes only on `select` or activation keys (`Enter` / `Space`)
185
+
186
+ ## Keyboard Contract
187
+
188
+ - Orientation-aware navigation: `ArrowRight`/`ArrowLeft` for horizontal, `ArrowDown`/`ArrowUp` for vertical
189
+ - `Home`/`End` for first/last tab
190
+ - Activation via `Enter` or `Space`
191
+ - Disabled tabs are skipped by navigation and cannot be selected
192
+ - Navigation wraps circularly (last -> first, first -> last)
193
+
194
+ ## Invariants
195
+
196
+ 1. `activeTabId` is `null` or an enabled tab id.
197
+ 2. `selectedTabId` is `null` or an enabled tab id.
198
+ 3. Selected panel visibility derives only from `selectedTabId`.
199
+ 4. State transitions never select or activate disabled tabs.
200
+ 5. Navigation wraps circularly through enabled tabs only.
201
+ 6. `getTabProps` and `getPanelProps` throw for unknown tab ids.
202
+ 7. When all tabs are disabled, both `activeTabId` and `selectedTabId` are `null` and all actions are no-ops.
203
+
204
+ ## Adapter Expectations
205
+
206
+ This section lists exactly what the UIKit adapter layer binds to.
207
+
208
+ ### Signals Read
209
+
210
+ | Signal | UIKit Usage |
211
+ | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
212
+ | `state.activeTabId()` | Determines roving tabindex; drives `data-active` attribute on tab elements; used for focus management. |
213
+ | `state.selectedTabId()` | Determines `aria-selected` on tabs; drives panel visibility (`hidden`); drives `data-selected` attribute; used for active indicator positioning. |
214
+
215
+ ### Actions Called
216
+
217
+ | Action | UIKit Trigger |
218
+ | ---------------------------- | ---------------------------------------------------------------- |
219
+ | `setActive(id)` | Tab receives focus (e.g., pointer click on a tab). |
220
+ | `select(id)` | Tab is clicked or tapped (pointer activation). |
221
+ | `handleKeyDown(event)` | `keydown` event on the tablist or individual tab. |
222
+ | `moveNext()` / `movePrev()` | Not called directly by UIKit; delegated through `handleKeyDown`. |
223
+ | `moveFirst()` / `moveLast()` | Not called directly by UIKit; delegated through `handleKeyDown`. |
224
+
225
+ ### Contracts Spread
226
+
227
+ | Contract | UIKit Target |
228
+ | ------------------- | ------------------------------------------ |
229
+ | `getTabListProps()` | Spread onto the tablist container element. |
230
+ | `getTabProps(id)` | Spread onto each tab trigger element. |
231
+ | `getPanelProps(id)` | Spread onto each tab panel element. |
232
+
233
+ ### UIKit-Only Concerns (Not in Headless)
234
+
235
+ - **Active indicator animation**: Positioned and animated at the UIKit layer using `selectedTabId` to determine which tab to highlight.
236
+ - **Closable tabs**: Close button rendering and close orchestration are UIKit concerns. Headless handles selection fallback implicitly through model rebuild with an updated tab list (without the closed tab).
237
+ - **`input` / `change` events**: Custom DOM events dispatched by the UIKit wrapper, not part of the headless model.
238
+
239
+ ## Minimum Test Matrix
240
+
241
+ - automatic activation behavior
242
+ - manual activation behavior
243
+ - Home/End behavior
244
+ - disabled-tab skip and rejection behavior
245
+ - vertical orientation behavior
246
+ - aria linkage integrity (`aria-controls`, `aria-labelledby`)
247
+ - initial state resolution (invalid/disabled initial ids)
248
+ - all-disabled edge case (null-safe behavior)
249
+ - wrapping navigation
250
+ - unsupported key no-op behavior
251
+ - `setActive` auto-activation in automatic mode
252
+
253
+ ## ADR-001 Compliance
254
+
255
+ - **Runtime Policy**: Reatom v1000 only; no @statx/\* in headless core.
256
+ - **Layering**: core -> interactions -> a11y-contracts -> adapters; adapters remain thin mappings.
257
+ - **Independence**: No imports from @project/_, apps/_, or other out-of-package modules.
258
+ - **Verification**: Mandatory adapter integration tests and standalone package test execution.
259
+
260
+ ## Out of Scope (Current)
261
+
262
+ - dynamic tab insertion/removal orchestration
263
+ - lazy panel mount orchestration
264
+
265
+ **Note on closable tabs**: Close orchestration (close button, remove animation, user confirmation) is a UIKit-layer concern. Headless handles selection fallback implicitly through model rebuild with an updated tab list (i.e., the adapter recreates the model without the closed tab, and initial state resolution picks the appropriate fallback).
@@ -0,0 +1,185 @@
1
+ # Textarea Component Contract
2
+
3
+ ## Purpose
4
+
5
+ `Textarea` is a headless contract for a native multi-line text field. It manages value, disabled/readonly/required semantics, placeholder, geometry (`rows`, `cols`), length constraints, resize mode, and focus tracking. It provides ready-to-spread ARIA and native attribute maps for the underlying `<textarea>` element.
6
+
7
+ ## Component Files
8
+
9
+ - `src/textarea/index.ts` - model and public `createTextarea` API
10
+ - `src/textarea/textarea.test.ts` - unit behavior tests
11
+
12
+ ## Public API
13
+
14
+ - `createTextarea(options)`
15
+ - `options`:
16
+ - `idBase?`: `string` - base for generated IDs (default: `"textarea"`)
17
+ - `value?`: `string` - initial value (default: `""`)
18
+ - `disabled?`: `boolean` - initial disabled state (default: `false`)
19
+ - `readonly?`: `boolean` - initial readonly state (default: `false`)
20
+ - `required?`: `boolean` - initial required state (default: `false`)
21
+ - `placeholder?`: `string` - initial placeholder text (default: `""`)
22
+ - `rows?`: `number` - visible text rows (default: `4`)
23
+ - `cols?`: `number` - visible text columns (default: `20`)
24
+ - `minLength?`: `number` - minimum text length, omitted when unset
25
+ - `maxLength?`: `number` - maximum text length, omitted when unset
26
+ - `resize?`: `TextareaResize` - resize mode (default: `"vertical"`)
27
+ - `onInput?`: `(value: string) => void` - callback on user input (`handleInput`)
28
+ - **Types**:
29
+ - `TextareaResize = "none" | "vertical"`
30
+ - `state` (signal-backed):
31
+ - `value()`: `string` - current value
32
+ - `disabled()`: `boolean` - whether field is disabled
33
+ - `readonly()`: `boolean` - whether field is readonly
34
+ - `required()`: `boolean` - whether field is required
35
+ - `placeholder()`: `string` - placeholder text
36
+ - `rows()`: `number` - visible text rows
37
+ - `cols()`: `number` - visible text columns
38
+ - `minLength()`: `number | undefined` - min length constraint
39
+ - `maxLength()`: `number | undefined` - max length constraint
40
+ - `resize()`: `TextareaResize` - resize mode
41
+ - `focused()`: `boolean` - focus state
42
+ - `filled()`: `boolean` - **derived**: `value.length > 0`
43
+ - `actions`:
44
+ - `setValue(value: string)`: updates value programmatically
45
+ - `setDisabled(disabled: boolean)`: updates disabled state
46
+ - `setReadonly(readonly: boolean)`: updates readonly state
47
+ - `setRequired(required: boolean)`: updates required state
48
+ - `setPlaceholder(placeholder: string)`: updates placeholder
49
+ - `setRows(rows: number | undefined)`: updates row count when valid positive integer
50
+ - `setCols(cols: number | undefined)`: updates col count when valid positive integer
51
+ - `setMinLength(minLength: number | undefined)`: updates minimum length constraint
52
+ - `setMaxLength(maxLength: number | undefined)`: updates maximum length constraint
53
+ - `setResize(resize: TextareaResize)`: updates resize mode
54
+ - `setFocused(focused: boolean)`: updates focus state
55
+ - `handleInput(value: string)`: processes user input; no-op when disabled/readonly; invokes `onInput`
56
+ - `contracts`:
57
+ - `getTextareaProps()`: returns complete attribute map for native `<textarea>`
58
+
59
+ ## APG and A11y Contract
60
+
61
+ ### Native `<textarea>` element
62
+
63
+ - `id`: `"{idBase}-textarea"`
64
+ - `aria-disabled`: `"true"` when disabled, otherwise omitted
65
+ - `aria-readonly`: `"true"` when readonly, otherwise omitted
66
+ - `aria-required`: `"true"` when required, otherwise omitted
67
+ - `disabled`: `true` when disabled (native form behavior)
68
+ - `readonly`: `true` when readonly (focusable, non-editable)
69
+ - `required`: `true` when required (native constraint validation)
70
+ - `placeholder`: current placeholder value, omitted when empty
71
+ - `tabindex`: `"0"` when interactive, `"-1"` when disabled
72
+ - `rows`: current rows value
73
+ - `cols`: current cols value
74
+ - `minlength`: `minLength` when set
75
+ - `maxlength`: `maxLength` when set
76
+
77
+ Note: role is not set explicitly. Native `<textarea>` semantics are used.
78
+
79
+ ## Behavior Contract
80
+
81
+ ### Value management
82
+
83
+ - `setValue(v)` always updates `state.value` (programmatic/controlled path).
84
+ - `handleInput(v)` updates `state.value` only when interactive and invokes `onInput(v)`.
85
+
86
+ ### Disabled and readonly
87
+
88
+ - A disabled or readonly textarea ignores `handleInput(v)`.
89
+ - Disabled uses `tabindex="-1"`; readonly remains `tabindex="0"`.
90
+
91
+ ### Geometry and constraints
92
+
93
+ - `rows` and `cols` accept positive finite integers only.
94
+ - `minLength` and `maxLength` accept non-negative finite integers or `undefined`.
95
+ - `resize` is constrained to `"none" | "vertical"`.
96
+
97
+ ### Focus management
98
+
99
+ - `setFocused(true)` / `setFocused(false)` reflects native focus/blur.
100
+
101
+ ## Transitions Table
102
+
103
+ | Event / Action | Guard | Effect | Next State |
104
+ | ------------------- | ---------------------------------------- | ---------------------------- | ----------------- | --- | --------- |
105
+ | `handleInput(v)` | `!disabled && !readonly` | set value, call `onInput(v)` | `value = v` |
106
+ | `handleInput(v)` | `disabled | | readonly` | -- | no change |
107
+ | `setValue(v)` | -- | set value | `value = v` |
108
+ | `setDisabled(d)` | -- | set disabled | `disabled = d` |
109
+ | `setReadonly(r)` | -- | set readonly | `readonly = r` |
110
+ | `setRequired(r)` | -- | set required | `required = r` |
111
+ | `setPlaceholder(p)` | -- | set placeholder | `placeholder = p` |
112
+ | `setRows(n)` | `n` is positive integer | set rows | `rows = n` |
113
+ | `setRows(n)` | invalid `n` | -- | no change |
114
+ | `setCols(n)` | `n` is positive integer | set cols | `cols = n` |
115
+ | `setCols(n)` | invalid `n` | -- | no change |
116
+ | `setMinLength(n)` | `n` is non-negative integer or undefined | set minLength | `minLength = n` |
117
+ | `setMaxLength(n)` | `n` is non-negative integer or undefined | set maxLength | `maxLength = n` |
118
+ | `setResize(mode)` | -- | set resize | `resize = mode` |
119
+ | `setFocused(f)` | -- | set focused | `focused = f` |
120
+
121
+ ## Adapter Expectations
122
+
123
+ UIKit (`cv-textarea`) binds to the headless contract as follows:
124
+
125
+ - **Signals read**:
126
+ - `state.value()` - value reflection to DOM
127
+ - `state.disabled()` - host `[disabled]` reflection
128
+ - `state.readonly()` - host `[readonly]` reflection
129
+ - `state.required()` - host `[required]` reflection
130
+ - `state.focused()` - host `[focused]` reflection
131
+ - `state.filled()` - host `[filled]` reflection
132
+ - `state.resize()` - host `[resize]` reflection
133
+ - **Actions called**:
134
+ - `setValue`, `setDisabled`, `setReadonly`, `setRequired`, `setPlaceholder`, `setRows`, `setCols`, `setMinLength`, `setMaxLength`, `setResize`
135
+ - `setFocused` on native focus/blur
136
+ - `handleInput` on native input
137
+ - **Contracts spread**:
138
+ - `contracts.getTextareaProps()` - spread onto native `<textarea>`
139
+ - **Events dispatched by UIKit**:
140
+ - `cv-input` on user input
141
+ - `cv-change` on blur commit when value changed since focus
142
+ - `cv-focus` / `cv-blur` on focus transitions
143
+
144
+ ## Invariants
145
+
146
+ 1. `filled` is `true` iff `value.length > 0`.
147
+ 2. `handleInput` is a no-op when `disabled` or `readonly`.
148
+ 3. `setValue` remains available while `disabled`/`readonly` (programmatic updates).
149
+ 4. `tabindex` is `"-1"` when disabled, `"0"` otherwise.
150
+ 5. `aria-disabled` is `"true"` only when disabled.
151
+ 6. `aria-readonly` is `"true"` only when readonly.
152
+ 7. `aria-required` is `"true"` only when required.
153
+ 8. `rows` and `cols` are always positive integers.
154
+ 9. `minLength` and `maxLength` are undefined or non-negative integers.
155
+ 10. `resize` is always `"none"` or `"vertical"`.
156
+
157
+ ## Minimum Test Matrix
158
+
159
+ - initial defaults for all state values
160
+ - `handleInput(v)` updates value and calls `onInput`
161
+ - `handleInput(v)` no-op for disabled
162
+ - `handleInput(v)` no-op for readonly
163
+ - `setValue(v)` updates while disabled
164
+ - `setValue(v)` updates while readonly
165
+ - `filled` derives from value emptiness
166
+ - `setRows` and `setCols` accept valid positive integers
167
+ - `setRows` and `setCols` ignore invalid numbers
168
+ - `setMinLength` / `setMaxLength` set and clear constraints
169
+ - `getTextareaProps()` returns proper ARIA and native attributes
170
+ - `getTextareaProps()` omits role
171
+ - `setFocused` updates focus state
172
+
173
+ ## ADR-001 Compliance
174
+
175
+ - **Runtime Policy**: Reatom v1000 only; no @statx/\* in headless core.
176
+ - **Layering**: core/interactions/a11y-contracts/adapters boundaries preserved.
177
+ - **Independence**: no monorepo app imports.
178
+ - **Verification**: standalone headless tests via package command.
179
+
180
+ ## Out of Scope (Current)
181
+
182
+ - auto-growing textarea height based on content
183
+ - validation message state and error modeling
184
+ - rich text / markdown semantics
185
+ - custom keyboard shortcuts beyond native textarea behavior
@@ -0,0 +1,198 @@
1
+ # Toast Component Contract
2
+
3
+ ## Purpose
4
+
5
+ `Toast` provides a headless notification queue model with dismiss, auto-dismiss, and pause/resume timing behavior. Composite architecture: `cv-toast-region` (container) + `cv-toast` (per-item).
6
+
7
+ ## Component Files
8
+
9
+ - `src/toast/index.ts` - model and public `createToast` API
10
+ - `src/toast/toast.test.ts` - unit behavior tests
11
+
12
+ ## Public API
13
+
14
+ - `createToast(options)`
15
+ - `state` (signal-backed):
16
+ - `items()` - full toast queue
17
+ - `visibleItems()` - top `maxVisible` slice
18
+ - `isPaused()` - pause state for timers
19
+ - `actions`:
20
+ - `push(item)` - enqueue toast and return generated id
21
+ - `dismiss(id)`
22
+ - `clear()`
23
+ - `pause()`
24
+ - `resume()`
25
+ - `contracts`:
26
+ - `getRegionProps()`
27
+ - `getToastProps(id)`
28
+ - `getDismissButtonProps(id)`
29
+
30
+ ## CreateToastOptions
31
+
32
+ | Option | Type | Default | Description |
33
+ | ------------------- | ------------------------- | ---------- | --------------------------------------------- |
34
+ | `idBase` | `string` | `'toast'` | Base id prefix for all generated ids |
35
+ | `initialItems` | `readonly ToastItem[]` | `[]` | Pre-populated toast items |
36
+ | `maxVisible` | `number` | `3` | Maximum number of toasts shown (clamped >= 1) |
37
+ | `defaultDurationMs` | `number` | `5000` | Default auto-dismiss duration (clamped >= 0) |
38
+ | `ariaLive` | `'polite' \| 'assertive'` | `'polite'` | `aria-live` value for the region |
39
+
40
+ ## State Signal Surface
41
+
42
+ | Signal | Type | Derived? | Description |
43
+ | -------------- | ----------------------- | -------- | -------------------------------------- |
44
+ | `items` | `Atom<ToastItem[]>` | No | Full toast queue, newest-first |
45
+ | `visibleItems` | `Computed<ToastItem[]>` | Yes | `items().slice(0, maxVisible)` |
46
+ | `isPaused` | `Atom<boolean>` | No | Whether auto-dismiss timers are paused |
47
+
48
+ ## APG and A11y Contract
49
+
50
+ - region role: `region`
51
+ - region attributes:
52
+ - `aria-live` (`polite` or `assertive`)
53
+ - `aria-atomic="false"`
54
+ - toast item role:
55
+ - `status` for `info`/`success`
56
+ - `alert` for `warning`/`error`
57
+ - dismiss button role: `button`
58
+
59
+ ## Keyboard Contract
60
+
61
+ - model-level keyboard behavior is intentionally minimal
62
+ - keyboard bindings for dismiss shortcuts are adapter-level
63
+ - dismiss action is exposed through dismiss-button contract handlers
64
+
65
+ ## Behavior Contract
66
+
67
+ - pushing a toast prepends it to queue (`newest-first`).
68
+ - queue visibility is constrained by `maxVisible`.
69
+ - auto-dismiss timers are tracked per toast id.
70
+ - pause computes and stores remaining durations; resume continues from remaining time.
71
+ - no auto-dismiss is scheduled when duration is `<= 0`.
72
+
73
+ ## Contract Prop Shapes
74
+
75
+ ### `getRegionProps()`
76
+
77
+ ```ts
78
+ {
79
+ id: string // '{idBase}-region'
80
+ role: 'region'
81
+ 'aria-live': 'polite' | 'assertive' // from options.ariaLive
82
+ 'aria-atomic': 'false'
83
+ }
84
+ ```
85
+
86
+ ### `getToastProps(id)`
87
+
88
+ ```ts
89
+ {
90
+ id: string // '{idBase}-item-{id}'
91
+ role: 'status' | 'alert' // 'status' for info/success, 'alert' for warning/error
92
+ 'data-level': ToastLevel // 'info' | 'success' | 'warning' | 'error'
93
+ }
94
+ ```
95
+
96
+ ### `getDismissButtonProps(id)`
97
+
98
+ ```ts
99
+ {
100
+ id: string // '{idBase}-dismiss-{id}'
101
+ role: 'button'
102
+ tabindex: '0'
103
+ 'aria-label': 'Dismiss notification'
104
+ onClick: () => void // calls dismiss(id)
105
+ }
106
+ ```
107
+
108
+ ## Transitions Table
109
+
110
+ | Event / Action | Current State | Next State / Effect |
111
+ | -------------------------- | ------------------------ | -------------------------------------------------------------------------------------- |
112
+ | `push(item)` | any | New toast prepended to `items`; auto-dismiss timer scheduled; returns generated id |
113
+ | `push(item)` (paused) | `isPaused = true` | New toast prepended to `items`; remaining duration stored but no timer started |
114
+ | `dismiss(id)` | toast exists in `items` | Toast removed from `items`; timer and tracking data cleared |
115
+ | `clear()` | any | All items removed; all timers and tracking data cleared |
116
+ | `pause()` | `isPaused = false` | `isPaused = true`; all running timers stopped; remaining durations computed and stored |
117
+ | `pause()` | `isPaused = true` | no-op |
118
+ | `resume()` | `isPaused = true` | `isPaused = false`; auto-dismiss timers rescheduled from remaining durations |
119
+ | `resume()` | `isPaused = false` | no-op |
120
+ | timer fires (auto-dismiss) | toast exists, timer done | `dismiss(id)` called; toast removed from queue |
121
+
122
+ ### Derived state reactions
123
+
124
+ | State Change | `visibleItems` |
125
+ | --------------- | -------------------------------------------- |
126
+ | `items` changes | Recomputed as `items().slice(0, maxVisible)` |
127
+
128
+ ## Invariants
129
+
130
+ 1. `visibleItems` always equals `items().slice(0, maxVisible)`.
131
+ 2. `clear` removes all queue items and all timer tracking data.
132
+ 3. No auto-dismiss is scheduled when duration is `<= 0`.
133
+ 4. Role mapping is level-dependent: `role="status"` for `info`/`success`, `role="alert"` for `warning`/`error`.
134
+ 5. `getToastProps(id)` throws if the toast id is not found in `items`.
135
+ 6. `pause()` is idempotent when already paused; `resume()` is idempotent when not paused.
136
+ 7. Remaining duration after pause/resume must preserve elapsed time accurately.
137
+
138
+ ## Adapter Expectations
139
+
140
+ UIKit adapters MUST bind to the headless model as follows:
141
+
142
+ **Signals read (reactive, drive re-renders):**
143
+
144
+ - `state.items()` — full toast queue for iteration
145
+ - `state.visibleItems()` — sliced queue for rendering visible toasts
146
+ - `state.isPaused()` — whether auto-dismiss timers are paused (for hover pause behavior)
147
+
148
+ **Actions called (event handlers, never mutate state directly):**
149
+
150
+ - `actions.push(item)` — enqueue a new toast notification
151
+ - `actions.dismiss(id)` — dismiss a specific toast
152
+ - `actions.clear()` — dismiss all toasts
153
+ - `actions.pause()` — pause auto-dismiss timers (e.g., on mouse enter region)
154
+ - `actions.resume()` — resume auto-dismiss timers (e.g., on mouse leave region)
155
+
156
+ **Contracts spread (attribute maps applied directly to DOM elements):**
157
+
158
+ - `contracts.getRegionProps()` — spread onto the `cv-toast-region` container element
159
+ - `contracts.getToastProps(id)` — spread onto each `cv-toast` item element (returns `role: 'status' | 'alert'` based on toast level)
160
+ - `contracts.getDismissButtonProps(id)` — spread onto the dismiss button inside each toast (includes `onClick` handler)
161
+
162
+ **UIKit-only concerns (NOT in headless):**
163
+
164
+ - Positioning and stacking layout (`position` attribute on `cv-toast-region`)
165
+ - Entry/exit animations and transitions
166
+ - Icon slot rendering per severity level
167
+ - Closable attribute controlling dismiss button visibility
168
+ - Lifecycle events (`cv-dismiss`, etc.)
169
+ - Mouse enter/leave region handlers that call `pause()`/`resume()`
170
+
171
+ ## Minimum Test Matrix
172
+
173
+ - push/dismiss queue operations
174
+ - auto-dismiss timing behavior
175
+ - pause/resume preserving remaining duration
176
+ - max-visible slicing behavior
177
+ - role mapping for different toast levels (`status` for info/success, `alert` for warning/error)
178
+ - `getRegionProps` returns correct `aria-live` and `role`
179
+ - `getDismissButtonProps` onClick calls dismiss
180
+ - `getToastProps` throws for unknown id
181
+ - clear removes all items and tracking
182
+ - push while paused stores duration without starting timer
183
+
184
+ ## ADR-001 Compliance
185
+
186
+ - **Runtime Policy**: Reatom v1000 only; no @statx/\* in headless core.
187
+ - **Layering**: core -> interactions -> a11y-contracts -> adapters; adapters remain thin mappings.
188
+ - **Independence**: No imports from @project/_, apps/_, or other out-of-package modules.
189
+ - **Verification**: Mandatory adapter integration tests and standalone package test execution.
190
+
191
+ ## Out of Scope (Current)
192
+
193
+ - animation and transition orchestration
194
+ - swipe/gesture dismissal
195
+ - viewport positioning and stacking rules
196
+ - cross-tab or persistent notification history
197
+ - progress bar or loading variant
198
+ - title field (message-only content)