@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,140 @@
|
|
|
1
|
+
# Spinbutton Component Contract
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
`Spinbutton` provides a headless APG-aligned model for a numeric input that allows users to select a value by typing or using increment/decrement controls.
|
|
6
|
+
|
|
7
|
+
It handles numeric normalization (snap-to-step), optional range constraints, keyboard-driven value adjustments, and ARIA contracts.
|
|
8
|
+
|
|
9
|
+
## Component Files
|
|
10
|
+
|
|
11
|
+
- `src/spinbutton/index.ts` - model and public `createSpinbutton` API
|
|
12
|
+
- `src/spinbutton/spinbutton.test.ts` - unit behavior tests
|
|
13
|
+
|
|
14
|
+
## Public API
|
|
15
|
+
|
|
16
|
+
- `createSpinbutton(options)`
|
|
17
|
+
- `CreateSpinbuttonOptions`:
|
|
18
|
+
- `idBase?: string`
|
|
19
|
+
- `value?: number`
|
|
20
|
+
- `min?: number`
|
|
21
|
+
- `max?: number`
|
|
22
|
+
- `step?: number`
|
|
23
|
+
- `largeStep?: number`
|
|
24
|
+
- `isDisabled?: boolean`
|
|
25
|
+
- `isReadOnly?: boolean`
|
|
26
|
+
- `ariaLabel?: string`
|
|
27
|
+
- `ariaLabelledBy?: string`
|
|
28
|
+
- `ariaDescribedBy?: string`
|
|
29
|
+
- `formatValueText?: (value: number) => string`
|
|
30
|
+
- `onValueChange?: (value: number) => void`
|
|
31
|
+
- `state` (signal-backed):
|
|
32
|
+
- `value()`: `number`
|
|
33
|
+
- `min()`: `number | undefined`
|
|
34
|
+
- `max()`: `number | undefined`
|
|
35
|
+
- `step()`: `number`
|
|
36
|
+
- `largeStep()`: `number`
|
|
37
|
+
- `isDisabled()`: `boolean`
|
|
38
|
+
- `isReadOnly()`: `boolean`
|
|
39
|
+
- `hasMin()`: `boolean`
|
|
40
|
+
- `hasMax()`: `boolean`
|
|
41
|
+
- `actions`:
|
|
42
|
+
- `setValue(value)`: sets the value with clamping and snapping
|
|
43
|
+
- `increment()`, `decrement()`
|
|
44
|
+
- `incrementLarge()`, `decrementLarge()`
|
|
45
|
+
- `setFirst()`, `setLast()`
|
|
46
|
+
- `setDisabled(value)`, `setReadOnly(value)`
|
|
47
|
+
- `handleKeyDown(event)`
|
|
48
|
+
- `contracts`:
|
|
49
|
+
- `getSpinbuttonProps()`
|
|
50
|
+
- `getIncrementButtonProps()`
|
|
51
|
+
- `getDecrementButtonProps()`
|
|
52
|
+
|
|
53
|
+
Range semantics:
|
|
54
|
+
|
|
55
|
+
- `min` / `max` are optional; when both are absent the model is truly unbounded.
|
|
56
|
+
- When both `min` and `max` are provided in reverse order, they are normalized so `min <= max`.
|
|
57
|
+
- Step snapping is anchored to `min` when provided, otherwise to `0`.
|
|
58
|
+
|
|
59
|
+
## APG and A11y Contract
|
|
60
|
+
|
|
61
|
+
- role: `spinbutton`
|
|
62
|
+
- `aria-valuenow`: current numeric value
|
|
63
|
+
- `aria-valuemin`: minimum value (if defined)
|
|
64
|
+
- `aria-valuemax`: maximum value (if defined)
|
|
65
|
+
- `aria-valuetext`: optional string representation
|
|
66
|
+
- `aria-disabled`: boolean
|
|
67
|
+
- `aria-readonly`: boolean
|
|
68
|
+
- increment/decrement button contracts expose `aria-disabled` when interaction is blocked by disabled/read-only state or by reaching a corresponding range boundary.
|
|
69
|
+
|
|
70
|
+
## Behavior Contract
|
|
71
|
+
|
|
72
|
+
- `ArrowUp` increments the value by `step`.
|
|
73
|
+
- `ArrowDown` decrements the value by `step`.
|
|
74
|
+
- `PageUp` increments the value by a larger step (default 10 \* `step`).
|
|
75
|
+
- `PageDown` decrements the value by a larger step.
|
|
76
|
+
- `Home` sets the value to `min` (if defined).
|
|
77
|
+
- `End` sets the value to `max` (if defined).
|
|
78
|
+
- Snapping to `step` occurs on all value changes (`nearest` strategy).
|
|
79
|
+
- `PageUp` / `PageDown` always apply `largeStep`; range boundaries only clamp when they exist.
|
|
80
|
+
|
|
81
|
+
## Transition Table
|
|
82
|
+
|
|
83
|
+
| Trigger | Guard | Action | Next Value |
|
|
84
|
+
| -------------------------- | -------------------------------------- | ------------------------------ | ------------------------------------- |
|
|
85
|
+
| `setValue(v)` | `!isDisabled && !isReadOnly` | snap + optional clamp + commit | constrained `v` |
|
|
86
|
+
| `increment()` | `!isDisabled && !isReadOnly` | step up | `value + step` (clamped/snapped) |
|
|
87
|
+
| `decrement()` | `!isDisabled && !isReadOnly` | step down | `value - step` (clamped/snapped) |
|
|
88
|
+
| `incrementLarge()` | `!isDisabled && !isReadOnly` | large-step up | `value + largeStep` (clamped/snapped) |
|
|
89
|
+
| `decrementLarge()` | `!isDisabled && !isReadOnly` | large-step down | `value - largeStep` (clamped/snapped) |
|
|
90
|
+
| `setFirst()` | `!isDisabled && !isReadOnly && hasMin` | jump to min | `min` |
|
|
91
|
+
| `setLast()` | `!isDisabled && !isReadOnly && hasMax` | jump to max | `max` |
|
|
92
|
+
| `handleKeyDown(ArrowUp)` | handled key | `increment()` | stepped up value |
|
|
93
|
+
| `handleKeyDown(ArrowDown)` | handled key | `decrement()` | stepped down value |
|
|
94
|
+
| `handleKeyDown(PageUp)` | handled key | `incrementLarge()` | large-stepped value |
|
|
95
|
+
| `handleKeyDown(PageDown)` | handled key | `decrementLarge()` | large-stepped value |
|
|
96
|
+
| `handleKeyDown(Home)` | handled key | `setFirst()` | min or unchanged |
|
|
97
|
+
| `handleKeyDown(End)` | handled key | `setLast()` | max or unchanged |
|
|
98
|
+
|
|
99
|
+
## Invariants
|
|
100
|
+
|
|
101
|
+
- If `min` and `max` are defined, `min <= value <= max`.
|
|
102
|
+
- If `min` is undefined and `max` is undefined, value is unbounded.
|
|
103
|
+
- `step > 0`.
|
|
104
|
+
- `largeStep > 0`.
|
|
105
|
+
- Disabled or Read-only states prevent value changes via actions.
|
|
106
|
+
|
|
107
|
+
## Adapter Expectations
|
|
108
|
+
|
|
109
|
+
- UIKit reads these signals directly: `state.value`, `state.min`, `state.max`, `state.step`, `state.largeStep`, `state.isDisabled`, `state.isReadOnly`, `state.hasMin`, `state.hasMax`.
|
|
110
|
+
- UIKit calls only actions for mutations: `setValue`, `increment`, `decrement`, `incrementLarge`, `decrementLarge`, `setFirst`, `setLast`, `setDisabled`, `setReadOnly`, `handleKeyDown`.
|
|
111
|
+
- UIKit spreads contract outputs without recomputation:
|
|
112
|
+
- `contracts.getSpinbuttonProps()` on the focusable spinbutton root.
|
|
113
|
+
- `contracts.getIncrementButtonProps()` on increment control.
|
|
114
|
+
- `contracts.getDecrementButtonProps()` on decrement control.
|
|
115
|
+
- UIKit may keep transient text input state, but committed numeric state MUST come from headless `state.value()`.
|
|
116
|
+
- UIKit must not reconstruct ARIA values (`aria-valuenow`, bounds, disabled/readonly) from parallel state.
|
|
117
|
+
|
|
118
|
+
## Minimum Test Matrix
|
|
119
|
+
|
|
120
|
+
- increment/decrement behavior
|
|
121
|
+
- large step (PageUp/PageDown) behavior
|
|
122
|
+
- Home/End behavior with defined/undefined boundaries
|
|
123
|
+
- value clamping and snapping
|
|
124
|
+
- unbounded behavior when no bounds are defined
|
|
125
|
+
- boundary-specific disabled semantics for increment/decrement button contracts
|
|
126
|
+
- disabled and read-only state enforcement
|
|
127
|
+
- keyboard interaction parity with APG
|
|
128
|
+
|
|
129
|
+
## ADR-001 Compliance
|
|
130
|
+
|
|
131
|
+
- **Runtime Policy**: Reatom v1000 only; no @statx/\* in headless core.
|
|
132
|
+
- **Layering**: core -> interactions -> a11y-contracts -> adapters; adapters remain thin mappings.
|
|
133
|
+
- **Independence**: No imports from @project/_, apps/_, or other out-of-package modules.
|
|
134
|
+
- **Verification**: Mandatory adapter integration tests and standalone package test execution.
|
|
135
|
+
|
|
136
|
+
## Out of Scope (Current)
|
|
137
|
+
|
|
138
|
+
- non-numeric spinbuttons (e.g., days of the week)
|
|
139
|
+
- custom string parsing (handled by adapters)
|
|
140
|
+
- acceleration (increasing step size when holding buttons)
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# Spinner Component Contract
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
`Spinner` is a headless contract for an indeterminate-only loading indicator. It provides ARIA semantics for a progressbar with no determinate value, ensuring assistive technology correctly announces an ongoing loading state.
|
|
6
|
+
|
|
7
|
+
## Component Files
|
|
8
|
+
|
|
9
|
+
- `src/spinner/index.ts` - model and public `createSpinner` API
|
|
10
|
+
- `src/spinner/spinner.test.ts` - unit behavior tests
|
|
11
|
+
|
|
12
|
+
## Options (`CreateSpinnerOptions`)
|
|
13
|
+
|
|
14
|
+
| Option | Type | Default | Description |
|
|
15
|
+
| ------- | -------- | ----------- | ------------------------------------------------------------------ |
|
|
16
|
+
| `label` | `string` | `'Loading'` | Accessible name for the spinner, announced by assistive technology |
|
|
17
|
+
|
|
18
|
+
## Public API
|
|
19
|
+
|
|
20
|
+
### `createSpinner(options?: CreateSpinnerOptions): SpinnerModel`
|
|
21
|
+
|
|
22
|
+
### State (signal-backed)
|
|
23
|
+
|
|
24
|
+
| Signal | Type | Description |
|
|
25
|
+
| --------- | -------------- | --------------------------------------- |
|
|
26
|
+
| `label()` | `Atom<string>` | Current accessible name for the spinner |
|
|
27
|
+
|
|
28
|
+
### Actions
|
|
29
|
+
|
|
30
|
+
| Action | Signature | Description |
|
|
31
|
+
| ---------- | ------------------------- | ---------------------------- |
|
|
32
|
+
| `setLabel` | `(value: string) => void` | Updates the accessible label |
|
|
33
|
+
|
|
34
|
+
### Contracts
|
|
35
|
+
|
|
36
|
+
| Contract | Return type | Description |
|
|
37
|
+
| ------------------- | -------------- | ---------------------------------------------------------- |
|
|
38
|
+
| `getSpinnerProps()` | `SpinnerProps` | Ready-to-spread ARIA attribute map for the spinner element |
|
|
39
|
+
|
|
40
|
+
#### `SpinnerProps` Shape
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
{
|
|
44
|
+
role: 'progressbar'
|
|
45
|
+
'aria-label': string // from state.label()
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Note: `aria-valuenow`, `aria-valuemin`, `aria-valuemax`, and `aria-valuetext` are intentionally omitted because the spinner is always indeterminate.
|
|
50
|
+
|
|
51
|
+
## APG and A11y Contract
|
|
52
|
+
|
|
53
|
+
- `role="progressbar"` - indicates a loading process
|
|
54
|
+
- Indeterminate mode only: `aria-valuenow` is never present, signaling to assistive technology that progress is unknown
|
|
55
|
+
- `aria-label` provides the accessible name (defaults to `"Loading"`)
|
|
56
|
+
- Non-interactive: no keyboard interaction, no `tabindex`, no focus management
|
|
57
|
+
- Sizing is controlled entirely via CSS `font-size` on the host element (UIKit concern)
|
|
58
|
+
|
|
59
|
+
## Keyboard Contract
|
|
60
|
+
|
|
61
|
+
Spinner is not keyboard-interactive. No keyboard handling is needed.
|
|
62
|
+
|
|
63
|
+
## Behavior Contract
|
|
64
|
+
|
|
65
|
+
- The spinner is always indeterminate; there is no determinate mode.
|
|
66
|
+
- `label` defaults to `"Loading"` and can be updated programmatically via `setLabel`.
|
|
67
|
+
- The headless layer does not manage visual rendering (SVG circles, animation). That is a UIKit concern.
|
|
68
|
+
|
|
69
|
+
## Transitions Table
|
|
70
|
+
|
|
71
|
+
| Trigger | Precondition | State Change | Contract Effect |
|
|
72
|
+
| --------------------- | ------------ | ------------ | ----------------------------------------------------- |
|
|
73
|
+
| `actions.setLabel(v)` | any | `label` = v | `getSpinnerProps()` updates `aria-label` to new value |
|
|
74
|
+
|
|
75
|
+
## Invariants
|
|
76
|
+
|
|
77
|
+
- `role` must always be `'progressbar'`.
|
|
78
|
+
- `aria-valuenow`, `aria-valuemin`, `aria-valuemax`, and `aria-valuetext` must never be present in `getSpinnerProps()`.
|
|
79
|
+
- `aria-label` must always be a non-empty string; defaults to `"Loading"` if no label is provided.
|
|
80
|
+
- Spinner must never produce `tabindex`, keyboard event handlers, or focus-related attributes.
|
|
81
|
+
|
|
82
|
+
## Adapter Expectations
|
|
83
|
+
|
|
84
|
+
This section defines what UIKit (`cv-spinner`) binds to from the headless model.
|
|
85
|
+
|
|
86
|
+
### Signals read by adapter
|
|
87
|
+
|
|
88
|
+
| Signal | UIKit usage |
|
|
89
|
+
| --------------- | ------------------------------------------------------------ |
|
|
90
|
+
| `state.label()` | Not read directly; consumed via `getSpinnerProps()` contract |
|
|
91
|
+
|
|
92
|
+
### Actions called by adapter
|
|
93
|
+
|
|
94
|
+
| Action | UIKit trigger |
|
|
95
|
+
| --------------------- | ----------------------------------------------------------- |
|
|
96
|
+
| `actions.setLabel(v)` | When `label` attribute/property changes on the host element |
|
|
97
|
+
|
|
98
|
+
### Contracts spread by adapter
|
|
99
|
+
|
|
100
|
+
| Contract | Target element | Notes |
|
|
101
|
+
| ------------------- | ------------------------------------ | ------------------------------------------------------ |
|
|
102
|
+
| `getSpinnerProps()` | Root spinner element (`part="base"`) | Spread as attributes; provides `role` and `aria-label` |
|
|
103
|
+
|
|
104
|
+
### Options passed through from UIKit attributes
|
|
105
|
+
|
|
106
|
+
| UIKit attribute | Headless option | Notes |
|
|
107
|
+
| --------------- | --------------- | ------------------------------- |
|
|
108
|
+
| `label` | `label` | String, defaults to `"Loading"` |
|
|
109
|
+
|
|
110
|
+
## Minimum Test Matrix
|
|
111
|
+
|
|
112
|
+
- default state: `label` is `"Loading"`
|
|
113
|
+
- `getSpinnerProps()` returns `{ role: 'progressbar', 'aria-label': 'Loading' }` with defaults
|
|
114
|
+
- `getSpinnerProps()` never includes `aria-valuenow`, `aria-valuemin`, `aria-valuemax`, or `aria-valuetext`
|
|
115
|
+
- `setLabel` updates `aria-label` in contract output
|
|
116
|
+
- custom `label` option is reflected in initial `getSpinnerProps()`
|
|
117
|
+
- spinner never produces `tabindex` or keyboard handler attributes
|
|
118
|
+
|
|
119
|
+
## ADR-001 Compliance
|
|
120
|
+
|
|
121
|
+
- **Runtime Policy**: Reatom v1000 only; no `@statx/*` in headless core.
|
|
122
|
+
- **Layering**: `core -> interactions -> a11y-contracts -> adapters`; adapters remain thin mappings.
|
|
123
|
+
- **Independence**: No imports from `@project/*`, `apps/*`, or other out-of-package modules.
|
|
124
|
+
- **Verification**: Mandatory adapter integration tests and standalone package test execution.
|
|
125
|
+
|
|
126
|
+
## Out of Scope (Current)
|
|
127
|
+
|
|
128
|
+
- Determinate/progress mode (use `Progress` component instead)
|
|
129
|
+
- Size attribute (sizing is handled via CSS `font-size`)
|
|
130
|
+
- SVG rendering and animation (UIKit concern)
|
|
131
|
+
- Color/variant theming (UIKit concern)
|
|
132
|
+
- Overlay/backdrop integration
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# Switch Component Contract
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
`Switch` provides a headless APG-aligned model for a toggle control that represents a strictly binary on/off state.
|
|
6
|
+
|
|
7
|
+
While functionally similar to a checkbox, it follows distinct APG keyboard and semantic conventions: the `switch` role uses `aria-checked` (not `aria-pressed`), and both `Space` and `Enter` toggle the state (checkbox only requires `Space`).
|
|
8
|
+
|
|
9
|
+
## Component Files
|
|
10
|
+
|
|
11
|
+
- `src/switch/index.ts` - model and public `createSwitch` API
|
|
12
|
+
- `src/switch/switch.test.ts` - unit behavior tests
|
|
13
|
+
|
|
14
|
+
## Public API
|
|
15
|
+
|
|
16
|
+
- `createSwitch(options)`
|
|
17
|
+
- `state` (signal-backed):
|
|
18
|
+
- `isOn()`: `boolean`
|
|
19
|
+
- `isDisabled()`: `boolean`
|
|
20
|
+
- `actions`:
|
|
21
|
+
- `toggle()`: toggles the on/off state (no-op if disabled)
|
|
22
|
+
- `setOn(value: boolean)`: explicitly sets the on state; fires `onCheckedChange` callback
|
|
23
|
+
- `setDisabled(value: boolean)`: explicitly sets the disabled state
|
|
24
|
+
- `handleClick()`: delegates to `toggle()`
|
|
25
|
+
- `handleKeyDown(event)`: handles keyboard interaction (`Space`, `Enter`)
|
|
26
|
+
- `contracts`:
|
|
27
|
+
- `getSwitchProps()`: returns complete ARIA and event handler attribute map for the switch element
|
|
28
|
+
|
|
29
|
+
## CreateSwitchOptions
|
|
30
|
+
|
|
31
|
+
| Option | Type | Default | Description |
|
|
32
|
+
| ----------------- | -------------------------- | ---------- | ------------------------------------------------------------- |
|
|
33
|
+
| `idBase` | `string` | `'switch'` | Base id prefix for generated ids |
|
|
34
|
+
| `isOn` | `boolean` | `false` | Initial on/off state |
|
|
35
|
+
| `isDisabled` | `boolean` | `false` | Initial disabled state |
|
|
36
|
+
| `ariaLabelledBy` | `string` | — | Id reference for external label (`aria-labelledby`) |
|
|
37
|
+
| `ariaDescribedBy` | `string` | — | Id reference for help text / description (`aria-describedby`) |
|
|
38
|
+
| `onCheckedChange` | `(value: boolean) => void` | — | Callback fired when `isOn` changes via `setOn` |
|
|
39
|
+
|
|
40
|
+
## State Signal Surface
|
|
41
|
+
|
|
42
|
+
| Signal | Type | Derived? | Description |
|
|
43
|
+
| ------------ | --------------- | -------- | --------------------------------------- |
|
|
44
|
+
| `isOn` | `Atom<boolean>` | No | Single source of truth for on/off state |
|
|
45
|
+
| `isDisabled` | `Atom<boolean>` | No | Whether user interaction is blocked |
|
|
46
|
+
|
|
47
|
+
## APG and A11y Contract
|
|
48
|
+
|
|
49
|
+
- role: `switch` (strictly binary; no indeterminate/mixed state)
|
|
50
|
+
- `aria-checked`: `"true" | "false"` (reflects `isOn` state)
|
|
51
|
+
- `aria-disabled`: `"true" | "false"` (reflects `isDisabled` state)
|
|
52
|
+
- `tabindex`: `"0"` when enabled, `"-1"` when disabled
|
|
53
|
+
- linkage:
|
|
54
|
+
- `aria-labelledby`: optional, references an external label element
|
|
55
|
+
- `aria-describedby`: optional, references help text or description element
|
|
56
|
+
|
|
57
|
+
## Keyboard Contract
|
|
58
|
+
|
|
59
|
+
| Key | Action |
|
|
60
|
+
| ------- | ----------------------------------------------- |
|
|
61
|
+
| `Space` | Toggle the `isOn` state; calls `preventDefault` |
|
|
62
|
+
| `Enter` | Toggle the `isOn` state; calls `preventDefault` |
|
|
63
|
+
|
|
64
|
+
All keyboard actions are no-ops when `isDisabled` is `true`.
|
|
65
|
+
|
|
66
|
+
## Behavior Contract
|
|
67
|
+
|
|
68
|
+
- `Space` key toggles the on/off state.
|
|
69
|
+
- `Enter` key toggles the on/off state (specific to `switch` pattern in APG; checkbox only requires `Space`).
|
|
70
|
+
- `Click` interaction toggles the on/off state.
|
|
71
|
+
- Disabled switches do not respond to any toggle actions (`toggle`, `handleClick`, `handleKeyDown`).
|
|
72
|
+
- Unrelated keys (anything other than `Space` or `Enter`) are ignored; `preventDefault` is NOT called.
|
|
73
|
+
|
|
74
|
+
## Contract Prop Shapes
|
|
75
|
+
|
|
76
|
+
### `getSwitchProps()`
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
{
|
|
80
|
+
id: string // '{idBase}-root'
|
|
81
|
+
role: 'switch'
|
|
82
|
+
tabindex: '0' | '-1' // '-1' when disabled
|
|
83
|
+
'aria-checked': 'true' | 'false' // reflects isOn
|
|
84
|
+
'aria-disabled': 'true' | 'false' // reflects isDisabled
|
|
85
|
+
'aria-labelledby'?: string // present when ariaLabelledBy option is set
|
|
86
|
+
'aria-describedby'?: string // present when ariaDescribedBy option is set
|
|
87
|
+
onClick: () => void // calls handleClick
|
|
88
|
+
onKeyDown: (event) => void // calls handleKeyDown
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Transitions Table
|
|
93
|
+
|
|
94
|
+
| Event / Action | Current State | Next State / Effect |
|
|
95
|
+
| ---------------------- | ----------------- | -------------------------------------------- |
|
|
96
|
+
| `toggle()` | `isOn=false` | `isOn=true`; fires `onCheckedChange(true)` |
|
|
97
|
+
| `toggle()` | `isOn=true` | `isOn=false`; fires `onCheckedChange(false)` |
|
|
98
|
+
| `toggle()` | `isDisabled=true` | no-op |
|
|
99
|
+
| `setOn(value)` | any | `isOn=value`; fires `onCheckedChange(value)` |
|
|
100
|
+
| `setDisabled(value)` | any | `isDisabled=value` |
|
|
101
|
+
| `handleClick()` | any | delegates to `toggle()` |
|
|
102
|
+
| `handleKeyDown(Space)` | not disabled | delegates to `toggle()`; `preventDefault` |
|
|
103
|
+
| `handleKeyDown(Enter)` | not disabled | delegates to `toggle()`; `preventDefault` |
|
|
104
|
+
| `handleKeyDown(other)` | any | no-op; no `preventDefault` |
|
|
105
|
+
| `handleKeyDown(any)` | `isDisabled=true` | no-op; no `preventDefault` |
|
|
106
|
+
|
|
107
|
+
## Invariants
|
|
108
|
+
|
|
109
|
+
1. `isOn` is always a `boolean` (strictly binary; no third state).
|
|
110
|
+
2. A disabled switch cannot be toggled via `toggle()`, `handleClick()`, or `handleKeyDown()`.
|
|
111
|
+
3. `aria-checked` is `"true"` when `isOn` is `true`, and `"false"` otherwise.
|
|
112
|
+
4. `aria-disabled` is `"true"` when `isDisabled` is `true`, and `"false"` otherwise.
|
|
113
|
+
5. `tabindex` is `"0"` when enabled, `"-1"` when disabled.
|
|
114
|
+
6. `aria-labelledby` is present in props only when `ariaLabelledBy` option is provided.
|
|
115
|
+
7. `aria-describedby` is present in props only when `ariaDescribedBy` option is provided.
|
|
116
|
+
8. `getSwitchProps()` always returns a complete, ready-to-spread attribute object.
|
|
117
|
+
|
|
118
|
+
## Adapter Expectations
|
|
119
|
+
|
|
120
|
+
UIKit adapter (`cv-switch`) will:
|
|
121
|
+
|
|
122
|
+
**Signals read (reactive, drive re-renders):**
|
|
123
|
+
|
|
124
|
+
- `state.isOn()` — whether the switch is on or off
|
|
125
|
+
- `state.isDisabled()` — whether user interaction is blocked
|
|
126
|
+
|
|
127
|
+
**Actions called (event handlers, never mutate state directly):**
|
|
128
|
+
|
|
129
|
+
- `actions.toggle()` — toggle on/off
|
|
130
|
+
- `actions.setOn(value)` — programmatic state set
|
|
131
|
+
- `actions.setDisabled(value)` — update disabled state
|
|
132
|
+
- `actions.handleClick()` — on switch click
|
|
133
|
+
- `actions.handleKeyDown(event)` — on switch keydown
|
|
134
|
+
|
|
135
|
+
**Contracts spread (attribute maps applied directly to DOM elements):**
|
|
136
|
+
|
|
137
|
+
- `contracts.getSwitchProps()` — spread onto the switch host element
|
|
138
|
+
|
|
139
|
+
**UIKit-only concerns (NOT in headless):**
|
|
140
|
+
|
|
141
|
+
- Toggled/untoggled content slots (on/off icons/text inside the track) — purely visual, no state or ARIA changes
|
|
142
|
+
- Help text slot rendering — headless provides `aria-describedby` linkage via `ariaDescribedBy` option; UIKit renders the visible help text element and generates the matching id
|
|
143
|
+
- CSS custom properties, animations, and size variants
|
|
144
|
+
- `help-text` attribute and slot
|
|
145
|
+
- Lifecycle events (`cv-change`, etc.)
|
|
146
|
+
|
|
147
|
+
## Minimum Test Matrix
|
|
148
|
+
|
|
149
|
+
- toggle behavior (off -> on -> off)
|
|
150
|
+
- keyboard `Space` interaction
|
|
151
|
+
- keyboard `Enter` interaction
|
|
152
|
+
- click interaction
|
|
153
|
+
- disabled state prevents state changes via all interaction paths (`toggle`, `handleClick`, `handleKeyDown`)
|
|
154
|
+
- correct `aria-checked` mapping (`"true"` / `"false"`)
|
|
155
|
+
- correct `aria-disabled` mapping
|
|
156
|
+
- correct `tabindex` mapping (enabled vs disabled)
|
|
157
|
+
- `aria-labelledby` and `aria-describedby` presence in contract props
|
|
158
|
+
- `onCheckedChange` callback fires on state transitions
|
|
159
|
+
- `preventDefault` called on `Space` and `Enter`
|
|
160
|
+
- `preventDefault` NOT called on unrelated keys
|
|
161
|
+
- unrelated keys do not toggle state
|
|
162
|
+
|
|
163
|
+
## ADR-001 Compliance
|
|
164
|
+
|
|
165
|
+
- **Runtime Policy**: Reatom v1000 only; no @statx/\* in headless core.
|
|
166
|
+
- **Layering**: core -> interactions -> a11y-contracts -> adapters; adapters remain thin mappings.
|
|
167
|
+
- **Independence**: No imports from @project/_, apps/_, or other out-of-package modules.
|
|
168
|
+
- **Verification**: Mandatory adapter integration tests and standalone package test execution.
|
|
169
|
+
|
|
170
|
+
## Out of Scope (Current)
|
|
171
|
+
|
|
172
|
+
- indeterminate state (not applicable to `switch` role)
|
|
173
|
+
- native form submission integration (handled by adapters/wrappers)
|
|
174
|
+
- toggled/untoggled content slots (UIKit visual concern)
|
|
175
|
+
- help text rendering (UIKit visual concern; headless provides `aria-describedby` linkage only)
|