@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,298 @@
|
|
|
1
|
+
# Dialog Component Contract
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
`Dialog` is a headless APG-aligned contract for modal and non-modal dialogs. It manages visibility, focus trapping (modal only), scroll locking (modal only), and dismissal behavior. Supports both `dialog` and `alertdialog` ARIA roles.
|
|
6
|
+
|
|
7
|
+
## Component Files
|
|
8
|
+
|
|
9
|
+
- `src/dialog/index.ts` - model and public `createDialog` API
|
|
10
|
+
- `src/dialog/dialog.test.ts` - unit behavior tests
|
|
11
|
+
|
|
12
|
+
## Public API
|
|
13
|
+
|
|
14
|
+
- `createDialog(options)`
|
|
15
|
+
- `state` (signal-backed):
|
|
16
|
+
- `isOpen()` — whether the dialog is currently visible
|
|
17
|
+
- `isModal()` — whether the dialog is in modal mode
|
|
18
|
+
- `type()` — `'dialog' | 'alertdialog'`
|
|
19
|
+
- `restoreTargetId()` — element id to return focus to on close
|
|
20
|
+
- `isFocusTrapped()` — computed: `true` when open AND modal
|
|
21
|
+
- `shouldLockScroll()` — computed: `true` when open AND modal
|
|
22
|
+
- `initialFocusTargetId()` — id of element to focus on open (or `null`)
|
|
23
|
+
- `actions`:
|
|
24
|
+
- `open(source?)`, `close(intent?)`, `toggle(source?)`
|
|
25
|
+
- `setTriggerId(id)`
|
|
26
|
+
- `handleTriggerClick()`
|
|
27
|
+
- `handleTriggerKeyDown(event)`
|
|
28
|
+
- `handleKeyDown(event)`
|
|
29
|
+
- `handleOutsidePointer()`
|
|
30
|
+
- `handleOutsideFocus()`
|
|
31
|
+
- `contracts`:
|
|
32
|
+
- `getTriggerProps()`
|
|
33
|
+
- `getOverlayProps()`
|
|
34
|
+
- `getContentProps()`
|
|
35
|
+
- `getTitleProps()`
|
|
36
|
+
- `getDescriptionProps()`
|
|
37
|
+
- `getCloseButtonProps()` — footer/generic close button
|
|
38
|
+
- `getHeaderCloseButtonProps()` — header close icon button
|
|
39
|
+
|
|
40
|
+
## CreateDialogOptions
|
|
41
|
+
|
|
42
|
+
| Option | Type | Default | Description |
|
|
43
|
+
| ----------------------- | --------------------------- | ---------------------- | ---------------------------------------------- |
|
|
44
|
+
| `idBase` | `string` | `'dialog'` | Base id prefix for all generated ids |
|
|
45
|
+
| `type` | `'dialog' \| 'alertdialog'` | `'dialog'` | ARIA role for the content element |
|
|
46
|
+
| `initialOpen` | `boolean` | `false` | Whether the dialog starts open |
|
|
47
|
+
| `isModal` | `boolean` | `true` | Modal mode enables focus trap and scroll lock |
|
|
48
|
+
| `closeOnEscape` | `boolean` | `true` | Whether Escape key closes the dialog |
|
|
49
|
+
| `closeOnOutsidePointer` | `boolean` | `true` | Whether clicking outside closes the dialog |
|
|
50
|
+
| `closeOnOutsideFocus` | `boolean` | `true` | Whether focusing outside closes the dialog |
|
|
51
|
+
| `initialFocusId` | `string` | — | Id of element to receive initial focus on open |
|
|
52
|
+
| `ariaLabelledBy` | `string` | `{idBase}-title` | Custom id for `aria-labelledby` |
|
|
53
|
+
| `ariaDescribedBy` | `string` | `{idBase}-description` | Custom id for `aria-describedby` |
|
|
54
|
+
|
|
55
|
+
## State Signal Surface
|
|
56
|
+
|
|
57
|
+
| Signal | Type | Derived? | Description |
|
|
58
|
+
| ---------------------- | --------------------------------- | -------- | -------------------------------------------------------- |
|
|
59
|
+
| `isOpen` | `Atom<boolean>` | No | Single source of truth for visibility |
|
|
60
|
+
| `isModal` | `Atom<boolean>` | No | Whether modal behaviors (focus trap, scroll lock) are on |
|
|
61
|
+
| `type` | `Atom<'dialog' \| 'alertdialog'>` | No | ARIA role type |
|
|
62
|
+
| `restoreTargetId` | `Atom<string \| null>` | No | Element id to return focus to after close |
|
|
63
|
+
| `isFocusTrapped` | `Computed<boolean>` | Yes | `isOpen() && isModal()` |
|
|
64
|
+
| `shouldLockScroll` | `Computed<boolean>` | Yes | `isOpen() && isModal()` |
|
|
65
|
+
| `initialFocusTargetId` | `Atom<string \| null>` | No | Id of element to receive focus when dialog opens |
|
|
66
|
+
|
|
67
|
+
## APG and A11y Contract
|
|
68
|
+
|
|
69
|
+
- content role: `dialog` (default) or `alertdialog` (when `type: 'alertdialog'`)
|
|
70
|
+
- required attributes:
|
|
71
|
+
- content: `aria-modal`, `aria-labelledby`
|
|
72
|
+
- content: `aria-describedby` (required when `type: 'alertdialog'`, recommended for `dialog`)
|
|
73
|
+
- trigger: `aria-haspopup="dialog"`, `aria-expanded`, `aria-controls`
|
|
74
|
+
- focus management:
|
|
75
|
+
- **modal**: focus trap within the dialog; Tab/Shift+Tab cycle through focusable elements
|
|
76
|
+
- **non-modal**: no focus trap; focus can move freely to/from the dialog
|
|
77
|
+
- initial focus on a specific target (via `initialFocusId`) or the first focusable element
|
|
78
|
+
- return focus to the trigger upon closing (both modal and non-modal)
|
|
79
|
+
- alertdialog specifics:
|
|
80
|
+
- role `alertdialog` signals that the dialog contains an alert message requiring user response
|
|
81
|
+
- `aria-describedby` is required (per W3C APG) to point to the alert message content
|
|
82
|
+
|
|
83
|
+
## Behavior Contract
|
|
84
|
+
|
|
85
|
+
### Modal (`isModal: true`, default)
|
|
86
|
+
|
|
87
|
+
- `Escape` key closes the dialog (configurable via `closeOnEscape`)
|
|
88
|
+
- Outside pointer click closes the dialog (configurable via `closeOnOutsidePointer`)
|
|
89
|
+
- Outside focus closes the dialog (configurable via `closeOnOutsideFocus`)
|
|
90
|
+
- Scroll lock on the body while the dialog is open
|
|
91
|
+
- Focus trap: `Tab` and `Shift+Tab` cycle through focusable elements inside the dialog
|
|
92
|
+
- Initial focus: defaults to the first focusable element, can be overridden via `initialFocusId`
|
|
93
|
+
|
|
94
|
+
### Non-modal (`isModal: false`)
|
|
95
|
+
|
|
96
|
+
- `Escape` key closes the dialog (configurable via `closeOnEscape`)
|
|
97
|
+
- Outside pointer click closes the dialog (configurable via `closeOnOutsidePointer`)
|
|
98
|
+
- Outside focus closes the dialog (configurable via `closeOnOutsideFocus`)
|
|
99
|
+
- No scroll lock — page remains scrollable
|
|
100
|
+
- No focus trap — user can Tab out of the dialog freely
|
|
101
|
+
- `aria-modal` is `'false'`
|
|
102
|
+
- Initial focus: same as modal (configurable via `initialFocusId`)
|
|
103
|
+
|
|
104
|
+
## Contract Prop Shapes
|
|
105
|
+
|
|
106
|
+
### `getTriggerProps()`
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
{
|
|
110
|
+
id: string // trigger element id
|
|
111
|
+
role: 'button'
|
|
112
|
+
tabindex: '0'
|
|
113
|
+
'aria-haspopup': 'dialog'
|
|
114
|
+
'aria-expanded': 'true' | 'false' // reflects isOpen
|
|
115
|
+
'aria-controls': string // points to content id
|
|
116
|
+
onClick: () => void
|
|
117
|
+
onKeyDown: (event) => void
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### `getOverlayProps()`
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
{
|
|
125
|
+
id: string // overlay element id
|
|
126
|
+
hidden: boolean // !isOpen
|
|
127
|
+
'data-open': 'true' | 'false' // reflects isOpen
|
|
128
|
+
onPointerDownOutside: () => void
|
|
129
|
+
onFocusOutside: () => void
|
|
130
|
+
}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### `getContentProps()`
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
{
|
|
137
|
+
id: string // content element id
|
|
138
|
+
role: 'dialog' | 'alertdialog' // based on type option
|
|
139
|
+
tabindex: '-1'
|
|
140
|
+
'aria-modal': 'true' | 'false' // reflects isModal
|
|
141
|
+
'aria-labelledby': string // points to title id
|
|
142
|
+
'aria-describedby'?: string // points to description id (required for alertdialog)
|
|
143
|
+
'data-initial-focus'?: string // initialFocusId if provided
|
|
144
|
+
onKeyDown: (event) => void
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### `getTitleProps()`
|
|
149
|
+
|
|
150
|
+
```ts
|
|
151
|
+
{
|
|
152
|
+
id: string // title element id
|
|
153
|
+
}
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
### `getDescriptionProps()`
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
{
|
|
160
|
+
id: string // description element id
|
|
161
|
+
}
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### `getCloseButtonProps()` (footer/generic close)
|
|
165
|
+
|
|
166
|
+
```ts
|
|
167
|
+
{
|
|
168
|
+
id: string // '{idBase}-close'
|
|
169
|
+
role: 'button'
|
|
170
|
+
tabindex: '0'
|
|
171
|
+
onClick: () => void // calls close('programmatic')
|
|
172
|
+
}
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
### `getHeaderCloseButtonProps()` (header close icon)
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
{
|
|
179
|
+
id: string // '{idBase}-header-close'
|
|
180
|
+
role: 'button'
|
|
181
|
+
tabindex: '0'
|
|
182
|
+
'aria-label': 'Close'
|
|
183
|
+
onClick: () => void // calls close('programmatic')
|
|
184
|
+
}
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
## Transitions Table
|
|
188
|
+
|
|
189
|
+
| Event / Action | Current State | Next State / Effect |
|
|
190
|
+
| ----------------------------------- | ----------------------------------------------- | ---------------------------------------------------------------- |
|
|
191
|
+
| `open(source)` | `isOpen = false` | `isOpen = true`; restore target cleared; focus management begins |
|
|
192
|
+
| `close(intent)` | `isOpen = true` | `isOpen = false`; `restoreTargetId` set to trigger id |
|
|
193
|
+
| `toggle(source)` | `isOpen = false` | calls `open(source)` |
|
|
194
|
+
| `toggle(source)` | `isOpen = true` | calls `close('programmatic')` |
|
|
195
|
+
| `handleTriggerClick()` | any | calls `toggle('pointer')` |
|
|
196
|
+
| `handleTriggerKeyDown(Enter/Space)` | any | calls `toggle('keyboard')` |
|
|
197
|
+
| `handleKeyDown(Escape)` | `isOpen = true`, `closeOnEscape = true` | calls `close('escape')` |
|
|
198
|
+
| `handleKeyDown(Escape)` | `closeOnEscape = false` | no-op |
|
|
199
|
+
| `handleOutsidePointer()` | `isOpen = true`, `closeOnOutsidePointer = true` | calls `close('outside-pointer')` |
|
|
200
|
+
| `handleOutsidePointer()` | `closeOnOutsidePointer = false` | no-op |
|
|
201
|
+
| `handleOutsideFocus()` | `isOpen = true`, `closeOnOutsideFocus = true` | calls `close('outside-focus')` |
|
|
202
|
+
| `handleOutsideFocus()` | `closeOnOutsideFocus = false` | no-op |
|
|
203
|
+
| `setTriggerId(id)` | any | trigger id updated; affects future `restoreTargetId` |
|
|
204
|
+
|
|
205
|
+
### Derived state reactions
|
|
206
|
+
|
|
207
|
+
| State Change | `isFocusTrapped` | `shouldLockScroll` |
|
|
208
|
+
| ---------------- | ---------------- | ------------------ |
|
|
209
|
+
| open + modal | `true` | `true` |
|
|
210
|
+
| open + non-modal | `false` | `false` |
|
|
211
|
+
| closed (any) | `false` | `false` |
|
|
212
|
+
|
|
213
|
+
## Invariants
|
|
214
|
+
|
|
215
|
+
1. Modal dialogs must trap focus and prevent interaction with the rest of the page.
|
|
216
|
+
2. Non-modal dialogs must NOT trap focus and must NOT lock scroll.
|
|
217
|
+
3. `isOpen` state is the single source of truth for visibility.
|
|
218
|
+
4. Closing the dialog must set `restoreTargetId` to the trigger element id for focus restoration.
|
|
219
|
+
5. `isFocusTrapped` === `isOpen && isModal` — always derived, never set directly.
|
|
220
|
+
6. `shouldLockScroll` === `isOpen && isModal` — always derived, never set directly.
|
|
221
|
+
7. When `type` is `'alertdialog'`, the content role must be `'alertdialog'` (not `'dialog'`).
|
|
222
|
+
8. `aria-describedby` is always included for `alertdialog` (W3C APG requirement).
|
|
223
|
+
9. Both `getCloseButtonProps()` and `getHeaderCloseButtonProps()` must call the same `close('programmatic')` action.
|
|
224
|
+
10. `getHeaderCloseButtonProps()` must include `aria-label: 'Close'` for icon-only buttons.
|
|
225
|
+
|
|
226
|
+
## Adapter Expectations
|
|
227
|
+
|
|
228
|
+
UIKit adapters MUST bind to the headless model as follows:
|
|
229
|
+
|
|
230
|
+
**Signals read (reactive, drive re-renders):**
|
|
231
|
+
|
|
232
|
+
- `state.isOpen()` — whether the dialog is visible
|
|
233
|
+
- `state.isModal()` — whether modal behaviors are active
|
|
234
|
+
- `state.type()` — dialog type for role assignment
|
|
235
|
+
- `state.isFocusTrapped()` — whether focus trap should be active
|
|
236
|
+
- `state.shouldLockScroll()` — whether body scroll lock should be active
|
|
237
|
+
- `state.restoreTargetId()` — element id to focus after close
|
|
238
|
+
- `state.initialFocusTargetId()` — element id to focus on open
|
|
239
|
+
|
|
240
|
+
**Actions called (event handlers, never mutate state directly):**
|
|
241
|
+
|
|
242
|
+
- `actions.open(source?)` / `actions.close(intent?)` — programmatic open/close
|
|
243
|
+
- `actions.toggle(source?)` — toggle open state
|
|
244
|
+
- `actions.setTriggerId(id)` — set custom trigger element id
|
|
245
|
+
- `actions.handleTriggerClick()` — on trigger click
|
|
246
|
+
- `actions.handleTriggerKeyDown(event)` — on trigger keydown
|
|
247
|
+
- `actions.handleKeyDown(event)` — on content keydown (Escape handling)
|
|
248
|
+
- `actions.handleOutsidePointer()` — on pointer outside the dialog
|
|
249
|
+
- `actions.handleOutsideFocus()` — on focus outside the dialog
|
|
250
|
+
|
|
251
|
+
**Contracts spread (attribute maps applied directly to DOM elements):**
|
|
252
|
+
|
|
253
|
+
- `contracts.getTriggerProps()` — spread onto the trigger button element
|
|
254
|
+
- `contracts.getOverlayProps()` — spread onto the overlay/backdrop element
|
|
255
|
+
- `contracts.getContentProps()` — spread onto the dialog content panel (returns `role: 'dialog' | 'alertdialog'` based on `type`)
|
|
256
|
+
- `contracts.getTitleProps()` — spread onto the dialog title element
|
|
257
|
+
- `contracts.getDescriptionProps()` — spread onto the dialog description element
|
|
258
|
+
- `contracts.getCloseButtonProps()` — spread onto a footer/generic close button
|
|
259
|
+
- `contracts.getHeaderCloseButtonProps()` — spread onto a header close icon button (includes `aria-label: 'Close'`)
|
|
260
|
+
|
|
261
|
+
**UIKit-only concerns (NOT in headless):**
|
|
262
|
+
|
|
263
|
+
- Lifecycle events (`cv-open`, `cv-close`, `cv-after-open`, `cv-after-close`)
|
|
264
|
+
- CSS transitions and animations
|
|
265
|
+
- Backdrop rendering and styling
|
|
266
|
+
- Scroll lock implementation (headless provides the signal, UIKit applies the side effect)
|
|
267
|
+
- Focus trap implementation (headless provides the signal, UIKit manages DOM focus)
|
|
268
|
+
|
|
269
|
+
## Minimum Test Matrix
|
|
270
|
+
|
|
271
|
+
- open/close lifecycle via actions
|
|
272
|
+
- `Escape` key dismissal (with and without `closeOnEscape`)
|
|
273
|
+
- outside pointer dismissal (with and without `closeOnOutsidePointer`)
|
|
274
|
+
- outside focus dismissal (with and without `closeOnOutsideFocus`)
|
|
275
|
+
- focus trap behavior: `isFocusTrapped` is `true` for modal, `false` for non-modal
|
|
276
|
+
- scroll lock: `shouldLockScroll` is `true` for modal, `false` for non-modal
|
|
277
|
+
- return focus to trigger on close (`restoreTargetId`)
|
|
278
|
+
- initial focus placement (`initialFocusTargetId`, `data-initial-focus`)
|
|
279
|
+
- trigger/content/title/description aria linkage consistency
|
|
280
|
+
- `type: 'alertdialog'` produces `role: 'alertdialog'` in content props
|
|
281
|
+
- `type: 'dialog'` (default) produces `role: 'dialog'` in content props
|
|
282
|
+
- `aria-describedby` present for alertdialog
|
|
283
|
+
- `getHeaderCloseButtonProps()` returns `aria-label: 'Close'` and closes the dialog
|
|
284
|
+
- `getCloseButtonProps()` closes the dialog
|
|
285
|
+
- trigger click and keyboard (Enter, Space) handlers
|
|
286
|
+
- overlay props reflect open state
|
|
287
|
+
|
|
288
|
+
## ADR-001 Compliance
|
|
289
|
+
|
|
290
|
+
- **Runtime Policy**: Reatom v1000 only; no `@statx/*` in headless core.
|
|
291
|
+
- **Layering**: `core -> interactions -> a11y-contracts -> adapters`; adapters remain thin mappings.
|
|
292
|
+
- **Independence**: No imports from `@project/*`, `apps/*`, or other out-of-package modules.
|
|
293
|
+
- **Verification**: Mandatory adapter integration tests and standalone package test execution.
|
|
294
|
+
|
|
295
|
+
## Out of Scope (Current)
|
|
296
|
+
|
|
297
|
+
- Nested/stacked dialogs management
|
|
298
|
+
- Complex animations/transitions (CSS/JS animations are UIKit concerns)
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
# Disclosure Component Contract
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
`Disclosure` is a headless APG-aligned contract for a simple interactive element that controls the visibility of a single content area. It manages the open/closed state, ensures correct ARIA linkage between the trigger and the panel, and supports name-based exclusive grouping for accordion-like behavior.
|
|
6
|
+
|
|
7
|
+
## Component Files
|
|
8
|
+
|
|
9
|
+
- `src/disclosure/index.ts` - model, registry, and public `createDisclosure` API
|
|
10
|
+
- `src/disclosure/disclosure.test.ts` - unit behavior tests
|
|
11
|
+
|
|
12
|
+
## Public API
|
|
13
|
+
|
|
14
|
+
- `createDisclosure(options)`
|
|
15
|
+
- `options`:
|
|
16
|
+
- `idBase?`: string — base id prefix for generated ids (default: `'disclosure'`)
|
|
17
|
+
- `isOpen?`: boolean — initial open state (default: `false`)
|
|
18
|
+
- `isDisabled?`: boolean — initial disabled state (default: `false`)
|
|
19
|
+
- `name?`: string — group name for exclusive accordion-like behavior; when set, opening this disclosure closes all others sharing the same `name`
|
|
20
|
+
- `onOpenChange?`: `(isOpen: boolean) => void` — callback fired on state change
|
|
21
|
+
- `state` (signal-backed):
|
|
22
|
+
- `isOpen()` — boolean indicating if the content is visible
|
|
23
|
+
- `isDisabled()` — boolean indicating if user interaction is blocked
|
|
24
|
+
- `name()` — current group name or `null` (reactive)
|
|
25
|
+
- `actions`:
|
|
26
|
+
- `open()` — shows the content; if `name` is set, closes all other disclosures in the same group
|
|
27
|
+
- `close()` — hides the content
|
|
28
|
+
- `toggle()` — toggles visibility (delegates to `open` or `close`)
|
|
29
|
+
- `setDisabled(value)` — sets the disabled state
|
|
30
|
+
- `setName(value)` — updates the group name; re-registers in the new group
|
|
31
|
+
- `handleClick()` — delegates to `toggle()`
|
|
32
|
+
- `handleKeyDown(event)` — processes keyboard input (see Keyboard Contract)
|
|
33
|
+
- `destroy()` — unregisters from the group registry; MUST be called on teardown
|
|
34
|
+
- `contracts`:
|
|
35
|
+
- `getTriggerProps()` — returns complete ARIA and event handler attribute map for the trigger element
|
|
36
|
+
- `getPanelProps()` — returns complete ARIA attribute map for the content panel
|
|
37
|
+
|
|
38
|
+
## CreateDisclosureOptions
|
|
39
|
+
|
|
40
|
+
| Option | Type | Default | Description |
|
|
41
|
+
| -------------- | --------------------------- | -------------- | --------------------------------------------- |
|
|
42
|
+
| `idBase` | `string` | `'disclosure'` | Base id prefix for all generated ids |
|
|
43
|
+
| `isOpen` | `boolean` | `false` | Whether the disclosure starts open |
|
|
44
|
+
| `isDisabled` | `boolean` | `false` | Whether user interaction is initially blocked |
|
|
45
|
+
| `name` | `string` | — | Group name for exclusive behavior |
|
|
46
|
+
| `onOpenChange` | `(isOpen: boolean) => void` | — | Callback fired when `isOpen` changes |
|
|
47
|
+
|
|
48
|
+
## State Signal Surface
|
|
49
|
+
|
|
50
|
+
| Signal | Type | Derived? | Description |
|
|
51
|
+
| ------------ | ---------------------- | -------- | -------------------------------------------- |
|
|
52
|
+
| `isOpen` | `Atom<boolean>` | No | Single source of truth for visibility |
|
|
53
|
+
| `isDisabled` | `Atom<boolean>` | No | Whether user interaction is blocked |
|
|
54
|
+
| `name` | `Atom<string \| null>` | No | Group name for exclusive behavior, or `null` |
|
|
55
|
+
|
|
56
|
+
## APG and A11y Contract
|
|
57
|
+
|
|
58
|
+
- trigger role: `button`
|
|
59
|
+
- panel role: none (usually a `div`)
|
|
60
|
+
- required attributes:
|
|
61
|
+
- trigger: `aria-expanded`, `aria-controls`, `id`, `aria-disabled` (when disabled)
|
|
62
|
+
- panel: `id`, `aria-labelledby`, `hidden`
|
|
63
|
+
- focus management:
|
|
64
|
+
- the trigger is in the page tab sequence (`tabindex: '0'`); removed from tab order when disabled (`tabindex: '-1'`)
|
|
65
|
+
- focus remains on the trigger when toggled, unless the content contains focusable elements that the user chooses to move focus to
|
|
66
|
+
|
|
67
|
+
## Keyboard Contract
|
|
68
|
+
|
|
69
|
+
| Key | Action |
|
|
70
|
+
| -------------------------- | ---------------------------------------------------------- |
|
|
71
|
+
| `Enter` | Toggle the `isOpen` state; calls `preventDefault` |
|
|
72
|
+
| `Space` | Toggle the `isOpen` state; calls `preventDefault` |
|
|
73
|
+
| `ArrowDown` / `ArrowRight` | Expand (open) if currently closed; calls `preventDefault` |
|
|
74
|
+
| `ArrowUp` / `ArrowLeft` | Collapse (close) if currently open; calls `preventDefault` |
|
|
75
|
+
|
|
76
|
+
All keyboard actions are no-ops when `isDisabled` is `true`.
|
|
77
|
+
|
|
78
|
+
## Behavior Contract
|
|
79
|
+
|
|
80
|
+
- **Toggle Behavior**:
|
|
81
|
+
- `Enter` or `Space` on the trigger toggles the `isOpen` state
|
|
82
|
+
- clicking the trigger toggles the `isOpen` state
|
|
83
|
+
- **Directional Expand/Collapse**:
|
|
84
|
+
- `ArrowDown` or `ArrowRight` on the trigger opens the disclosure (no-op if already open)
|
|
85
|
+
- `ArrowUp` or `ArrowLeft` on the trigger closes the disclosure (no-op if already closed)
|
|
86
|
+
- **Name-Based Exclusive Grouping**:
|
|
87
|
+
- when `name` is set, the disclosure is registered in a shared group registry keyed by `name`
|
|
88
|
+
- when a named disclosure opens (via any action: `open`, `toggle`, `handleKeyDown`, `handleClick`), all other disclosures in the same `name` group are closed
|
|
89
|
+
- closing a disclosure does not open any other disclosure in the group
|
|
90
|
+
- the registry is a module-level `Map<string, Set<DisclosureModel>>` — not global, scoped to the headless package
|
|
91
|
+
- `destroy()` removes the disclosure from the registry; adapters MUST call this on `disconnectedCallback` or equivalent teardown
|
|
92
|
+
- `setName(value)` re-registers: unregisters from the old group, registers in the new group
|
|
93
|
+
- **Linkage**:
|
|
94
|
+
- `aria-controls` on the trigger must match the `id` of the panel
|
|
95
|
+
- `aria-expanded` reflects the `isOpen` state
|
|
96
|
+
- `aria-labelledby` on the panel must match the `id` of the trigger
|
|
97
|
+
|
|
98
|
+
## Contract Prop Shapes
|
|
99
|
+
|
|
100
|
+
### `getTriggerProps()`
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
{
|
|
104
|
+
id: string // '{idBase}-trigger'
|
|
105
|
+
role: 'button'
|
|
106
|
+
tabindex: '0' | '-1' // '-1' when disabled
|
|
107
|
+
'aria-expanded': 'true' | 'false' // reflects isOpen
|
|
108
|
+
'aria-controls': string // points to panel id
|
|
109
|
+
'aria-disabled'?: 'true' // present only when disabled
|
|
110
|
+
onClick: () => void // calls handleClick
|
|
111
|
+
onKeyDown: (event) => void // calls handleKeyDown
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### `getPanelProps()`
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
{
|
|
119
|
+
id: string // '{idBase}-panel'
|
|
120
|
+
'aria-labelledby': string // points to trigger id
|
|
121
|
+
hidden: boolean // !isOpen
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Transitions Table
|
|
126
|
+
|
|
127
|
+
| Event / Action | Current State | Next State / Effect |
|
|
128
|
+
| --------------------------- | ------------------------------ | ---------------------------------------------------------------- |
|
|
129
|
+
| `open()` | `isOpen = false` | `isOpen = true`; if `name` set, close all other group members |
|
|
130
|
+
| `open()` | `isOpen = true` | no-op |
|
|
131
|
+
| `close()` | `isOpen = true` | `isOpen = false` |
|
|
132
|
+
| `close()` | `isOpen = false` | no-op |
|
|
133
|
+
| `toggle()` | `isOpen = false` | delegates to `open()` |
|
|
134
|
+
| `toggle()` | `isOpen = true` | delegates to `close()` |
|
|
135
|
+
| `handleClick()` | any | delegates to `toggle()` |
|
|
136
|
+
| `handleKeyDown(Enter)` | any, not disabled | delegates to `toggle()`; `preventDefault` |
|
|
137
|
+
| `handleKeyDown(Space)` | any, not disabled | delegates to `toggle()`; `preventDefault` |
|
|
138
|
+
| `handleKeyDown(ArrowDown)` | `isOpen = false`, not disabled | delegates to `open()`; `preventDefault` |
|
|
139
|
+
| `handleKeyDown(ArrowRight)` | `isOpen = false`, not disabled | delegates to `open()`; `preventDefault` |
|
|
140
|
+
| `handleKeyDown(ArrowDown)` | `isOpen = true`, not disabled | no-op (already open); `preventDefault` |
|
|
141
|
+
| `handleKeyDown(ArrowRight)` | `isOpen = true`, not disabled | no-op (already open); `preventDefault` |
|
|
142
|
+
| `handleKeyDown(ArrowUp)` | `isOpen = true`, not disabled | delegates to `close()`; `preventDefault` |
|
|
143
|
+
| `handleKeyDown(ArrowLeft)` | `isOpen = true`, not disabled | delegates to `close()`; `preventDefault` |
|
|
144
|
+
| `handleKeyDown(ArrowUp)` | `isOpen = false`, not disabled | no-op (already closed); `preventDefault` |
|
|
145
|
+
| `handleKeyDown(ArrowLeft)` | `isOpen = false`, not disabled | no-op (already closed); `preventDefault` |
|
|
146
|
+
| `handleKeyDown(other)` | any | no-op; no `preventDefault` |
|
|
147
|
+
| `handleKeyDown(any)` | `isDisabled = true` | no-op; no `preventDefault` |
|
|
148
|
+
| `setDisabled(value)` | any | `isDisabled = value` |
|
|
149
|
+
| `setName(value)` | any | unregister from old group; `name = value`; register in new group |
|
|
150
|
+
| `destroy()` | any | unregister from group registry |
|
|
151
|
+
|
|
152
|
+
### Group Side Effects
|
|
153
|
+
|
|
154
|
+
| Trigger | Side Effect on Other Group Members |
|
|
155
|
+
| --------------------- | ------------------------------------------------------------ |
|
|
156
|
+
| `open()` with `name` | all other disclosures with the same `name` receive `close()` |
|
|
157
|
+
| `close()` with `name` | none |
|
|
158
|
+
|
|
159
|
+
## Invariants
|
|
160
|
+
|
|
161
|
+
1. `isOpen` is a boolean.
|
|
162
|
+
2. `aria-expanded` is `"true"` when `isOpen` is `true`, and `"false"` otherwise.
|
|
163
|
+
3. If the trigger is disabled, `toggle`, `open`, `close`, and `handleKeyDown` actions are no-ops for user interactions.
|
|
164
|
+
4. `aria-controls` on the trigger always matches the `id` of the panel.
|
|
165
|
+
5. `aria-labelledby` on the panel always matches the `id` of the trigger.
|
|
166
|
+
6. When `name` is set, at most one disclosure in the group can be open at any time (exclusive constraint).
|
|
167
|
+
7. Arrow keys (`ArrowDown`/`ArrowRight`) only open; they never close. Arrow keys (`ArrowUp`/`ArrowLeft`) only close; they never open.
|
|
168
|
+
8. `destroy()` must remove the disclosure from the group registry; failure to call `destroy()` constitutes a memory leak.
|
|
169
|
+
|
|
170
|
+
## Name-Based Group Registry
|
|
171
|
+
|
|
172
|
+
The registry is a module-level data structure within `src/disclosure/index.ts`:
|
|
173
|
+
|
|
174
|
+
```ts
|
|
175
|
+
// Module-level registry — not exported, internal implementation detail
|
|
176
|
+
const groupRegistry = new Map<string, Set<DisclosureModel>>()
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
**Registration lifecycle:**
|
|
180
|
+
|
|
181
|
+
- On `createDisclosure({ name })`: if `name` is provided, add the model to `groupRegistry.get(name)`
|
|
182
|
+
- On `setName(newName)`: remove from old group set, add to new group set
|
|
183
|
+
- On `destroy()`: remove from current group set; clean up empty sets from the map
|
|
184
|
+
|
|
185
|
+
**Exclusive open enforcement:**
|
|
186
|
+
|
|
187
|
+
- When `open()` is called on a model with a `name`, iterate `groupRegistry.get(name)` and call `close()` on every other model in the set
|
|
188
|
+
- This is an internal side effect of `open()`, not a separate action
|
|
189
|
+
|
|
190
|
+
## Adapter Expectations
|
|
191
|
+
|
|
192
|
+
UIKit adapters MUST bind to the headless model as follows:
|
|
193
|
+
|
|
194
|
+
**Signals read (reactive, drive re-renders):**
|
|
195
|
+
|
|
196
|
+
- `state.isOpen()` — whether the disclosure content is visible
|
|
197
|
+
- `state.isDisabled()` — whether user interaction is blocked
|
|
198
|
+
- `state.name()` — current group name (used for registration lifecycle)
|
|
199
|
+
|
|
200
|
+
**Actions called (event handlers, never mutate state directly):**
|
|
201
|
+
|
|
202
|
+
- `actions.open()` / `actions.close()` — programmatic show/hide
|
|
203
|
+
- `actions.toggle()` — toggle visibility
|
|
204
|
+
- `actions.setDisabled(value)` — update disabled state
|
|
205
|
+
- `actions.setName(value)` — update group name
|
|
206
|
+
- `actions.handleClick()` — on trigger click
|
|
207
|
+
- `actions.handleKeyDown(event)` — on trigger keydown
|
|
208
|
+
- `actions.destroy()` — on `disconnectedCallback` or equivalent teardown
|
|
209
|
+
|
|
210
|
+
**Contracts spread (attribute maps applied directly to DOM elements):**
|
|
211
|
+
|
|
212
|
+
- `contracts.getTriggerProps()` — spread onto the trigger button element
|
|
213
|
+
- `contracts.getPanelProps()` — spread onto the content panel element
|
|
214
|
+
|
|
215
|
+
**UIKit-only concerns (NOT in headless):**
|
|
216
|
+
|
|
217
|
+
- CSS animations and transitions for open/close
|
|
218
|
+
- `show()` / `hide()` imperative methods (delegate to `actions.open()` / `actions.close()`)
|
|
219
|
+
- CSS custom properties and animation tokens
|
|
220
|
+
- Lifecycle events (`cv-open`, `cv-close`)
|
|
221
|
+
|
|
222
|
+
## Minimum Test Matrix
|
|
223
|
+
|
|
224
|
+
- initialize in both open and closed states
|
|
225
|
+
- toggle state via `toggle()` action
|
|
226
|
+
- toggle state via `Enter` and `Space` keys on the trigger
|
|
227
|
+
- `ArrowDown` and `ArrowRight` open a closed disclosure
|
|
228
|
+
- `ArrowDown` and `ArrowRight` are no-ops on an already open disclosure
|
|
229
|
+
- `ArrowUp` and `ArrowLeft` close an open disclosure
|
|
230
|
+
- `ArrowUp` and `ArrowLeft` are no-ops on an already closed disclosure
|
|
231
|
+
- arrow keys call `preventDefault`
|
|
232
|
+
- arrow keys are no-ops when disabled (no `preventDefault`)
|
|
233
|
+
- verify `aria-expanded` updates correctly
|
|
234
|
+
- verify `aria-controls` matches panel `id`
|
|
235
|
+
- verify `aria-labelledby` matches trigger `id`
|
|
236
|
+
- ensure disabled trigger does not toggle state
|
|
237
|
+
- `onOpenChange` callback fires on state transitions
|
|
238
|
+
- named group: opening one disclosure closes the other in the same group
|
|
239
|
+
- named group: closing a disclosure does not affect others in the group
|
|
240
|
+
- named group: disclosures with different names are independent
|
|
241
|
+
- named group: `destroy()` removes from registry
|
|
242
|
+
- named group: `setName()` re-registers in new group
|
|
243
|
+
- named group: ungrouped disclosures (no `name`) are not affected by grouped ones
|
|
244
|
+
|
|
245
|
+
## ADR-001 Compliance
|
|
246
|
+
|
|
247
|
+
- **Runtime Policy**: Reatom v1000 only; no @statx/\* in headless core.
|
|
248
|
+
- **Layering**: core -> interactions -> a11y-contracts -> adapters; adapters remain thin mappings.
|
|
249
|
+
- **Independence**: No imports from @project/_, apps/_, or other out-of-package modules.
|
|
250
|
+
- **Verification**: Mandatory adapter integration tests and standalone package test execution.
|
|
251
|
+
|
|
252
|
+
## Out of Scope (Current)
|
|
253
|
+
|
|
254
|
+
- multiple panels controlled by one trigger
|
|
255
|
+
- hover-based disclosure (not APG compliant for this pattern)
|
|
256
|
+
- animation state management (UIKit concern)
|
|
257
|
+
- `allowZeroExpanded` constraint within a named group (all can be closed)
|