@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,144 @@
1
+ # Select Component Contract
2
+
3
+ ## Purpose
4
+
5
+ `Select` provides a headless single- or multi-selection model composed from trigger + listbox behavior, following the W3C APG Select-Only Combobox pattern.
6
+
7
+ ## Component Files
8
+
9
+ - `src/select/index.ts` - model and public `createSelect` API
10
+ - `src/select/select.test.ts` - unit behavior tests
11
+
12
+ ## Public API
13
+
14
+ - `createSelect(options)`
15
+ - `options` — `readonly ListboxOption[]`
16
+ - `idBase` — optional id prefix
17
+ - `ariaLabel` — optional accessible label
18
+ - `initialOpen` — optional initial popup state
19
+ - `initialSelectedId` — optional initial selected id (single convenience)
20
+ - `initialSelectedIds` — optional initial selected ids array
21
+ - `selectionMode` — `'single'` (default) or `'multiple'`
22
+ - `closeOnSelect` — close popup on selection (default `true`)
23
+ - `placeholder` — fallback value text
24
+ - `disabled` — initial disabled state (default `false`)
25
+ - `required` — initial required state (default `false`)
26
+ - `onSelectedIdChange` — callback on first-selected-id change
27
+ - `state` (signal-backed):
28
+ - `isOpen()` - popup visibility
29
+ - `activeId()` - currently focused option id
30
+ - `selectedIds()` - selected ids array
31
+ - `selectedId()` - computed first selected id
32
+ - `selectedLabel()` - computed first selected label
33
+ - `selectedLabels()` - computed ordered labels for all selected ids
34
+ - `restoreTargetId()` - trigger restore target after close
35
+ - `disabled()` - whether the select is disabled
36
+ - `required()` - whether the select is required
37
+ - `actions`:
38
+ - `open`, `close`, `toggle`
39
+ - `select(id)` — in single mode calls `selectOnly`; in multiple mode calls `toggleSelected`
40
+ - `clear()`
41
+ - `setDisabled(value)` — update disabled state
42
+ - `setRequired(value)` — update required state
43
+ - `handleTriggerKeyDown`
44
+ - `handleListboxKeyDown`
45
+ - `contracts`:
46
+ - `getTriggerProps()` — returns combobox ARIA props including `aria-activedescendant` when open
47
+ - `getListboxProps()` — includes `aria-multiselectable: 'true'` when `selectionMode='multiple'`
48
+ - `getOptionProps(id)`
49
+ - `getValueText()` — single: first label; multiple: comma-joined labels; fallback: placeholder
50
+
51
+ ## APG and A11y Contract
52
+
53
+ Follows the W3C APG Select-Only Combobox pattern. DOM focus stays on the trigger; visual focus is managed via `aria-activedescendant`.
54
+
55
+ - trigger role: `combobox`
56
+ - trigger attributes:
57
+ - `aria-haspopup="listbox"`
58
+ - `aria-expanded`
59
+ - `aria-controls`
60
+ - `aria-activedescendant` — references the active option DOM id when open
61
+ - `aria-disabled` — when `disabled` is `true`
62
+ - `aria-required` — when `required` is `true`
63
+ - `aria-label` — optional accessible label
64
+ - popup role: `listbox`
65
+ - popup attributes:
66
+ - `aria-multiselectable` (when `selectionMode='multiple'`)
67
+ - `hidden`
68
+ - option role: `option`
69
+ - option attributes:
70
+ - `aria-selected`
71
+ - `aria-disabled` (when disabled)
72
+
73
+ ## Keyboard Contract
74
+
75
+ - trigger (closed):
76
+ - `ArrowDown` / `Home`: open and focus first option
77
+ - `ArrowUp` / `End`: open and focus last option
78
+ - `Enter` / `Space`: toggle popup
79
+ - listbox (open — DOM focus remains on trigger, visual focus via `aria-activedescendant`):
80
+ - `ArrowDown` / `ArrowUp` / `Home` / `End`: navigation delegated to listbox keyboard contract
81
+ - `Enter` / `Space`: select active option (single: `selectOnly`; multiple: `toggleSelected`)
82
+ - `Escape` / `Tab`: close and restore focus target
83
+ - when disabled: all keyboard handlers are no-ops
84
+
85
+ ## Behavior Contract
86
+
87
+ - `Select` reuses `createListbox` with configurable `selectionMode`.
88
+ - Opening behavior chooses initial focus strategy (`selected`, `first`, `last`).
89
+ - `select(id)` in single mode calls `selectOnly`; in multiple mode calls `toggleSelected`.
90
+ - Selection callback `onSelectedIdChange` fires only on actual first-selected-id changes.
91
+ - `getValueText()` returns comma-joined labels in multiple mode, first label in single mode, fallback placeholder, or empty string.
92
+ - When `disabled` is `true`, all interaction actions (`open`, `close`, `toggle`, `select`, `clear`, keyboard handlers) are no-ops.
93
+ - `disabled` and `required` are mutable via `setDisabled`/`setRequired` actions for dynamic updates.
94
+
95
+ ## Invariants
96
+
97
+ - `selectedId` must equal `selectedIds[0]` when present.
98
+ - `selectedLabel` must resolve from `selectedId` and option map.
99
+ - `selectedLabels` must resolve from `selectedIds` and option map, preserving order.
100
+ - Trigger `data-selected-id` / `data-selected-label` must reflect computed selection state.
101
+ - Close path must set `restoreTargetId` to trigger id.
102
+ - When open, trigger `aria-activedescendant` must reference the active option DOM id.
103
+ - When disabled, `aria-disabled="true"` must be present on trigger props.
104
+ - When required, `aria-required="true"` must be present on trigger props.
105
+
106
+ ## Minimum Test Matrix
107
+
108
+ - trigger keyboard open/close flow
109
+ - trigger/listbox role and `aria-controls` linkage (trigger role is `combobox`)
110
+ - trigger `aria-activedescendant` references active option when open
111
+ - active option selection with `Enter`
112
+ - selected state and value text synchronization
113
+ - open-on-arrow-up path focusing last option
114
+ - multi-select toggle via `select()` — `selectedIds` grows/shrinks
115
+ - multi-select `getValueText()` — comma-joined labels
116
+ - multi-select `getListboxProps()` — `aria-multiselectable: 'true'`
117
+ - multi-select + `closeOnSelect: false` — popup stays open
118
+ - `selectedLabels` computed — returns ordered labels
119
+ - disabled blocks all interactions (open, select, keyboard)
120
+ - disabled `getTriggerProps()` includes `aria-disabled: 'true'`
121
+ - required `getTriggerProps()` includes `aria-required: 'true'`
122
+ - `clear()` is no-op when disabled
123
+
124
+ ## Adapter Expectations
125
+
126
+ UIKit adapter will:
127
+
128
+ - Read: `state.isOpen`, `state.activeId`, `state.selectedId`, `state.selectedLabel`, `state.selectedIds`, `state.selectedLabels`, `state.disabled`, `state.required`, `state.restoreTargetId`
129
+ - Call: `actions.open`, `actions.close`, `actions.toggle`, `actions.select`, `actions.clear`, `actions.setDisabled`, `actions.setRequired`, `actions.handleTriggerKeyDown`, `actions.handleListboxKeyDown`
130
+ - Spread: `contracts.getTriggerProps()` onto trigger element, `contracts.getListboxProps()` onto listbox container, `contracts.getOptionProps(id)` onto each option
131
+ - Display: `contracts.getValueText()` as trigger display text
132
+
133
+ ## ADR-001 Compliance
134
+
135
+ - **Runtime Policy**: Reatom v1000 only; no @statx/\* in headless core.
136
+ - **Layering**: core -> interactions -> a11y-contracts -> adapters; adapters remain thin mappings.
137
+ - **Independence**: No imports from @project/_, apps/_, or other out-of-package modules.
138
+ - **Verification**: Mandatory adapter integration tests and standalone package test execution.
139
+
140
+ ## Out of Scope (Current)
141
+
142
+ - async option loading and virtualization
143
+ - option grouping and section headers (visual-only, UIKit concern)
144
+ - native `<select>` form submission parity edge cases
@@ -0,0 +1,321 @@
1
+ # Sidebar Component Contract
2
+
3
+ ## Purpose
4
+
5
+ `Sidebar` is a headless contract for a persistent layout panel that lives at the inline-start edge of the viewport. Unlike `Drawer`, which is an overlay dialog, `Sidebar` is always in the DOM/layout flow. It supports two modes:
6
+
7
+ 1. **Desktop (persistent)** — a fixed panel that can be **expanded** (full width with labels) or **collapsed** (narrow icon rail). Not an overlay; no backdrop, no focus trap, no scroll lock.
8
+ 2. **Mobile (overlay)** — below a configurable breakpoint the sidebar switches to a modal overlay (backdrop, focus trap, Escape dismissal), delegating to `createDialog` internally for that behavior.
9
+
10
+ ### How it differs from Drawer
11
+
12
+ | Concern | Drawer | Sidebar |
13
+ | ------------------ | ------------------------------------------- | -------------------------------------------------------- |
14
+ | Presence in layout | Overlay only; removed from flow when closed | Always in DOM/layout flow (desktop) |
15
+ | Placement | Any edge (`start`, `end`, `top`, `bottom`) | Inline-start only |
16
+ | Collapsed state | N/A | Collapses to icon rail |
17
+ | Responsive mode | N/A (always overlay) | Auto-switches between persistent panel and modal overlay |
18
+ | Dialog delegation | Always | Mobile overlay mode only |
19
+
20
+ ## Component Files
21
+
22
+ - `src/sidebar/index.ts` - model and public `createSidebar` API
23
+ - `src/sidebar/sidebar.test.ts` - unit behavior tests
24
+
25
+ ## Public API
26
+
27
+ - `createSidebar(options)`
28
+ - `state` (signal-backed):
29
+ - `expanded()` — whether the sidebar is in full-width mode (`true`) or icon-rail mode (`false`)
30
+ - `overlayOpen()` — whether the mobile overlay is open (only meaningful when `mobile` is `true`)
31
+ - `mobile()` — whether the sidebar is in mobile/overlay mode
32
+ - `isFocusTrapped()` — computed: `true` when `mobile && overlayOpen` (delegated from dialog)
33
+ - `shouldLockScroll()` — computed: `true` when `mobile && overlayOpen` (delegated from dialog)
34
+ - `restoreTargetId()` — element id to return focus to on overlay close (delegated from dialog)
35
+ - `initialFocusTargetId()` — id of element to focus when overlay opens (delegated from dialog)
36
+ - `actions`:
37
+ - `toggle()` — in desktop mode, toggles `expanded`; in mobile mode, toggles `overlayOpen`
38
+ - `expand()` — sets `expanded` to `true` (desktop mode only, no-op in mobile)
39
+ - `collapse()` — sets `expanded` to `false` (desktop mode only, no-op in mobile)
40
+ - `openOverlay()` — opens the mobile overlay (no-op if not in mobile mode)
41
+ - `closeOverlay(intent?)` — closes the mobile overlay (no-op if not in mobile mode)
42
+ - `setMobile(value)` — switches between desktop and mobile mode; when switching to desktop, closes overlay; when switching to mobile, collapses sidebar
43
+ - `handleKeyDown(event)` — Escape handling for mobile overlay (delegated from dialog)
44
+ - `handleOutsidePointer()` — outside click handling for mobile overlay (delegated from dialog)
45
+ - `handleOutsideFocus()` — outside focus handling for mobile overlay (delegated from dialog)
46
+ - `contracts`:
47
+ - `getSidebarProps()` — props for the sidebar container element
48
+ - `getToggleProps()` — props for the expand/collapse toggle button
49
+ - `getOverlayProps()` — props for the mobile overlay backdrop
50
+ - `getRailProps()` — props for the collapsed rail container
51
+
52
+ ## CreateSidebarOptions
53
+
54
+ | Option | Type | Default | Description |
55
+ | ----------------------- | ----------------------------- | ---------------------- | -------------------------------------------------- |
56
+ | `id` | `string` | `'sidebar'` | Base id prefix for all generated ids |
57
+ | `defaultExpanded` | `boolean` | `true` | Whether the sidebar starts expanded (desktop mode) |
58
+ | `onExpandedChange` | `(expanded: boolean) => void` | --- | Callback fired when `expanded` state changes |
59
+ | `closeOnEscape` | `boolean` | `true` | Whether Escape key closes mobile overlay |
60
+ | `closeOnOutsidePointer` | `boolean` | `true` | Whether clicking outside closes mobile overlay |
61
+ | `initialFocusId` | `string` | --- | Id of element to focus when mobile overlay opens |
62
+ | `ariaLabel` | `string` | `'Sidebar navigation'` | Accessible label for the sidebar landmark |
63
+
64
+ ## State Signal Surface
65
+
66
+ | Signal | Type | Derived? | Source | Description |
67
+ | ---------------------- | ---------------------- | -------- | ------- | ---------------------------------------------------------------- |
68
+ | `expanded` | `Atom<boolean>` | No | sidebar | Whether sidebar shows full width (`true`) or icon rail (`false`) |
69
+ | `overlayOpen` | `Atom<boolean>` | No | dialog | Whether mobile overlay is visible |
70
+ | `mobile` | `Atom<boolean>` | No | sidebar | Whether in mobile/overlay mode |
71
+ | `isFocusTrapped` | `Computed<boolean>` | Yes | dialog | `mobile() && overlayOpen()` |
72
+ | `shouldLockScroll` | `Computed<boolean>` | Yes | dialog | `mobile() && overlayOpen()` |
73
+ | `restoreTargetId` | `Atom<string \| null>` | No | dialog | Element id to return focus to after overlay close |
74
+ | `initialFocusTargetId` | `Atom<string \| null>` | No | dialog | Id of element to receive focus when overlay opens |
75
+
76
+ ## APG and A11y Contract
77
+
78
+ ### Desktop (persistent) mode
79
+
80
+ - Sidebar container: `role="navigation"`, `aria-label`
81
+ - No dialog semantics; the sidebar is a landmark, not an overlay
82
+ - Toggle button: `aria-expanded`, `aria-controls` pointing to sidebar id
83
+ - Rail state communicated via `data-collapsed="true|false"` on sidebar container
84
+
85
+ ### Mobile (overlay) mode
86
+
87
+ - Overlay container: `role="dialog"`, `aria-modal="true"`, `aria-label`
88
+ - Focus trap within the overlay panel
89
+ - Escape key dismisses the overlay
90
+ - Backdrop click dismisses the overlay
91
+ - Return focus to toggle button on close
92
+ - Initial focus on first focusable element or `initialFocusId` target
93
+
94
+ ## Behavior Contract
95
+
96
+ ### Desktop mode (`mobile: false`)
97
+
98
+ - Sidebar is always visible in the layout, either expanded or collapsed to rail
99
+ - `toggle()` switches between expanded and collapsed (icon rail)
100
+ - `expand()` sets `expanded = true`; `collapse()` sets `expanded = false`
101
+ - No focus trap, no scroll lock, no backdrop
102
+ - `overlayOpen` is always `false` in desktop mode
103
+ - `onExpandedChange` fires when `expanded` transitions
104
+
105
+ ### Mobile mode (`mobile: true`)
106
+
107
+ - Sidebar is hidden by default; `overlayOpen` controls visibility
108
+ - `toggle()` toggles `overlayOpen`
109
+ - `expand()` and `collapse()` are no-ops
110
+ - When `overlayOpen = true`: focus trap, scroll lock, backdrop visible
111
+ - Escape key closes overlay (configurable via `closeOnEscape`)
112
+ - Outside pointer closes overlay (configurable via `closeOnOutsidePointer`)
113
+ - Focus returns to toggle button on close
114
+
115
+ ### Mode switching (`setMobile`)
116
+
117
+ - `setMobile(true)`: closes any expanded state, sidebar enters overlay mode (closed by default)
118
+ - `setMobile(false)`: closes overlay if open, sidebar enters persistent mode with `expanded = defaultExpanded`
119
+
120
+ ## Contract Prop Shapes
121
+
122
+ ### `getSidebarProps()`
123
+
124
+ Props for the sidebar container element.
125
+
126
+ ```ts
127
+ // Desktop mode:
128
+ {
129
+ id: string // `${id}-panel`
130
+ role: 'navigation'
131
+ 'aria-label': string
132
+ 'data-collapsed': 'true' | 'false' // reflects !expanded
133
+ 'data-mobile': 'true' | 'false'
134
+ }
135
+
136
+ // Mobile mode (when overlayOpen):
137
+ {
138
+ id: string // `${id}-panel`
139
+ role: 'dialog'
140
+ 'aria-modal': 'true'
141
+ 'aria-label': string
142
+ 'data-collapsed': 'false'
143
+ 'data-mobile': 'true'
144
+ 'data-initial-focus'?: string
145
+ onKeyDown: (event) => void
146
+ }
147
+ ```
148
+
149
+ ### `getToggleProps()`
150
+
151
+ Props for the expand/collapse toggle button.
152
+
153
+ ```ts
154
+ {
155
+ id: string // `${id}-toggle`
156
+ role: 'button'
157
+ tabindex: '0'
158
+ 'aria-expanded': 'true' | 'false' // desktop: reflects expanded; mobile: reflects overlayOpen
159
+ 'aria-controls': string // `${id}-panel`
160
+ 'aria-label': string // 'Expand sidebar' | 'Collapse sidebar' | 'Open sidebar' | 'Close sidebar'
161
+ onClick: () => void
162
+ }
163
+ ```
164
+
165
+ ### `getOverlayProps()`
166
+
167
+ Props for the mobile overlay backdrop. Only meaningful in mobile mode.
168
+
169
+ ```ts
170
+ {
171
+ id: string // `${id}-overlay`
172
+ hidden: boolean // !overlayOpen || !mobile
173
+ 'data-open': 'true' | 'false'
174
+ onPointerDownOutside: () => void
175
+ onFocusOutside: () => void
176
+ }
177
+ ```
178
+
179
+ ### `getRailProps()`
180
+
181
+ Props for the collapsed rail container. Only meaningful in desktop mode.
182
+
183
+ ```ts
184
+ {
185
+ id: string // `${id}-rail`
186
+ role: 'navigation'
187
+ 'aria-label': string
188
+ 'data-visible': 'true' | 'false' // reflects !expanded && !mobile
189
+ }
190
+ ```
191
+
192
+ ## Transitions Table
193
+
194
+ | Event / Action | Current State | Next State / Effect |
195
+ | ------------------------ | --------------------------------------------------------------------- | --------------------------------------------------------------------- |
196
+ | `toggle()` | `mobile = false`, `expanded = false` | `expanded = true`; fire `onExpandedChange(true)` |
197
+ | `toggle()` | `mobile = false`, `expanded = true` | `expanded = false`; fire `onExpandedChange(false)` |
198
+ | `toggle()` | `mobile = true`, `overlayOpen = false` | `overlayOpen = true`; focus management begins |
199
+ | `toggle()` | `mobile = true`, `overlayOpen = true` | `overlayOpen = false`; focus returns to toggle |
200
+ | `expand()` | `mobile = false`, `expanded = false` | `expanded = true`; fire `onExpandedChange(true)` |
201
+ | `expand()` | `mobile = false`, `expanded = true` | no-op |
202
+ | `expand()` | `mobile = true` | no-op |
203
+ | `collapse()` | `mobile = false`, `expanded = true` | `expanded = false`; fire `onExpandedChange(false)` |
204
+ | `collapse()` | `mobile = false`, `expanded = false` | no-op |
205
+ | `collapse()` | `mobile = true` | no-op |
206
+ | `openOverlay()` | `mobile = true`, `overlayOpen = false` | `overlayOpen = true`; focus management begins |
207
+ | `openOverlay()` | `mobile = true`, `overlayOpen = true` | no-op |
208
+ | `openOverlay()` | `mobile = false` | no-op |
209
+ | `closeOverlay(intent)` | `mobile = true`, `overlayOpen = true` | `overlayOpen = false`; focus returns to toggle |
210
+ | `closeOverlay(intent)` | `mobile = true`, `overlayOpen = false` | no-op |
211
+ | `closeOverlay(intent)` | `mobile = false` | no-op |
212
+ | `setMobile(true)` | `mobile = false` | `mobile = true`; `overlayOpen = false` |
213
+ | `setMobile(false)` | `mobile = true` | `mobile = false`; `overlayOpen = false`; `expanded = defaultExpanded` |
214
+ | `setMobile(value)` | `mobile = value` | no-op |
215
+ | `handleKeyDown(Escape)` | `mobile = true`, `overlayOpen = true`, `closeOnEscape = true` | `closeOverlay('escape')` |
216
+ | `handleKeyDown(Escape)` | `mobile = false` OR `closeOnEscape = false` | no-op |
217
+ | `handleOutsidePointer()` | `mobile = true`, `overlayOpen = true`, `closeOnOutsidePointer = true` | `closeOverlay('outside-pointer')` |
218
+ | `handleOutsidePointer()` | `mobile = false` OR `closeOnOutsidePointer = false` | no-op |
219
+ | `handleOutsideFocus()` | `mobile = true`, `overlayOpen = true` | `closeOverlay('outside-focus')` |
220
+
221
+ ### Derived state reactions
222
+
223
+ | State Change | `isFocusTrapped` | `shouldLockScroll` |
224
+ | --------------------------- | ---------------- | ------------------ |
225
+ | desktop, any expanded | `false` | `false` |
226
+ | mobile, overlayOpen = true | `true` | `true` |
227
+ | mobile, overlayOpen = false | `false` | `false` |
228
+
229
+ ## Invariants
230
+
231
+ 1. `expanded` and `overlayOpen` are independent signals; `expanded` governs desktop rail/full, `overlayOpen` governs mobile visibility.
232
+ 2. When `mobile = false`, `overlayOpen` must be `false`. Switching to desktop mode force-closes the overlay.
233
+ 3. When `mobile = true`, `expand()` and `collapse()` are no-ops; `expanded` value is irrelevant to rendering.
234
+ 4. When `mobile = false`, `openOverlay()` and `closeOverlay()` are no-ops.
235
+ 5. `getSidebarProps()` must return `role="navigation"` in desktop mode and `role="dialog"` with `aria-modal="true"` in mobile overlay mode (when open).
236
+ 6. `getToggleProps()` must always include `aria-expanded` reflecting the appropriate state (`expanded` in desktop, `overlayOpen` in mobile).
237
+ 7. `getOverlayProps().hidden` must be `true` whenever `mobile = false` or `overlayOpen = false`.
238
+ 8. `getRailProps()['data-visible']` must be `'true'` only when `!expanded && !mobile`.
239
+ 9. `setMobile(false)` must restore `expanded` to `defaultExpanded`.
240
+ 10. Focus trap and scroll lock must only be active when `mobile = true` AND `overlayOpen = true`.
241
+ 11. The sidebar must compose `createDialog` for mobile overlay mode; no duplication of dialog internals.
242
+ 12. `onExpandedChange` must fire only on actual transitions of `expanded`, not on no-ops or mobile mode changes.
243
+
244
+ ## Adapter Expectations
245
+
246
+ UIKit adapters MUST bind to the headless model as follows:
247
+
248
+ **Signals read (reactive, drive re-renders):**
249
+
250
+ - `state.expanded()` — whether full-width or icon rail (desktop)
251
+ - `state.overlayOpen()` — whether mobile overlay is visible
252
+ - `state.mobile()` — whether in mobile/overlay mode
253
+ - `state.isFocusTrapped()` — whether focus trap should be active
254
+ - `state.shouldLockScroll()` — whether body scroll lock should be active
255
+ - `state.restoreTargetId()` — element id to focus after overlay close
256
+ - `state.initialFocusTargetId()` — element id to focus on overlay open
257
+
258
+ **Actions called (event handlers, never mutate state directly):**
259
+
260
+ - `actions.toggle()` — toggle expand/collapse (desktop) or open/close (mobile)
261
+ - `actions.expand()` — expand sidebar (desktop only)
262
+ - `actions.collapse()` — collapse to rail (desktop only)
263
+ - `actions.openOverlay()` — open mobile overlay
264
+ - `actions.closeOverlay(intent?)` — close mobile overlay
265
+ - `actions.setMobile(value)` — switch between desktop/mobile mode (typically driven by a media query observer)
266
+ - `actions.handleKeyDown(event)` — Escape handling for mobile overlay
267
+ - `actions.handleOutsidePointer()` — outside click for mobile overlay
268
+ - `actions.handleOutsideFocus()` — outside focus for mobile overlay
269
+
270
+ **Contracts spread (attribute maps applied directly to DOM elements):**
271
+
272
+ - `contracts.getSidebarProps()` — spread onto the sidebar panel element (role switches between `navigation` and `dialog` based on mode)
273
+ - `contracts.getToggleProps()` — spread onto the toggle button element
274
+ - `contracts.getOverlayProps()` — spread onto the mobile backdrop element
275
+ - `contracts.getRailProps()` — spread onto the collapsed rail element
276
+
277
+ **UIKit-only concerns (NOT in headless):**
278
+
279
+ - Lifecycle events (`cv-expand`, `cv-collapse`, `cv-overlay-open`, `cv-overlay-close`)
280
+ - CSS transitions for expand/collapse and slide-in overlay animations
281
+ - Backdrop rendering and styling
282
+ - Scroll lock implementation (headless provides the signal, UIKit applies the side effect)
283
+ - Focus trap implementation (headless provides the signal, UIKit manages DOM focus)
284
+ - Media query observer for automatic `setMobile()` calls based on breakpoint
285
+ - Icon-only rendering logic for rail mode
286
+
287
+ ## Minimum Test Matrix
288
+
289
+ - Default state: `expanded = true`, `overlayOpen = false`, `mobile = false`
290
+ - `toggle()` in desktop mode toggles `expanded`
291
+ - `toggle()` in mobile mode toggles `overlayOpen`
292
+ - `expand()` and `collapse()` work in desktop, no-op in mobile
293
+ - `openOverlay()` and `closeOverlay()` work in mobile, no-op in desktop
294
+ - `setMobile(true)` closes overlay, switches mode
295
+ - `setMobile(false)` closes overlay, restores `expanded` to `defaultExpanded`
296
+ - `getSidebarProps()` returns `role="navigation"` in desktop, `role="dialog"` in mobile overlay
297
+ - `getToggleProps()` returns correct `aria-expanded` for each mode
298
+ - `getOverlayProps().hidden` is `true` in desktop mode
299
+ - `getRailProps()['data-visible']` is `'true'` only when collapsed in desktop mode
300
+ - Escape key closes mobile overlay (when enabled)
301
+ - Outside pointer closes mobile overlay (when enabled)
302
+ - `onExpandedChange` fires on expand/collapse transitions only
303
+ - `isFocusTrapped` and `shouldLockScroll` are `true` only when mobile overlay is open
304
+ - Custom `id` propagates to all generated ids
305
+ - `defaultExpanded: false` starts sidebar collapsed
306
+
307
+ ## ADR-001 Compliance
308
+
309
+ - **Runtime Policy**: Reatom v1000 only; no `@statx/*` in headless core.
310
+ - **Layering**: `core -> interactions -> a11y-contracts -> adapters`; adapters remain thin mappings.
311
+ - **Independence**: No imports from `@project/*`, `apps/*`, or other out-of-package modules.
312
+ - **Composition**: `createSidebar` composes `createDialog` for mobile overlay mode; no duplication of dialog internals.
313
+ - **Verification**: Mandatory adapter integration tests and standalone package test execution.
314
+
315
+ ## Out of Scope (Current)
316
+
317
+ - Inline-end placement (right sidebar in LTR)
318
+ - Nested sidebars or multiple sidebar instances
319
+ - Swipe-to-dismiss gesture handling for mobile overlay
320
+ - Complex animations/transitions (CSS/JS animations are UIKit concerns)
321
+ - Resizable sidebar (drag to resize width)
@@ -0,0 +1,78 @@
1
+ # Multi-Thumb Slider Component Contract
2
+
3
+ ## Purpose
4
+
5
+ `SliderMultiThumb` provides a headless APG-aligned model for a range input with multiple handles (thumbs), typically used for selecting a sub-range (e.g., price range).
6
+
7
+ It handles multi-value state, thumb collision/crossing prevention, and independent keyboard navigation for each thumb.
8
+
9
+ ## Component Files
10
+
11
+ - `src/slider-multi-thumb/index.ts` - model and public `createSliderMultiThumb` API
12
+ - `src/slider-multi-thumb/slider-multi-thumb.test.ts` - unit behavior tests
13
+
14
+ ## Public API
15
+
16
+ - `createSliderMultiThumb(options)`
17
+ - `state` (signal-backed):
18
+ - `values()`: `number[]`
19
+ - `min()`: `number`
20
+ - `max()`: `number`
21
+ - `step()`: `number`
22
+ - `activeThumbIndex()`: `number | null`
23
+ - `isDisabled()`: `boolean`
24
+ - `actions`:
25
+ - `setValue(index, value)`: sets a specific thumb's value
26
+ - `increment(index)`, `decrement(index)`
27
+ - `incrementLarge(index)`, `decrementLarge(index)`
28
+ - `handleKeyDown(index, event)`
29
+ - `contracts`:
30
+ - `getRootProps()`
31
+ - `getThumbProps(index)`
32
+ - `getTrackProps()`
33
+
34
+ ## APG and A11y Contract
35
+
36
+ - each thumb role: `slider`
37
+ - `aria-valuenow`: current value of the specific thumb
38
+ - `aria-valuemin`: minimum possible value for this thumb (often constrained by adjacent thumbs)
39
+ - `aria-valuemax`: maximum possible value for this thumb
40
+ - `aria-orientation`: `"horizontal" | "vertical"`
41
+ - `tabindex`: `0` on each thumb
42
+ - linkage: each thumb should have an `aria-label` or `aria-labelledby` identifying its purpose (e.g., "Minimum price", "Maximum price")
43
+
44
+ ## Behavior Contract
45
+
46
+ - Keyboard interactions (`Arrows`, `PageUp/Down`, `Home`, `End`) apply to the currently focused thumb.
47
+ - Thumbs are constrained by the global `min` and `max`.
48
+ - By default, thumbs cannot cross each other (e.g., `values[0] <= values[1]`).
49
+ - Moving a thumb to its limit (adjacent thumb or range boundary) stops the movement.
50
+ - Optional "push" behavior: moving one thumb can push adjacent thumbs if they collide (out of scope for baseline).
51
+
52
+ ## Invariants
53
+
54
+ - `min <= values[i] <= max` for all `i`.
55
+ - `values[i] <= values[i+1]` (non-crossing invariant).
56
+ - `activeThumbIndex` refers to the thumb that last received focus or interaction.
57
+
58
+ ## Minimum Test Matrix
59
+
60
+ - independent thumb movement via keyboard
61
+ - collision prevention (thumb 0 cannot exceed thumb 1)
62
+ - boundary constraints (min/max)
63
+ - Home/End behavior for each thumb (Home on thumb 1 moves it to thumb 0's value or min)
64
+ - snapping to step for all thumbs
65
+ - correct `aria-valuemin/max` updates when adjacent thumbs move
66
+
67
+ ## ADR-001 Compliance
68
+
69
+ - **Runtime Policy**: Reatom v1000 only; no @statx/\* in headless core.
70
+ - **Layering**: core -> interactions -> a11y-contracts -> adapters; adapters remain thin mappings.
71
+ - **Independence**: No imports from @project/_, apps/_, or other out-of-package modules.
72
+ - **Verification**: Mandatory adapter integration tests and standalone package test execution.
73
+
74
+ ## Out of Scope (Current)
75
+
76
+ - crossing thumbs (where `values[0] > values[1]` is allowed)
77
+ - dynamic thumb addition/removal
78
+ - non-linear scales
@@ -0,0 +1,84 @@
1
+ # Slider Component Contract
2
+
3
+ ## Purpose
4
+
5
+ `Slider` provides a headless APG-aligned model for an input where the user selects a value from within a given range.
6
+
7
+ It handles range constraints, step increments, and standard keyboard navigation for single-thumb sliders.
8
+
9
+ ## Component Files
10
+
11
+ - `src/slider/index.ts` - model and public `createSlider` API
12
+ - `src/slider/slider.test.ts` - unit behavior tests
13
+
14
+ ## Public API
15
+
16
+ - `createSlider(options)`
17
+ - `state` (signal-backed):
18
+ - `value()`: `number`
19
+ - `min()`: `number`
20
+ - `max()`: `number`
21
+ - `step()`: `number`
22
+ - `percentage()`: `number` (0 to 100)
23
+ - `isDisabled()`: `boolean`
24
+ - `actions`:
25
+ - `setValue(value)`: sets the value within constraints
26
+ - `increment()`, `decrement()`
27
+ - `incrementLarge()`, `decrementLarge()`
28
+ - `setFirst()`, `setLast()`
29
+ - `handleKeyDown(event)`
30
+ - `contracts`:
31
+ - `getRootProps()`
32
+ - `getThumbProps()`
33
+ - `getTrackProps()`
34
+
35
+ ## APG and A11y Contract
36
+
37
+ - thumb role: `slider`
38
+ - `aria-valuenow`: current value
39
+ - `aria-valuemin`: minimum value
40
+ - `aria-valuemax`: maximum value
41
+ - `aria-valuetext`: optional string representation of the value
42
+ - `aria-orientation`: `"horizontal" | "vertical"`
43
+ - `aria-disabled`: boolean
44
+ - `tabindex`: `0` on the thumb
45
+
46
+ ## Behavior Contract
47
+
48
+ - `ArrowRight` / `ArrowUp` increments the value by `step`.
49
+ - `ArrowLeft` / `ArrowDown` decrements the value by `step`.
50
+ - `PageUp` increments the value by a larger step (default 10% of range).
51
+ - `PageDown` decrements the value by a larger step.
52
+ - `Home` sets the value to `min`.
53
+ - `End` sets the value to `max`.
54
+ - Values are always clamped between `min` and `max`.
55
+ - Values are always snapped to the nearest `step` increment.
56
+
57
+ ## Invariants
58
+
59
+ - `min <= value <= max`
60
+ - `min < max`
61
+ - `step > 0`
62
+
63
+ ## Minimum Test Matrix
64
+
65
+ - value clamping at min/max boundaries
66
+ - step increment/decrement behavior
67
+ - large step (PageUp/PageDown) behavior
68
+ - Home/End key behavior
69
+ - vertical orientation keyboard parity
70
+ - snapping to nearest step on manual `setValue`
71
+ - disabled state prevents value changes
72
+
73
+ ## ADR-001 Compliance
74
+
75
+ - **Runtime Policy**: Reatom v1000 only; no @statx/\* in headless core.
76
+ - **Layering**: core -> interactions -> a11y-contracts -> adapters; adapters remain thin mappings.
77
+ - **Independence**: No imports from @project/_, apps/_, or other out-of-package modules.
78
+ - **Verification**: Mandatory adapter integration tests and standalone package test execution.
79
+
80
+ ## Out of Scope (Current)
81
+
82
+ - multiple thumbs (see `SliderMultiThumb` spec)
83
+ - non-linear scales (logarithmic, etc.)
84
+ - inverted ranges (max < min)