@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.
- package/LICENSE +21 -0
- package/README.md +99 -0
- package/dist/a11y-contracts/index.d.ts +23 -0
- package/dist/a11y-contracts/index.js +1 -0
- package/dist/accordion/index.d.ts +78 -0
- package/dist/accordion/index.js +264 -0
- package/dist/adapters/index.d.ts +9 -0
- package/dist/adapters/index.js +1 -0
- package/dist/alert/index.d.ts +33 -0
- package/dist/alert/index.js +54 -0
- package/dist/alert-dialog/index.d.ts +69 -0
- package/dist/alert-dialog/index.js +94 -0
- package/dist/badge/index.d.ts +48 -0
- package/dist/badge/index.js +89 -0
- package/dist/breadcrumb/index.d.ts +55 -0
- package/dist/breadcrumb/index.js +77 -0
- package/dist/button/index.d.ts +46 -0
- package/dist/button/index.js +86 -0
- package/dist/callout/index.d.ts +41 -0
- package/dist/callout/index.js +63 -0
- package/dist/card/index.d.ts +54 -0
- package/dist/card/index.js +103 -0
- package/dist/carousel/index.d.ts +98 -0
- package/dist/carousel/index.js +243 -0
- package/dist/checkbox/index.d.ts +50 -0
- package/dist/checkbox/index.js +87 -0
- package/dist/combobox/index.d.ts +114 -0
- package/dist/combobox/index.js +431 -0
- package/dist/command-palette/index.d.ts +73 -0
- package/dist/command-palette/index.js +147 -0
- package/dist/context-menu/index.d.ts +111 -0
- package/dist/context-menu/index.js +372 -0
- package/dist/copy-button/index.d.ts +62 -0
- package/dist/copy-button/index.js +183 -0
- package/dist/core/index.d.ts +20 -0
- package/dist/core/index.js +2 -0
- package/dist/core/selection.d.ts +5 -0
- package/dist/core/selection.js +39 -0
- package/dist/core/value-range.d.ts +49 -0
- package/dist/core/value-range.js +134 -0
- package/dist/date-picker/index.d.ts +210 -0
- package/dist/date-picker/index.js +895 -0
- package/dist/dialog/index.d.ts +95 -0
- package/dist/dialog/index.js +153 -0
- package/dist/disclosure/index.d.ts +52 -0
- package/dist/disclosure/index.js +159 -0
- package/dist/drawer/index.d.ts +30 -0
- package/dist/drawer/index.js +39 -0
- package/dist/feed/index.d.ts +77 -0
- package/dist/feed/index.js +260 -0
- package/dist/grid/index.d.ts +103 -0
- package/dist/grid/index.js +415 -0
- package/dist/index.d.ts +51 -0
- package/dist/index.js +51 -0
- package/dist/input/index.d.ts +86 -0
- package/dist/input/index.js +156 -0
- package/dist/interactions/composite-navigation.d.ts +69 -0
- package/dist/interactions/composite-navigation.js +169 -0
- package/dist/interactions/index.d.ts +15 -0
- package/dist/interactions/index.js +4 -0
- package/dist/interactions/keyboard-intents.d.ts +16 -0
- package/dist/interactions/keyboard-intents.js +33 -0
- package/dist/interactions/overlay-focus.d.ts +40 -0
- package/dist/interactions/overlay-focus.js +93 -0
- package/dist/interactions/typeahead.d.ts +20 -0
- package/dist/interactions/typeahead.js +41 -0
- package/dist/landmarks/index.d.ts +39 -0
- package/dist/landmarks/index.js +58 -0
- package/dist/link/index.d.ts +34 -0
- package/dist/link/index.js +39 -0
- package/dist/listbox/index.d.ts +92 -0
- package/dist/listbox/index.js +337 -0
- package/dist/menu/index.d.ts +132 -0
- package/dist/menu/index.js +541 -0
- package/dist/menu-button/index.d.ts +71 -0
- package/dist/menu-button/index.js +121 -0
- package/dist/meter/index.d.ts +45 -0
- package/dist/meter/index.js +106 -0
- package/dist/number/index.d.ts +113 -0
- package/dist/number/index.js +252 -0
- package/dist/popover/index.d.ts +70 -0
- package/dist/popover/index.js +126 -0
- package/dist/progress/index.d.ts +49 -0
- package/dist/progress/index.js +79 -0
- package/dist/radio-group/index.d.ts +61 -0
- package/dist/radio-group/index.js +150 -0
- package/dist/select/index.d.ts +92 -0
- package/dist/select/index.js +239 -0
- package/dist/sidebar/index.d.ts +74 -0
- package/dist/sidebar/index.js +186 -0
- package/dist/slider/index.d.ts +61 -0
- package/dist/slider/index.js +150 -0
- package/dist/slider-multi-thumb/index.d.ts +70 -0
- package/dist/slider-multi-thumb/index.js +222 -0
- package/dist/spinbutton/index.d.ts +75 -0
- package/dist/spinbutton/index.js +214 -0
- package/dist/spinner/index.d.ts +1 -0
- package/dist/spinner/index.js +1 -0
- package/dist/spinner/spinner.d.ts +23 -0
- package/dist/spinner/spinner.js +25 -0
- package/dist/switch/index.d.ts +40 -0
- package/dist/switch/index.js +61 -0
- package/dist/table/index.d.ts +117 -0
- package/dist/table/index.js +377 -0
- package/dist/tabs/index.d.ts +63 -0
- package/dist/tabs/index.js +174 -0
- package/dist/textarea/index.d.ts +68 -0
- package/dist/textarea/index.js +137 -0
- package/dist/toast/index.d.ts +67 -0
- package/dist/toast/index.js +145 -0
- package/dist/toolbar/index.d.ts +59 -0
- package/dist/toolbar/index.js +139 -0
- package/dist/tooltip/index.d.ts +52 -0
- package/dist/tooltip/index.js +169 -0
- package/dist/treegrid/index.d.ts +101 -0
- package/dist/treegrid/index.js +463 -0
- package/dist/treeview/index.d.ts +68 -0
- package/dist/treeview/index.js +370 -0
- package/dist/window-splitter/index.d.ts +65 -0
- package/dist/window-splitter/index.js +204 -0
- package/package.json +92 -0
- package/specs/ADR-001-headless-architecture.md +461 -0
- package/specs/ADR-002-repo-release-model.md +108 -0
- package/specs/ADR-003-public-api-versioning.md +136 -0
- package/specs/ADR-004-focus-selection-policy.md +117 -0
- package/specs/IMPLEMENTATION-ROADMAP.md +237 -0
- package/specs/ISSUE-BACKLOG.md +681 -0
- package/specs/RELEASE-CANDIDATE.md +30 -0
- package/specs/components/accordion.md +130 -0
- package/specs/components/alert-dialog.md +72 -0
- package/specs/components/alert.md +65 -0
- package/specs/components/badge.md +220 -0
- package/specs/components/breadcrumb.md +74 -0
- package/specs/components/button.md +115 -0
- package/specs/components/callout.md +195 -0
- package/specs/components/card.md +280 -0
- package/specs/components/carousel.md +140 -0
- package/specs/components/checkbox.md +172 -0
- package/specs/components/combobox.md +423 -0
- package/specs/components/command-palette.md +92 -0
- package/specs/components/context-menu.md +556 -0
- package/specs/components/copy-button.md +293 -0
- package/specs/components/date-picker.md +400 -0
- package/specs/components/dialog.md +298 -0
- package/specs/components/disclosure.md +257 -0
- package/specs/components/drawer.md +353 -0
- package/specs/components/feed.md +265 -0
- package/specs/components/grid.md +186 -0
- package/specs/components/input.md +254 -0
- package/specs/components/landmarks.md +136 -0
- package/specs/components/link.md +134 -0
- package/specs/components/listbox.md +351 -0
- package/specs/components/menu-button.md +76 -0
- package/specs/components/menu.md +623 -0
- package/specs/components/meter.md +149 -0
- package/specs/components/number.md +393 -0
- package/specs/components/popover.md +252 -0
- package/specs/components/progress.md +188 -0
- package/specs/components/radio-group.md +151 -0
- package/specs/components/select.md +144 -0
- package/specs/components/sidebar.md +321 -0
- package/specs/components/slider-multi-thumb.md +78 -0
- package/specs/components/slider.md +84 -0
- package/specs/components/spinbutton.md +140 -0
- package/specs/components/spinner.md +132 -0
- package/specs/components/switch.md +175 -0
- package/specs/components/table.md +403 -0
- package/specs/components/tabs.md +265 -0
- package/specs/components/textarea.md +185 -0
- package/specs/components/toast.md +198 -0
- package/specs/components/toolbar.md +278 -0
- package/specs/components/tooltip.md +252 -0
- package/specs/components/treegrid.md +281 -0
- package/specs/components/treeview.md +91 -0
- package/specs/components/window-splitter.md +297 -0
- package/specs/ops/git-shard-sync.md +107 -0
- package/specs/ops/release-checklist.md +76 -0
- package/specs/release/GAP-TO-GREEN-ISSUES.md +88 -0
- package/specs/release/api-freeze-candidate.md +54 -0
- package/specs/release/changelog-automation.md +76 -0
- package/specs/release/changelog.generated.md +53 -0
- package/specs/release/changelog.patch.generated.md +46 -0
- package/specs/release/consumer-integration.md +53 -0
- package/specs/release/migration-notes-pre-v1.md +40 -0
- package/specs/release/mvp-changelog.md +57 -0
- package/specs/release/release-notes-template.md +61 -0
- package/specs/release/release-rehearsal.md +113 -0
- package/specs/release/semver-deprecation-dry-run.md +89 -0
- package/specs/release/shard-release-drill-report.md +50 -0
- package/specs/release/shard-release-follow-ups.md +31 -0
- 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)
|