@chromvoid/headless-ui 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +99 -0
- package/dist/a11y-contracts/index.d.ts +23 -0
- package/dist/a11y-contracts/index.js +1 -0
- package/dist/accordion/index.d.ts +78 -0
- package/dist/accordion/index.js +264 -0
- package/dist/adapters/index.d.ts +9 -0
- package/dist/adapters/index.js +1 -0
- package/dist/alert/index.d.ts +33 -0
- package/dist/alert/index.js +54 -0
- package/dist/alert-dialog/index.d.ts +69 -0
- package/dist/alert-dialog/index.js +94 -0
- package/dist/badge/index.d.ts +48 -0
- package/dist/badge/index.js +89 -0
- package/dist/breadcrumb/index.d.ts +55 -0
- package/dist/breadcrumb/index.js +77 -0
- package/dist/button/index.d.ts +46 -0
- package/dist/button/index.js +86 -0
- package/dist/callout/index.d.ts +41 -0
- package/dist/callout/index.js +63 -0
- package/dist/card/index.d.ts +54 -0
- package/dist/card/index.js +103 -0
- package/dist/carousel/index.d.ts +98 -0
- package/dist/carousel/index.js +243 -0
- package/dist/checkbox/index.d.ts +50 -0
- package/dist/checkbox/index.js +87 -0
- package/dist/combobox/index.d.ts +114 -0
- package/dist/combobox/index.js +431 -0
- package/dist/command-palette/index.d.ts +73 -0
- package/dist/command-palette/index.js +147 -0
- package/dist/context-menu/index.d.ts +111 -0
- package/dist/context-menu/index.js +372 -0
- package/dist/copy-button/index.d.ts +62 -0
- package/dist/copy-button/index.js +183 -0
- package/dist/core/index.d.ts +20 -0
- package/dist/core/index.js +2 -0
- package/dist/core/selection.d.ts +5 -0
- package/dist/core/selection.js +39 -0
- package/dist/core/value-range.d.ts +49 -0
- package/dist/core/value-range.js +134 -0
- package/dist/date-picker/index.d.ts +210 -0
- package/dist/date-picker/index.js +895 -0
- package/dist/dialog/index.d.ts +95 -0
- package/dist/dialog/index.js +153 -0
- package/dist/disclosure/index.d.ts +52 -0
- package/dist/disclosure/index.js +159 -0
- package/dist/drawer/index.d.ts +30 -0
- package/dist/drawer/index.js +39 -0
- package/dist/feed/index.d.ts +77 -0
- package/dist/feed/index.js +260 -0
- package/dist/grid/index.d.ts +103 -0
- package/dist/grid/index.js +415 -0
- package/dist/index.d.ts +51 -0
- package/dist/index.js +51 -0
- package/dist/input/index.d.ts +86 -0
- package/dist/input/index.js +156 -0
- package/dist/interactions/composite-navigation.d.ts +69 -0
- package/dist/interactions/composite-navigation.js +169 -0
- package/dist/interactions/index.d.ts +15 -0
- package/dist/interactions/index.js +4 -0
- package/dist/interactions/keyboard-intents.d.ts +16 -0
- package/dist/interactions/keyboard-intents.js +33 -0
- package/dist/interactions/overlay-focus.d.ts +40 -0
- package/dist/interactions/overlay-focus.js +93 -0
- package/dist/interactions/typeahead.d.ts +20 -0
- package/dist/interactions/typeahead.js +41 -0
- package/dist/landmarks/index.d.ts +39 -0
- package/dist/landmarks/index.js +58 -0
- package/dist/link/index.d.ts +34 -0
- package/dist/link/index.js +39 -0
- package/dist/listbox/index.d.ts +92 -0
- package/dist/listbox/index.js +337 -0
- package/dist/menu/index.d.ts +132 -0
- package/dist/menu/index.js +541 -0
- package/dist/menu-button/index.d.ts +71 -0
- package/dist/menu-button/index.js +121 -0
- package/dist/meter/index.d.ts +45 -0
- package/dist/meter/index.js +106 -0
- package/dist/number/index.d.ts +113 -0
- package/dist/number/index.js +252 -0
- package/dist/popover/index.d.ts +70 -0
- package/dist/popover/index.js +126 -0
- package/dist/progress/index.d.ts +49 -0
- package/dist/progress/index.js +79 -0
- package/dist/radio-group/index.d.ts +61 -0
- package/dist/radio-group/index.js +150 -0
- package/dist/select/index.d.ts +92 -0
- package/dist/select/index.js +239 -0
- package/dist/sidebar/index.d.ts +74 -0
- package/dist/sidebar/index.js +186 -0
- package/dist/slider/index.d.ts +61 -0
- package/dist/slider/index.js +150 -0
- package/dist/slider-multi-thumb/index.d.ts +70 -0
- package/dist/slider-multi-thumb/index.js +222 -0
- package/dist/spinbutton/index.d.ts +75 -0
- package/dist/spinbutton/index.js +214 -0
- package/dist/spinner/index.d.ts +1 -0
- package/dist/spinner/index.js +1 -0
- package/dist/spinner/spinner.d.ts +23 -0
- package/dist/spinner/spinner.js +25 -0
- package/dist/switch/index.d.ts +40 -0
- package/dist/switch/index.js +61 -0
- package/dist/table/index.d.ts +117 -0
- package/dist/table/index.js +377 -0
- package/dist/tabs/index.d.ts +63 -0
- package/dist/tabs/index.js +174 -0
- package/dist/textarea/index.d.ts +68 -0
- package/dist/textarea/index.js +137 -0
- package/dist/toast/index.d.ts +67 -0
- package/dist/toast/index.js +145 -0
- package/dist/toolbar/index.d.ts +59 -0
- package/dist/toolbar/index.js +139 -0
- package/dist/tooltip/index.d.ts +52 -0
- package/dist/tooltip/index.js +169 -0
- package/dist/treegrid/index.d.ts +101 -0
- package/dist/treegrid/index.js +463 -0
- package/dist/treeview/index.d.ts +68 -0
- package/dist/treeview/index.js +370 -0
- package/dist/window-splitter/index.d.ts +65 -0
- package/dist/window-splitter/index.js +204 -0
- package/package.json +92 -0
- package/specs/ADR-001-headless-architecture.md +461 -0
- package/specs/ADR-002-repo-release-model.md +108 -0
- package/specs/ADR-003-public-api-versioning.md +136 -0
- package/specs/ADR-004-focus-selection-policy.md +117 -0
- package/specs/IMPLEMENTATION-ROADMAP.md +237 -0
- package/specs/ISSUE-BACKLOG.md +681 -0
- package/specs/RELEASE-CANDIDATE.md +30 -0
- package/specs/components/accordion.md +130 -0
- package/specs/components/alert-dialog.md +72 -0
- package/specs/components/alert.md +65 -0
- package/specs/components/badge.md +220 -0
- package/specs/components/breadcrumb.md +74 -0
- package/specs/components/button.md +115 -0
- package/specs/components/callout.md +195 -0
- package/specs/components/card.md +280 -0
- package/specs/components/carousel.md +140 -0
- package/specs/components/checkbox.md +172 -0
- package/specs/components/combobox.md +423 -0
- package/specs/components/command-palette.md +92 -0
- package/specs/components/context-menu.md +556 -0
- package/specs/components/copy-button.md +293 -0
- package/specs/components/date-picker.md +400 -0
- package/specs/components/dialog.md +298 -0
- package/specs/components/disclosure.md +257 -0
- package/specs/components/drawer.md +353 -0
- package/specs/components/feed.md +265 -0
- package/specs/components/grid.md +186 -0
- package/specs/components/input.md +254 -0
- package/specs/components/landmarks.md +136 -0
- package/specs/components/link.md +134 -0
- package/specs/components/listbox.md +351 -0
- package/specs/components/menu-button.md +76 -0
- package/specs/components/menu.md +623 -0
- package/specs/components/meter.md +149 -0
- package/specs/components/number.md +393 -0
- package/specs/components/popover.md +252 -0
- package/specs/components/progress.md +188 -0
- package/specs/components/radio-group.md +151 -0
- package/specs/components/select.md +144 -0
- package/specs/components/sidebar.md +321 -0
- package/specs/components/slider-multi-thumb.md +78 -0
- package/specs/components/slider.md +84 -0
- package/specs/components/spinbutton.md +140 -0
- package/specs/components/spinner.md +132 -0
- package/specs/components/switch.md +175 -0
- package/specs/components/table.md +403 -0
- package/specs/components/tabs.md +265 -0
- package/specs/components/textarea.md +185 -0
- package/specs/components/toast.md +198 -0
- package/specs/components/toolbar.md +278 -0
- package/specs/components/tooltip.md +252 -0
- package/specs/components/treegrid.md +281 -0
- package/specs/components/treeview.md +91 -0
- package/specs/components/window-splitter.md +297 -0
- package/specs/ops/git-shard-sync.md +107 -0
- package/specs/ops/release-checklist.md +76 -0
- package/specs/release/GAP-TO-GREEN-ISSUES.md +88 -0
- package/specs/release/api-freeze-candidate.md +54 -0
- package/specs/release/changelog-automation.md +76 -0
- package/specs/release/changelog.generated.md +53 -0
- package/specs/release/changelog.patch.generated.md +46 -0
- package/specs/release/consumer-integration.md +53 -0
- package/specs/release/migration-notes-pre-v1.md +40 -0
- package/specs/release/mvp-changelog.md +57 -0
- package/specs/release/release-notes-template.md +61 -0
- package/specs/release/release-rehearsal.md +113 -0
- package/specs/release/semver-deprecation-dry-run.md +89 -0
- package/specs/release/shard-release-drill-report.md +50 -0
- package/specs/release/shard-release-follow-ups.md +31 -0
- package/specs/signals.md +208 -0
|
@@ -0,0 +1,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)
|