@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,403 @@
1
+ # Table Component Contract
2
+
3
+ ## Purpose
4
+
5
+ `Table` provides a headless APG-aligned model for tabular data. In its default (non-interactive) mode it is a structural container navigated by screen reader reading commands. When `interactive` mode is enabled, the root role switches to `grid` and full APG keyboard cell navigation is activated. Optional row selection support (single or multi) is available in both modes.
6
+
7
+ ## Component Files
8
+
9
+ - `src/table/index.ts` - model and public `createTable` API
10
+ - `src/table/table.test.ts` - unit behavior tests
11
+
12
+ ## Public API
13
+
14
+ ### `createTable(options: CreateTableOptions): TableModel`
15
+
16
+ ### CreateTableOptions
17
+
18
+ | Option | Type | Default | Description |
19
+ | --------------------------- | ------------------------------ | ---------------- | -------------------------------------------------------------------- |
20
+ | `columns` | `readonly TableColumn[]` | required | Column definitions (`{ id, index? }`) |
21
+ | `rows` | `readonly TableRow[]` | required | Row definitions (`{ id, index? }`) |
22
+ | `totalColumnCount` | `number` | `columns.length` | Logical column count (for virtualization) |
23
+ | `totalRowCount` | `number` | `rows.length` | Logical row count (for virtualization) |
24
+ | `initialSortColumnId` | `string \| null` | `null` | Initial sort column |
25
+ | `initialSortDirection` | `TableSortDirection` | `'none'` | Initial sort direction |
26
+ | `ariaLabel` | `string` | -- | Static `aria-label` for the table root |
27
+ | `ariaLabelledBy` | `string` | -- | `aria-labelledby` reference for the table root |
28
+ | `idBase` | `string` | `'table'` | Prefix for generated DOM ids |
29
+ | `selectable` | `'single' \| 'multi' \| false` | `false` | Row selection mode |
30
+ | `initialSelectedRowIds` | `readonly string[]` | `[]` | Initial selected row ids (filtered for validity on create) |
31
+ | `interactive` | `boolean` | `false` | Enable grid navigation mode |
32
+ | `initialFocusedRowIndex` | `number \| null` | `null` | Initial focused row index (interactive mode only) |
33
+ | `initialFocusedColumnIndex` | `number \| null` | `null` | Initial focused column index (interactive mode only) |
34
+ | `pageSize` | `number` | `10` | Rows per page for PageUp/PageDown (interactive mode only, minimum 1) |
35
+
36
+ ### State (signal-backed)
37
+
38
+ | Signal | Type | Description |
39
+ | -------------------- | -------------------------- | ------------------------------------------------------------------------------- |
40
+ | `rowCount` | `Computed<number>` | `max(totalRowCount, rows.length)` |
41
+ | `columnCount` | `Computed<number>` | `max(totalColumnCount, columns.length)` |
42
+ | `sortColumnId` | `Atom<string \| null>` | Currently sorted column id |
43
+ | `sortDirection` | `Atom<TableSortDirection>` | `'ascending' \| 'descending' \| 'none'` |
44
+ | `selectedRowIds` | `Atom<Set<string>>` | Set of selected row ids (empty when `selectable` is `false`) |
45
+ | `focusedRowIndex` | `Atom<number \| null>` | Currently focused row index (null when `interactive` is `false` or no focus) |
46
+ | `focusedColumnIndex` | `Atom<number \| null>` | Currently focused column index (null when `interactive` is `false` or no focus) |
47
+
48
+ Static config values exposed on state for adapter convenience:
49
+
50
+ | Property | Type | Description |
51
+ | ------------- | ------------------------------ | ----------------------------------------------- |
52
+ | `selectable` | `'single' \| 'multi' \| false` | Current selection mode (from config) |
53
+ | `interactive` | `boolean` | Whether grid navigation is active (from config) |
54
+
55
+ ### Actions
56
+
57
+ #### Sorting
58
+
59
+ | Action | Signature | Description |
60
+ | ----------- | ----------------------------------------------------------- | ----------------------------- |
61
+ | `sortBy` | `(columnId: string, direction: TableSortDirection) => void` | Set sort column and direction |
62
+ | `clearSort` | `() => void` | Reset sort to `none` |
63
+
64
+ #### Selection
65
+
66
+ | Action | Signature | Description |
67
+ | -------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
68
+ | `selectRow` | `(rowId: string) => void` | Select a row. In single mode, clears other selections first. No-op when `selectable` is `false` or `rowId` is unknown. |
69
+ | `deselectRow` | `(rowId: string) => void` | Deselect a row. No-op when `selectable` is `false` or `rowId` is not selected. |
70
+ | `toggleRowSelection` | `(rowId: string) => void` | Toggle selection state for a row. In single mode, toggling on clears other selections. No-op when `selectable` is `false`. |
71
+ | `selectAllRows` | `() => void` | Select all rows. Only works in `multi` mode. No-op when `selectable` is not `multi`. |
72
+ | `clearSelection` | `() => void` | Clear all row selections. No-op when `selectable` is `false`. |
73
+
74
+ #### Grid Navigation (interactive mode only)
75
+
76
+ All navigation actions are no-ops when `interactive` is `false`.
77
+
78
+ | Action | Signature | Description |
79
+ | --------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
80
+ | `moveFocus` | `(direction: 'up' \| 'down' \| 'left' \| 'right') => void` | Move focused cell one step in the given direction. No change at boundary. |
81
+ | `moveFocusToStart` | `() => void` | Move focus to first cell (row 0, column 0). Equivalent to Ctrl+Home. |
82
+ | `moveFocusToEnd` | `() => void` | Move focus to last cell (last row, last column). Equivalent to Ctrl+End. |
83
+ | `moveFocusToRowStart` | `() => void` | Move focus to first cell in the current row. Equivalent to Home. |
84
+ | `moveFocusToRowEnd` | `() => void` | Move focus to last cell in the current row. Equivalent to End. |
85
+ | `setFocusedCell` | `(rowIndex: number, columnIndex: number) => void` | Programmatically set the focused cell. Clamped to valid bounds. |
86
+ | `pageUp` | `() => void` | Move focus up by `pageSize` rows (clamped). |
87
+ | `pageDown` | `() => void` | Move focus down by `pageSize` rows (clamped). |
88
+ | `handleKeyDown` | `(event: TableKeyboardEventLike) => void` | Delegates keyboard events to navigation/selection actions. Only active in interactive mode. |
89
+
90
+ ### Contracts (ready-to-spread ARIA prop objects)
91
+
92
+ | Contract | Return Type | Description |
93
+ | ----------------------------------- | ------------------------ | ----------------------------------------------- |
94
+ | `getTableProps()` | `TableProps` | ARIA attributes for the table/grid root element |
95
+ | `getRowProps(rowId)` | `TableRowProps` | ARIA attributes for a row element |
96
+ | `getCellProps(rowId, colId, span?)` | `TableCellProps` | ARIA attributes for a data cell |
97
+ | `getColumnHeaderProps(colId)` | `TableColumnHeaderProps` | ARIA attributes for a column header |
98
+ | `getRowHeaderProps(rowId, colId)` | `TableRowHeaderProps` | ARIA attributes for a row header cell |
99
+
100
+ #### `TableProps`
101
+
102
+ | Prop | Type | Notes |
103
+ | ---------------------- | --------------------- | --------------------------------------------------------------------------------- |
104
+ | `id` | `string` | `"{idBase}-root"` |
105
+ | `role` | `'table' \| 'grid'` | `'table'` when `interactive` is false, `'grid'` when true |
106
+ | `aria-label` | `string \| undefined` | From options |
107
+ | `aria-labelledby` | `string \| undefined` | From options |
108
+ | `aria-rowcount` | `number` | From `rowCount` computed |
109
+ | `aria-colcount` | `number` | From `columnCount` computed |
110
+ | `aria-multiselectable` | `'true' \| undefined` | Present only when `selectable` is `'multi'` |
111
+ | `tabindex` | `'0' \| undefined` | `'0'` when `interactive` is true (grid root receives focus), undefined when false |
112
+
113
+ #### `TableRowProps`
114
+
115
+ | Prop | Type | Notes |
116
+ | --------------- | -------------------------------- | ------------------------------------------------------------------------ |
117
+ | `id` | `string` | `"{idBase}-row-{rowId}"` |
118
+ | `role` | `'row'` | Always `'row'` regardless of mode |
119
+ | `aria-rowindex` | `number` | 1-based; uses `row.index` if provided, else positional index + 1 |
120
+ | `aria-selected` | `'true' \| 'false' \| undefined` | Present only when `selectable` is not `false`. Reflects selection state. |
121
+
122
+ #### `TableCellProps`
123
+
124
+ | Prop | Type | Notes |
125
+ | --------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------ |
126
+ | `id` | `string` | `"{idBase}-cell-{rowId}-{colId}"` |
127
+ | `role` | `'cell' \| 'gridcell'` | `'cell'` when `interactive` is false, `'gridcell'` when true |
128
+ | `aria-colindex` | `number` | 1-based column index |
129
+ | `aria-colspan` | `number \| undefined` | From span parameter |
130
+ | `aria-rowspan` | `number \| undefined` | From span parameter |
131
+ | `tabindex` | `'0' \| '-1' \| undefined` | Present only in interactive mode. `'0'` for the focused cell, `'-1'` for all others (roving tabindex). |
132
+ | `data-active` | `'true' \| 'false' \| undefined` | Present only in interactive mode. Indicates currently focused cell. |
133
+
134
+ #### `TableColumnHeaderProps`
135
+
136
+ | Prop | Type | Notes |
137
+ | --------------- | -------------------------- | ------------------------------------------------------------------------------------------- |
138
+ | `id` | `string` | `"{idBase}-column-header-{colId}"` |
139
+ | `role` | `'columnheader'` | Always `'columnheader'` |
140
+ | `aria-colindex` | `number` | 1-based column index |
141
+ | `aria-sort` | `TableSortDirection` | Sort state for this column |
142
+ | `tabindex` | `'0' \| '-1' \| undefined` | Present only in interactive mode. Roving tabindex (headers participate in grid navigation). |
143
+
144
+ #### `TableRowHeaderProps`
145
+
146
+ | Prop | Type | Notes |
147
+ | --------------- | ------------- | --------------------------------------- |
148
+ | `id` | `string` | `"{idBase}-row-header-{rowId}-{colId}"` |
149
+ | `role` | `'rowheader'` | Always `'rowheader'` |
150
+ | `aria-rowindex` | `number` | 1-based row index |
151
+ | `aria-colindex` | `number` | 1-based column index |
152
+
153
+ ## APG and A11y Contract
154
+
155
+ ### Non-interactive mode (default)
156
+
157
+ - root role: `table`
158
+ - row role: `row`
159
+ - cell role: `cell`
160
+ - header roles: `columnheader`, `rowheader`
161
+ - required attributes:
162
+ - root: `aria-label` or `aria-labelledby`, `aria-colcount`, `aria-rowcount`
163
+ - row: `aria-rowindex`, `aria-selected` (when selectable)
164
+ - cell: `aria-colindex`, `aria-colspan`, `aria-rowspan`
165
+ - header: `aria-sort` (if applicable)
166
+ - `Table` is a static structure; users navigate it using screen reader reading commands.
167
+ - If a cell contains interactive elements, those elements are part of the page's tab sequence.
168
+
169
+ ### Interactive mode (`interactive: true`)
170
+
171
+ - root role: `grid`
172
+ - row role: `row`
173
+ - cell role: `gridcell`
174
+ - header roles: `columnheader`, `rowheader`
175
+ - focus management: roving tabindex on cells
176
+ - required attributes:
177
+ - root: `aria-label` or `aria-labelledby`, `aria-colcount`, `aria-rowcount`, `tabindex="0"`, `aria-multiselectable` (if multi-select)
178
+ - row: `aria-rowindex`, `aria-selected` (when selectable)
179
+ - cell: `aria-colindex`, `tabindex`, `data-active`
180
+
181
+ ### Selection attributes (both modes)
182
+
183
+ - When `selectable` is not `false`:
184
+ - `aria-selected` is present on every `row` element (`'true'` or `'false'`)
185
+ - `aria-multiselectable="true"` is on root when `selectable` is `'multi'`
186
+
187
+ ## Keyboard Contract (interactive mode only)
188
+
189
+ | Key | Modifier | Action |
190
+ | -------------- | ----------- | ---------------------------------------------------- |
191
+ | `ArrowUp` | -- | `moveFocus('up')` |
192
+ | `ArrowDown` | -- | `moveFocus('down')` |
193
+ | `ArrowLeft` | -- | `moveFocus('left')` |
194
+ | `ArrowRight` | -- | `moveFocus('right')` |
195
+ | `Home` | -- | `moveFocusToRowStart()` |
196
+ | `End` | -- | `moveFocusToRowEnd()` |
197
+ | `Home` | Ctrl / Meta | `moveFocusToStart()` |
198
+ | `End` | Ctrl / Meta | `moveFocusToEnd()` |
199
+ | `PageUp` | -- | `pageUp()` |
200
+ | `PageDown` | -- | `pageDown()` |
201
+ | `Space` | -- | `toggleRowSelection(focusedRowId)` (when selectable) |
202
+ | `Ctrl/Cmd + A` | -- | `selectAllRows()` (when selectable is multi) |
203
+
204
+ ## Transitions Table
205
+
206
+ ### Sorting Transitions
207
+
208
+ | Trigger | Preconditions | Next State |
209
+ | -------------------------- | ---------------------------------------- | -------------------------------------------- |
210
+ | `sortBy(columnId, dir)` | `columnId` exists, `dir` is not `'none'` | `sortColumnId=columnId`, `sortDirection=dir` |
211
+ | `sortBy(columnId, 'none')` | any | `sortColumnId=null`, `sortDirection='none'` |
212
+ | `clearSort()` | any | `sortColumnId=null`, `sortDirection='none'` |
213
+
214
+ ### Selection Transitions
215
+
216
+ | Trigger | Preconditions | Next State |
217
+ | --------------------------- | ------------------------------------------- | ------------------------------------------ |
218
+ | `selectRow(rowId)` | `selectable='single'`, `rowId` is known | `selectedRowIds={rowId}` (clears previous) |
219
+ | `selectRow(rowId)` | `selectable='multi'`, `rowId` is known | `selectedRowIds` adds `rowId` |
220
+ | `deselectRow(rowId)` | `selectable` is not `false`, `rowId` in set | `selectedRowIds` removes `rowId` |
221
+ | `toggleRowSelection(rowId)` | `selectable='single'`, `rowId` not selected | `selectedRowIds={rowId}` |
222
+ | `toggleRowSelection(rowId)` | `selectable='single'`, `rowId` selected | `selectedRowIds={}` |
223
+ | `toggleRowSelection(rowId)` | `selectable='multi'`, `rowId` not selected | `selectedRowIds` adds `rowId` |
224
+ | `toggleRowSelection(rowId)` | `selectable='multi'`, `rowId` selected | `selectedRowIds` removes `rowId` |
225
+ | `selectAllRows()` | `selectable='multi'` | `selectedRowIds` = all known row ids |
226
+ | `selectAllRows()` | `selectable` is not `'multi'` | no-op |
227
+ | `clearSelection()` | `selectable` is not `false` | `selectedRowIds={}` |
228
+ | `clearSelection()` | `selectable=false` | no-op |
229
+
230
+ ### Grid Navigation Transitions (interactive mode only)
231
+
232
+ | Trigger | Preconditions | Next State |
233
+ | ----------------------- | -------------------------------------- | ------------------------------------------------------------------------------------------ |
234
+ | `moveFocus('up')` | `focusedRowIndex > 0` | `focusedRowIndex -= 1` |
235
+ | `moveFocus('up')` | `focusedRowIndex = 0` | no change |
236
+ | `moveFocus('down')` | `focusedRowIndex < rowCount - 1` | `focusedRowIndex += 1` |
237
+ | `moveFocus('down')` | `focusedRowIndex = rowCount - 1` | no change |
238
+ | `moveFocus('left')` | `focusedColumnIndex > 0` | `focusedColumnIndex -= 1` |
239
+ | `moveFocus('left')` | `focusedColumnIndex = 0` | no change |
240
+ | `moveFocus('right')` | `focusedColumnIndex < columnCount - 1` | `focusedColumnIndex += 1` |
241
+ | `moveFocus('right')` | `focusedColumnIndex = columnCount - 1` | no change |
242
+ | `moveFocusToStart()` | any | `focusedRowIndex=0`, `focusedColumnIndex=0` |
243
+ | `moveFocusToEnd()` | any | `focusedRowIndex=rowCount-1`, `focusedColumnIndex=columnCount-1` |
244
+ | `moveFocusToRowStart()` | any | `focusedColumnIndex=0` |
245
+ | `moveFocusToRowEnd()` | any | `focusedColumnIndex=columnCount-1` |
246
+ | `setFocusedCell(r, c)` | any | `focusedRowIndex=clamp(r, 0, rowCount-1)`, `focusedColumnIndex=clamp(c, 0, columnCount-1)` |
247
+ | `pageUp()` | any | `focusedRowIndex=max(0, focusedRowIndex - pageSize)` |
248
+ | `pageDown()` | any | `focusedRowIndex=min(rowCount-1, focusedRowIndex + pageSize)` |
249
+
250
+ ### Keyboard to Action Mapping (interactive mode)
251
+
252
+ | Key Event | Mapped Action |
253
+ | ------------------------------------------- | ---------------------------------------------- |
254
+ | `ArrowUp` | `moveFocus('up')` |
255
+ | `ArrowDown` | `moveFocus('down')` |
256
+ | `ArrowLeft` | `moveFocus('left')` |
257
+ | `ArrowRight` | `moveFocus('right')` |
258
+ | `Home` | `moveFocusToRowStart()` |
259
+ | `End` | `moveFocusToRowEnd()` |
260
+ | `Ctrl+Home` / `Meta+Home` | `moveFocusToStart()` |
261
+ | `Ctrl+End` / `Meta+End` | `moveFocusToEnd()` |
262
+ | `PageUp` | `pageUp()` |
263
+ | `PageDown` | `pageDown()` |
264
+ | `Space` (when selectable) | `toggleRowSelection(rows[focusedRowIndex].id)` |
265
+ | `Ctrl+A` / `Meta+A` (when selectable=multi) | `selectAllRows()` |
266
+
267
+ ## Invariants
268
+
269
+ ### General
270
+
271
+ - `aria-rowcount` and `aria-colcount` must reflect the total number of rows and columns in the data set, even if only a subset is rendered.
272
+ - `aria-rowindex` and `aria-colindex` must be 1-based and reflect the position in the total data set.
273
+ - `getRowProps`, `getCellProps`, `getColumnHeaderProps`, `getRowHeaderProps` throw `Error` for unknown row/column ids.
274
+
275
+ ### Selection
276
+
277
+ - When `selectable` is `false`, `selectedRowIds` is always empty and selection actions are no-ops.
278
+ - In `single` mode, `selectedRowIds` contains at most one id.
279
+ - `selectRow` in `single` mode clears all other selections before adding the new one.
280
+ - `selectAllRows` only has effect in `multi` mode.
281
+ - `aria-selected` is present on rows only when `selectable` is not `false`.
282
+ - `aria-multiselectable` is present on root only when `selectable` is `'multi'`.
283
+ - `initialSelectedRowIds` are filtered on create: unknown row ids are excluded. In single mode, only the first valid id is kept.
284
+
285
+ ### Grid Navigation
286
+
287
+ - When `interactive` is `false`, `focusedRowIndex` and `focusedColumnIndex` are always `null` and navigation actions are no-ops.
288
+ - When `interactive` is `true`, exactly one cell has `tabindex="0"` (roving tabindex); all other cells have `tabindex="-1"`.
289
+ - `focusedRowIndex` and `focusedColumnIndex` stay within grid bounds: `[0, rowCount-1]` and `[0, columnCount-1]` respectively.
290
+ - When `interactive` is `true` and initial focus indices are not provided, focus defaults to `(0, 0)`.
291
+ - Role switches: root is `'grid'` (not `'table'`), data cells are `'gridcell'` (not `'cell'`).
292
+ - `handleKeyDown` is a no-op when `interactive` is `false`.
293
+
294
+ ## Adapter Expectations
295
+
296
+ UIKit adapter will:
297
+
298
+ **Signals read (reactive, drive re-renders):**
299
+
300
+ - `state.rowCount()` -- total row count
301
+ - `state.columnCount()` -- total column count
302
+ - `state.sortColumnId()` -- currently sorted column id
303
+ - `state.sortDirection()` -- sort direction
304
+ - `state.selectedRowIds()` -- set of selected row ids
305
+ - `state.focusedRowIndex()` -- focused row index (interactive mode)
306
+ - `state.focusedColumnIndex()` -- focused column index (interactive mode)
307
+ - `state.selectable` -- selection mode (static, for conditional rendering)
308
+ - `state.interactive` -- interactive mode flag (static, for conditional rendering)
309
+
310
+ **Actions called (event handlers, never mutate state directly):**
311
+
312
+ - `actions.sortBy(columnId, direction)` -- column header click
313
+ - `actions.clearSort()` -- reset sort
314
+ - `actions.selectRow(rowId)` -- row click (single select)
315
+ - `actions.deselectRow(rowId)` -- row deselect
316
+ - `actions.toggleRowSelection(rowId)` -- row click (toggle)
317
+ - `actions.selectAllRows()` -- select-all checkbox/shortcut
318
+ - `actions.clearSelection()` -- clear all selections
319
+ - `actions.moveFocus(direction)` -- arrow key navigation (interactive)
320
+ - `actions.moveFocusToStart()` / `actions.moveFocusToEnd()` -- Ctrl+Home/End (interactive)
321
+ - `actions.moveFocusToRowStart()` / `actions.moveFocusToRowEnd()` -- Home/End (interactive)
322
+ - `actions.setFocusedCell(rowIndex, columnIndex)` -- programmatic focus / cell click (interactive)
323
+ - `actions.pageUp()` / `actions.pageDown()` -- PageUp/PageDown (interactive)
324
+ - `actions.handleKeyDown(event)` -- keyboard delegation (interactive)
325
+
326
+ **Contracts spread (attribute maps applied directly to DOM elements):**
327
+
328
+ - `contracts.getTableProps()` -- spread onto table/grid root element
329
+ - `contracts.getRowProps(rowId)` -- spread onto each row element
330
+ - `contracts.getCellProps(rowId, colId, span?)` -- spread onto each data cell
331
+ - `contracts.getColumnHeaderProps(colId)` -- spread onto each column header
332
+ - `contracts.getRowHeaderProps(rowId, colId)` -- spread onto row header cells
333
+
334
+ **UIKit-only concerns (NOT in headless):**
335
+
336
+ - Display variants (striped, compact, bordered) -- CSS-only
337
+ - Sticky header positioning -- CSS-only
338
+ - Visual selection indicators (checkbox column, row highlighting)
339
+ - DOM focus management (calling `.focus()` on cells when `focusedRowIndex`/`focusedColumnIndex` change)
340
+ - `preventDefault()` on keyboard events handled by `handleKeyDown`
341
+
342
+ ## Minimum Test Matrix
343
+
344
+ ### Structural (existing)
345
+
346
+ - correct structural ARIA roles (`table`, `row`, `cell`)
347
+ - 1-based index mapping for `aria-rowindex` and `aria-colindex`
348
+ - `aria-sort` state transitions when `sortBy` is called
349
+ - support for `colspan` and `rowspan` metadata in `getCellProps`
350
+ - virtualization support (correct total counts vs rendered counts)
351
+
352
+ ### Selection
353
+
354
+ - `selectable=false`: no `aria-selected` on rows, selection actions are no-ops
355
+ - `selectable='single'`: `selectRow` replaces current selection
356
+ - `selectable='single'`: `selectedRowIds` contains at most one id
357
+ - `selectable='single'`: `toggleRowSelection` toggles single row on/off
358
+ - `selectable='single'`: `selectAllRows` is a no-op
359
+ - `selectable='multi'`: `selectRow` adds to selection
360
+ - `selectable='multi'`: `toggleRowSelection` adds/removes individual row
361
+ - `selectable='multi'`: `selectAllRows` selects all known rows
362
+ - `selectable='multi'`: `clearSelection` empties selection set
363
+ - `aria-multiselectable="true"` present only when `selectable='multi'`
364
+ - `aria-selected` present on every row when `selectable` is not `false`
365
+ - `initialSelectedRowIds` filtered for validity and mode constraints
366
+
367
+ ### Grid Navigation (interactive mode)
368
+
369
+ - `interactive=false`: roles are `table`/`cell`, no `tabindex` on cells, navigation actions are no-ops
370
+ - `interactive=true`: root role is `grid`, cell role is `gridcell`
371
+ - `interactive=true`: exactly one cell has `tabindex="0"` (roving tabindex)
372
+ - arrow key navigation moves focus within bounds
373
+ - boundary clamping (no wrap, no change at edges)
374
+ - Home/End navigate to row start/end
375
+ - Ctrl+Home/End navigate to grid start/end
376
+ - PageUp/PageDown move by `pageSize` rows (clamped)
377
+ - `setFocusedCell` clamps to valid bounds
378
+ - `handleKeyDown` delegates to correct navigation actions
379
+ - Space key triggers `toggleRowSelection` when selectable
380
+ - Ctrl+A triggers `selectAllRows` when selectable is multi
381
+ - default focus is `(0, 0)` when interactive and no initial focus provided
382
+
383
+ ### Combined Modes
384
+
385
+ - interactive + selectable: Space key selects focused row
386
+ - interactive + selectable='multi': Ctrl+A selects all rows
387
+ - non-interactive + selectable: selection works without grid navigation
388
+
389
+ ## ADR-001 Compliance
390
+
391
+ - **Runtime Policy**: Reatom v1000 only; no @statx/\* in headless core.
392
+ - **Layering**: core -> interactions -> a11y-contracts -> adapters; adapters remain thin mappings.
393
+ - **Independence**: No imports from @project/_, apps/_, or other out-of-package modules.
394
+ - **Verification**: Mandatory adapter integration tests and standalone package test execution.
395
+
396
+ ## Out of Scope (Current)
397
+
398
+ - cell-level selection (only row selection is supported; use `Grid` for cell selection)
399
+ - cell editing mode (inline inputs)
400
+ - column/row reordering (drag and drop)
401
+ - column resizing
402
+ - complex filtering logic (should be handled in the model/service layer)
403
+ - `aria-activedescendant` focus strategy (only roving tabindex is supported in interactive mode)