@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,278 @@
1
+ # Toolbar Component Contract
2
+
3
+ ## Purpose
4
+
5
+ `Toolbar` is a headless contract for a container of interactive elements (buttons, checkboxes, etc.) that provides a single tab stop and arrow-key navigation between items. It supports separator items (non-focusable dividers skipped by navigation) and focus memory (restoring focus to the last-active item when the toolbar is re-entered via Tab).
6
+
7
+ ## Component Files
8
+
9
+ - `src/toolbar/index.ts` - model and public `createToolbar` API
10
+ - `src/toolbar/toolbar.test.ts` - unit behavior tests
11
+
12
+ ## Public API
13
+
14
+ - `createToolbar(options): ToolbarModel`
15
+ - `state` (signal-backed):
16
+ - `activeId()` - current roving-focus item id
17
+ - `lastActiveId()` - last item that held focus before blur (for focus memory)
18
+ - `actions`:
19
+ - `setActive`, `moveNext`, `movePrev`, `moveFirst`, `moveLast`
20
+ - `handleKeyDown`
21
+ - `handleToolbarFocus` - restores focus to `lastActiveId` on toolbar re-entry
22
+ - `handleToolbarBlur` - snapshots `activeId` into `lastActiveId`
23
+ - `contracts`:
24
+ - `getRootProps()`
25
+ - `getItemProps(id)`
26
+ - `getSeparatorProps(id)`
27
+
28
+ ## Options (`CreateToolbarOptions`)
29
+
30
+ | Option | Type | Default | Description |
31
+ | ----------------- | ---------------------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------- |
32
+ | `items` | `readonly ToolbarItem[]` | required | Item definitions. Each has `id: string`, optional `disabled?: boolean`, optional `separator?: boolean`. |
33
+ | `idBase` | `string` | `'toolbar'` | Prefix for generated DOM ids (`{idBase}-root`, `{idBase}.nav-item-{id}`). |
34
+ | `orientation` | `'horizontal' \| 'vertical'` | `'horizontal'` | Determines keyboard navigation axis and `aria-orientation` value. |
35
+ | `wrap` | `boolean` | `true` | Whether arrow navigation wraps from last to first and vice versa. `false` clamps at boundaries. |
36
+ | `ariaLabel` | `string \| undefined` | `undefined` | Optional `aria-label` for the toolbar root element. |
37
+ | `initialActiveId` | `string \| null` | first enabled non-separator item | Initial roving-focus item. Normalized to first navigable item if invalid, disabled, or a separator. |
38
+
39
+ ### `ToolbarItem`
40
+
41
+ ```ts
42
+ interface ToolbarItem {
43
+ id: string
44
+ disabled?: boolean
45
+ separator?: boolean // non-focusable, non-interactive divider
46
+ }
47
+ ```
48
+
49
+ Items with `separator: true` are excluded from the navigable set regardless of their `disabled` flag. They are purely visual dividers.
50
+
51
+ ### Initial State Resolution
52
+
53
+ 1. If `initialActiveId` is provided and refers to a valid enabled non-separator item, use it.
54
+ 2. Otherwise, fall back to the first enabled non-separator item.
55
+ 3. If no navigable items exist, `activeId` is `null`.
56
+
57
+ ## Reactive State Contract
58
+
59
+ Headless Toolbar exposes state as reactive signal-backed getters.
60
+
61
+ ### State Surface
62
+
63
+ - `state.activeId(): string | null`
64
+ - Current roving-focus item id.
65
+ - Only navigable items (enabled, non-separator) can be active.
66
+ - Changes on directional navigation, `setActive`, and `handleToolbarFocus`.
67
+ - `state.lastActiveId(): string | null`
68
+ - Snapshot of the most recent `activeId` before the toolbar lost focus.
69
+ - Updated by `handleToolbarBlur`.
70
+ - Read by `handleToolbarFocus` to restore focus position on re-entry.
71
+ - `state.orientation: 'horizontal' | 'vertical'`
72
+ - Static value from options. Determines arrow key mapping.
73
+
74
+ ### Derived Values
75
+
76
+ The following are computed by the underlying composite-navigation layer and available through contracts, not as separate signals:
77
+
78
+ - Whether an item is active (`data-active` in `getItemProps`)
79
+ - Roving tabindex value per item (`tabindex` in `getItemProps`)
80
+ - Enabled item list (used internally for navigation; separators excluded)
81
+
82
+ ### Reactivity Guarantees
83
+
84
+ - `state` values are read via getter calls (`Atom<string | null>`) and are suitable as reactive dependencies in adapters.
85
+ - Any state change MUST be observable synchronously by adapters after action execution.
86
+ - Adapters MUST treat `state` as source of truth; DOM flags are derived outputs.
87
+
88
+ ## Actions
89
+
90
+ ### `setActive(id: string)`
91
+
92
+ - If `id` is a valid enabled non-separator item, sets `activeId` to `id`.
93
+ - If `id` is disabled, a separator, or unknown, no state change.
94
+
95
+ ### `moveNext()` / `movePrev()`
96
+
97
+ - Moves `activeId` to the next/previous navigable item (enabled, non-separator), wrapping or clamping based on `wrap` option.
98
+ - Separators and disabled items are skipped.
99
+ - If no navigable items exist, sets `activeId` to `null`.
100
+ - If `activeId` is currently `null` or invalid, resets to the first/last navigable item.
101
+
102
+ ### `moveFirst()` / `moveLast()`
103
+
104
+ - Sets `activeId` to the first/last navigable item (enabled, non-separator), or `null` if none exist.
105
+
106
+ ### `handleKeyDown(event: Pick<KeyboardEvent, 'key'>)`
107
+
108
+ - Maps keys based on orientation:
109
+ - Horizontal: `ArrowRight` -> `moveNext()`, `ArrowLeft` -> `movePrev()`
110
+ - Vertical: `ArrowDown` -> `moveNext()`, `ArrowUp` -> `movePrev()`
111
+ - `Home` -> `moveFirst()`, `End` -> `moveLast()` (both orientations)
112
+ - Unrecognized keys produce no state change.
113
+
114
+ ### `handleToolbarFocus()`
115
+
116
+ - Called when the toolbar or any of its items receives focus after the toolbar was not focused.
117
+ - If `lastActiveId` is non-null and still refers to a navigable item, sets `activeId` to `lastActiveId` (focus memory).
118
+ - If `lastActiveId` is `null` or stale (item no longer navigable), falls back to the current `activeId` (no change).
119
+
120
+ ### `handleToolbarBlur()`
121
+
122
+ - Called when focus leaves the toolbar entirely.
123
+ - Snapshots the current `activeId` into `lastActiveId`.
124
+
125
+ ## Transitions Table
126
+
127
+ | Event / Action | `activeId` | `lastActiveId` |
128
+ | ------------------------------------------------------ | ----------------------------------- | ------------------------- |
129
+ | `setActive(id)` where id is navigable | set to `id` | unchanged |
130
+ | `setActive(id)` where id is disabled/separator/unknown | unchanged | unchanged |
131
+ | `moveNext()` / `movePrev()` | next/prev navigable (wrap or clamp) | unchanged |
132
+ | `moveFirst()` / `moveLast()` | first/last navigable | unchanged |
133
+ | `handleKeyDown` (orientation-matched arrow) | delegates to `moveNext`/`movePrev` | unchanged |
134
+ | `handleKeyDown` (Home/End) | delegates to `moveFirst`/`moveLast` | unchanged |
135
+ | `handleKeyDown` (unrecognized key) | unchanged | unchanged |
136
+ | `handleToolbarBlur()` | unchanged | set to current `activeId` |
137
+ | `handleToolbarFocus()` with valid `lastActiveId` | set to `lastActiveId` | unchanged |
138
+ | `handleToolbarFocus()` with null/stale `lastActiveId` | unchanged | unchanged |
139
+
140
+ ## Contracts
141
+
142
+ Contracts return ready-to-spread ARIA attribute maps.
143
+
144
+ ### `getRootProps(): ToolbarRootProps`
145
+
146
+ ```ts
147
+ interface ToolbarRootProps {
148
+ id: string // '{idBase}-root'
149
+ role: 'toolbar'
150
+ 'aria-orientation': 'horizontal' | 'vertical'
151
+ 'aria-label'?: string // from options.ariaLabel
152
+ }
153
+ ```
154
+
155
+ ### `getItemProps(id: string): ToolbarItemProps`
156
+
157
+ Returns props for a navigable (non-separator) item. Throws `Error` if `id` is unknown.
158
+
159
+ ```ts
160
+ interface ToolbarItemProps {
161
+ id: string // '{idBase}.nav-item-{id}'
162
+ tabindex: '0' | '-1' // '0' if active, '-1' otherwise
163
+ 'aria-disabled'?: 'true' // present only when item is disabled
164
+ 'data-active': 'true' | 'false' // matches activeId
165
+ onFocus: () => void // calls setActive(id)
166
+ }
167
+ ```
168
+
169
+ ### `getSeparatorProps(id: string): ToolbarSeparatorProps`
170
+
171
+ Returns props for a separator item. Throws `Error` if `id` is unknown or not a separator.
172
+
173
+ ```ts
174
+ interface ToolbarSeparatorProps {
175
+ id: string // '{idBase}-separator-{id}'
176
+ role: 'separator'
177
+ 'aria-orientation': 'vertical' | 'horizontal' // perpendicular to toolbar orientation
178
+ }
179
+ ```
180
+
181
+ The separator's `aria-orientation` is perpendicular to the toolbar's orientation: a horizontal toolbar gets vertical separators and vice versa.
182
+
183
+ ## APG and A11y Contract
184
+
185
+ - toolbar role: `toolbar`
186
+ - separator role: `separator` (with perpendicular `aria-orientation`)
187
+ - focus management:
188
+ - `roving-tabindex` (only one navigable item has `tabindex="0"` at a time)
189
+ - separators are never focusable and have no `tabindex`
190
+ - keyboard behavior:
191
+ - `ArrowRight` / `ArrowDown` moves focus to the next navigable item (orientation-dependent)
192
+ - `ArrowLeft` / `ArrowUp` moves focus to the previous navigable item (orientation-dependent)
193
+ - `Home` moves focus to the first navigable item
194
+ - `End` moves focus to the last navigable item
195
+ - Separators and disabled items are skipped
196
+ - orientation: `horizontal` (default) or `vertical`
197
+ - focus memory: re-entering the toolbar via Tab restores focus to the last-active item
198
+
199
+ ## Invariants
200
+
201
+ 1. `activeId` is always `null` or the id of a navigable (enabled, non-separator) item.
202
+ 2. Only the active item has `tabindex="0"`, all other navigable items have `tabindex="-1"`.
203
+ 3. Separator items never receive `tabindex`, are never active, and are never reached by keyboard navigation.
204
+ 4. Disabled items are skipped during navigation and cannot become active.
205
+ 5. `lastActiveId` is always `null` or the id of an item that was navigable at the time of blur.
206
+ 6. On toolbar re-entry via `handleToolbarFocus`, if `lastActiveId` still refers to a navigable item, `activeId` is restored to it.
207
+ 7. The toolbar root element itself is not focusable; only navigable items are.
208
+ 8. `getItemProps` throws for unknown item ids. `getSeparatorProps` throws for unknown ids or non-separator ids.
209
+
210
+ ## Adapter Expectations
211
+
212
+ This section lists exactly what the UIKit adapter layer binds to.
213
+
214
+ ### Signals Read
215
+
216
+ | Signal | UIKit Usage |
217
+ | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
218
+ | `state.activeId()` | Determines roving tabindex; drives `data-active` attribute on item elements; used for programmatic focus management (calling `.focus()` on the active DOM element). |
219
+ | `state.lastActiveId()` | Not directly read by UIKit for rendering; consumed internally by `handleToolbarFocus`. UIKit only needs to call the action. |
220
+
221
+ ### Actions Called
222
+
223
+ | Action | UIKit Trigger |
224
+ | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
225
+ | `setActive(id)` | An item receives focus via pointer click or programmatic focus. Also called internally by `getItemProps(id).onFocus`. |
226
+ | `handleKeyDown(event)` | `keydown` event on a toolbar item or the toolbar root. |
227
+ | `handleToolbarFocus()` | `focusin` event on the toolbar root when toolbar was not previously focused (re-entry detection). UIKit must track whether toolbar already has focus to avoid calling on internal focus moves. |
228
+ | `handleToolbarBlur()` | `focusout` event on the toolbar root when `relatedTarget` is outside the toolbar (full blur detection). |
229
+ | `moveNext()` / `movePrev()` | Not called directly by UIKit; delegated through `handleKeyDown`. |
230
+ | `moveFirst()` / `moveLast()` | Not called directly by UIKit; delegated through `handleKeyDown`. |
231
+
232
+ ### Contracts Spread
233
+
234
+ | Contract | UIKit Target |
235
+ | ----------------------- | ------------------------------------------------------------------------------------------------------------------------ |
236
+ | `getRootProps()` | Spread onto the toolbar root container element. |
237
+ | `getItemProps(id)` | Spread onto each navigable (non-separator) item element. The `onFocus` callback is bound to the element's `focus` event. |
238
+ | `getSeparatorProps(id)` | Spread onto each separator element. |
239
+
240
+ ### UIKit-Only Concerns (Not in Headless)
241
+
242
+ - **Focus management DOM calls**: Calling `.focus()` on the DOM element matching `activeId` after `handleToolbarFocus` restores `activeId`. Headless sets state; UIKit moves DOM focus.
243
+ - **Focus-in/focus-out tracking**: UIKit must detect toolbar entry vs. internal focus moves (e.g., using a `hasFocus` flag updated on `focusin`/`focusout` with `relatedTarget` checks).
244
+ - **Separator rendering**: The visual appearance of separators (line, gap, etc.) is a UIKit concern. Headless only provides the ARIA props.
245
+ - **Custom DOM events**: Any `input`/`change` events are UIKit concerns, not part of the headless model.
246
+
247
+ ## Minimum Test Matrix
248
+
249
+ - arrow navigation (next/prev) in horizontal orientation
250
+ - arrow navigation (next/prev) in vertical orientation
251
+ - Home/End navigation
252
+ - disabled item skip behavior
253
+ - separator item skip behavior (arrows, Home/End)
254
+ - separator items cannot become active via `setActive`
255
+ - orientation-aware key mapping (ArrowRight ignored in vertical, ArrowDown ignored in horizontal)
256
+ - roving tabindex contract verification (active item `tabindex="0"`, others `"-1"`)
257
+ - separator props contract (role, aria-orientation perpendicular)
258
+ - wrapping behavior (`wrap: true`)
259
+ - clamping behavior (`wrap: false`)
260
+ - focus memory: blur then re-focus restores `activeId` to `lastActiveId`
261
+ - focus memory: `lastActiveId` becomes stale (item disabled/removed) - falls back gracefully
262
+ - initial state resolution with separators present
263
+ - mixed items: navigable, disabled, and separator in various orders
264
+
265
+ ## ADR-001 Compliance
266
+
267
+ - **Runtime Policy**: Reatom v1000 only; no @statx/\* in headless core.
268
+ - **Layering**: core -> interactions -> a11y-contracts -> adapters; adapters remain thin mappings.
269
+ - **Independence**: No imports from @project/_, apps/_, or other out-of-package modules.
270
+ - **Verification**: Mandatory adapter integration tests and standalone package test execution.
271
+
272
+ ## Out of Scope (Current)
273
+
274
+ - nested toolbars
275
+ - complex grouping within toolbar
276
+ - automatic overflow management
277
+ - rich content within toolbar items
278
+ - dynamic item insertion/removal orchestration (adapter rebuilds model with updated items)
@@ -0,0 +1,252 @@
1
+ # Tooltip Component Contract
2
+
3
+ ## Purpose
4
+
5
+ `Tooltip` is a headless contract for providing contextual information when a user hovers over, focuses on, clicks, or programmatically targets an element. It manages visibility state, trigger mode routing, and ARIA linkage.
6
+
7
+ ## Component Files
8
+
9
+ - `src/tooltip/index.ts` - model and public `createTooltip` API
10
+ - `src/tooltip/tooltip.test.ts` - unit behavior tests
11
+
12
+ ## Public API
13
+
14
+ ### Factory
15
+
16
+ ```ts
17
+ createTooltip(options?: CreateTooltipOptions): TooltipModel
18
+ ```
19
+
20
+ ### Options
21
+
22
+ | Option | Type | Default | Description |
23
+ | ------------- | --------- | --------------- | -------------------------------------------------------------------------------------------------------------- |
24
+ | `idBase` | `string` | `'tooltip'` | Prefix used for all generated IDs. |
25
+ | `initialOpen` | `boolean` | `false` | Whether the tooltip is visible on first render. |
26
+ | `isDisabled` | `boolean` | `false` | When `true`, all trigger interactions and `open` are no-ops; `aria-describedby` is removed from trigger props. |
27
+ | `showDelay` | `number` | `0` | Milliseconds before the tooltip opens (clamped to `>= 0`). |
28
+ | `hideDelay` | `number` | `0` | Milliseconds before the tooltip closes (clamped to `>= 0`). |
29
+ | `trigger` | `string` | `'hover focus'` | Space-separated list of trigger modes. Supported tokens: `hover`, `focus`, `click`, `manual`. |
30
+
31
+ #### Trigger Mode Semantics
32
+
33
+ - **`hover`** — `pointerenter` schedules open (via `showDelay`); `pointerleave` schedules close (via `hideDelay`).
34
+ - **`focus`** — `focus` schedules open; `blur` schedules close.
35
+ - **`click`** — clicking the trigger element toggles open/close. The `Escape` key still closes.
36
+ - **`manual`** — no automatic interaction handlers; only `show()` and `hide()` actions control visibility. If `manual` is the **only** token in the list, pointer, focus, and keyboard events do not open or close the tooltip.
37
+ - Multiple tokens may be combined (e.g., `'hover focus'`, `'click focus'`).
38
+ - An unknown token is silently ignored.
39
+
40
+ ### Signals (State)
41
+
42
+ | Signal | Type | Description |
43
+ | ------------ | --------------- | ----------------------------------------- |
44
+ | `isOpen` | `Atom<boolean>` | Whether the tooltip is currently visible. |
45
+ | `isDisabled` | `Atom<boolean>` | Whether all interactions are suppressed. |
46
+
47
+ ### Actions
48
+
49
+ | Action | Signature | Description |
50
+ | -------------------- | --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
51
+ | `open` | `() => void` | Immediately opens the tooltip (no delay). No-op when `isDisabled` is `true`. |
52
+ | `close` | `() => void` | Immediately closes the tooltip (clears pending timers). |
53
+ | `show` | `() => void` | Programmatic open respecting `showDelay`. Intended as the primary action for `manual` mode. No-op when disabled. |
54
+ | `hide` | `() => void` | Programmatic close respecting `hideDelay`. Intended as the primary action for `manual` mode. |
55
+ | `setDisabled` | `(value: boolean) => void` | Updates `isDisabled`. When set to `true`, calls `close()`. |
56
+ | `handleKeyDown` | `(event: Pick<KeyboardEvent, 'key'>) => void` | `Escape` calls `close()`. Other keys are no-ops. Always active regardless of trigger mode (enables keyboard dismissal). |
57
+ | `handlePointerEnter` | `() => void` | Schedules open when `hover` is in trigger modes; otherwise no-op. |
58
+ | `handlePointerLeave` | `() => void` | Schedules close when `hover` is in trigger modes; otherwise no-op. |
59
+ | `handleFocus` | `() => void` | Schedules open when `focus` is in trigger modes; otherwise no-op. |
60
+ | `handleBlur` | `() => void` | Schedules close when `focus` is in trigger modes; otherwise no-op. |
61
+ | `handleClick` | `() => void` | Toggles `isOpen` when `click` is in trigger modes; otherwise no-op. Clears any pending timers on toggle. |
62
+
63
+ ### Contracts
64
+
65
+ #### `getTriggerProps()`
66
+
67
+ Returns props to spread onto the trigger element. The exact set of handlers depends on the active trigger modes.
68
+
69
+ | Prop | Always present | Condition | Value |
70
+ | ------------------ | -------------- | ----------------------------- | ---------------------------------- |
71
+ | `id` | Yes | — | `${idBase}-trigger` |
72
+ | `aria-describedby` | Yes | `undefined` when `isDisabled` | `${idBase}-content` or `undefined` |
73
+ | `onPointerEnter` | No | `hover` in trigger modes | `handlePointerEnter` |
74
+ | `onPointerLeave` | No | `hover` in trigger modes | `handlePointerLeave` |
75
+ | `onFocus` | No | `focus` in trigger modes | `handleFocus` |
76
+ | `onBlur` | No | `focus` in trigger modes | `handleBlur` |
77
+ | `onClick` | No | `click` in trigger modes | `handleClick` |
78
+ | `onKeyDown` | Yes | — | `handleKeyDown` |
79
+
80
+ When `manual` is the only trigger mode, only `id`, `aria-describedby`, and `onKeyDown` are returned. No pointer, focus, or click handlers are attached.
81
+
82
+ #### `getTooltipProps()`
83
+
84
+ Returns props to spread onto the tooltip content element.
85
+
86
+ | Prop | Value | Notes |
87
+ | ---------- | ------------------- | ---------------------------------- |
88
+ | `id` | `${idBase}-content` | Referenced by `aria-describedby`. |
89
+ | `role` | `'tooltip'` | ARIA landmark role. |
90
+ | `tabindex` | `'-1'` | Tooltip is never in the tab order. |
91
+ | `hidden` | `!isOpen()` | Reflects current visibility. |
92
+
93
+ ## APG and A11y Contract
94
+
95
+ - role: `tooltip`
96
+ - trigger: `aria-describedby` on the trigger links to the tooltip element ID
97
+ - behavior:
98
+ - when `hover` is active: opens on `pointerenter`, closes on `pointerleave`
99
+ - when `focus` is active: opens on `focus`, closes on `blur`
100
+ - when `click` is active: toggles on click
101
+ - `Escape` always dismisses regardless of trigger mode
102
+ - tooltip does not receive keyboard focus (`tabindex="-1"`)
103
+ - `aria-describedby` is omitted when the component is disabled
104
+
105
+ ## Behavior Contract
106
+
107
+ ### Delays
108
+
109
+ - `showDelay` applies to `scheduleOpen` (used by `hover`, `focus`, and `show`).
110
+ - `hideDelay` applies to `scheduleClose` (used by `hover`, `focus`, and `hide`).
111
+ - `handleClick` and `open`/`close` bypass delay timers and act immediately.
112
+ - Calling `scheduleOpen` cancels any pending `scheduleClose`, and vice-versa.
113
+
114
+ ### Timer Cancellation Rules
115
+
116
+ | Action | Cancels show timer | Cancels hide timer |
117
+ | --------------------- | ------------------ | ------------------ |
118
+ | `scheduleOpen` | Yes | Yes |
119
+ | `scheduleClose` | Yes | Yes |
120
+ | `open` (direct) | Yes | Yes |
121
+ | `close` (direct) | Yes | Yes |
122
+ | `handleClick` | Yes | Yes |
123
+ | `handleKeyDown (Esc)` | Yes (via `close`) | Yes (via `close`) |
124
+
125
+ ### Disabled State
126
+
127
+ - `open`, `show`, `handlePointerEnter`, `handleFocus`, and `handleClick` are all no-ops when `isDisabled` is `true`.
128
+ - `close` and `hide` remain operative to ensure the tooltip can always be dismissed.
129
+ - `setDisabled(true)` immediately calls `close()`.
130
+ - `aria-describedby` is `undefined` in trigger props when disabled.
131
+
132
+ ## State Transitions
133
+
134
+ | From state | Trigger / Event | Condition | To state |
135
+ | ----------- | ---------------------------------- | ---------------------------------------- | --------- |
136
+ | closed | `pointerenter` | `hover` in modes, not disabled | opening\* |
137
+ | opening\* | `showDelay` elapsed | — | open |
138
+ | open | `pointerleave` | `hover` in modes | closing\* |
139
+ | closing\* | `hideDelay` elapsed | — | closed |
140
+ | closed | `focus` | `focus` in modes, not disabled | opening\* |
141
+ | open | `blur` | `focus` in modes | closing\* |
142
+ | closed | `click` | `click` in modes, not disabled | open |
143
+ | open | `click` | `click` in modes | closed |
144
+ | open | `Escape` | any mode | closed |
145
+ | any | `show()` | `manual` in modes (or any), not disabled | opening\* |
146
+ | any | `hide()` | `manual` in modes (or any) | closing\* |
147
+ | any | `setDisabled(true)` | — | closed |
148
+ | closed/open | `pointerenter` / `focus` / `click` | `manual` is the only mode | no change |
149
+
150
+ \* "opening" and "closing" are transient timer states, not separate signal values. `isOpen` remains unchanged until the respective timer fires.
151
+
152
+ ## Invariants
153
+
154
+ 1. `isOpen` is always a `boolean`.
155
+ 2. The tooltip content element is never in the natural tab order (`tabindex="-1"` is always set).
156
+ 3. `aria-describedby` on the trigger always references the tooltip element ID, unless `isDisabled` is `true`.
157
+ 4. When `isDisabled` is `true`, `isOpen` must be `false` (enforced by `setDisabled`).
158
+ 5. `showDelay` and `hideDelay` are clamped to `>= 0`; negative values behave as `0`.
159
+ 6. Only one show timer and one hide timer may be pending at any time.
160
+ 7. When `manual` is the **only** trigger mode, `handlePointerEnter`, `handlePointerLeave`, `handleFocus`, `handleBlur`, and `handleClick` all have no effect on `isOpen`.
161
+ 8. `handleClick` has no effect unless `click` is in the trigger modes.
162
+ 9. `getTriggerProps()` must not include `onClick` unless `click` is an active trigger mode.
163
+ 10. `getTriggerProps()` must not include `onPointerEnter`/`onPointerLeave` unless `hover` is an active trigger mode.
164
+ 11. `getTriggerProps()` must not include `onFocus`/`onBlur` unless `focus` is an active trigger mode.
165
+ 12. `onKeyDown` (`handleKeyDown`) is always present in trigger props, regardless of trigger mode.
166
+
167
+ ## Minimum Test Matrix
168
+
169
+ ### Hover mode
170
+
171
+ - hover open/close lifecycle with delays
172
+ - timer cancellation on re-enter during hide delay
173
+ - timer cancellation on leave during show delay
174
+ - zero-delay immediate open/close
175
+
176
+ ### Focus mode
177
+
178
+ - focus open/close lifecycle with delays
179
+ - zero-delay immediate open/close
180
+
181
+ ### Click mode
182
+
183
+ - single click opens when closed
184
+ - single click closes when open
185
+ - click does not fire when disabled
186
+ - `Escape` still closes when in click mode
187
+
188
+ ### Manual mode
189
+
190
+ - `show()` opens (respects `showDelay`)
191
+ - `hide()` closes (respects `hideDelay`)
192
+ - `show()` is no-op when disabled
193
+ - `handlePointerEnter`, `handleFocus`, `handleClick` have no effect when `manual` is the only mode
194
+ - `getTriggerProps()` does not include pointer/focus/click handlers when `manual` is the only mode
195
+
196
+ ### Keyboard
197
+
198
+ - `Escape` dismisses
199
+ - `Escape` cancels pending show timer
200
+ - non-`Escape` keys are ignored
201
+
202
+ ### ARIA
203
+
204
+ - `aria-describedby` links trigger to tooltip ID when not disabled
205
+ - `aria-describedby` is `undefined` when disabled
206
+ - `aria-describedby` persists regardless of open state (when enabled)
207
+ - tooltip has `role="tooltip"` and `tabindex="-1"`
208
+ - `hidden` prop reflects `isOpen` state
209
+
210
+ ### Disabled
211
+
212
+ - `setDisabled(true)` immediately closes
213
+ - interactions do not open when disabled
214
+ - `open()` / `show()` are no-ops when disabled
215
+ - `setDisabled(false)` re-enables interactions
216
+
217
+ ### getTriggerProps handler presence
218
+
219
+ - `click` mode: `onClick` present, `onPointerEnter`/`onPointerLeave` absent (unless also `hover`)
220
+ - `hover` mode: pointer handlers present, `onClick` absent (unless also `click`)
221
+ - `manual`-only mode: only `id`, `aria-describedby`, `onKeyDown` returned
222
+ - `hover focus` (default): all four interaction handlers present, no `onClick`
223
+
224
+ ### Defaults
225
+
226
+ - `createTooltip()` with no options: `isOpen=false`, `isDisabled=false`, trigger=`'hover focus'`, IDs use `'tooltip'` prefix
227
+
228
+ ## Adapter Expectations
229
+
230
+ Headless adapters (e.g., the UIKit `cv-tooltip` web component, React wrappers) MUST:
231
+
232
+ 1. Read the `trigger` attribute/prop as a space-separated string and pass it to `createTooltip({ trigger })`.
233
+ 2. Spread `getTriggerProps()` onto the host trigger element. Adapters must not hard-code individual handler names; the returned props are the source of truth for which events to listen to.
234
+ 3. Spread `getTooltipProps()` onto the tooltip content container.
235
+ 4. Expose `show()` and `hide()` as public methods on the component (for `manual` mode consumers).
236
+ 5. Expose `setDisabled(value)` when the host element supports a `disabled` attribute.
237
+ 6. NOT attach `onclick`, `onfocus`, `onblur`, `onpointerenter`, or `onpointerleave` handlers independently—use only what `getTriggerProps()` returns.
238
+ 7. Correctly forward `handleKeyDown` to the `keydown` event of the trigger element at all times.
239
+ 8. Run integration tests that verify: (a) click toggle works end-to-end, (b) `show`/`hide` works in manual mode, (c) ARIA attributes are present and correctly linked.
240
+
241
+ ## ADR-001 Compliance
242
+
243
+ - **Runtime Policy**: Reatom v1000 only; no @statx/\* in headless core.
244
+ - **Layering**: core -> interactions -> a11y-contracts -> adapters; adapters remain thin mappings.
245
+ - **Independence**: No imports from @project/_, apps/_, or other out-of-package modules.
246
+ - **Verification**: Mandatory adapter integration tests and standalone package test execution.
247
+
248
+ ## Out of Scope (Current)
249
+
250
+ - rich content tooltips (use popover patterns)
251
+ - interactive tooltips (where user can move mouse into the tooltip)
252
+ - positioning logic (handled by consumer or dedicated utility like Floating UI)