@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,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)
|