@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,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)
|