@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,149 @@
1
+ # Meter Component Contract
2
+
3
+ ## Purpose
4
+
5
+ `Meter` provides a headless APG-aligned model for a graphical display of a numeric value within a known range (e.g., disk usage, password strength).
6
+
7
+ Unlike a progress bar, a meter represents a static measurement rather than the progress of a task.
8
+
9
+ ## Component Files
10
+
11
+ - `src/meter/index.ts` - model and public `createMeter` API
12
+ - `src/meter/meter.test.ts` - unit behavior tests
13
+
14
+ ## Options
15
+
16
+ `createMeter(options?: CreateMeterOptions)` accepts:
17
+
18
+ | Option | Type | Default | Description |
19
+ | ----------------- | --------------------------- | --------- | -------------------------------------------- |
20
+ | `idBase` | `string` | `'meter'` | Prefix for generated element IDs |
21
+ | `value` | `number` | `min` | Initial measured value |
22
+ | `min` | `number` | `0` | Minimum of the range |
23
+ | `max` | `number` | `100` | Maximum of the range (percentage convention) |
24
+ | `low` | `number` | — | Low threshold boundary |
25
+ | `high` | `number` | — | High threshold boundary |
26
+ | `optimum` | `number` | — | Optimum value within the range |
27
+ | `ariaLabel` | `string` | — | Accessible label |
28
+ | `ariaLabelledBy` | `string` | — | ID of labelling element |
29
+ | `ariaDescribedBy` | `string` | — | ID of describing element |
30
+ | `formatValueText` | `(value: number) => string` | — | Custom formatter for `aria-valuetext` |
31
+
32
+ ## Public API
33
+
34
+ - `createMeter(options)` returns `MeterModel`
35
+ - `state` (signal-backed):
36
+ - `value`: `Atom<number>` — current measured value
37
+ - `min`: `Atom<number>` — minimum of the range
38
+ - `max`: `Atom<number>` — maximum of the range
39
+ - `percentage`: `Computed<number>` — `(value - min) / (max - min) * 100`, rounded to 4 decimal places; 0 when span is zero
40
+ - `status`: `Computed<MeterStatus>` — derived zone: `'low' | 'high' | 'optimum' | 'normal'`
41
+ - `actions`:
42
+ - `setValue(value: number)`: updates the measured value (clamped to `[min, max]`)
43
+ - `contracts`:
44
+ - `getMeterProps()`: returns a spread-ready ARIA attribute map (`MeterProps`)
45
+
46
+ ### `MeterProps` Shape
47
+
48
+ ```ts
49
+ {
50
+ id: string // `${idBase}-root`
51
+ role: 'meter'
52
+ 'aria-valuenow': string // String(value)
53
+ 'aria-valuemin': string // String(min)
54
+ 'aria-valuemax': string // String(max)
55
+ 'aria-valuetext'?: string // formatValueText?.(value) if provided
56
+ 'aria-label'?: string // from options
57
+ 'aria-labelledby'?: string // from options
58
+ 'aria-describedby'?: string // from options
59
+ }
60
+ ```
61
+
62
+ ## APG and A11y Contract
63
+
64
+ - role: `meter`
65
+ - `aria-valuenow`: current value (string)
66
+ - `aria-valuemin`: minimum value (string)
67
+ - `aria-valuemax`: maximum value (string)
68
+ - `aria-valuetext`: optional, produced by `formatValueText` callback
69
+ - linkage: supports `aria-label`, `aria-labelledby`, and `aria-describedby`
70
+
71
+ ## Behavior Contract
72
+
73
+ - The component is read-only from a user interaction perspective (no keyboard/pointer input).
74
+ - Values are clamped between `min` and `max`.
75
+ - If `min >= max` is passed, values are swapped to enforce `min < max`.
76
+ - Thresholds (`low`, `high`, `optimum`) are normalized: clamped to `[min, max]`, and if `low > high` they are swapped.
77
+ - Status is calculated based on `low`, `high`, and `optimum` thresholds if provided in options.
78
+
79
+ ### Status Derivation
80
+
81
+ Status is computed by `getMeterStatus(value, low, high, optimum)`:
82
+
83
+ 1. If neither `low` nor `high` is set: return `'normal'`.
84
+ 2. Determine region: `isInLowRegion = low != null && value < low`; `isInHighRegion = high != null && value > high`.
85
+ 3. If `optimum` is set, check which region the optimum falls in:
86
+ - If optimum is in the low region and value is also in the low region: `'optimum'`.
87
+ - If optimum is in the high region and value is also in the high region: `'optimum'`.
88
+ - If optimum is in the normal region and value is also in the normal region: `'optimum'`.
89
+ 4. If value is in the low region: `'low'`.
90
+ 5. If value is in the high region: `'high'`.
91
+ 6. Otherwise: `'normal'`.
92
+
93
+ ## Transitions Table
94
+
95
+ | Trigger | Action | State Change |
96
+ | ------------------------- | --------------------- | ----------------------------------------------------------------- |
97
+ | Programmatic value update | `actions.setValue(n)` | `value` = clamp(n, min, max); `percentage` and `status` recompute |
98
+
99
+ Note: Meter has no user-interactive transitions (no keyboard/pointer). All state changes are programmatic via `setValue`.
100
+
101
+ ## Invariants
102
+
103
+ - `min <= value <= max`.
104
+ - `min < max` (enforced by swapping if needed).
105
+ - If thresholds are provided: `min <= low <= high <= max` (enforced by normalization).
106
+ - `percentage` is always in `[0, 100]`.
107
+ - `status` is always one of `'low' | 'high' | 'optimum' | 'normal'`.
108
+
109
+ ## Adapter Expectations
110
+
111
+ UIKit binds to the headless model as follows:
112
+
113
+ | Binding | Kind | Usage |
114
+ | --------------------------- | --------------- | ------------------------------------------------------ |
115
+ | `state.value` | signal read | Display current value |
116
+ | `state.min` | signal read | Range boundary |
117
+ | `state.max` | signal read | Range boundary |
118
+ | `state.percentage` | signal read | Indicator width (`width: ${percentage}%`) |
119
+ | `state.status` | signal read | Zone color mapping via CSS custom properties or class |
120
+ | `actions.setValue(n)` | action call | Update value from host property/attribute |
121
+ | `contracts.getMeterProps()` | contract spread | Spread onto the root meter element for ARIA compliance |
122
+
123
+ UIKit should **never** compute percentage, status, or ARIA attributes itself. All derived state comes from the headless model.
124
+
125
+ A default slot is supported in the UIKit layer for custom label content inside the indicator (visual concern only, no headless contract needed).
126
+
127
+ ## Minimum Test Matrix
128
+
129
+ - value clamping at boundaries
130
+ - percentage calculation accuracy
131
+ - status derivation based on thresholds (low/high/optimum)
132
+ - correct `aria-valuenow/min/max` mapping
133
+ - `aria-valuetext` via `formatValueText` callback
134
+ - reactive updates when value changes
135
+ - threshold normalization (swap when low > high)
136
+ - min/max swap when min >= max
137
+
138
+ ## ADR-001 Compliance
139
+
140
+ - **Runtime Policy**: Reatom v1000 only; no @statx/\* in headless core.
141
+ - **Layering**: core -> interactions -> a11y-contracts -> adapters; adapters remain thin mappings.
142
+ - **Independence**: No imports from @project/_, apps/_, or other out-of-package modules.
143
+ - **Verification**: Mandatory adapter integration tests and standalone package test execution.
144
+
145
+ ## Out of Scope (Current)
146
+
147
+ - animation orchestration (handled by visual layer)
148
+ - multi-segment meters
149
+ - vertical vs horizontal orientation (semantic parity)
@@ -0,0 +1,393 @@
1
+ # Number Component Contract
2
+
3
+ ## Purpose
4
+
5
+ `Number` is a headless contract for a numeric input field that **composes** `createSpinbutton` with additional features: clearable behavior, focus tracking, draft text management (transient editing state before numeric commit), placeholder support, and required semantics. It provides a unified API surface that proxies all spinbutton state, actions, and contracts while layering on input-style features needed by UIKit's `cv-number` component.
6
+
7
+ The `createNumber` factory internally instantiates a `createSpinbutton` model and exposes its capabilities through a single coherent interface, adding the following on top:
8
+
9
+ - **Draft text management** -- while the user types, the raw text is held in `draftText` without affecting the numeric `value`. On commit (blur, Enter), the draft is parsed and applied via spinbutton's `setValue`.
10
+ - **Clearable** -- an optional clear button that resets the value to the default and invokes an `onClear` callback.
11
+ - **Focus tracking** -- `focused` signal reflecting native focus/blur events.
12
+ - **Placeholder** -- a placeholder string displayed when the input is empty.
13
+ - **Required** -- marks the field as required for accessibility.
14
+ - **Stepper visibility** -- controls whether increment/decrement buttons are rendered.
15
+
16
+ ## Component Files
17
+
18
+ - `src/number/index.ts` -- model and public `createNumber` API
19
+ - `src/number/number.test.ts` -- unit behavior tests
20
+
21
+ ## Public API
22
+
23
+ - `createNumber(options)`
24
+ - `CreateNumberOptions`:
25
+ - `idBase?: string` -- base for generated IDs (default: `"number"`)
26
+ - `value?: number` -- initial numeric value (passed to spinbutton)
27
+ - `defaultValue?: number` -- the value to reset to on `clear()`; defaults to `min ?? 0`
28
+ - `min?: number` -- minimum bound (passed to spinbutton)
29
+ - `max?: number` -- maximum bound (passed to spinbutton)
30
+ - `step?: number` -- step size (passed to spinbutton)
31
+ - `largeStep?: number` -- large step size (passed to spinbutton)
32
+ - `disabled?: boolean` -- initial disabled state (default: `false`)
33
+ - `readonly?: boolean` -- initial readonly state (default: `false`)
34
+ - `required?: boolean` -- initial required state (default: `false`)
35
+ - `clearable?: boolean` -- whether the clear button is available (default: `false`)
36
+ - `stepper?: boolean` -- whether stepper buttons are visible (default: `false`)
37
+ - `placeholder?: string` -- initial placeholder text (default: `""`)
38
+ - `ariaLabel?: string` -- accessible label
39
+ - `ariaLabelledBy?: string` -- ID reference for labelling element
40
+ - `ariaDescribedBy?: string` -- ID reference for description element
41
+ - `formatValueText?: (value: number) => string` -- custom `aria-valuetext` formatter (passed to spinbutton)
42
+ - `onValueChange?: (value: number) => void` -- callback on committed value change
43
+ - `onClear?: () => void` -- callback when the value is cleared
44
+ - **Types**:
45
+ - `NumberKeyboardEventLike = Pick<KeyboardEvent, 'key'> & { preventDefault?: () => void }`
46
+
47
+ ## State Signal Surface
48
+
49
+ All signals are reactive (Reatom atoms/computed).
50
+
51
+ ### Proxied from spinbutton
52
+
53
+ | Signal | Type | Source |
54
+ | -------------- | --------------------- | ----------------------------- |
55
+ | `value()` | `number` | spinbutton `state.value` |
56
+ | `min()` | `number \| undefined` | spinbutton `state.min` |
57
+ | `max()` | `number \| undefined` | spinbutton `state.max` |
58
+ | `step()` | `number` | spinbutton `state.step` |
59
+ | `largeStep()` | `number` | spinbutton `state.largeStep` |
60
+ | `isDisabled()` | `boolean` | spinbutton `state.isDisabled` |
61
+ | `isReadOnly()` | `boolean` | spinbutton `state.isReadOnly` |
62
+ | `hasMin()` | `boolean` | spinbutton `state.hasMin` |
63
+ | `hasMax()` | `boolean` | spinbutton `state.hasMax` |
64
+
65
+ ### Number-specific
66
+
67
+ | Signal | Type | Description |
68
+ | ------------------- | ---------------- | ---------------------------------------------------------------------- |
69
+ | `focused()` | `boolean` | Whether the input currently has focus |
70
+ | `filled()` | `boolean` | **Derived**: `true` when `value !== defaultValue` |
71
+ | `clearable()` | `boolean` | Whether the clear button feature is enabled |
72
+ | `showClearButton()` | `boolean` | **Derived**: `clearable && filled && !isDisabled && !isReadOnly` |
73
+ | `stepper()` | `boolean` | Whether stepper (increment/decrement) buttons are visible |
74
+ | `draftText()` | `string \| null` | Transient editing text before commit; `null` when not actively editing |
75
+ | `placeholder()` | `string` | Placeholder text for the input |
76
+ | `required()` | `boolean` | Whether the field is required |
77
+ | `defaultValue()` | `number` | The value to reset to on clear |
78
+
79
+ ## Actions
80
+
81
+ All state transitions go through actions. UIKit must only call actions, never mutate state directly.
82
+
83
+ ### Proxied from spinbutton
84
+
85
+ | Action | Description |
86
+ | ------------------------- | ---------------------------------------------------------------------------------- |
87
+ | `setValue(value: number)` | Sets the numeric value with clamping and snapping; clears draft text |
88
+ | `increment()` | Increments value by `step` |
89
+ | `decrement()` | Decrements value by `step` |
90
+ | `incrementLarge()` | Increments value by `largeStep` |
91
+ | `decrementLarge()` | Decrements value by `largeStep` |
92
+ | `setFirst()` | Jumps to `min` (if defined) |
93
+ | `setLast()` | Jumps to `max` (if defined) |
94
+ | `handleKeyDown(event)` | Handles spinbutton keys (ArrowUp/Down, PageUp/Down, Home/End) AND Escape for clear |
95
+
96
+ ### Number-specific
97
+
98
+ | Action | Description |
99
+ | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
100
+ | `setDisabled(v: boolean)` | Updates disabled state; delegates to spinbutton |
101
+ | `setReadOnly(v: boolean)` | Updates readonly state; delegates to spinbutton |
102
+ | `setRequired(v: boolean)` | Updates required state |
103
+ | `setClearable(v: boolean)` | Updates clearable state |
104
+ | `setStepper(v: boolean)` | Updates stepper visibility state |
105
+ | `setFocused(v: boolean)` | Updates focus state; on `false` (blur), auto-commits draft |
106
+ | `setPlaceholder(v: string)` | Updates placeholder text |
107
+ | `setDraftText(v: string \| null)` | Updates transient draft text directly |
108
+ | `commitDraft()` | Parses `draftText` as a number; if valid, calls `setValue`; if empty, calls `clear()`; if invalid, reverts draft to current value display; always clears draft to `null` |
109
+ | `clear()` | Resets value to `defaultValue`, calls `onClear` callback; no-op when `isDisabled` or `isReadOnly` |
110
+ | `handleInput(text: string)` | Processes native input event; updates `draftText` to `text` |
111
+
112
+ ### Action semantics
113
+
114
+ - `setValue` bypasses the disabled/readonly guard (programmatic/controlled update), consistent with spinbutton behavior. It also clears `draftText` to `null`.
115
+ - `increment`, `decrement`, `incrementLarge`, `decrementLarge`, `setFirst`, `setLast` all clear `draftText` to `null` after modifying the value.
116
+ - `handleKeyDown` extends spinbutton's handler: if `key === 'Escape'` and `clearable && filled && !isDisabled && !isReadOnly`, calls `clear()` and prevents default. Otherwise delegates to spinbutton's `handleKeyDown`. If `key === 'Enter'`, calls `commitDraft()` and prevents default.
117
+ - `setFocused(false)` triggers `commitDraft()` automatically (blur commit).
118
+
119
+ ## Contracts
120
+
121
+ Contracts return ready-to-spread attribute maps for UIKit to apply directly to DOM elements.
122
+
123
+ ### `getInputProps()`
124
+
125
+ Returns attributes for the native `<input>` element:
126
+
127
+ | Attribute | Value |
128
+ | ------------------ | -------------------------------------------------------------- |
129
+ | `id` | `"{idBase}-input"` |
130
+ | `role` | `"spinbutton"` |
131
+ | `tabindex` | `"0"` when interactive, `"-1"` when `isDisabled` |
132
+ | `inputmode` | `"decimal"` |
133
+ | `aria-valuenow` | `String(value)` |
134
+ | `aria-valuemin` | `String(min)` when defined, otherwise omitted |
135
+ | `aria-valuemax` | `String(max)` when defined, otherwise omitted |
136
+ | `aria-valuetext` | Custom formatted text via `formatValueText`, otherwise omitted |
137
+ | `aria-disabled` | `"true"` when `isDisabled`, otherwise omitted |
138
+ | `aria-readonly` | `"true"` when `isReadOnly`, otherwise omitted |
139
+ | `aria-required` | `"true"` when `required`, otherwise omitted |
140
+ | `aria-label` | From options, when provided |
141
+ | `aria-labelledby` | From options, when provided |
142
+ | `aria-describedby` | From options, when provided |
143
+ | `placeholder` | Current placeholder value, omitted when empty |
144
+ | `autocomplete` | `"off"` |
145
+
146
+ ### `getIncrementButtonProps()`
147
+
148
+ Proxied from spinbutton's `getIncrementButtonProps()`, with `hidden` and `aria-hidden` added based on `stepper` state:
149
+
150
+ | Attribute | Value |
151
+ | --------------- | --------------------------------------------------- |
152
+ | `id` | `"{idBase}-increment"` |
153
+ | `tabindex` | `"-1"` |
154
+ | `aria-label` | `"Increment value"` |
155
+ | `aria-disabled` | `"true"` when disabled or at max, otherwise omitted |
156
+ | `hidden` | `true` when `stepper` is `false` |
157
+ | `aria-hidden` | `"true"` when `hidden` |
158
+ | `onClick` | `increment` handler |
159
+
160
+ ### `getDecrementButtonProps()`
161
+
162
+ Proxied from spinbutton's `getDecrementButtonProps()`, with `hidden` and `aria-hidden` added based on `stepper` state:
163
+
164
+ | Attribute | Value |
165
+ | --------------- | --------------------------------------------------- |
166
+ | `id` | `"{idBase}-decrement"` |
167
+ | `tabindex` | `"-1"` |
168
+ | `aria-label` | `"Decrement value"` |
169
+ | `aria-disabled` | `"true"` when disabled or at min, otherwise omitted |
170
+ | `hidden` | `true` when `stepper` is `false` |
171
+ | `aria-hidden` | `"true"` when `hidden` |
172
+ | `onClick` | `decrement` handler |
173
+
174
+ ### `getClearButtonProps()`
175
+
176
+ Returns attributes for the clear button:
177
+
178
+ | Attribute | Value |
179
+ | ------------- | ------------------------------------------------------------- |
180
+ | `role` | `"button"` |
181
+ | `aria-label` | `"Clear value"` |
182
+ | `tabindex` | `"-1"` (not in tab order; activated by Escape key or pointer) |
183
+ | `hidden` | `true` when `showClearButton` is `false` |
184
+ | `aria-hidden` | `"true"` when `hidden` |
185
+ | `onClick` | `clear` handler |
186
+
187
+ ## Transitions Table
188
+
189
+ | Event / Action | Guard | Effect | Next State |
190
+ | ------------------- | -------------------------------------------------------- | ------------------------------------------ | ------------------------------------------------------------------------------ |
191
+ | `setValue(v)` | -- | spinbutton `setValue(v)`, clear draft | `value = clamped/snapped v`; `draftText = null`; `onValueChange(v)` if changed |
192
+ | `increment()` | `!isDisabled && !isReadOnly` | spinbutton `increment()`, clear draft | `value = value + step`; `draftText = null` |
193
+ | `decrement()` | `!isDisabled && !isReadOnly` | spinbutton `decrement()`, clear draft | `value = value - step`; `draftText = null` |
194
+ | `incrementLarge()` | `!isDisabled && !isReadOnly` | spinbutton `incrementLarge()`, clear draft | `value = value + largeStep`; `draftText = null` |
195
+ | `decrementLarge()` | `!isDisabled && !isReadOnly` | spinbutton `decrementLarge()`, clear draft | `value = value - largeStep`; `draftText = null` |
196
+ | `setFirst()` | `!isDisabled && !isReadOnly && hasMin` | spinbutton `setFirst()`, clear draft | `value = min`; `draftText = null` |
197
+ | `setLast()` | `!isDisabled && !isReadOnly && hasMax` | spinbutton `setLast()`, clear draft | `value = max`; `draftText = null` |
198
+ | `handleInput(text)` | `!isDisabled && !isReadOnly` | set draft | `draftText = text` |
199
+ | `handleInput(text)` | `isDisabled \|\| isReadOnly` | -- | no change |
200
+ | `setDraftText(v)` | -- | set draft | `draftText = v` |
201
+ | `commitDraft()` | `draftText !== null && parseable` | parse, `setValue(parsed)` | `value = parsed`; `draftText = null` |
202
+ | `commitDraft()` | `draftText !== null && empty` | `clear()` | `value = defaultValue`; `draftText = null`; `onClear()` called |
203
+ | `commitDraft()` | `draftText !== null && invalid` | revert draft | `draftText = null` |
204
+ | `commitDraft()` | `draftText === null` | -- | no change |
205
+ | `clear()` | `!isDisabled && !isReadOnly` | reset to default | `value = defaultValue`; `draftText = null`; `onClear()` called |
206
+ | `clear()` | `isDisabled \|\| isReadOnly` | -- | no change |
207
+ | `keydown Escape` | `clearable && filled && !isDisabled && !isReadOnly` | `clear()` | `value = defaultValue`; `draftText = null`; `onClear()` called |
208
+ | `keydown Escape` | `!(clearable && filled) \|\| isDisabled \|\| isReadOnly` | -- | no change |
209
+ | `keydown Enter` | `draftText !== null` | `commitDraft()` | draft committed or reverted |
210
+ | `keydown ArrowUp` | -- | delegate to spinbutton | `value = value + step` |
211
+ | `keydown ArrowDown` | -- | delegate to spinbutton | `value = value - step` |
212
+ | `keydown PageUp` | -- | delegate to spinbutton | `value = value + largeStep` |
213
+ | `keydown PageDown` | -- | delegate to spinbutton | `value = value - largeStep` |
214
+ | `keydown Home` | -- | delegate to spinbutton | `value = min` or unchanged |
215
+ | `keydown End` | -- | delegate to spinbutton | `value = max` or unchanged |
216
+ | `setFocused(true)` | -- | set focused | `focused = true` |
217
+ | `setFocused(false)` | -- | set focused, commit draft | `focused = false`; `commitDraft()` triggered |
218
+ | `setDisabled(d)` | -- | delegate to spinbutton | `isDisabled = d` |
219
+ | `setReadOnly(r)` | -- | delegate to spinbutton | `isReadOnly = r` |
220
+ | `setRequired(r)` | -- | set required | `required = r` |
221
+ | `setClearable(c)` | -- | set clearable | `clearable = c` |
222
+ | `setStepper(s)` | -- | set stepper | `stepper = s` |
223
+ | `setPlaceholder(p)` | -- | set placeholder | `placeholder = p` |
224
+
225
+ ## Invariants
226
+
227
+ 1. `value` must always satisfy spinbutton invariants (clamped, snapped, within bounds when bounds are defined).
228
+ 2. `filled` must be `true` if and only if `value !== defaultValue`.
229
+ 3. `showClearButton` must be `true` if and only if `clearable && filled && !isDisabled && !isReadOnly`.
230
+ 4. `clear()` must be a no-op when `isDisabled` or `isReadOnly`.
231
+ 5. `draftText` must be `null` when not actively editing (i.e., after any commit, clear, or spinbutton action).
232
+ 6. `commitDraft()` with empty draft string must call `clear()` (reset to default).
233
+ 7. `commitDraft()` with non-parseable text must revert silently (set `draftText` to `null` without changing `value`).
234
+ 8. `setFocused(false)` must trigger `commitDraft()`.
235
+ 9. `handleKeyDown` must handle `Escape` (clear) and `Enter` (commit) before delegating remaining keys to spinbutton.
236
+ 10. `aria-disabled` on the input must be `"true"` when `isDisabled` is `true`.
237
+ 11. `aria-readonly` on the input must be `"true"` when `isReadOnly` is `true`.
238
+ 12. `aria-required` on the input must be `"true"` when `required` is `true`.
239
+ 13. `tabindex` on the input must be `"-1"` when `isDisabled`, `"0"` otherwise.
240
+ 14. Increment/decrement button contracts must have `hidden: true` when `stepper` is `false`.
241
+ 15. Clear button must have `hidden: true` when `showClearButton` is `false`.
242
+ 16. `defaultValue` must satisfy spinbutton normalization (clamped and snapped).
243
+ 17. Spinbutton actions (`increment`, `decrement`, etc.) must clear `draftText` to prevent stale draft display.
244
+ 18. `onValueChange` must not be called from `clear()` if the value is already equal to `defaultValue`.
245
+ 19. `getInputProps()` must return `role: "spinbutton"` to match APG spinbutton semantics.
246
+ 20. `inputmode` must be `"decimal"` to trigger numeric keyboard on mobile devices.
247
+
248
+ ## Adapter Expectations
249
+
250
+ UIKit (`cv-number`) binds to the headless contract as follows:
251
+
252
+ ### Signals read
253
+
254
+ - `state.value()` -- to display the formatted numeric value when not actively editing
255
+ - `state.isDisabled()` -- to reflect the `disabled` host attribute
256
+ - `state.isReadOnly()` -- to reflect the `readonly` host attribute
257
+ - `state.required()` -- to reflect the `required` host attribute
258
+ - `state.focused()` -- to apply `[focused]` styling on the host
259
+ - `state.filled()` -- to apply `[filled]` styling on the host (e.g., floating label position)
260
+ - `state.showClearButton()` -- to conditionally render the clear button
261
+ - `state.stepper()` -- to conditionally render increment/decrement buttons
262
+ - `state.draftText()` -- to display the raw editing text in the input; when `null`, display formatted `value`
263
+ - `state.placeholder()` -- read via contract, not recomputed
264
+ - `state.hasMin()`, `state.hasMax()` -- for conditional rendering or styling
265
+ - `state.min()`, `state.max()`, `state.step()` -- for display or validation hints
266
+
267
+ ### Actions called
268
+
269
+ - `actions.setValue(v)` -- when syncing attribute/property changes into headless
270
+ - `actions.setDisabled(d)` -- when the `disabled` attribute changes
271
+ - `actions.setReadOnly(r)` -- when the `readonly` attribute changes
272
+ - `actions.setRequired(r)` -- when the `required` attribute changes
273
+ - `actions.setPlaceholder(p)` -- when the `placeholder` attribute changes
274
+ - `actions.setClearable(c)` -- when the `clearable` attribute changes
275
+ - `actions.setStepper(s)` -- when the `stepper` attribute changes
276
+ - `actions.setFocused(f)` -- on native `<input>` `focus`/`blur` events
277
+ - `actions.handleInput(text)` -- on native `<input>` `input` event (updates draft)
278
+ - `actions.handleKeyDown(e)` -- on native `<input>` `keydown` event
279
+ - `actions.commitDraft()` -- on native `<input>` `change` event (if needed beyond blur)
280
+ - `actions.clear()` -- on clear button click
281
+ - `actions.increment()` -- on increment button click (when stepper is visible)
282
+ - `actions.decrement()` -- on decrement button click (when stepper is visible)
283
+
284
+ ### Contracts spread
285
+
286
+ - `contracts.getInputProps()` -- spread onto the native `<input>` element for `id`, `role`, `tabindex`, `inputmode`, `aria-valuenow`, `aria-valuemin`, `aria-valuemax`, `aria-valuetext`, `aria-disabled`, `aria-readonly`, `aria-required`, `aria-label`, `aria-labelledby`, `aria-describedby`, `placeholder`, `autocomplete`
287
+ - `contracts.getIncrementButtonProps()` -- spread onto the increment button for `id`, `tabindex`, `aria-label`, `aria-disabled`, `hidden`, `aria-hidden`, `onClick`
288
+ - `contracts.getDecrementButtonProps()` -- spread onto the decrement button for `id`, `tabindex`, `aria-label`, `aria-disabled`, `hidden`, `aria-hidden`, `onClick`
289
+ - `contracts.getClearButtonProps()` -- spread onto the clear button for `role`, `aria-label`, `tabindex`, `hidden`, `aria-hidden`, `onClick`
290
+
291
+ ### Events dispatched by UIKit
292
+
293
+ - `cv-change` CustomEvent on committed value changes (from user interaction, not programmatic `setValue`)
294
+ - `cv-clear` CustomEvent when the value is cleared via clear button or Escape key
295
+
296
+ ### Input display logic (UIKit responsibility)
297
+
298
+ UIKit reads `state.draftText()` and `state.value()` to determine what to display in the native `<input>`:
299
+
300
+ - When `draftText !== null`: display `draftText` (user is actively editing)
301
+ - When `draftText === null`: display formatted `String(value)` (committed state)
302
+
303
+ ## Minimum Test Matrix
304
+
305
+ ### Value management
306
+
307
+ - Set initial value via options and verify `state.value()`
308
+ - Default value defaults to `min` when provided, otherwise `0`
309
+ - `setValue(v)` updates value with clamping and snapping
310
+ - `setValue(v)` clears `draftText` to `null`
311
+ - `setValue(v)` works even when disabled (programmatic/controlled update)
312
+
313
+ ### Spinbutton behavior
314
+
315
+ - `increment()` / `decrement()` behavior with step
316
+ - `incrementLarge()` / `decrementLarge()` behavior with largeStep
317
+ - `Home` / `End` behavior with defined/undefined boundaries
318
+ - Value clamping and snapping
319
+ - Spinbutton actions clear `draftText` to `null`
320
+ - Disabled and readonly state prevent spinbutton mutations
321
+
322
+ ### Draft text management
323
+
324
+ - `handleInput(text)` sets `draftText` to the provided text
325
+ - `handleInput(text)` is no-op when disabled
326
+ - `handleInput(text)` is no-op when readonly
327
+ - `commitDraft()` with valid numeric text parses and sets value
328
+ - `commitDraft()` with empty string calls `clear()`
329
+ - `commitDraft()` with invalid text reverts (sets `draftText` to `null`, value unchanged)
330
+ - `commitDraft()` with `draftText === null` is no-op
331
+ - `setFocused(false)` triggers `commitDraft()`
332
+
333
+ ### Clearable
334
+
335
+ - `clear()` sets value to `defaultValue` and calls `onClear`
336
+ - `clear()` is no-op when disabled
337
+ - `clear()` is no-op when readonly
338
+ - `clear()` clears `draftText` to `null`
339
+ - `Escape` key clears value when clearable and filled
340
+ - `Escape` key does nothing when not clearable
341
+ - `Escape` key does nothing when value equals defaultValue (not filled)
342
+
343
+ ### Keyboard
344
+
345
+ - `Enter` key calls `commitDraft()`
346
+ - `ArrowUp` / `ArrowDown` delegate to spinbutton increment/decrement
347
+ - `PageUp` / `PageDown` delegate to spinbutton large step
348
+ - `Home` / `End` delegate to spinbutton setFirst/setLast
349
+
350
+ ### Focus
351
+
352
+ - `setFocused(true)` sets focused to `true`
353
+ - `setFocused(false)` sets focused to `false` and commits draft
354
+
355
+ ### Derived state
356
+
357
+ - `filled` is `true` when value differs from defaultValue, `false` when equal
358
+ - `showClearButton` reflects `clearable && filled && !isDisabled && !isReadOnly`
359
+
360
+ ### Contracts
361
+
362
+ - `getInputProps()` returns correct `role`, `inputmode`, `aria-valuenow`, `aria-disabled`, `aria-readonly`, `aria-required`
363
+ - `getInputProps()` returns `tabindex "-1"` when disabled, `"0"` otherwise
364
+ - `getInputProps()` returns placeholder when non-empty, omitted when empty
365
+ - `getIncrementButtonProps()` returns `hidden: true` when stepper is `false`
366
+ - `getDecrementButtonProps()` returns `hidden: true` when stepper is `false`
367
+ - `getClearButtonProps()` returns `hidden: true` when `showClearButton` is `false`
368
+ - `getClearButtonProps()` returns correct `aria-label`
369
+
370
+ ### Stepper
371
+
372
+ - Stepper buttons hidden by default (`stepper` defaults to `false`)
373
+ - `setStepper(true)` makes stepper buttons visible
374
+ - `setStepper(false)` hides stepper buttons
375
+
376
+ ## ADR-001 Compliance
377
+
378
+ - **Runtime Policy**: Reatom v1000 only; no @statx/\* in headless core.
379
+ - **Layering**: core -> interactions -> a11y-contracts -> adapters; adapters remain thin mappings. The number module sits in the interactions layer, composing the spinbutton core.
380
+ - **Independence**: No imports from @project/_, apps/_, or other out-of-package modules. Only imports from `../spinbutton` (intra-package) and `@reatom/core`.
381
+ - **Composition**: `createNumber` internally creates a `createSpinbutton` instance. It does NOT duplicate spinbutton logic.
382
+ - **Verification**: Mandatory adapter integration tests and standalone package test execution.
383
+
384
+ ## Out of Scope (Current)
385
+
386
+ - Locale-aware number formatting and parsing (handled by adapters/UIKit)
387
+ - Thousand separators or currency formatting
388
+ - Input masking or restricted character filtering
389
+ - Validation / error state management (future form-level spec)
390
+ - Acceleration (increasing step size when holding stepper buttons)
391
+ - Native form submission integration (handled by adapters/wrappers)
392
+ - Prefix / suffix slot content management (UIKit layout concern)
393
+ - Custom parsers beyond `parseFloat` (adapters may override)