@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,353 @@
|
|
|
1
|
+
# Drawer Component Contract
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
`Drawer` is a headless contract for slide-out panel dialogs. It wraps `createDialog` internally, delegating all dialog behavior (visibility, focus trapping, scroll locking, dismissal), and adds a `placement` dimension that determines which edge the panel slides from. Supports all dialog features including modal/non-modal modes and `alertdialog` role.
|
|
6
|
+
|
|
7
|
+
## Component Files
|
|
8
|
+
|
|
9
|
+
- `src/drawer/index.ts` - model and public `createDrawer` API
|
|
10
|
+
- `src/drawer/drawer.test.ts` - unit behavior tests
|
|
11
|
+
|
|
12
|
+
## Public API
|
|
13
|
+
|
|
14
|
+
- `createDrawer(options)`
|
|
15
|
+
- `state` (signal-backed):
|
|
16
|
+
- `isOpen()` — whether the drawer is currently visible (delegated from dialog)
|
|
17
|
+
- `isModal()` — whether the drawer is in modal mode (delegated from dialog)
|
|
18
|
+
- `type()` — `'dialog' | 'alertdialog'` (delegated from dialog)
|
|
19
|
+
- `restoreTargetId()` — element id to return focus to on close (delegated from dialog)
|
|
20
|
+
- `isFocusTrapped()` — computed: `true` when open AND modal (delegated from dialog)
|
|
21
|
+
- `shouldLockScroll()` — computed: `true` when open AND modal (delegated from dialog)
|
|
22
|
+
- `initialFocusTargetId()` — id of element to focus on open (delegated from dialog)
|
|
23
|
+
- `placement()` — current placement edge: `'start' | 'end' | 'top' | 'bottom'`
|
|
24
|
+
- `actions`:
|
|
25
|
+
- `open(source?)`, `close(intent?)`, `toggle(source?)` (delegated from dialog)
|
|
26
|
+
- `setTriggerId(id)` (delegated from dialog)
|
|
27
|
+
- `setPlacement(placement)` — update the placement edge at runtime
|
|
28
|
+
- `handleTriggerClick()` (delegated from dialog)
|
|
29
|
+
- `handleTriggerKeyDown(event)` (delegated from dialog)
|
|
30
|
+
- `handleKeyDown(event)` (delegated from dialog)
|
|
31
|
+
- `handleOutsidePointer()` (delegated from dialog)
|
|
32
|
+
- `handleOutsideFocus()` (delegated from dialog)
|
|
33
|
+
- `contracts`:
|
|
34
|
+
- `getTriggerProps()` (delegated from dialog)
|
|
35
|
+
- `getOverlayProps()` (delegated from dialog)
|
|
36
|
+
- `getPanelProps()` — drawer-specific: extends dialog content props with `data-placement`
|
|
37
|
+
- `getTitleProps()` (delegated from dialog)
|
|
38
|
+
- `getDescriptionProps()` (delegated from dialog)
|
|
39
|
+
- `getCloseButtonProps()` (delegated from dialog)
|
|
40
|
+
- `getHeaderCloseButtonProps()` (delegated from dialog)
|
|
41
|
+
|
|
42
|
+
## CreateDrawerOptions
|
|
43
|
+
|
|
44
|
+
Extends `CreateDialogOptions` with:
|
|
45
|
+
|
|
46
|
+
| Option | Type | Default | Description |
|
|
47
|
+
| ----------- | --------------------------------------- | ------- | --------------------------------------- |
|
|
48
|
+
| `placement` | `'start' \| 'end' \| 'top' \| 'bottom'` | `'end'` | Which edge the drawer panel slides from |
|
|
49
|
+
|
|
50
|
+
All options from `CreateDialogOptions` are supported and forwarded to the internal dialog:
|
|
51
|
+
|
|
52
|
+
| Option | Type | Default | Description |
|
|
53
|
+
| ----------------------- | --------------------------- | ---------------------- | ---------------------------------------------- |
|
|
54
|
+
| `idBase` | `string` | `'drawer'` | Base id prefix for all generated ids |
|
|
55
|
+
| `type` | `'dialog' \| 'alertdialog'` | `'dialog'` | ARIA role for the content element |
|
|
56
|
+
| `initialOpen` | `boolean` | `false` | Whether the drawer starts open |
|
|
57
|
+
| `isModal` | `boolean` | `true` | Modal mode enables focus trap and scroll lock |
|
|
58
|
+
| `closeOnEscape` | `boolean` | `true` | Whether Escape key closes the drawer |
|
|
59
|
+
| `closeOnOutsidePointer` | `boolean` | `true` | Whether clicking outside closes the drawer |
|
|
60
|
+
| `closeOnOutsideFocus` | `boolean` | `true` | Whether focusing outside closes the drawer |
|
|
61
|
+
| `initialFocusId` | `string` | --- | Id of element to receive initial focus on open |
|
|
62
|
+
| `ariaLabelledBy` | `string` | `{idBase}-title` | Custom id for `aria-labelledby` |
|
|
63
|
+
| `ariaDescribedBy` | `string` | `{idBase}-description` | Custom id for `aria-describedby` |
|
|
64
|
+
|
|
65
|
+
## State Signal Surface
|
|
66
|
+
|
|
67
|
+
| Signal | Type | Derived? | Source | Description |
|
|
68
|
+
| ---------------------- | --------------------------------- | -------- | ------ | -------------------------------------------------------- |
|
|
69
|
+
| `isOpen` | `Atom<boolean>` | No | dialog | Single source of truth for visibility |
|
|
70
|
+
| `isModal` | `Atom<boolean>` | No | dialog | Whether modal behaviors (focus trap, scroll lock) are on |
|
|
71
|
+
| `type` | `Atom<'dialog' \| 'alertdialog'>` | No | dialog | ARIA role type |
|
|
72
|
+
| `restoreTargetId` | `Atom<string \| null>` | No | dialog | Element id to return focus to after close |
|
|
73
|
+
| `isFocusTrapped` | `Computed<boolean>` | Yes | dialog | `isOpen() && isModal()` |
|
|
74
|
+
| `shouldLockScroll` | `Computed<boolean>` | Yes | dialog | `isOpen() && isModal()` |
|
|
75
|
+
| `initialFocusTargetId` | `Atom<string \| null>` | No | dialog | Id of element to receive focus when drawer opens |
|
|
76
|
+
| `placement` | `Atom<DrawerPlacement>` | No | drawer | Current placement edge |
|
|
77
|
+
|
|
78
|
+
### DrawerPlacement type
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
type DrawerPlacement = 'start' | 'end' | 'top' | 'bottom'
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Logical values (`start`/`end`) follow the CSS inline direction, meaning `start` is left in LTR and right in RTL. Physical values (`top`/`bottom`) are always relative to the viewport block axis.
|
|
85
|
+
|
|
86
|
+
## APG and A11y Contract
|
|
87
|
+
|
|
88
|
+
Inherits all dialog APG requirements:
|
|
89
|
+
|
|
90
|
+
- content role: `dialog` (default) or `alertdialog` (when `type: 'alertdialog'`)
|
|
91
|
+
- required attributes:
|
|
92
|
+
- panel (content): `aria-modal`, `aria-labelledby`
|
|
93
|
+
- panel (content): `aria-describedby` (required when `type: 'alertdialog'`, recommended for `dialog`)
|
|
94
|
+
- trigger: `aria-haspopup="dialog"`, `aria-expanded`, `aria-controls`
|
|
95
|
+
- focus management:
|
|
96
|
+
- **modal**: focus trap within the drawer; Tab/Shift+Tab cycle through focusable elements
|
|
97
|
+
- **non-modal**: no focus trap; focus can move freely to/from the drawer
|
|
98
|
+
- initial focus on a specific target (via `initialFocusId`) or the first focusable element
|
|
99
|
+
- return focus to the trigger upon closing (both modal and non-modal)
|
|
100
|
+
- alertdialog specifics:
|
|
101
|
+
- role `alertdialog` signals that the drawer contains an alert message requiring user response
|
|
102
|
+
- `aria-describedby` is required (per W3C APG) to point to the alert message content
|
|
103
|
+
|
|
104
|
+
Drawer additions:
|
|
105
|
+
|
|
106
|
+
- `data-placement` attribute on the panel element reflecting the current placement value
|
|
107
|
+
- No additional ARIA roles or attributes are required for placement; it is a visual/layout concern
|
|
108
|
+
|
|
109
|
+
## Behavior Contract
|
|
110
|
+
|
|
111
|
+
All dialog behaviors are inherited. See Dialog spec for full details.
|
|
112
|
+
|
|
113
|
+
### Modal (`isModal: true`, default)
|
|
114
|
+
|
|
115
|
+
- `Escape` key closes the drawer (configurable via `closeOnEscape`)
|
|
116
|
+
- Outside pointer click closes the drawer (configurable via `closeOnOutsidePointer`)
|
|
117
|
+
- Outside focus closes the drawer (configurable via `closeOnOutsideFocus`)
|
|
118
|
+
- Scroll lock on the body while the drawer is open
|
|
119
|
+
- Focus trap: `Tab` and `Shift+Tab` cycle through focusable elements inside the drawer
|
|
120
|
+
- Initial focus: defaults to the first focusable element, can be overridden via `initialFocusId`
|
|
121
|
+
|
|
122
|
+
### Non-modal (`isModal: false`)
|
|
123
|
+
|
|
124
|
+
- `Escape` key closes the drawer (configurable via `closeOnEscape`)
|
|
125
|
+
- Outside pointer click closes the drawer (configurable via `closeOnOutsidePointer`)
|
|
126
|
+
- Outside focus closes the drawer (configurable via `closeOnOutsideFocus`)
|
|
127
|
+
- No scroll lock
|
|
128
|
+
- No focus trap
|
|
129
|
+
- `aria-modal` is `'false'`
|
|
130
|
+
- Initial focus: same as modal (configurable via `initialFocusId`)
|
|
131
|
+
|
|
132
|
+
### Placement
|
|
133
|
+
|
|
134
|
+
- `placement` determines the edge from which the drawer visually appears
|
|
135
|
+
- Changing placement at runtime (via `setPlacement`) updates the `data-placement` attribute
|
|
136
|
+
- Placement has no effect on ARIA attributes or focus behavior; it is purely a layout/animation hint
|
|
137
|
+
|
|
138
|
+
## Contract Prop Shapes
|
|
139
|
+
|
|
140
|
+
### `getTriggerProps()`
|
|
141
|
+
|
|
142
|
+
Delegated from dialog. Same shape as `DialogTriggerProps`.
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
{
|
|
146
|
+
id: string
|
|
147
|
+
role: 'button'
|
|
148
|
+
tabindex: '0'
|
|
149
|
+
'aria-haspopup': 'dialog'
|
|
150
|
+
'aria-expanded': 'true' | 'false'
|
|
151
|
+
'aria-controls': string
|
|
152
|
+
onClick: () => void
|
|
153
|
+
onKeyDown: (event) => void
|
|
154
|
+
}
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
### `getOverlayProps()`
|
|
158
|
+
|
|
159
|
+
Delegated from dialog. Same shape as `DialogOverlayProps`.
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
{
|
|
163
|
+
id: string
|
|
164
|
+
hidden: boolean
|
|
165
|
+
'data-open': 'true' | 'false'
|
|
166
|
+
onPointerDownOutside: () => void
|
|
167
|
+
onFocusOutside: () => void
|
|
168
|
+
}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
### `getPanelProps()`
|
|
172
|
+
|
|
173
|
+
Extends dialog content props with `data-placement`.
|
|
174
|
+
|
|
175
|
+
```ts
|
|
176
|
+
{
|
|
177
|
+
id: string
|
|
178
|
+
role: 'dialog' | 'alertdialog'
|
|
179
|
+
tabindex: '-1'
|
|
180
|
+
'aria-modal': 'true' | 'false'
|
|
181
|
+
'aria-labelledby'?: string
|
|
182
|
+
'aria-describedby'?: string
|
|
183
|
+
'data-initial-focus'?: string
|
|
184
|
+
'data-placement': DrawerPlacement
|
|
185
|
+
onKeyDown: (event) => void
|
|
186
|
+
}
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
### `getTitleProps()`
|
|
190
|
+
|
|
191
|
+
Delegated from dialog. Same shape as `DialogTitleProps`.
|
|
192
|
+
|
|
193
|
+
```ts
|
|
194
|
+
{
|
|
195
|
+
id: string
|
|
196
|
+
}
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
### `getDescriptionProps()`
|
|
200
|
+
|
|
201
|
+
Delegated from dialog. Same shape as `DialogDescriptionProps`.
|
|
202
|
+
|
|
203
|
+
```ts
|
|
204
|
+
{
|
|
205
|
+
id: string
|
|
206
|
+
}
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
### `getCloseButtonProps()` (footer/generic close)
|
|
210
|
+
|
|
211
|
+
Delegated from dialog. Same shape as `DialogCloseButtonProps`.
|
|
212
|
+
|
|
213
|
+
```ts
|
|
214
|
+
{
|
|
215
|
+
id: string
|
|
216
|
+
role: 'button'
|
|
217
|
+
tabindex: '0'
|
|
218
|
+
onClick: () => void
|
|
219
|
+
}
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
### `getHeaderCloseButtonProps()` (header close icon)
|
|
223
|
+
|
|
224
|
+
Delegated from dialog. Same shape as `DialogHeaderCloseButtonProps`.
|
|
225
|
+
|
|
226
|
+
```ts
|
|
227
|
+
{
|
|
228
|
+
id: string
|
|
229
|
+
role: 'button'
|
|
230
|
+
tabindex: '0'
|
|
231
|
+
'aria-label': 'Close'
|
|
232
|
+
onClick: () => void
|
|
233
|
+
}
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
## Transitions Table
|
|
237
|
+
|
|
238
|
+
All dialog transitions apply. See Dialog spec for the full table.
|
|
239
|
+
|
|
240
|
+
Drawer-specific additions:
|
|
241
|
+
|
|
242
|
+
| Event / Action | Current State | Next State / Effect |
|
|
243
|
+
| ------------------------- | ------------- | ------------------------------------- |
|
|
244
|
+
| `setPlacement(placement)` | any | `placement` atom updated to new value |
|
|
245
|
+
|
|
246
|
+
### Inherited transitions (from dialog)
|
|
247
|
+
|
|
248
|
+
| Event / Action | Current State | Next State / Effect |
|
|
249
|
+
| ----------------------------------- | ----------------------------------------------- | ---------------------------------------------------------------- |
|
|
250
|
+
| `open(source)` | `isOpen = false` | `isOpen = true`; restore target cleared; focus management begins |
|
|
251
|
+
| `close(intent)` | `isOpen = true` | `isOpen = false`; `restoreTargetId` set to trigger id |
|
|
252
|
+
| `toggle(source)` | `isOpen = false` | calls `open(source)` |
|
|
253
|
+
| `toggle(source)` | `isOpen = true` | calls `close('programmatic')` |
|
|
254
|
+
| `handleTriggerClick()` | any | calls `toggle('pointer')` |
|
|
255
|
+
| `handleTriggerKeyDown(Enter/Space)` | any | calls `toggle('keyboard')` |
|
|
256
|
+
| `handleKeyDown(Escape)` | `isOpen = true`, `closeOnEscape = true` | calls `close('escape')` |
|
|
257
|
+
| `handleKeyDown(Escape)` | `closeOnEscape = false` | no-op |
|
|
258
|
+
| `handleOutsidePointer()` | `isOpen = true`, `closeOnOutsidePointer = true` | calls `close('outside-pointer')` |
|
|
259
|
+
| `handleOutsidePointer()` | `closeOnOutsidePointer = false` | no-op |
|
|
260
|
+
| `handleOutsideFocus()` | `isOpen = true`, `closeOnOutsideFocus = true` | calls `close('outside-focus')` |
|
|
261
|
+
| `handleOutsideFocus()` | `closeOnOutsideFocus = false` | no-op |
|
|
262
|
+
| `setTriggerId(id)` | any | trigger id updated; affects future `restoreTargetId` |
|
|
263
|
+
|
|
264
|
+
### Derived state reactions
|
|
265
|
+
|
|
266
|
+
| State Change | `isFocusTrapped` | `shouldLockScroll` |
|
|
267
|
+
| ---------------- | ---------------- | ------------------ |
|
|
268
|
+
| open + modal | `true` | `true` |
|
|
269
|
+
| open + non-modal | `false` | `false` |
|
|
270
|
+
| closed (any) | `false` | `false` |
|
|
271
|
+
|
|
272
|
+
## Invariants
|
|
273
|
+
|
|
274
|
+
1. All dialog invariants apply (see Dialog spec invariants 1-10).
|
|
275
|
+
2. `placement` must always be one of `'start' | 'end' | 'top' | 'bottom'`.
|
|
276
|
+
3. `placement` defaults to `'end'` when not specified.
|
|
277
|
+
4. `getPanelProps()` must always include `data-placement` reflecting the current `placement` value.
|
|
278
|
+
5. `getPanelProps()` must include all attributes from dialog's `getContentProps()` (role, aria-modal, aria-labelledby, etc.).
|
|
279
|
+
6. The drawer must not duplicate any dialog logic; all dialog behavior is delegated to the internal `createDialog` instance.
|
|
280
|
+
7. Changing `placement` must not affect open/close state, focus behavior, or ARIA attributes.
|
|
281
|
+
|
|
282
|
+
## Adapter Expectations
|
|
283
|
+
|
|
284
|
+
UIKit adapters MUST bind to the headless model as follows:
|
|
285
|
+
|
|
286
|
+
**Signals read (reactive, drive re-renders):**
|
|
287
|
+
|
|
288
|
+
- `state.isOpen()` — whether the drawer is visible
|
|
289
|
+
- `state.isModal()` — whether modal behaviors are active
|
|
290
|
+
- `state.type()` — dialog type for role assignment
|
|
291
|
+
- `state.isFocusTrapped()` — whether focus trap should be active
|
|
292
|
+
- `state.shouldLockScroll()` — whether body scroll lock should be active
|
|
293
|
+
- `state.restoreTargetId()` — element id to focus after close
|
|
294
|
+
- `state.initialFocusTargetId()` — element id to focus on open
|
|
295
|
+
- `state.placement()` — current placement edge for layout/animation
|
|
296
|
+
|
|
297
|
+
**Actions called (event handlers, never mutate state directly):**
|
|
298
|
+
|
|
299
|
+
- `actions.open(source?)` / `actions.close(intent?)` — programmatic open/close
|
|
300
|
+
- `actions.toggle(source?)` — toggle open state
|
|
301
|
+
- `actions.setTriggerId(id)` — set custom trigger element id
|
|
302
|
+
- `actions.setPlacement(placement)` — update placement edge at runtime
|
|
303
|
+
- `actions.handleTriggerClick()` — on trigger click
|
|
304
|
+
- `actions.handleTriggerKeyDown(event)` — on trigger keydown
|
|
305
|
+
- `actions.handleKeyDown(event)` — on panel keydown (Escape handling)
|
|
306
|
+
- `actions.handleOutsidePointer()` — on pointer outside the drawer
|
|
307
|
+
- `actions.handleOutsideFocus()` — on focus outside the drawer
|
|
308
|
+
|
|
309
|
+
**Contracts spread (attribute maps applied directly to DOM elements):**
|
|
310
|
+
|
|
311
|
+
- `contracts.getTriggerProps()` — spread onto the trigger button element
|
|
312
|
+
- `contracts.getOverlayProps()` — spread onto the overlay/backdrop element
|
|
313
|
+
- `contracts.getPanelProps()` — spread onto the drawer panel element (includes all dialog content attrs plus `data-placement`)
|
|
314
|
+
- `contracts.getTitleProps()` — spread onto the drawer title element
|
|
315
|
+
- `contracts.getDescriptionProps()` — spread onto the drawer description element
|
|
316
|
+
- `contracts.getCloseButtonProps()` — spread onto a footer/generic close button
|
|
317
|
+
- `contracts.getHeaderCloseButtonProps()` — spread onto a header close icon button (includes `aria-label: 'Close'`)
|
|
318
|
+
|
|
319
|
+
**UIKit-only concerns (NOT in headless):**
|
|
320
|
+
|
|
321
|
+
- Lifecycle events (`cv-open`, `cv-close`, `cv-after-open`, `cv-after-close`)
|
|
322
|
+
- CSS transitions and slide animations (direction determined by `data-placement`)
|
|
323
|
+
- Backdrop rendering and styling
|
|
324
|
+
- Scroll lock implementation (headless provides the signal, UIKit applies the side effect)
|
|
325
|
+
- Focus trap implementation (headless provides the signal, UIKit manages DOM focus)
|
|
326
|
+
- Contained mode (deferred to v2)
|
|
327
|
+
|
|
328
|
+
## Minimum Test Matrix
|
|
329
|
+
|
|
330
|
+
- All dialog tests apply (open/close, Escape, outside pointer, outside focus, focus trap, scroll lock, return focus, initial focus, ARIA linkage, alertdialog, close buttons, trigger handlers, overlay state)
|
|
331
|
+
- Default placement is `'end'`
|
|
332
|
+
- `getPanelProps()` returns `data-placement` matching current placement
|
|
333
|
+
- `setPlacement()` updates placement and `getPanelProps()` reflects the change
|
|
334
|
+
- All four placement values (`start`, `end`, `top`, `bottom`) are accepted
|
|
335
|
+
- Placement change does not affect `isOpen` state
|
|
336
|
+
- Placement change does not affect ARIA attributes (role, aria-modal, aria-labelledby, aria-describedby)
|
|
337
|
+
- `getPanelProps()` includes all dialog content attributes (role, tabindex, aria-modal, etc.)
|
|
338
|
+
- Custom `idBase` propagates through to all generated ids (uses `'drawer'` as default, not `'dialog'`)
|
|
339
|
+
|
|
340
|
+
## ADR-001 Compliance
|
|
341
|
+
|
|
342
|
+
- **Runtime Policy**: Reatom v1000 only; no `@statx/*` in headless core.
|
|
343
|
+
- **Layering**: `core -> interactions -> a11y-contracts -> adapters`; adapters remain thin mappings.
|
|
344
|
+
- **Independence**: No imports from `@project/*`, `apps/*`, or other out-of-package modules.
|
|
345
|
+
- **Composition**: `createDrawer` wraps `createDialog`; no duplication of dialog internals.
|
|
346
|
+
- **Verification**: Mandatory adapter integration tests and standalone package test execution.
|
|
347
|
+
|
|
348
|
+
## Out of Scope (Current)
|
|
349
|
+
|
|
350
|
+
- Contained mode (drawer within a parent container instead of viewport) - deferred to v2
|
|
351
|
+
- Swipe-to-dismiss gesture handling
|
|
352
|
+
- Nested/stacked drawers management
|
|
353
|
+
- Complex animations/transitions (CSS/JS animations are UIKit concerns)
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
# Feed Component Contract
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
`Feed` provides a headless APG-aligned model for bidirectional infinite scrolling content, where sections of content (articles) are loaded dynamically as the user scrolls or triggers load actions. Supports both appending (bottom) and prepending (top) of articles.
|
|
6
|
+
|
|
7
|
+
## Component Files
|
|
8
|
+
|
|
9
|
+
- `src/feed/index.ts` - model and public `createFeed` API
|
|
10
|
+
- `src/feed/feed.test.ts` - unit behavior tests
|
|
11
|
+
|
|
12
|
+
## Public API
|
|
13
|
+
|
|
14
|
+
- `createFeed(options)`
|
|
15
|
+
- `state` (signal-backed):
|
|
16
|
+
- `articleIds()` - ordered list of loaded article identifiers
|
|
17
|
+
- `activeArticleId()` - identifier of the article currently focused or "active"
|
|
18
|
+
- `isLoading()` - boolean indicating if content is being fetched
|
|
19
|
+
- `isBusy()` - boolean for `aria-busy`
|
|
20
|
+
- `totalCount()` - total number of articles if known, or -1 if unknown/infinite
|
|
21
|
+
- `isEmpty()` - derived: `articleIds.length === 0`
|
|
22
|
+
- `hasError()` - derived: whether an error is present
|
|
23
|
+
- `error()` - current error value/message, or `null`
|
|
24
|
+
- `canLoadMore()` - derived: whether more bottom-loading is possible
|
|
25
|
+
- `canLoadNewer()` - derived: whether more top-loading is possible
|
|
26
|
+
- `actions`:
|
|
27
|
+
- navigation: `focusNextArticle`, `focusPrevArticle`
|
|
28
|
+
- lifecycle: `loadMore` (append), `loadNewer` (prepend), `setArticles`, `appendArticles`, `prependArticles`, `removeArticle`
|
|
29
|
+
- state: `setBusy`, `setError`, `clearError`, `setTotalCount`
|
|
30
|
+
- keyboard: `handleKeyDown`
|
|
31
|
+
- `contracts`:
|
|
32
|
+
- `getFeedProps()`
|
|
33
|
+
- `getArticleProps(articleId)`
|
|
34
|
+
|
|
35
|
+
## APG and A11y Contract
|
|
36
|
+
|
|
37
|
+
- root role: `feed`
|
|
38
|
+
- item role: `article`
|
|
39
|
+
- required attributes:
|
|
40
|
+
- root: `aria-label` or `aria-labelledby`, `aria-busy`
|
|
41
|
+
- article: `aria-posinset`, `aria-setsize`, `tabindex`
|
|
42
|
+
- focus management:
|
|
43
|
+
- the feed container itself is not focusable
|
|
44
|
+
- articles are focusable and managed via `roving-tabindex`
|
|
45
|
+
|
|
46
|
+
## Keyboard Contract
|
|
47
|
+
|
|
48
|
+
Per W3C APG Feed Pattern:
|
|
49
|
+
|
|
50
|
+
- `PageDown`: move focus to the next article (`focusNextArticle`)
|
|
51
|
+
- `PageUp`: move focus to the previous article (`focusPrevArticle`)
|
|
52
|
+
- `Ctrl + End`: move focus to the first focusable element AFTER the feed (not to the last article). The headless `handleKeyDown` signals this intent via a return value or flag; the adapter is responsible for actual DOM focus movement.
|
|
53
|
+
- `Ctrl + Home`: move focus to the first focusable element BEFORE the feed (not to the first article). Same adapter delegation as above.
|
|
54
|
+
|
|
55
|
+
The `handleKeyDown` action returns a `FeedKeyboardResult` indicating the action taken:
|
|
56
|
+
|
|
57
|
+
- `'next'` - moved to next article
|
|
58
|
+
- `'prev'` - moved to previous article
|
|
59
|
+
- `'exit-after'` - adapter should move focus after the feed
|
|
60
|
+
- `'exit-before'` - adapter should move focus before the feed
|
|
61
|
+
- `null` - key not handled
|
|
62
|
+
|
|
63
|
+
## Behavior Contract
|
|
64
|
+
|
|
65
|
+
### Bidirectional Loading
|
|
66
|
+
|
|
67
|
+
- `loadMore` triggers when the user/adapter requests more content at the bottom (append direction). The adapter (UIKit) uses IntersectionObserver on a bottom sentinel to call this action.
|
|
68
|
+
- `loadNewer` triggers when the user/adapter requests newer content at the top (prepend direction). The adapter (UIKit) uses IntersectionObserver on a top sentinel to call this action.
|
|
69
|
+
- Both `loadMore` and `loadNewer` are action-only: headless exposes the actions, UIKit decides when to call them.
|
|
70
|
+
|
|
71
|
+
### Aria Busy
|
|
72
|
+
|
|
73
|
+
- `aria-busy` is set to `true` during both `loadMore` and `loadNewer` operations.
|
|
74
|
+
- `setBusy(true)` is called at the start of loading; `setBusy(false)` at completion.
|
|
75
|
+
|
|
76
|
+
### Focus Preservation
|
|
77
|
+
|
|
78
|
+
- When articles are prepended, the currently focused article must retain its focus. The `activeArticleId` remains stable; `aria-posinset` values shift for all articles.
|
|
79
|
+
- When the active article is removed, focus moves to the nearest enabled article (prefer next, fallback to prev).
|
|
80
|
+
|
|
81
|
+
### Position Recalculation
|
|
82
|
+
|
|
83
|
+
- `aria-posinset` and `aria-setsize` are recalculated on any article list change (`setArticles`, `appendArticles`, `prependArticles`, `removeArticle`).
|
|
84
|
+
- `aria-setsize` equals `totalCount` if known, or `-1` if unknown/infinite.
|
|
85
|
+
- `aria-posinset` is 1-based and reflects the article's position in the full ordered list.
|
|
86
|
+
|
|
87
|
+
## State Signal Surface
|
|
88
|
+
|
|
89
|
+
| Signal | Type | Description |
|
|
90
|
+
| ----------------- | ---------------------- | ---------------------------------------------------------------------- |
|
|
91
|
+
| `articleIds` | `Computed<string[]>` | Ordered list of loaded article IDs |
|
|
92
|
+
| `activeArticleId` | `Atom<string \| null>` | Currently focused article ID |
|
|
93
|
+
| `isLoading` | `Atom<boolean>` | Whether a load operation is in progress |
|
|
94
|
+
| `isBusy` | `Atom<boolean>` | Maps to `aria-busy` |
|
|
95
|
+
| `totalCount` | `Atom<number>` | Total articles count, or `-1` if unknown |
|
|
96
|
+
| `isEmpty` | `Computed<boolean>` | `articleIds.length === 0` |
|
|
97
|
+
| `hasError` | `Computed<boolean>` | `error !== null` |
|
|
98
|
+
| `error` | `Atom<string \| null>` | Current error message, or `null` |
|
|
99
|
+
| `canLoadMore` | `Computed<boolean>` | Whether bottom-loading is possible (not loading AND not all loaded) |
|
|
100
|
+
| `canLoadNewer` | `Computed<boolean>` | Whether top-loading is possible (not loading AND newer content exists) |
|
|
101
|
+
|
|
102
|
+
## Actions
|
|
103
|
+
|
|
104
|
+
| Action | Signature | Description |
|
|
105
|
+
| ------------------ | ------------------------------------------------------ | ------------------------------------------------------------ |
|
|
106
|
+
| `focusNextArticle` | `() => void` | Move active to next enabled article |
|
|
107
|
+
| `focusPrevArticle` | `() => void` | Move active to previous enabled article |
|
|
108
|
+
| `loadMore` | `() => Promise<void>` | Append articles at bottom; sets busy, calls adapter callback |
|
|
109
|
+
| `loadNewer` | `() => Promise<void>` | Prepend articles at top; sets busy, calls adapter callback |
|
|
110
|
+
| `setArticles` | `(articles: FeedArticle[]) => void` | Replace entire article list |
|
|
111
|
+
| `appendArticles` | `(articles: FeedArticle[]) => void` | Add articles to the end |
|
|
112
|
+
| `prependArticles` | `(articles: FeedArticle[]) => void` | Add articles to the beginning |
|
|
113
|
+
| `removeArticle` | `(articleId: string) => void` | Remove a single article by ID |
|
|
114
|
+
| `setBusy` | `(value: boolean) => void` | Set `aria-busy` state |
|
|
115
|
+
| `setError` | `(message: string) => void` | Set error state with message |
|
|
116
|
+
| `clearError` | `() => void` | Clear error state |
|
|
117
|
+
| `setTotalCount` | `(count: number) => void` | Set total article count (`-1` for unknown) |
|
|
118
|
+
| `handleKeyDown` | `(event: FeedKeyboardEventLike) => FeedKeyboardResult` | Process keyboard event per APG |
|
|
119
|
+
|
|
120
|
+
## Contracts
|
|
121
|
+
|
|
122
|
+
### `getFeedProps()`
|
|
123
|
+
|
|
124
|
+
Returns a complete ARIA prop object ready to spread on the feed root element:
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
{
|
|
128
|
+
id: string
|
|
129
|
+
role: 'feed'
|
|
130
|
+
'aria-label'?: string
|
|
131
|
+
'aria-labelledby'?: string
|
|
132
|
+
'aria-busy': 'true' | 'false'
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
### `getArticleProps(articleId)`
|
|
137
|
+
|
|
138
|
+
Returns a complete ARIA prop object ready to spread on each article element:
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
{
|
|
142
|
+
id: string
|
|
143
|
+
role: 'article'
|
|
144
|
+
tabindex: '0' | '-1'
|
|
145
|
+
'aria-posinset': number
|
|
146
|
+
'aria-setsize': number
|
|
147
|
+
'aria-labelledby'?: string
|
|
148
|
+
'aria-describedby'?: string
|
|
149
|
+
'aria-disabled'?: 'true'
|
|
150
|
+
'data-active': 'true' | 'false'
|
|
151
|
+
onFocus: () => void
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## Transitions Table
|
|
156
|
+
|
|
157
|
+
| Event / Action | Current State | Next State / Effect |
|
|
158
|
+
| -------------------------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
159
|
+
| `focusNextArticle()` | any | `activeArticleId` = next enabled article ID; clamps at last |
|
|
160
|
+
| `focusPrevArticle()` | any | `activeArticleId` = previous enabled article ID; clamps at first |
|
|
161
|
+
| `loadMore()` | `isLoading = false` | `isLoading` = `true`; `isBusy` = `true`; invoke callback; on resolve: append articles, `isLoading` = `false`, `isBusy` = `false`; on reject: set error, `isLoading` = `false`, `isBusy` = `false` |
|
|
162
|
+
| `loadMore()` | `isLoading = true` | no-op (guard against concurrent loads) |
|
|
163
|
+
| `loadNewer()` | `isLoading = false` | `isLoading` = `true`; `isBusy` = `true`; invoke callback; on resolve: prepend articles, `isLoading` = `false`, `isBusy` = `false`; on reject: set error, `isLoading` = `false`, `isBusy` = `false` |
|
|
164
|
+
| `loadNewer()` | `isLoading = true` | no-op (guard against concurrent loads) |
|
|
165
|
+
| `setArticles(list)` | any | `articles` = deduplicated list; recalculate all derived signals; ensure `activeArticleId` invariant |
|
|
166
|
+
| `appendArticles(list)` | any | `articles` = current + new (deduplicated); recalculate positions |
|
|
167
|
+
| `prependArticles(list)` | any | `articles` = new + current (deduplicated); recalculate positions; `activeArticleId` preserved |
|
|
168
|
+
| `removeArticle(id)` | `activeArticleId = id` | remove article; `activeArticleId` = nearest enabled (prefer next, fallback prev) |
|
|
169
|
+
| `removeArticle(id)` | `activeArticleId != id` | remove article; `activeArticleId` unchanged |
|
|
170
|
+
| `setBusy(value)` | any | `isBusy` = value |
|
|
171
|
+
| `setError(message)` | any | `error` = message; `hasError` = `true` |
|
|
172
|
+
| `clearError()` | any | `error` = `null`; `hasError` = `false` |
|
|
173
|
+
| `setTotalCount(count)` | any | `totalCount` = count; `canLoadMore` / `canLoadNewer` recalculated |
|
|
174
|
+
| `handleKeyDown(PageDown)` | any | calls `focusNextArticle()`; returns `'next'` |
|
|
175
|
+
| `handleKeyDown(PageUp)` | any | calls `focusPrevArticle()`; returns `'prev'` |
|
|
176
|
+
| `handleKeyDown(Ctrl+End)` | any | returns `'exit-after'` (adapter handles DOM focus) |
|
|
177
|
+
| `handleKeyDown(Ctrl+Home)` | any | returns `'exit-before'` (adapter handles DOM focus) |
|
|
178
|
+
| `handleKeyDown(other)` | any | returns `null` (not handled) |
|
|
179
|
+
|
|
180
|
+
## Invariants
|
|
181
|
+
|
|
182
|
+
1. `activeArticleId` must always be `null` or one of the currently loaded enabled `articleIds`.
|
|
183
|
+
2. `aria-setsize` equals `totalCount` if known (>= 0), or `-1` if unknown/infinite.
|
|
184
|
+
3. `aria-posinset` is 1-based and sequential across the ordered `articleIds` list.
|
|
185
|
+
4. Only the active article has `tabindex="0"`; all others have `tabindex="-1"`.
|
|
186
|
+
5. Disabled articles are skipped during keyboard navigation.
|
|
187
|
+
6. Focus must be preserved or logically moved when articles are prepended, appended, or removed.
|
|
188
|
+
7. `isEmpty` is always equivalent to `articleIds.length === 0`.
|
|
189
|
+
8. `hasError` is always equivalent to `error !== null`.
|
|
190
|
+
9. Concurrent `loadMore`/`loadNewer` calls are guarded — only one load operation at a time.
|
|
191
|
+
10. After any article list mutation, `activeArticleId` is validated against the new list and corrected if needed.
|
|
192
|
+
|
|
193
|
+
## Adapter Expectations
|
|
194
|
+
|
|
195
|
+
UIKit adapters MUST bind to the headless model as follows:
|
|
196
|
+
|
|
197
|
+
**Signals read (reactive, drive re-renders):**
|
|
198
|
+
|
|
199
|
+
- `state.articleIds()` — ordered article IDs for rendering the list
|
|
200
|
+
- `state.activeArticleId()` — for focus management
|
|
201
|
+
- `state.isLoading()` — for rendering loading indicators
|
|
202
|
+
- `state.isBusy()` — reflected in `aria-busy` via `getFeedProps()`
|
|
203
|
+
- `state.isEmpty()` — for rendering empty state slot
|
|
204
|
+
- `state.hasError()` — for rendering error state slot
|
|
205
|
+
- `state.error()` — for rendering error message
|
|
206
|
+
- `state.canLoadMore()` — for showing/hiding bottom sentinel or load-more button
|
|
207
|
+
- `state.canLoadNewer()` — for showing/hiding top sentinel or load-newer button
|
|
208
|
+
- `state.totalCount()` — reflected in `aria-setsize` via `getArticleProps()`
|
|
209
|
+
|
|
210
|
+
**Actions called (event handlers, never mutate state directly):**
|
|
211
|
+
|
|
212
|
+
- `actions.focusNextArticle()` / `actions.focusPrevArticle()` — article navigation
|
|
213
|
+
- `actions.loadMore()` — called by IntersectionObserver on bottom sentinel
|
|
214
|
+
- `actions.loadNewer()` — called by IntersectionObserver on top sentinel
|
|
215
|
+
- `actions.setArticles(list)` — to replace the full article list
|
|
216
|
+
- `actions.appendArticles(list)` / `actions.prependArticles(list)` — for manual batch additions
|
|
217
|
+
- `actions.removeArticle(id)` — to remove a single article
|
|
218
|
+
- `actions.setBusy(value)` — for external busy state control
|
|
219
|
+
- `actions.setError(message)` / `actions.clearError()` — for error state management
|
|
220
|
+
- `actions.setTotalCount(count)` — when total becomes known or changes
|
|
221
|
+
- `actions.handleKeyDown(event)` — on keydown within feed root; adapter inspects return value for `'exit-after'`/`'exit-before'` to handle DOM focus transfer
|
|
222
|
+
|
|
223
|
+
**Contracts spread (attribute maps applied directly to DOM elements):**
|
|
224
|
+
|
|
225
|
+
- `contracts.getFeedProps()` — spread onto the feed root element
|
|
226
|
+
- `contracts.getArticleProps(articleId)` — spread onto each article element
|
|
227
|
+
|
|
228
|
+
**UIKit-only concerns (NOT in headless):**
|
|
229
|
+
|
|
230
|
+
- IntersectionObserver setup for top/bottom sentinels
|
|
231
|
+
- DOM focus transfer for `Ctrl+End` / `Ctrl+Home` (moving focus outside the feed)
|
|
232
|
+
- Empty state and error state slot rendering
|
|
233
|
+
- Scroll position management
|
|
234
|
+
- Touch/gesture handling
|
|
235
|
+
|
|
236
|
+
## Minimum Test Matrix
|
|
237
|
+
|
|
238
|
+
- article navigation via `focusNextArticle` / `focusPrevArticle`
|
|
239
|
+
- correct `aria-posinset` and `aria-setsize` calculation during dynamic loading
|
|
240
|
+
- `aria-busy` state transitions during `loadMore` and `loadNewer`
|
|
241
|
+
- focus preservation when articles are prepended
|
|
242
|
+
- focus recovery when active article is removed
|
|
243
|
+
- boundary handling (first/last article clamping)
|
|
244
|
+
- disabled article skip behavior
|
|
245
|
+
- `setArticles` replaces list and validates active
|
|
246
|
+
- `appendArticles` / `prependArticles` deduplication
|
|
247
|
+
- `handleKeyDown` returns correct `FeedKeyboardResult` values
|
|
248
|
+
- `isEmpty`, `hasError`, `canLoadMore`, `canLoadNewer` derived state accuracy
|
|
249
|
+
- error state transitions (`setError`, `clearError`)
|
|
250
|
+
- concurrent load guard (second `loadMore` during active load is no-op)
|
|
251
|
+
|
|
252
|
+
## ADR-001 Compliance
|
|
253
|
+
|
|
254
|
+
- **Runtime Policy**: Reatom v1000 only; no @statx/\* in headless core.
|
|
255
|
+
- **Layering**: core -> interactions -> a11y-contracts -> adapters; adapters remain thin mappings.
|
|
256
|
+
- **Independence**: No imports from @project/_, apps/_, or other out-of-package modules.
|
|
257
|
+
- **Verification**: Mandatory adapter integration tests and standalone package test execution.
|
|
258
|
+
|
|
259
|
+
## Out of Scope (Current)
|
|
260
|
+
|
|
261
|
+
- scroll position restoration
|
|
262
|
+
- complex filtering or sorting of the feed
|
|
263
|
+
- nested feeds
|
|
264
|
+
- automatic scroll-to-load logic (handled by IntersectionObserver in the adapter)
|
|
265
|
+
- virtualization of off-screen articles
|