@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,186 @@
1
+ # Grid Component Contract
2
+
3
+ ## Purpose
4
+
5
+ `Grid` provides a headless APG-aligned model for interactive tabular data, enabling users to navigate across rows and columns using directional keys.
6
+
7
+ ## Component Files
8
+
9
+ - `src/grid/index.ts` - model and public `createGrid` API
10
+ - `src/grid/grid.test.ts` - unit behavior tests
11
+
12
+ ## Public API
13
+
14
+ ### `createGrid(options): GridModel`
15
+
16
+ #### Options (`CreateGridOptions`)
17
+
18
+ | Option | Type | Default | Description |
19
+ | ------------------------ | ---------------------------------------------- | ------------------- | ------------------------------------------------ |
20
+ | `rows` | `readonly GridRow[]` | required | Row definitions (`{ id, index?, disabled? }`) |
21
+ | `columns` | `readonly GridColumn[]` | required | Column definitions (`{ id, index?, disabled? }`) |
22
+ | `disabledCells` | `readonly GridCellId[]` | `[]` | Individually disabled cells |
23
+ | `idBase` | `string` | `'grid'` | Prefix for generated DOM ids |
24
+ | `ariaLabel` | `string` | — | Static `aria-label` for the grid root |
25
+ | `ariaLabelledBy` | `string` | — | `aria-labelledby` reference for the grid root |
26
+ | `focusStrategy` | `'roving-tabindex' \| 'aria-activedescendant'` | `'roving-tabindex'` | Focus management strategy |
27
+ | `selectionMode` | `'single' \| 'multiple'` | `'single'` | Whether multi-cell selection is allowed |
28
+ | `selectionFollowsFocus` | `boolean` | `false` | Auto-select cell on focus move |
29
+ | `totalRowCount` | `number` | `rows.length` | Logical row count (for virtualization) |
30
+ | `totalColumnCount` | `number` | `columns.length` | Logical column count (for virtualization) |
31
+ | `pageSize` | `number` | `10` | Rows per page for PageUp/PageDown (minimum 1) |
32
+ | `initialActiveCellId` | `GridCellId \| null` | `null` | Initial active cell (normalized on create) |
33
+ | `initialSelectedCellIds` | `readonly GridCellId[]` | `[]` | Initial selected cells (filtered for validity) |
34
+ | `isReadOnly` | `boolean` | `false` | Marks all cells as `aria-readonly` |
35
+
36
+ ### State (signal-backed)
37
+
38
+ - `activeCellId: Atom<GridCellId | null>` - currently focused cell `{ rowId, colId }`, or `null`
39
+ - `selectedCellIds: Atom<Set<string>>` - set of selected cell keys (format: `"rowId::colId"`)
40
+ - `rowCount: Computed<number>` - `max(totalRowCount, rows.length)`
41
+ - `columnCount: Computed<number>` - `max(totalColumnCount, columns.length)`
42
+
43
+ ### Actions
44
+
45
+ - **focus**: `setActiveCell(cell: GridCellId)` - set active cell (normalizes to nearest valid cell; syncs selection if `selectionFollowsFocus`)
46
+ - **navigation**: `moveUp`, `moveDown`, `moveLeft`, `moveRight`, `moveRowStart`, `moveRowEnd`, `moveGridStart`, `moveGridEnd`, `pageUp`, `pageDown`
47
+ - **selection**: `selectCell(cell)`, `toggleCellSelection(cell)`, `selectRow(rowId)`, `selectColumn(colId)`
48
+ - **keyboard**: `handleKeyDown(event: GridKeyboardEventLike)` - dispatches to navigation/selection actions based on key
49
+
50
+ ### Contracts (ready-to-spread ARIA prop objects)
51
+
52
+ - `getGridProps(): GridProps`
53
+ - `getRowProps(rowId: string): GridRowProps`
54
+ - `getCellProps(rowId: string, colId: string): GridCellProps`
55
+
56
+ #### `GridProps`
57
+
58
+ | Prop | Type | Notes |
59
+ | ----------------------- | --------------------- | ------------------------------------------------------------------------ |
60
+ | `id` | `string` | `"{idBase}-root"` |
61
+ | `role` | `'grid'` | |
62
+ | `tabindex` | `'0' \| '-1'` | `'0'` for `aria-activedescendant` strategy, `'-1'` for `roving-tabindex` |
63
+ | `aria-label` | `string \| undefined` | From options |
64
+ | `aria-labelledby` | `string \| undefined` | From options |
65
+ | `aria-multiselectable` | `'true' \| 'false'` | Based on `selectionMode` |
66
+ | `aria-colcount` | `number` | From `columnCount` computed |
67
+ | `aria-rowcount` | `number` | From `rowCount` computed |
68
+ | `aria-activedescendant` | `string \| undefined` | Cell DOM id when using `aria-activedescendant` strategy |
69
+
70
+ #### `GridRowProps`
71
+
72
+ | Prop | Type | Notes |
73
+ | --------------- | -------- | ---------------------------------------------------------------- |
74
+ | `id` | `string` | `"{idBase}-row-{rowId}"` |
75
+ | `role` | `'row'` | |
76
+ | `aria-rowindex` | `number` | 1-based; uses `row.index` if provided, else positional index + 1 |
77
+
78
+ #### `GridCellProps`
79
+
80
+ | Prop | Type | Notes |
81
+ | --------------- | --------------------- | -------------------------------------------------------------------------------- |
82
+ | `id` | `string` | `"{idBase}-cell-{rowId}-{colId}"` |
83
+ | `role` | `'gridcell'` | |
84
+ | `tabindex` | `'0' \| '-1'` | `'0'` only for active cell in `roving-tabindex` mode (and not disabled) |
85
+ | `aria-colindex` | `number` | 1-based; uses `column.index` if provided, else positional index + 1 |
86
+ | `aria-selected` | `'true' \| 'false'` | Whether cell is in `selectedCellIds` |
87
+ | `aria-readonly` | `'true' \| undefined` | Set when `isReadOnly` option is true |
88
+ | `aria-disabled` | `'true' \| undefined` | Set when cell is disabled (row disabled, column disabled, or in `disabledCells`) |
89
+ | `data-active` | `'true' \| 'false'` | Whether cell is the active cell |
90
+ | `onFocus` | `() => void` | Calls `setActiveCell` for this cell |
91
+
92
+ ## APG and A11y Contract
93
+
94
+ - root role: `grid`
95
+ - row role: `row`
96
+ - cell role: `gridcell`
97
+ - focus strategies:
98
+ - `roving-tabindex` (default) - active cell gets `tabindex="0"`, all others `tabindex="-1"`
99
+ - `aria-activedescendant` - grid root gets `tabindex="0"` and `aria-activedescendant` pointing to active cell DOM id
100
+ - required attributes:
101
+ - root: `aria-label` or `aria-labelledby`, `aria-multiselectable`, `aria-colcount`, `aria-rowcount`
102
+ - row: `aria-rowindex`
103
+ - cell: `aria-colindex`, `aria-selected`, `aria-readonly`, `tabindex`
104
+
105
+ ## Keyboard Contract
106
+
107
+ | Key | Modifier | Action |
108
+ | ------------ | ----------- | ------------------------------------------------------------------------------------------- |
109
+ | `ArrowUp` | — | `moveUp()` |
110
+ | `ArrowDown` | — | `moveDown()` |
111
+ | `ArrowLeft` | — | `moveLeft()` |
112
+ | `ArrowRight` | — | `moveRight()` |
113
+ | `Home` | — | `moveRowStart()` |
114
+ | `End` | — | `moveRowEnd()` |
115
+ | `Home` | Ctrl / Meta | `moveGridStart()` |
116
+ | `End` | Ctrl / Meta | `moveGridEnd()` |
117
+ | `PageUp` | — | `pageUp()` |
118
+ | `PageDown` | — | `pageDown()` |
119
+ | `Enter` | — | `moveDown()` |
120
+ | `Space` | — | `toggleCellSelection(activeCell)` (multiple mode) or `selectCell(activeCell)` (single mode) |
121
+
122
+ ## Transitions Table
123
+
124
+ | Trigger | Current State | Action | Next State |
125
+ | --------------------- | -------------------------- | ---------------------------------- | ---------------------------------------------------------------------------------- |
126
+ | Arrow key | any active cell | `moveUp/Down/Left/Right` | Active cell moves to nearest non-disabled cell in direction; no change at boundary |
127
+ | Home | active cell | `moveRowStart` | Active cell moves to first enabled cell in current row |
128
+ | End | active cell | `moveRowEnd` | Active cell moves to last enabled cell in current row |
129
+ | Ctrl+Home | active cell | `moveGridStart` | Active cell moves to first enabled cell in entire grid |
130
+ | Ctrl+End | active cell | `moveGridEnd` | Active cell moves to last enabled cell in entire grid |
131
+ | PageUp | active cell | `pageUp` | Active cell moves up by `pageSize` rows (clamped), skipping disabled |
132
+ | PageDown | active cell | `pageDown` | Active cell moves down by `pageSize` rows (clamped), skipping disabled |
133
+ | Enter | active cell | `moveDown` | Active cell moves to next row (same column) |
134
+ | Space | active cell, single mode | `selectCell(activeCell)` | `selectedCellIds` set to `{activeCell}` |
135
+ | Space | active cell, multiple mode | `toggleCellSelection(activeCell)` | `selectedCellIds` toggled for active cell |
136
+ | `setActiveCell(cell)` | any | normalize + set | Active cell set to normalized cell; if `selectionFollowsFocus`, selection synced |
137
+ | `selectRow(rowId)` | any | select all enabled cells in row | `selectedCellIds` set to enabled cells in row (single mode: first only) |
138
+ | `selectColumn(colId)` | any | select all enabled cells in column | `selectedCellIds` set to enabled cells in column (single mode: first only) |
139
+
140
+ ## Invariants
141
+
142
+ - `activeCellId` must always point to a valid, non-disabled cell if the grid has any enabled cells; `null` only when no enabled cells exist
143
+ - `activeCellId` is normalized on creation: invalid or disabled initial values fall back to the first enabled cell
144
+ - `aria-rowcount` and `aria-colcount` must match the logical dimensions, even if only a subset is rendered (virtualization)
145
+ - Selection state is independent of focus unless `selectionFollowsFocus` is enabled
146
+ - Disabled cells (via row `disabled`, column `disabled`, or `disabledCells`) are always skipped during keyboard navigation
147
+ - `initialSelectedCellIds` are filtered on creation: disabled or non-existent cells are excluded
148
+ - `getRowProps` and `getCellProps` throw `Error` for unknown row/cell ids
149
+
150
+ ## Adapter Expectations
151
+
152
+ UIKit (or any rendering adapter) must:
153
+
154
+ 1. **Render structure**: create elements for grid root, rows, and cells; spread the prop objects from `getGridProps()`, `getRowProps()`, `getCellProps()` onto the respective elements
155
+ 2. **Wire keyboard**: attach a `keydown` listener on the grid root that calls `actions.handleKeyDown(event)` and calls `event.preventDefault()` for handled keys
156
+ 3. **Manage DOM focus**: when `activeCellId` changes, call `.focus()` on the corresponding cell element (for `roving-tabindex` strategy) — the headless model sets `tabindex` but does not perform DOM focus
157
+ 4. **Subscribe to state**: re-render when `state.activeCellId` or `state.selectedCellIds` change, so that contract prop objects reflect the latest values
158
+ 5. **Forward `onFocus`**: the `onFocus` handler in `getCellProps` must be connected to the cell element's `focus` event so that clicking or tabbing into a cell updates the model
159
+ 6. **Prevent default**: adapter should `preventDefault()` on keyboard events that the grid handles, to avoid page scroll on arrow/space keys
160
+
161
+ ## Minimum Test Matrix
162
+
163
+ - 2D navigation (arrows) across rows and columns
164
+ - boundary handling (Home, End, Ctrl+Home, Ctrl+End)
165
+ - skipping disabled cells during navigation
166
+ - multi-selection behavior (if enabled)
167
+ - correct ARIA attribute mapping for virtualized grids
168
+ - focus strategy parity (`roving-tabindex` vs `aria-activedescendant`)
169
+ - Space key selection (single and multiple mode)
170
+ - Enter key navigation
171
+ - `selectionFollowsFocus` behavior
172
+ - initial state normalization (invalid `initialActiveCellId`, disabled cells in `initialSelectedCellIds`)
173
+
174
+ ## ADR-001 Compliance
175
+
176
+ - **Runtime Policy**: Reatom only; no @statx/\* in headless core.
177
+ - **Layering**: core -> interactions -> a11y-contracts -> adapters; adapters remain thin mappings.
178
+ - **Independence**: No imports from @project/_, apps/_, or other out-of-package modules.
179
+ - **Verification**: Mandatory adapter integration tests and standalone package test execution.
180
+
181
+ ## Out of Scope (Current)
182
+
183
+ - cell editing mode (inline inputs)
184
+ - column/row reordering (drag and drop)
185
+ - column resizing
186
+ - context menu integration
@@ -0,0 +1,254 @@
1
+ # Input Component Contract
2
+
3
+ ## Purpose
4
+
5
+ `Input` is a headless APG-aligned contract for a single-line text input control. It manages value state, input type resolution (text, password, email, url, tel, search), disabled/readonly/required semantics, clearable behavior, password visibility toggling, and focus tracking. It provides ready-to-spread ARIA attribute maps for the native input element, the clear button, and the password toggle button.
6
+
7
+ ## Component Files
8
+
9
+ - `src/input/index.ts` - model and public `createInput` API
10
+ - `src/input/input.test.ts` - unit behavior tests
11
+
12
+ ## Public API
13
+
14
+ - `createInput(options)`
15
+ - `options`:
16
+ - `idBase?`: `string` - base for generated IDs (default: `"input"`)
17
+ - `value?`: `string` - initial value (default: `""`)
18
+ - `type?`: `InputType` - initial input type (default: `"text"`)
19
+ - `disabled?`: `boolean` - initial disabled state (default: `false`)
20
+ - `readonly?`: `boolean` - initial readonly state (default: `false`)
21
+ - `required?`: `boolean` - initial required state (default: `false`)
22
+ - `placeholder?`: `string` - initial placeholder text (default: `""`)
23
+ - `clearable?`: `boolean` - whether the clear button is available (default: `false`)
24
+ - `passwordToggle?`: `boolean` - whether the password toggle is available (default: `false`)
25
+ - `onInput?`: `(value: string) => void` - callback on value change via user action
26
+ - `onClear?`: `() => void` - callback when the value is cleared
27
+ - **Types**:
28
+ - `InputType = "text" | "password" | "email" | "url" | "tel" | "search"`
29
+ - `state` (signal-backed):
30
+ - `value()`: `string` - current input value
31
+ - `type()`: `InputType` - configured input type
32
+ - `disabled()`: `boolean` - whether the input is disabled
33
+ - `readonly()`: `boolean` - whether the input is readonly
34
+ - `required()`: `boolean` - whether the input is required
35
+ - `placeholder()`: `string` - placeholder text
36
+ - `clearable()`: `boolean` - whether the clear button is enabled
37
+ - `passwordToggle()`: `boolean` - whether the password toggle is enabled
38
+ - `passwordVisible()`: `boolean` - whether password text is currently revealed
39
+ - `focused()`: `boolean` - whether the input has focus
40
+ - `filled()`: `boolean` - **derived**: `true` when `value.length > 0`
41
+ - `resolvedType()`: `string` - **derived**: the effective `type` attribute for the native `<input>` element; equals `"text"` when `type === "password" && passwordVisible === true`, otherwise equals `type`
42
+ - `showClearButton()`: `boolean` - **derived**: `true` when `clearable && filled && !disabled && !readonly`
43
+ - `showPasswordToggle()`: `boolean` - **derived**: `true` when `type === "password" && passwordToggle`
44
+ - `actions`:
45
+ - `setValue(value: string)`: updates the value; calls `onInput` callback
46
+ - `setType(type: InputType)`: updates the input type; resets `passwordVisible` to `false`
47
+ - `setDisabled(disabled: boolean)`: updates disabled state
48
+ - `setReadonly(readonly: boolean)`: updates readonly state
49
+ - `setRequired(required: boolean)`: updates required state
50
+ - `setPlaceholder(placeholder: string)`: updates placeholder text
51
+ - `setClearable(clearable: boolean)`: updates clearable state
52
+ - `setPasswordToggle(toggle: boolean)`: updates password toggle state; resets `passwordVisible` to `false` when `toggle` becomes `false`
53
+ - `togglePasswordVisibility()`: toggles `passwordVisible`; no-op if `type !== "password"` or `passwordToggle === false`
54
+ - `setFocused(focused: boolean)`: updates focus state
55
+ - `clear()`: sets value to `""`, calls `onClear` callback; no-op if `disabled` or `readonly`
56
+ - `handleInput(value: string)`: processes native input event; delegates to `setValue`
57
+ - `handleKeyDown(event: Pick<KeyboardEvent, "key"> & { preventDefault?: () => void })`: handles `Escape` key to clear (when clearable and filled)
58
+ - `contracts`:
59
+ - `getInputProps()`: returns attribute map for the native `<input>` element
60
+ - `getClearButtonProps()`: returns attribute map for the clear button
61
+ - `getPasswordToggleProps()`: returns attribute map for the password toggle button
62
+
63
+ ## APG and A11y Contract
64
+
65
+ ### Native `<input>` element
66
+
67
+ - `id`: `"{idBase}-input"`
68
+ - `type`: `resolvedType` (resolved from `type` and `passwordVisible`)
69
+ - `aria-disabled`: `"true"` when `disabled`, otherwise omitted
70
+ - `aria-readonly`: `"true"` when `readonly`, otherwise omitted
71
+ - `aria-required`: `"true"` when `required`, otherwise omitted
72
+ - `aria-invalid`: reserved for future validation integration; omitted by default
73
+ - `placeholder`: current placeholder value, omitted when empty
74
+ - `disabled`: `true` when `disabled` (native attribute for form semantics)
75
+ - `readonly`: `true` when `readonly` (native attribute for form semantics)
76
+ - `tabindex`: `"0"` when interactive, `"-1"` when `disabled`
77
+ - `autocomplete`: `"off"` when `type === "password"` (prevents autofill conflicts with toggle)
78
+
79
+ ### Clear button
80
+
81
+ - `role`: `"button"`
82
+ - `aria-label`: `"Clear input"` (localizable by adapter)
83
+ - `tabindex`: `"-1"` (not in tab order; activated by Escape key or pointer)
84
+ - `hidden`: `true` when `showClearButton` is `false`
85
+ - `aria-hidden`: `"true"` when `hidden` (prevents screen readers from announcing an unavailable control)
86
+
87
+ ### Password toggle button
88
+
89
+ - `role`: `"button"`
90
+ - `aria-label`: `"Show password"` when `passwordVisible === false`, `"Hide password"` when `passwordVisible === true`
91
+ - `aria-pressed`: `"true"` when `passwordVisible`, `"false"` otherwise
92
+ - `tabindex`: `"0"` when visible, `"-1"` when hidden
93
+ - `hidden`: `true` when `showPasswordToggle` is `false`
94
+ - `aria-hidden`: `"true"` when `hidden`
95
+
96
+ ## Behavior Contract
97
+
98
+ ### Value management
99
+
100
+ - Setting a value via `setValue` or `handleInput` updates `state.value` and calls the `onInput` callback.
101
+ - `clear()` sets value to `""`, calls `onClear`, and is a no-op when `disabled` or `readonly`.
102
+
103
+ ### Clearable
104
+
105
+ - When `clearable` is `true`, the clear button becomes visible if the input is non-empty and not disabled/readonly.
106
+ - Pressing `Escape` while the input is focused clears the value when `clearable && filled` are both true.
107
+ - After clearing, the input retains focus.
108
+
109
+ ### Password toggle
110
+
111
+ - When `type === "password"` and `passwordToggle === true`, the toggle button is visible.
112
+ - Activating the toggle switches `passwordVisible`, which changes `resolvedType` between `"password"` and `"text"`.
113
+ - `togglePasswordVisibility()` is a no-op when `type !== "password"` or `passwordToggle === false`.
114
+ - Changing `type` away from `"password"` resets `passwordVisible` to `false`.
115
+ - Disabling `passwordToggle` resets `passwordVisible` to `false`.
116
+
117
+ ### Focus management
118
+
119
+ - `setFocused(true)` / `setFocused(false)` reflect the native focus/blur events.
120
+ - UIKit listens for `focus`/`blur` on the native `<input>` and calls `setFocused`.
121
+
122
+ ### Disabled and readonly
123
+
124
+ - A disabled input does not respond to `clear()`, `handleInput()`, or keyboard interactions.
125
+ - A readonly input does not respond to `clear()` or `handleInput()`, but retains focus and keyboard navigation.
126
+ - `setValue` bypasses the disabled/readonly guard (controlled/programmatic update).
127
+
128
+ ## Transitions Table
129
+
130
+ | Event / Action | Guard | Effect | Next State |
131
+ | ---------------------------- | ---------------------------------------------------- | --------------- | -------------------------------------------------------- |
132
+ | `handleInput(v)` | `!disabled && !readonly` | `setValue(v)` | `value = v`; `onInput(v)` called |
133
+ | `handleInput(v)` | `disabled \|\| readonly` | -- | no change |
134
+ | `clear()` | `!disabled && !readonly` | `setValue("")` | `value = ""`; `onClear()` called |
135
+ | `clear()` | `disabled \|\| readonly` | -- | no change |
136
+ | `keydown Escape` | `clearable && filled && !disabled && !readonly` | `clear()` | `value = ""`; `onClear()` called |
137
+ | `keydown Escape` | `!(clearable && filled) \|\| disabled \|\| readonly` | -- | no change |
138
+ | `togglePasswordVisibility()` | `type === "password" && passwordToggle` | toggle | `passwordVisible = !passwordVisible` |
139
+ | `togglePasswordVisibility()` | `type !== "password" \|\| !passwordToggle` | -- | no change |
140
+ | `setValue(v)` | -- | set value | `value = v`; `onInput(v)` called |
141
+ | `setType(t)` | -- | set type | `type = t`; `passwordVisible = false` |
142
+ | `setDisabled(d)` | -- | set disabled | `disabled = d` |
143
+ | `setReadonly(r)` | -- | set readonly | `readonly = r` |
144
+ | `setRequired(r)` | -- | set required | `required = r` |
145
+ | `setPlaceholder(p)` | -- | set placeholder | `placeholder = p` |
146
+ | `setClearable(c)` | -- | set clearable | `clearable = c` |
147
+ | `setPasswordToggle(t)` | -- | set toggle | `passwordToggle = t`; if `!t`: `passwordVisible = false` |
148
+ | `setFocused(f)` | -- | set focused | `focused = f` |
149
+
150
+ ## Adapter Expectations
151
+
152
+ UIKit (`cv-input`) binds to the headless contract as follows:
153
+
154
+ - **Signals read**:
155
+ - `state.value()` - to reflect the current value and sync with the native `<input>`
156
+ - `state.disabled()` - to reflect the `disabled` host attribute
157
+ - `state.readonly()` - to reflect the `readonly` host attribute
158
+ - `state.required()` - to reflect the `required` host attribute
159
+ - `state.focused()` - to apply `:focus` / `[focused]` styling on the host
160
+ - `state.filled()` - to apply `[filled]` styling on the host (e.g., floating label position)
161
+ - `state.passwordVisible()` - to update the toggle button icon
162
+ - `state.showClearButton()` - to conditionally render the clear button
163
+ - `state.showPasswordToggle()` - to conditionally render the password toggle
164
+ - `state.resolvedType()` - UIKit does NOT recompute this; reads directly from headless
165
+ - **Actions called**:
166
+ - `actions.setValue(v)` - when syncing attribute/property changes into headless
167
+ - `actions.setType(t)` - when the `type` attribute changes
168
+ - `actions.setDisabled(d)` - when the `disabled` attribute changes
169
+ - `actions.setReadonly(r)` - when the `readonly` attribute changes
170
+ - `actions.setRequired(r)` - when the `required` attribute changes
171
+ - `actions.setPlaceholder(p)` - when the `placeholder` attribute changes
172
+ - `actions.setClearable(c)` - when the `clearable` attribute changes
173
+ - `actions.setPasswordToggle(t)` - when the `password-toggle` attribute changes
174
+ - `actions.setFocused(f)` - on native `<input>` `focus`/`blur` events
175
+ - `actions.handleInput(v)` - on native `<input>` `input` event
176
+ - `actions.handleKeyDown(e)` - on native `<input>` `keydown` event
177
+ - `actions.clear()` - on clear button click
178
+ - `actions.togglePasswordVisibility()` - on password toggle button click
179
+ - **Contracts spread**:
180
+ - `contracts.getInputProps()` - spread onto the native `<input>` element for `id`, `type`, `aria-disabled`, `aria-readonly`, `aria-required`, `placeholder`, `disabled`, `readonly`, `tabindex`, `autocomplete`
181
+ - `contracts.getClearButtonProps()` - spread onto the clear button `<button>` for `role`, `aria-label`, `tabindex`, `hidden`, `aria-hidden`
182
+ - `contracts.getPasswordToggleProps()` - spread onto the toggle button `<button>` for `role`, `aria-label`, `aria-pressed`, `tabindex`, `hidden`, `aria-hidden`
183
+ - **Events dispatched by UIKit**:
184
+ - `cv-input` CustomEvent on value changes (from user interaction, not programmatic `setValue`)
185
+ - `cv-clear` CustomEvent when the value is cleared via clear button or Escape key
186
+
187
+ ## Invariants
188
+
189
+ 1. `resolvedType` must equal `"text"` when `type === "password" && passwordVisible === true`; otherwise it must equal `type`.
190
+ 2. `filled` must be `true` if and only if `value.length > 0`.
191
+ 3. `showClearButton` must be `true` if and only if `clearable && filled && !disabled && !readonly`.
192
+ 4. `showPasswordToggle` must be `true` if and only if `type === "password" && passwordToggle`.
193
+ 5. `passwordVisible` must be `false` whenever `type !== "password"` or `passwordToggle === false`.
194
+ 6. `clear()` must be a no-op when `disabled` or `readonly`.
195
+ 7. `togglePasswordVisibility()` must be a no-op when `type !== "password"` or `passwordToggle === false`.
196
+ 8. `aria-disabled` on the input must be `"true"` when `disabled` is `true`.
197
+ 9. `aria-readonly` on the input must be `"true"` when `readonly` is `true`.
198
+ 10. `aria-required` on the input must be `"true"` when `required` is `true`.
199
+ 11. The clear button must have `hidden: true` whenever `showClearButton` is `false`.
200
+ 12. The password toggle must have `hidden: true` whenever `showPasswordToggle` is `false`.
201
+ 13. The password toggle `aria-pressed` must reflect `passwordVisible`.
202
+ 14. `tabindex` on the native input must be `"-1"` when `disabled`, `"0"` otherwise.
203
+ 15. `onInput` must not be called from `clear()`; `onClear` is called instead.
204
+ 16. `setType` must always reset `passwordVisible` to `false`.
205
+
206
+ ## Minimum Test Matrix
207
+
208
+ - set initial value via options and verify `state.value()`
209
+ - `handleInput(v)` updates value and calls `onInput`
210
+ - `handleInput(v)` is no-op when disabled
211
+ - `handleInput(v)` is no-op when readonly
212
+ - `clear()` sets value to `""` and calls `onClear`
213
+ - `clear()` is no-op when disabled
214
+ - `clear()` is no-op when readonly
215
+ - `Escape` key clears value when clearable and filled
216
+ - `Escape` key does nothing when not clearable
217
+ - `Escape` key does nothing when value is empty
218
+ - `togglePasswordVisibility()` toggles `passwordVisible` when type is password and toggle enabled
219
+ - `togglePasswordVisibility()` is no-op when type is not password
220
+ - `togglePasswordVisibility()` is no-op when password toggle is disabled
221
+ - `resolvedType` returns `"text"` when password is visible
222
+ - `resolvedType` returns `"password"` when password is not visible
223
+ - `setType` resets `passwordVisible` to false
224
+ - `setPasswordToggle(false)` resets `passwordVisible` to false
225
+ - `filled` is true when value is non-empty, false when empty
226
+ - `showClearButton` reflects `clearable && filled && !disabled && !readonly`
227
+ - `showPasswordToggle` reflects `type === "password" && passwordToggle`
228
+ - `getInputProps()` returns correct `aria-disabled`, `aria-readonly`, `aria-required`
229
+ - `getInputProps()` returns `tabindex "-1"` when disabled, `"0"` otherwise
230
+ - `getInputProps()` returns `resolvedType` as the `type` attribute
231
+ - `getClearButtonProps()` returns `hidden: true` when clear button should not show
232
+ - `getClearButtonProps()` returns correct `aria-label`
233
+ - `getPasswordToggleProps()` returns correct `aria-pressed` reflecting visibility
234
+ - `getPasswordToggleProps()` returns correct `aria-label` based on visibility
235
+ - `getPasswordToggleProps()` returns `hidden: true` when toggle should not show
236
+ - `setValue` works even when disabled (programmatic/controlled update)
237
+ - `setFocused` correctly updates focused state
238
+
239
+ ## ADR-001 Compliance
240
+
241
+ - **Runtime Policy**: Reatom v1000 only; no @statx/\* in headless core.
242
+ - **Layering**: core -> interactions -> a11y-contracts -> adapters; adapters remain thin mappings.
243
+ - **Independence**: No imports from @project/_, apps/_, or other out-of-package modules.
244
+ - **Verification**: Mandatory adapter integration tests and standalone package test execution.
245
+
246
+ ## Out of Scope (Current)
247
+
248
+ - validation / error state management (future `ValidatedInput` or form-level spec)
249
+ - multi-line input / textarea (separate `Textarea` component)
250
+ - input masking or formatting
251
+ - prefix / suffix slot content management (UIKit layout concern)
252
+ - autofill / autocomplete beyond password `autocomplete="off"`
253
+ - native form submission integration (handled by adapters/wrappers)
254
+ - number, date, time, or other non-text input types
@@ -0,0 +1,136 @@
1
+ # Landmarks Component Contract
2
+
3
+ ## Purpose
4
+
5
+ `Landmarks` is a headless contract for defining structural regions of a page to improve navigation for assistive technology users. It ensures that page regions are correctly identified with ARIA roles and accessible labels.
6
+
7
+ ## Component Files
8
+
9
+ - `src/landmarks/index.ts` - model and public `createLandmark` API
10
+ - `src/landmarks/landmarks.test.ts` - unit behavior tests
11
+
12
+ ## Public API
13
+
14
+ - `createLandmark(options)`
15
+ - `options`:
16
+ - `type`: landmark type (`banner`, `main`, `navigation`, `complementary`, `contentinfo`, `search`, `form`, `region`)
17
+ - `label?`: string or signal — accessible label text
18
+ - `labelId?`: string or signal — ID of the labelling element
19
+ - `idBase?`: string — base id prefix for generated ids (default: `'landmark-{type}'`)
20
+ - `state` (signal-backed):
21
+ - `type()` — the ARIA landmark role
22
+ - `label()` — accessible label string, or `null` if not provided
23
+ - `labelId()` — ID of the labelling element, or `null` if not provided
24
+ - `actions`: none (landmark is a pure semantic wrapper with no state transitions)
25
+ - `contracts`:
26
+ - `getLandmarkProps()` — returns complete ARIA prop object for the landmark element
27
+
28
+ ## State Signal Surface
29
+
30
+ | Signal | Type | Derived? | Description |
31
+ | --------- | ---------------------- | -------- | -------------------------------------- |
32
+ | `type` | `Atom<LandmarkType>` | No | The ARIA landmark role |
33
+ | `label` | `Atom<string \| null>` | No | Accessible label text, or `null` |
34
+ | `labelId` | `Atom<string \| null>` | No | ID of the labelling element, or `null` |
35
+
36
+ ## APG and A11y Contract
37
+
38
+ - roles:
39
+ - `banner` (header)
40
+ - `main`
41
+ - `navigation` (nav)
42
+ - `complementary` (aside)
43
+ - `contentinfo` (footer)
44
+ - `search`
45
+ - `form`
46
+ - `region`
47
+ - required attributes:
48
+ - `aria-label` or `aria-labelledby` if multiple landmarks of the same type exist on a page (e.g., multiple `navigation` regions)
49
+ - `role` attribute if using non-semantic HTML elements
50
+
51
+ ## Behavior Contract
52
+
53
+ - **Semantic Mapping**:
54
+ - the component ensures that the correct ARIA role is applied to the element
55
+ - if a semantic HTML element is used (e.g., `<main>`, `<nav>`), the role is redundant but harmless; if a `<div>` is used, the role is mandatory
56
+ - **Labeling**:
57
+ - if a `label` is provided, it is applied via `aria-label`
58
+ - if a `labelId` is provided, it is applied via `aria-labelledby`
59
+ - if both `label` and `labelId` are provided, `aria-labelledby` takes precedence and `aria-label` is omitted
60
+ - **Static Behavior**:
61
+ - landmark has no user-driven state transitions; it is a pure semantic wrapper
62
+ - state is set at creation time and may be updated reactively if atom-backed options are provided
63
+
64
+ ## Contract Prop Shapes
65
+
66
+ ### `getLandmarkProps()`
67
+
68
+ ```ts
69
+ {
70
+ role: LandmarkType // the landmark ARIA role
71
+ 'aria-label'?: string // present when label is set AND labelId is NOT set
72
+ 'aria-labelledby'?: string // present when labelId is set (takes precedence over label)
73
+ }
74
+ ```
75
+
76
+ ## Transitions Table
77
+
78
+ | Event / Action | Current State | Next State / Effect |
79
+ | ------------------------ | -------------------- | ---------------------------------------------------------------------------------- |
80
+ | `createLandmark(opts)` | — | `type = opts.type`, `label = opts.label ?? null`, `labelId = opts.labelId ?? null` |
81
+ | atom update on `label` | `label = oldValue` | `label = newValue`; `getLandmarkProps()` recomputes |
82
+ | atom update on `labelId` | `labelId = oldValue` | `labelId = newValue`; `getLandmarkProps()` recomputes |
83
+
84
+ > No user-driven actions exist. State changes only via reactive atom updates from the consumer.
85
+
86
+ ## Invariants
87
+
88
+ 1. `role` is always one of the eight valid `LandmarkType` values.
89
+ 2. When `labelId` is set, `aria-labelledby` is present and `aria-label` is omitted (labelledby takes precedence).
90
+ 3. When only `label` is set (no `labelId`), `aria-label` is present.
91
+ 4. When neither `label` nor `labelId` is set, neither `aria-label` nor `aria-labelledby` is present.
92
+ 5. A page should generally have only one `banner`, `main`, and `contentinfo` landmark.
93
+ 6. If multiple landmarks of the same type are used, they **must** have unique labels to be distinguishable by assistive technology (enforced via `findLandmarkUniquenessIssues` and `hasLandmarkUniquenessIssues` utilities).
94
+
95
+ ## Adapter Expectations
96
+
97
+ UIKit adapters MUST bind to the headless model as follows:
98
+
99
+ **Signals read (reactive, drive re-renders):**
100
+
101
+ - `state.type()` — the ARIA landmark role (used to select semantic HTML element or apply role attribute)
102
+ - `state.label()` — accessible label text (synced from the `label` attribute on the host)
103
+ - `state.labelId()` — ID of the labelling element (synced from the `label-id` attribute on the host)
104
+
105
+ **Actions called:**
106
+
107
+ - None. Landmark is a pure semantic wrapper with no user-driven state transitions.
108
+
109
+ **Contracts spread (attribute maps applied directly to DOM elements):**
110
+
111
+ - `contracts.getLandmarkProps()` — spread onto the root landmark element
112
+
113
+ **UIKit-only concerns (NOT in headless):**
114
+
115
+ - `display: block` styling on the host element
116
+ - Attribute-to-signal synchronization (`label` attr to `state.label`, `label-id` attr to `state.labelId`)
117
+ - Choice of semantic HTML element vs `<div>` with explicit `role`
118
+
119
+ ## Minimum Test Matrix
120
+
121
+ - verify correct role application for each landmark type
122
+ - verify `aria-label` application when provided
123
+ - verify `aria-labelledby` application when provided
124
+ - ensure no role is applied if a semantic element is already sufficient (optional optimization)
125
+
126
+ ## ADR-001 Compliance
127
+
128
+ - **Runtime Policy**: Reatom v1000 only; no `@statx/*` in headless core.
129
+ - **Layering**: `core -> interactions -> a11y-contracts -> adapters`; adapters remain thin mappings.
130
+ - **Independence**: No imports from `@project/*`, `apps/*`, or other out-of-package modules.
131
+ - **Verification**: Mandatory adapter integration tests and standalone package test execution.
132
+
133
+ ## Out of Scope (Current)
134
+
135
+ - automatic landmark detection/audit
136
+ - skip-link generation (handled by a separate utility)