@chromvoid/headless-ui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (191) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +99 -0
  3. package/dist/a11y-contracts/index.d.ts +23 -0
  4. package/dist/a11y-contracts/index.js +1 -0
  5. package/dist/accordion/index.d.ts +78 -0
  6. package/dist/accordion/index.js +264 -0
  7. package/dist/adapters/index.d.ts +9 -0
  8. package/dist/adapters/index.js +1 -0
  9. package/dist/alert/index.d.ts +33 -0
  10. package/dist/alert/index.js +54 -0
  11. package/dist/alert-dialog/index.d.ts +69 -0
  12. package/dist/alert-dialog/index.js +94 -0
  13. package/dist/badge/index.d.ts +48 -0
  14. package/dist/badge/index.js +89 -0
  15. package/dist/breadcrumb/index.d.ts +55 -0
  16. package/dist/breadcrumb/index.js +77 -0
  17. package/dist/button/index.d.ts +46 -0
  18. package/dist/button/index.js +86 -0
  19. package/dist/callout/index.d.ts +41 -0
  20. package/dist/callout/index.js +63 -0
  21. package/dist/card/index.d.ts +54 -0
  22. package/dist/card/index.js +103 -0
  23. package/dist/carousel/index.d.ts +98 -0
  24. package/dist/carousel/index.js +243 -0
  25. package/dist/checkbox/index.d.ts +50 -0
  26. package/dist/checkbox/index.js +87 -0
  27. package/dist/combobox/index.d.ts +114 -0
  28. package/dist/combobox/index.js +431 -0
  29. package/dist/command-palette/index.d.ts +73 -0
  30. package/dist/command-palette/index.js +147 -0
  31. package/dist/context-menu/index.d.ts +111 -0
  32. package/dist/context-menu/index.js +372 -0
  33. package/dist/copy-button/index.d.ts +62 -0
  34. package/dist/copy-button/index.js +183 -0
  35. package/dist/core/index.d.ts +20 -0
  36. package/dist/core/index.js +2 -0
  37. package/dist/core/selection.d.ts +5 -0
  38. package/dist/core/selection.js +39 -0
  39. package/dist/core/value-range.d.ts +49 -0
  40. package/dist/core/value-range.js +134 -0
  41. package/dist/date-picker/index.d.ts +210 -0
  42. package/dist/date-picker/index.js +895 -0
  43. package/dist/dialog/index.d.ts +95 -0
  44. package/dist/dialog/index.js +153 -0
  45. package/dist/disclosure/index.d.ts +52 -0
  46. package/dist/disclosure/index.js +159 -0
  47. package/dist/drawer/index.d.ts +30 -0
  48. package/dist/drawer/index.js +39 -0
  49. package/dist/feed/index.d.ts +77 -0
  50. package/dist/feed/index.js +260 -0
  51. package/dist/grid/index.d.ts +103 -0
  52. package/dist/grid/index.js +415 -0
  53. package/dist/index.d.ts +51 -0
  54. package/dist/index.js +51 -0
  55. package/dist/input/index.d.ts +86 -0
  56. package/dist/input/index.js +156 -0
  57. package/dist/interactions/composite-navigation.d.ts +69 -0
  58. package/dist/interactions/composite-navigation.js +169 -0
  59. package/dist/interactions/index.d.ts +15 -0
  60. package/dist/interactions/index.js +4 -0
  61. package/dist/interactions/keyboard-intents.d.ts +16 -0
  62. package/dist/interactions/keyboard-intents.js +33 -0
  63. package/dist/interactions/overlay-focus.d.ts +40 -0
  64. package/dist/interactions/overlay-focus.js +93 -0
  65. package/dist/interactions/typeahead.d.ts +20 -0
  66. package/dist/interactions/typeahead.js +41 -0
  67. package/dist/landmarks/index.d.ts +39 -0
  68. package/dist/landmarks/index.js +58 -0
  69. package/dist/link/index.d.ts +34 -0
  70. package/dist/link/index.js +39 -0
  71. package/dist/listbox/index.d.ts +92 -0
  72. package/dist/listbox/index.js +337 -0
  73. package/dist/menu/index.d.ts +132 -0
  74. package/dist/menu/index.js +541 -0
  75. package/dist/menu-button/index.d.ts +71 -0
  76. package/dist/menu-button/index.js +121 -0
  77. package/dist/meter/index.d.ts +45 -0
  78. package/dist/meter/index.js +106 -0
  79. package/dist/number/index.d.ts +113 -0
  80. package/dist/number/index.js +252 -0
  81. package/dist/popover/index.d.ts +70 -0
  82. package/dist/popover/index.js +126 -0
  83. package/dist/progress/index.d.ts +49 -0
  84. package/dist/progress/index.js +79 -0
  85. package/dist/radio-group/index.d.ts +61 -0
  86. package/dist/radio-group/index.js +150 -0
  87. package/dist/select/index.d.ts +92 -0
  88. package/dist/select/index.js +239 -0
  89. package/dist/sidebar/index.d.ts +74 -0
  90. package/dist/sidebar/index.js +186 -0
  91. package/dist/slider/index.d.ts +61 -0
  92. package/dist/slider/index.js +150 -0
  93. package/dist/slider-multi-thumb/index.d.ts +70 -0
  94. package/dist/slider-multi-thumb/index.js +222 -0
  95. package/dist/spinbutton/index.d.ts +75 -0
  96. package/dist/spinbutton/index.js +214 -0
  97. package/dist/spinner/index.d.ts +1 -0
  98. package/dist/spinner/index.js +1 -0
  99. package/dist/spinner/spinner.d.ts +23 -0
  100. package/dist/spinner/spinner.js +25 -0
  101. package/dist/switch/index.d.ts +40 -0
  102. package/dist/switch/index.js +61 -0
  103. package/dist/table/index.d.ts +117 -0
  104. package/dist/table/index.js +377 -0
  105. package/dist/tabs/index.d.ts +63 -0
  106. package/dist/tabs/index.js +174 -0
  107. package/dist/textarea/index.d.ts +68 -0
  108. package/dist/textarea/index.js +137 -0
  109. package/dist/toast/index.d.ts +67 -0
  110. package/dist/toast/index.js +145 -0
  111. package/dist/toolbar/index.d.ts +59 -0
  112. package/dist/toolbar/index.js +139 -0
  113. package/dist/tooltip/index.d.ts +52 -0
  114. package/dist/tooltip/index.js +169 -0
  115. package/dist/treegrid/index.d.ts +101 -0
  116. package/dist/treegrid/index.js +463 -0
  117. package/dist/treeview/index.d.ts +68 -0
  118. package/dist/treeview/index.js +370 -0
  119. package/dist/window-splitter/index.d.ts +65 -0
  120. package/dist/window-splitter/index.js +204 -0
  121. package/package.json +92 -0
  122. package/specs/ADR-001-headless-architecture.md +461 -0
  123. package/specs/ADR-002-repo-release-model.md +108 -0
  124. package/specs/ADR-003-public-api-versioning.md +136 -0
  125. package/specs/ADR-004-focus-selection-policy.md +117 -0
  126. package/specs/IMPLEMENTATION-ROADMAP.md +237 -0
  127. package/specs/ISSUE-BACKLOG.md +681 -0
  128. package/specs/RELEASE-CANDIDATE.md +30 -0
  129. package/specs/components/accordion.md +130 -0
  130. package/specs/components/alert-dialog.md +72 -0
  131. package/specs/components/alert.md +65 -0
  132. package/specs/components/badge.md +220 -0
  133. package/specs/components/breadcrumb.md +74 -0
  134. package/specs/components/button.md +115 -0
  135. package/specs/components/callout.md +195 -0
  136. package/specs/components/card.md +280 -0
  137. package/specs/components/carousel.md +140 -0
  138. package/specs/components/checkbox.md +172 -0
  139. package/specs/components/combobox.md +423 -0
  140. package/specs/components/command-palette.md +92 -0
  141. package/specs/components/context-menu.md +556 -0
  142. package/specs/components/copy-button.md +293 -0
  143. package/specs/components/date-picker.md +400 -0
  144. package/specs/components/dialog.md +298 -0
  145. package/specs/components/disclosure.md +257 -0
  146. package/specs/components/drawer.md +353 -0
  147. package/specs/components/feed.md +265 -0
  148. package/specs/components/grid.md +186 -0
  149. package/specs/components/input.md +254 -0
  150. package/specs/components/landmarks.md +136 -0
  151. package/specs/components/link.md +134 -0
  152. package/specs/components/listbox.md +351 -0
  153. package/specs/components/menu-button.md +76 -0
  154. package/specs/components/menu.md +623 -0
  155. package/specs/components/meter.md +149 -0
  156. package/specs/components/number.md +393 -0
  157. package/specs/components/popover.md +252 -0
  158. package/specs/components/progress.md +188 -0
  159. package/specs/components/radio-group.md +151 -0
  160. package/specs/components/select.md +144 -0
  161. package/specs/components/sidebar.md +321 -0
  162. package/specs/components/slider-multi-thumb.md +78 -0
  163. package/specs/components/slider.md +84 -0
  164. package/specs/components/spinbutton.md +140 -0
  165. package/specs/components/spinner.md +132 -0
  166. package/specs/components/switch.md +175 -0
  167. package/specs/components/table.md +403 -0
  168. package/specs/components/tabs.md +265 -0
  169. package/specs/components/textarea.md +185 -0
  170. package/specs/components/toast.md +198 -0
  171. package/specs/components/toolbar.md +278 -0
  172. package/specs/components/tooltip.md +252 -0
  173. package/specs/components/treegrid.md +281 -0
  174. package/specs/components/treeview.md +91 -0
  175. package/specs/components/window-splitter.md +297 -0
  176. package/specs/ops/git-shard-sync.md +107 -0
  177. package/specs/ops/release-checklist.md +76 -0
  178. package/specs/release/GAP-TO-GREEN-ISSUES.md +88 -0
  179. package/specs/release/api-freeze-candidate.md +54 -0
  180. package/specs/release/changelog-automation.md +76 -0
  181. package/specs/release/changelog.generated.md +53 -0
  182. package/specs/release/changelog.patch.generated.md +46 -0
  183. package/specs/release/consumer-integration.md +53 -0
  184. package/specs/release/migration-notes-pre-v1.md +40 -0
  185. package/specs/release/mvp-changelog.md +57 -0
  186. package/specs/release/release-notes-template.md +61 -0
  187. package/specs/release/release-rehearsal.md +113 -0
  188. package/specs/release/semver-deprecation-dry-run.md +89 -0
  189. package/specs/release/shard-release-drill-report.md +50 -0
  190. package/specs/release/shard-release-follow-ups.md +31 -0
  191. package/specs/signals.md +208 -0
@@ -0,0 +1,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)