@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,115 @@
|
|
|
1
|
+
# Button Component Contract
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
`Button` is a headless APG-aligned contract for an interactive element that allows users to trigger an action or event. It handles activation via click and keyboard, focus management, disabled state, and loading gating.
|
|
6
|
+
|
|
7
|
+
## Component Files
|
|
8
|
+
|
|
9
|
+
- `src/button/index.ts` - model and public `createButton` API
|
|
10
|
+
- `src/button/button.test.ts` - unit behavior tests
|
|
11
|
+
|
|
12
|
+
## Public API
|
|
13
|
+
|
|
14
|
+
- `createButton(options)`
|
|
15
|
+
- `options`:
|
|
16
|
+
- `isDisabled?`: boolean signal or value
|
|
17
|
+
- `isLoading?`: boolean signal or value
|
|
18
|
+
- `isPressed?`: boolean signal or value (for toggle buttons)
|
|
19
|
+
- `onPress?`: callback function
|
|
20
|
+
- `state` (signal-backed):
|
|
21
|
+
- `isDisabled()` - boolean indicating if the button is disabled
|
|
22
|
+
- `isLoading()` - boolean indicating if the button is loading
|
|
23
|
+
- `isPressed()` - boolean indicating if the button is in a pressed state (for toggle buttons)
|
|
24
|
+
- `actions`:
|
|
25
|
+
- `setDisabled(next)` - updates disabled state
|
|
26
|
+
- `setLoading(next)` - updates loading state
|
|
27
|
+
- `setPressed(next)` - controlled pressed update
|
|
28
|
+
- `press()` - manually triggers the button's action
|
|
29
|
+
- `handleKeyDown(event)` - processes activation keys (`Enter`, `Space`)
|
|
30
|
+
- `handleKeyUp(event)` - processes `Space` key release
|
|
31
|
+
- `contracts`:
|
|
32
|
+
- `getButtonProps()` - returns ARIA and event handler props for the button element
|
|
33
|
+
|
|
34
|
+
## APG and A11y Contract
|
|
35
|
+
|
|
36
|
+
- role: `button`
|
|
37
|
+
- required attributes:
|
|
38
|
+
- `aria-disabled`: reflects interaction unavailability (`isDisabled || isLoading`)
|
|
39
|
+
- `aria-busy`: reflects loading state
|
|
40
|
+
- `aria-pressed`: reflects `isPressed` state (only for toggle buttons)
|
|
41
|
+
- `tabindex`: `0` when interactive, `-1` when unavailable (`isDisabled || isLoading`)
|
|
42
|
+
- focus management:
|
|
43
|
+
- the button is in the page tab sequence when interactive
|
|
44
|
+
- keyboard interaction:
|
|
45
|
+
- `Enter`: triggers the button action on `keydown`
|
|
46
|
+
- `Space`: triggers the button action on `keyup`
|
|
47
|
+
|
|
48
|
+
## Behavior Contract
|
|
49
|
+
|
|
50
|
+
- **Activation**:
|
|
51
|
+
- clicking the button triggers the `onPress` callback
|
|
52
|
+
- pressing `Enter` triggers the `onPress` callback on `keydown`
|
|
53
|
+
- pressing `Space` triggers the `onPress` callback on `keyup`
|
|
54
|
+
- **Toggle Button**:
|
|
55
|
+
- if `isPressed` is provided in options, the button acts as a toggle button
|
|
56
|
+
- activation toggles the `isPressed` state
|
|
57
|
+
- **Unavailable State**:
|
|
58
|
+
- if `isDisabled` is true, activation actions are ignored
|
|
59
|
+
- if `isLoading` is true, activation actions are ignored
|
|
60
|
+
- controlled actions (`setPressed`, `setDisabled`) remain available while loading
|
|
61
|
+
|
|
62
|
+
## Transitions Table
|
|
63
|
+
|
|
64
|
+
| Event | Guard | Action | Next State |
|
|
65
|
+
| ------------------- | --------------------------- | ------------------- | ---------------------------------------------------- |
|
|
66
|
+
| click | `!isDisabled && !isLoading` | `press()` | calls `onPress`; if toggle: `isPressed = !isPressed` |
|
|
67
|
+
| click | `isDisabled \|\| isLoading` | — | no change |
|
|
68
|
+
| `keydown Enter` | `!isDisabled && !isLoading` | `handleKeyDown(e)` | calls `onPress`; if toggle: `isPressed = !isPressed` |
|
|
69
|
+
| `keydown Enter` | `isDisabled \|\| isLoading` | — | no change |
|
|
70
|
+
| `keyup Space` | `!isDisabled && !isLoading` | `handleKeyUp(e)` | calls `onPress`; if toggle: `isPressed = !isPressed` |
|
|
71
|
+
| `keyup Space` | `isDisabled \|\| isLoading` | — | no change |
|
|
72
|
+
| `setDisabled(next)` | — | `setDisabled(next)` | `isDisabled = next` |
|
|
73
|
+
| `setLoading(next)` | — | `setLoading(next)` | `isLoading = next` |
|
|
74
|
+
| `setPressed(next)` | — | `setPressed(next)` | `isPressed = next` |
|
|
75
|
+
|
|
76
|
+
## Adapter Expectations
|
|
77
|
+
|
|
78
|
+
UIKit (`cv-button`) binds to the headless contract as follows:
|
|
79
|
+
|
|
80
|
+
- **Signals read**: `state.isDisabled()`, `state.isLoading()`, `state.isPressed()` — to reflect host attributes and visual states
|
|
81
|
+
- **Actions called**: `actions.setDisabled(v)`, `actions.setLoading(v)`, `actions.setPressed(v)` — to sync attribute changes into headless state
|
|
82
|
+
- **Contracts spread**: `contracts.getButtonProps()` — spread onto the inner `[part="base"]` element to apply `role`, `aria-disabled`, `aria-busy`, `aria-pressed`, `tabindex`, and keyboard/click handlers
|
|
83
|
+
- **Toggle events**: UIKit dispatches `input` and `change` events by observing `isPressed` changes triggered by user activation (not by controlled `setPressed`)
|
|
84
|
+
|
|
85
|
+
## Invariants
|
|
86
|
+
|
|
87
|
+
- `aria-pressed` must only be present if the button is a toggle button
|
|
88
|
+
- `aria-disabled` must be `true` when `isDisabled` or `isLoading` is true
|
|
89
|
+
- `aria-busy` must be `true` when `isLoading` is true
|
|
90
|
+
- `onPress` must not be called when interaction is unavailable
|
|
91
|
+
|
|
92
|
+
## Minimum Test Matrix
|
|
93
|
+
|
|
94
|
+
- trigger `onPress` on click
|
|
95
|
+
- trigger `onPress` on `Enter` keydown
|
|
96
|
+
- trigger `onPress` on `Space` keyup
|
|
97
|
+
- verify `aria-disabled` reflects unavailable state (`isDisabled || isLoading`)
|
|
98
|
+
- verify `aria-busy` reflects `isLoading`
|
|
99
|
+
- verify `aria-pressed` reflects `isPressed` for toggle buttons
|
|
100
|
+
- ensure `onPress` is not triggered when unavailable
|
|
101
|
+
- verify `tabindex` behavior for interactive vs unavailable states
|
|
102
|
+
- verify `setPressed` remains available in controlled loading scenario
|
|
103
|
+
|
|
104
|
+
## ADR-001 Compliance
|
|
105
|
+
|
|
106
|
+
- **Runtime Policy**: Reatom v1000 only; no @statx/\* in headless core.
|
|
107
|
+
- **Layering**: core -> interactions -> a11y-contracts -> adapters; adapters remain thin mappings.
|
|
108
|
+
- **Independence**: No imports from @project/_, apps/_, or other out-of-package modules.
|
|
109
|
+
- **Verification**: Mandatory adapter integration tests and standalone package test execution.
|
|
110
|
+
|
|
111
|
+
## Out of Scope (Current)
|
|
112
|
+
|
|
113
|
+
- menu buttons (handled by `Menu` component)
|
|
114
|
+
- split buttons
|
|
115
|
+
- button groups (layout only)
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# Callout Component Contract
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
`Callout` is a headless contract for static supplementary content blocks that highlight important information to the user. Unlike `Alert`, a callout is not a live region — it does not announce dynamically. It uses `role="note"` to convey that the content is parenthetic or ancillary. Callouts optionally support a closable dismiss pattern.
|
|
6
|
+
|
|
7
|
+
## Component Files
|
|
8
|
+
|
|
9
|
+
- `src/callout/index.ts` - model and public `createCallout` API
|
|
10
|
+
- `src/callout/callout.test.ts` - unit behavior tests
|
|
11
|
+
|
|
12
|
+
## Options (`CreateCalloutOptions`)
|
|
13
|
+
|
|
14
|
+
| Option | Type | Default | Description |
|
|
15
|
+
| ---------- | ---------------- | ----------- | ------------------------------------------------ |
|
|
16
|
+
| `idBase` | `string` | `'callout'` | Base id prefix for generated ids |
|
|
17
|
+
| `variant` | `CalloutVariant` | `'info'` | Visual variant for theming |
|
|
18
|
+
| `closable` | `boolean` | `false` | Whether the callout can be dismissed by the user |
|
|
19
|
+
| `open` | `boolean` | `true` | Initial visibility state |
|
|
20
|
+
|
|
21
|
+
## Type Definitions
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
type CalloutVariant = 'info' | 'success' | 'warning' | 'danger' | 'neutral'
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Public API
|
|
28
|
+
|
|
29
|
+
### `createCallout(options?: CreateCalloutOptions): CalloutModel`
|
|
30
|
+
|
|
31
|
+
### State (signal-backed)
|
|
32
|
+
|
|
33
|
+
| Signal | Type | Derived? | Description |
|
|
34
|
+
| ---------- | ---------------------- | -------- | ------------------------------------- |
|
|
35
|
+
| `variant` | `Atom<CalloutVariant>` | No | Current visual variant |
|
|
36
|
+
| `closable` | `Atom<boolean>` | No | Whether the close button is available |
|
|
37
|
+
| `open` | `Atom<boolean>` | No | Whether the callout is visible |
|
|
38
|
+
|
|
39
|
+
### Actions
|
|
40
|
+
|
|
41
|
+
| Action | Signature | Description |
|
|
42
|
+
| ------------- | --------------------------------- | ------------------------------------------------------- |
|
|
43
|
+
| `setVariant` | `(value: CalloutVariant) => void` | Updates the visual variant |
|
|
44
|
+
| `setClosable` | `(value: boolean) => void` | Toggles whether the callout is closable |
|
|
45
|
+
| `close` | `() => void` | Sets `open` to `false`. No-op if `closable` is `false`. |
|
|
46
|
+
| `show` | `() => void` | Sets `open` to `true` |
|
|
47
|
+
|
|
48
|
+
### Contracts
|
|
49
|
+
|
|
50
|
+
| Contract | Return type | Description |
|
|
51
|
+
| ----------------------- | ------------------------- | --------------------------------------------------------------- |
|
|
52
|
+
| `getCalloutProps()` | `CalloutProps` | Ready-to-spread ARIA attribute map for the callout root element |
|
|
53
|
+
| `getCloseButtonProps()` | `CalloutCloseButtonProps` | Ready-to-spread attribute map for the close button element |
|
|
54
|
+
|
|
55
|
+
#### `CalloutProps` Shape
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
{
|
|
59
|
+
id: string
|
|
60
|
+
role: 'note'
|
|
61
|
+
'data-variant': CalloutVariant
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
The `role` is always `'note'` — a static structural role indicating parenthetic or ancillary content. This is NOT a live region; content is not announced dynamically by assistive technologies.
|
|
66
|
+
|
|
67
|
+
#### `CalloutCloseButtonProps` Shape
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
{
|
|
71
|
+
id: string
|
|
72
|
+
role: 'button'
|
|
73
|
+
tabindex: '0'
|
|
74
|
+
'aria-label': 'Dismiss'
|
|
75
|
+
onClick: () => void
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
The close button props are only meaningful when `closable` is `true`. UIKit should conditionally render the close button based on the `closable` signal.
|
|
80
|
+
|
|
81
|
+
## APG and A11y Contract
|
|
82
|
+
|
|
83
|
+
- **Role**: `role="note"` on the root element — indicates supplementary content
|
|
84
|
+
- **Not a live region**: No `aria-live`, no `aria-atomic`. Content is read inline when encountered, not announced on change.
|
|
85
|
+
- **Close button**: When `closable` is `true`, a button with `aria-label="Dismiss"` is rendered. Standard button keyboard interaction applies (Enter/Space activates).
|
|
86
|
+
- **Non-interactive root**: The callout root itself is not focusable and has no keyboard interactions. Only the close button (when present) is interactive.
|
|
87
|
+
|
|
88
|
+
## Keyboard Contract
|
|
89
|
+
|
|
90
|
+
| Key | Element | Condition | Action |
|
|
91
|
+
| ------- | ------------ | -------------------- | ------------------------------------------------------- |
|
|
92
|
+
| `Enter` | Close button | `closable` is `true` | Activates close (handled by native button or `onClick`) |
|
|
93
|
+
| `Space` | Close button | `closable` is `true` | Activates close (handled by native button or `onClick`) |
|
|
94
|
+
|
|
95
|
+
No keyboard handling is needed on the callout root itself.
|
|
96
|
+
|
|
97
|
+
## Behavior Contract
|
|
98
|
+
|
|
99
|
+
- `variant` is set programmatically; it affects visual theming only, not ARIA semantics.
|
|
100
|
+
- `closable` controls whether the close button is rendered and whether the `close` action has effect.
|
|
101
|
+
- `close` action: sets `open` to `false` only when `closable` is `true`; otherwise it is a no-op.
|
|
102
|
+
- `show` action: sets `open` to `true` unconditionally (allows programmatic re-showing after dismiss).
|
|
103
|
+
- `open` determines visibility; when `false`, UIKit should hide the callout (e.g., via `hidden` attribute or conditional rendering).
|
|
104
|
+
- The callout does not manage focus. Closing does not move focus elsewhere (UIKit may handle focus restoration if needed).
|
|
105
|
+
- No automatic dismissal timer. The callout remains visible until explicitly closed.
|
|
106
|
+
|
|
107
|
+
## Transitions Table
|
|
108
|
+
|
|
109
|
+
| Trigger | Precondition | State Change | Contract Effect |
|
|
110
|
+
| ------------------------ | ------------------------------------ | ---------------- | ------------------------------------------- |
|
|
111
|
+
| `actions.setVariant(v)` | valid variant | `variant` = v | `getCalloutProps()` updates `data-variant` |
|
|
112
|
+
| `actions.setVariant(v)` | invalid variant | no change | no effect |
|
|
113
|
+
| `actions.setClosable(v)` | any | `closable` = v | UIKit conditionally renders close button |
|
|
114
|
+
| `actions.close()` | `closable` = `true`, `open` = `true` | `open` = `false` | UIKit hides callout; emits `cv-close` event |
|
|
115
|
+
| `actions.close()` | `closable` = `false` | no change | no-op |
|
|
116
|
+
| `actions.close()` | `open` = `false` | no change | no-op |
|
|
117
|
+
| `actions.show()` | `open` = `false` | `open` = `true` | UIKit shows callout |
|
|
118
|
+
| `actions.show()` | `open` = `true` | no change | no-op |
|
|
119
|
+
|
|
120
|
+
## Invariants
|
|
121
|
+
|
|
122
|
+
1. `role` is always `'note'` on the root element — never changes regardless of variant or state.
|
|
123
|
+
2. No `aria-live` or `aria-atomic` attributes are ever produced. Callout is not a live region.
|
|
124
|
+
3. `variant` must always be one of `'info' | 'success' | 'warning' | 'danger' | 'neutral'`. Invalid values are rejected.
|
|
125
|
+
4. `variant` defaults to `'info'` when not specified or when an invalid value is provided.
|
|
126
|
+
5. `close` action is a no-op when `closable` is `false`.
|
|
127
|
+
6. `getCloseButtonProps()` always returns the button attribute map, but UIKit must only render the button when `closable` is `true`.
|
|
128
|
+
7. Callout must never produce `tabindex` or focus-related attributes on the root element.
|
|
129
|
+
8. `open` defaults to `true` (callout is visible by default).
|
|
130
|
+
|
|
131
|
+
## Adapter Expectations
|
|
132
|
+
|
|
133
|
+
This section defines what UIKit (`cv-callout`) binds to from the headless model.
|
|
134
|
+
|
|
135
|
+
### Signals read by adapter
|
|
136
|
+
|
|
137
|
+
| Signal | UIKit usage |
|
|
138
|
+
| ------------------ | -------------------------------------------------------------------- |
|
|
139
|
+
| `state.variant()` | Maps to `variant` host attribute and CSS class for color theming |
|
|
140
|
+
| `state.closable()` | Determines whether the close button is rendered in the template |
|
|
141
|
+
| `state.open()` | Controls visibility of the callout; maps to `open` attribute on host |
|
|
142
|
+
|
|
143
|
+
### Actions called by adapter
|
|
144
|
+
|
|
145
|
+
| Action | UIKit trigger |
|
|
146
|
+
| ------------------------ | -------------------------------------------------------------- |
|
|
147
|
+
| `actions.setVariant(v)` | When `variant` attribute/property changes on the host element |
|
|
148
|
+
| `actions.setClosable(v)` | When `closable` attribute/property changes on the host element |
|
|
149
|
+
| `actions.close()` | When the close button is clicked; emits `cv-close` event |
|
|
150
|
+
| `actions.show()` | When programmatically re-showing the callout |
|
|
151
|
+
|
|
152
|
+
### Contracts spread by adapter
|
|
153
|
+
|
|
154
|
+
| Contract | Target element | Notes |
|
|
155
|
+
| ----------------------- | -------------------------------------------- | ------------------------------------------------------------- |
|
|
156
|
+
| `getCalloutProps()` | Root callout element (`part="base"`) | Spread as attributes; provides `id`, `role`, `data-variant` |
|
|
157
|
+
| `getCloseButtonProps()` | Close button element (`part="close-button"`) | Spread as attributes; only rendered when `closable` is `true` |
|
|
158
|
+
|
|
159
|
+
### Options passed through from UIKit attributes
|
|
160
|
+
|
|
161
|
+
| UIKit attribute | Headless option | Notes |
|
|
162
|
+
| --------------- | --------------- | -------------------------------------- |
|
|
163
|
+
| `variant` | `variant` | String enum, defaults to `'info'` |
|
|
164
|
+
| `closable` | `closable` | Boolean attribute, defaults to `false` |
|
|
165
|
+
| `open` | `open` | Boolean attribute, defaults to `true` |
|
|
166
|
+
|
|
167
|
+
## Minimum Test Matrix
|
|
168
|
+
|
|
169
|
+
- default state: `variant='info'`, `closable=false`, `open=true`
|
|
170
|
+
- `getCalloutProps()` returns `role='note'` and `data-variant` matching current variant
|
|
171
|
+
- `getCalloutProps()` never includes `aria-live` or `aria-atomic`
|
|
172
|
+
- `setVariant` updates variant signal; invalid values rejected
|
|
173
|
+
- all five variant values are accepted: `info`, `success`, `warning`, `danger`, `neutral`
|
|
174
|
+
- `close()` sets `open` to `false` when `closable` is `true`
|
|
175
|
+
- `close()` is a no-op when `closable` is `false`
|
|
176
|
+
- `show()` sets `open` to `true`
|
|
177
|
+
- `getCloseButtonProps()` returns `role='button'`, `tabindex='0'`, `aria-label='Dismiss'`
|
|
178
|
+
- clicking close button (calling `onClick` from props) triggers `close` action
|
|
179
|
+
- callout never produces `tabindex` on root element
|
|
180
|
+
|
|
181
|
+
## ADR-001 Compliance
|
|
182
|
+
|
|
183
|
+
- **Runtime Policy**: Reatom v1000 only; no `@statx/*` in headless core.
|
|
184
|
+
- **Layering**: `core -> interactions -> a11y-contracts -> adapters`; adapters remain thin mappings.
|
|
185
|
+
- **Independence**: No imports from `@project/*`, `apps/*`, or other out-of-package modules.
|
|
186
|
+
- **Verification**: Mandatory adapter integration tests and standalone package test execution.
|
|
187
|
+
|
|
188
|
+
## Out of Scope (Current)
|
|
189
|
+
|
|
190
|
+
- Rich content slots (icon, title, description) — handled by UIKit template only
|
|
191
|
+
- Collapsible/expandable content within the callout
|
|
192
|
+
- Auto-dismiss timer (use `Alert` for time-sensitive announcements)
|
|
193
|
+
- Live region behavior (use `Alert` for dynamic announcements)
|
|
194
|
+
- Focus management on close (UIKit concern if needed)
|
|
195
|
+
- Animation/transition on show/hide (UIKit/CSS concern)
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
# Card Component Contract
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
`Card` is a headless contract for a visual container that groups related content into a cohesive unit. It supports an optional expandable variant that follows the disclosure pattern: a trigger area (header) toggles the visibility of body content. When not expandable, the card is a simple non-interactive container with minimal ARIA semantics.
|
|
6
|
+
|
|
7
|
+
## Component Files
|
|
8
|
+
|
|
9
|
+
- `src/card/index.ts` - model and public `createCard` API
|
|
10
|
+
- `src/card/card.test.ts` - unit behavior tests
|
|
11
|
+
|
|
12
|
+
## Options (`CreateCardOptions`)
|
|
13
|
+
|
|
14
|
+
| Option | Type | Default | Description |
|
|
15
|
+
| ------------------ | ------------------------------- | ----------- | ------------------------------------------------------------------------------ |
|
|
16
|
+
| `idBase` | `string` | `'card'` | Base id prefix for generated ids |
|
|
17
|
+
| `isExpandable` | `boolean` | `false` | Whether the card has disclosure (expand/collapse) behavior |
|
|
18
|
+
| `isExpanded` | `boolean` | `false` | Initial expanded state (only meaningful when `isExpandable` is `true`) |
|
|
19
|
+
| `isDisabled` | `boolean` | `false` | Whether interaction is blocked (only meaningful when `isExpandable` is `true`) |
|
|
20
|
+
| `onExpandedChange` | `(isExpanded: boolean) => void` | `undefined` | Callback fired when expanded state changes |
|
|
21
|
+
|
|
22
|
+
## Public API
|
|
23
|
+
|
|
24
|
+
### `createCard(options?: CreateCardOptions): CardModel`
|
|
25
|
+
|
|
26
|
+
### State (signal-backed)
|
|
27
|
+
|
|
28
|
+
| Signal | Type | Derived? | Description |
|
|
29
|
+
| -------------- | --------------- | -------- | -------------------------------------------------------------------------- |
|
|
30
|
+
| `isExpandable` | `Atom<boolean>` | No | Whether the card has disclosure behavior |
|
|
31
|
+
| `isExpanded` | `Atom<boolean>` | No | Whether the card body content is visible (only meaningful when expandable) |
|
|
32
|
+
| `isDisabled` | `Atom<boolean>` | No | Whether user interaction is blocked (only meaningful when expandable) |
|
|
33
|
+
|
|
34
|
+
### Actions
|
|
35
|
+
|
|
36
|
+
| Action | Signature | Description |
|
|
37
|
+
| --------------- | ------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
|
|
38
|
+
| `toggle` | `() => void` | Toggles expanded state; no-op when not expandable or disabled |
|
|
39
|
+
| `expand` | `() => void` | Sets expanded to `true`; no-op when not expandable, disabled, or already expanded |
|
|
40
|
+
| `collapse` | `() => void` | Sets expanded to `false`; no-op when not expandable, disabled, or already collapsed |
|
|
41
|
+
| `setDisabled` | `(value: boolean) => void` | Updates the disabled state |
|
|
42
|
+
| `handleClick` | `() => void` | Delegates to `toggle()`; intended for the trigger element |
|
|
43
|
+
| `handleKeyDown` | `(event: Pick<KeyboardEvent, 'key'> & { preventDefault?: () => void }) => void` | Processes keyboard input on the trigger (see Keyboard Contract) |
|
|
44
|
+
|
|
45
|
+
### Contracts
|
|
46
|
+
|
|
47
|
+
| Contract | Return Type | Description |
|
|
48
|
+
| ------------------- | ------------------ | ---------------------------------------------------------------------------------------- |
|
|
49
|
+
| `getCardProps()` | `CardProps` | Ready-to-spread ARIA attribute map for the card root element |
|
|
50
|
+
| `getTriggerProps()` | `CardTriggerProps` | Ready-to-spread ARIA and event handler attribute map for the expandable trigger (header) |
|
|
51
|
+
| `getContentProps()` | `CardContentProps` | Ready-to-spread ARIA attribute map for the expandable content region (body) |
|
|
52
|
+
|
|
53
|
+
## Contract Prop Shapes
|
|
54
|
+
|
|
55
|
+
### `getCardProps()`
|
|
56
|
+
|
|
57
|
+
**When `isExpandable` is `false` (static card):**
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
{
|
|
61
|
+
// No role, no interactive attributes — plain container
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
**When `isExpandable` is `true`:**
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
{
|
|
69
|
+
// No role — the card root is a container; trigger and content carry the ARIA semantics
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
The card root element does not carry interactive ARIA attributes in either mode. The expandable semantics are carried by the trigger and content sub-elements.
|
|
74
|
+
|
|
75
|
+
### `getTriggerProps()`
|
|
76
|
+
|
|
77
|
+
Only meaningful when `isExpandable` is `true`. When `isExpandable` is `false`, returns an empty object.
|
|
78
|
+
|
|
79
|
+
**When `isExpandable` is `true`:**
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
{
|
|
83
|
+
id: string // '{idBase}-trigger'
|
|
84
|
+
role: 'button'
|
|
85
|
+
tabindex: '0' | '-1' // '-1' when disabled
|
|
86
|
+
'aria-expanded': 'true' | 'false' // reflects isExpanded
|
|
87
|
+
'aria-controls': string // points to content region id
|
|
88
|
+
'aria-disabled'?: 'true' // present only when disabled
|
|
89
|
+
onClick: () => void // calls handleClick
|
|
90
|
+
onKeyDown: (event) => void // calls handleKeyDown
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### `getContentProps()`
|
|
95
|
+
|
|
96
|
+
Only meaningful when `isExpandable` is `true`. When `isExpandable` is `false`, returns an empty object.
|
|
97
|
+
|
|
98
|
+
**When `isExpandable` is `true`:**
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
{
|
|
102
|
+
id: string // '{idBase}-content'
|
|
103
|
+
role: 'region'
|
|
104
|
+
'aria-labelledby': string // points to trigger id
|
|
105
|
+
hidden: boolean // !isExpanded
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## APG and A11y Contract
|
|
110
|
+
|
|
111
|
+
### Static card (not expandable)
|
|
112
|
+
|
|
113
|
+
- No role on the card root — it is a presentational grouping container
|
|
114
|
+
- No keyboard interaction, no `tabindex`, no focus management
|
|
115
|
+
- Content is always visible
|
|
116
|
+
|
|
117
|
+
### Expandable card
|
|
118
|
+
|
|
119
|
+
- Trigger: `role="button"` with `aria-expanded`, `aria-controls`, `tabindex`
|
|
120
|
+
- Content region: `role="region"` with `aria-labelledby`, `hidden`
|
|
121
|
+
- This follows the APG Disclosure (Show/Hide) pattern
|
|
122
|
+
- Focus management:
|
|
123
|
+
- The trigger is in the page tab sequence (`tabindex: '0'`); removed from tab order when disabled (`tabindex: '-1'`)
|
|
124
|
+
- Focus remains on the trigger when toggled
|
|
125
|
+
|
|
126
|
+
## Keyboard Contract
|
|
127
|
+
|
|
128
|
+
Only applies when `isExpandable` is `true`. All keyboard actions are no-ops when `isDisabled` is `true`.
|
|
129
|
+
|
|
130
|
+
| Key | Action |
|
|
131
|
+
| -------------------------- | --------------------------------------------------------------------- |
|
|
132
|
+
| `Enter` | Toggle the `isExpanded` state; calls `preventDefault` |
|
|
133
|
+
| `Space` | Toggle the `isExpanded` state; calls `preventDefault` |
|
|
134
|
+
| `ArrowDown` / `ArrowRight` | Expand (show content) if currently collapsed; calls `preventDefault` |
|
|
135
|
+
| `ArrowUp` / `ArrowLeft` | Collapse (hide content) if currently expanded; calls `preventDefault` |
|
|
136
|
+
|
|
137
|
+
## Behavior Contract
|
|
138
|
+
|
|
139
|
+
- **Static Card**:
|
|
140
|
+
- Acts as a non-interactive container. No state transitions occur from user interaction.
|
|
141
|
+
- `toggle()`, `expand()`, `collapse()` are no-ops when `isExpandable` is `false`.
|
|
142
|
+
- `getTriggerProps()` and `getContentProps()` return empty objects.
|
|
143
|
+
- **Expandable Card**:
|
|
144
|
+
- `Enter` or `Space` on the trigger toggles the `isExpanded` state.
|
|
145
|
+
- Clicking the trigger toggles the `isExpanded` state.
|
|
146
|
+
- `ArrowDown` or `ArrowRight` on the trigger opens the content (no-op if already expanded).
|
|
147
|
+
- `ArrowUp` or `ArrowLeft` on the trigger closes the content (no-op if already collapsed).
|
|
148
|
+
- When `isDisabled` is `true`, all user-driven interactions (click, keyboard) are no-ops.
|
|
149
|
+
- `setDisabled(value)` always updates the disabled state regardless of `isExpandable`.
|
|
150
|
+
- **Linkage** (expandable only):
|
|
151
|
+
- `aria-controls` on the trigger matches the `id` of the content region.
|
|
152
|
+
- `aria-expanded` on the trigger reflects the `isExpanded` state.
|
|
153
|
+
- `aria-labelledby` on the content region matches the `id` of the trigger.
|
|
154
|
+
|
|
155
|
+
## Transitions Table
|
|
156
|
+
|
|
157
|
+
| Event / Action | Guard | Next State / Effect |
|
|
158
|
+
| --------------------------- | ------------------------------------------------ | ----------------------------------------------------- |
|
|
159
|
+
| `toggle()` | `isExpandable && !isDisabled && !isExpanded` | `isExpanded = true`; fires `onExpandedChange(true)` |
|
|
160
|
+
| `toggle()` | `isExpandable && !isDisabled && isExpanded` | `isExpanded = false`; fires `onExpandedChange(false)` |
|
|
161
|
+
| `toggle()` | `!isExpandable \|\| isDisabled` | no-op |
|
|
162
|
+
| `expand()` | `isExpandable && !isDisabled && !isExpanded` | `isExpanded = true`; fires `onExpandedChange(true)` |
|
|
163
|
+
| `expand()` | `!isExpandable \|\| isDisabled \|\| isExpanded` | no-op |
|
|
164
|
+
| `collapse()` | `isExpandable && !isDisabled && isExpanded` | `isExpanded = false`; fires `onExpandedChange(false)` |
|
|
165
|
+
| `collapse()` | `!isExpandable \|\| isDisabled \|\| !isExpanded` | no-op |
|
|
166
|
+
| `handleClick()` | any | delegates to `toggle()` |
|
|
167
|
+
| `handleKeyDown(Enter)` | `isExpandable && !isDisabled` | delegates to `toggle()`; `preventDefault` |
|
|
168
|
+
| `handleKeyDown(Space)` | `isExpandable && !isDisabled` | delegates to `toggle()`; `preventDefault` |
|
|
169
|
+
| `handleKeyDown(ArrowDown)` | `isExpandable && !isDisabled && !isExpanded` | delegates to `expand()`; `preventDefault` |
|
|
170
|
+
| `handleKeyDown(ArrowRight)` | `isExpandable && !isDisabled && !isExpanded` | delegates to `expand()`; `preventDefault` |
|
|
171
|
+
| `handleKeyDown(ArrowDown)` | `isExpandable && !isDisabled && isExpanded` | no-op (already expanded); `preventDefault` |
|
|
172
|
+
| `handleKeyDown(ArrowRight)` | `isExpandable && !isDisabled && isExpanded` | no-op (already expanded); `preventDefault` |
|
|
173
|
+
| `handleKeyDown(ArrowUp)` | `isExpandable && !isDisabled && isExpanded` | delegates to `collapse()`; `preventDefault` |
|
|
174
|
+
| `handleKeyDown(ArrowLeft)` | `isExpandable && !isDisabled && isExpanded` | delegates to `collapse()`; `preventDefault` |
|
|
175
|
+
| `handleKeyDown(ArrowUp)` | `isExpandable && !isDisabled && !isExpanded` | no-op (already collapsed); `preventDefault` |
|
|
176
|
+
| `handleKeyDown(ArrowLeft)` | `isExpandable && !isDisabled && !isExpanded` | no-op (already collapsed); `preventDefault` |
|
|
177
|
+
| `handleKeyDown(other)` | any | no-op; no `preventDefault` |
|
|
178
|
+
| `handleKeyDown(any)` | `!isExpandable \|\| isDisabled` | no-op; no `preventDefault` |
|
|
179
|
+
| `setDisabled(value)` | any | `isDisabled = value` |
|
|
180
|
+
|
|
181
|
+
## Invariants
|
|
182
|
+
|
|
183
|
+
1. `isExpandable`, `isExpanded`, and `isDisabled` are booleans.
|
|
184
|
+
2. When `isExpandable` is `false`, `getTriggerProps()` and `getContentProps()` return empty objects (no ARIA attributes, no event handlers).
|
|
185
|
+
3. When `isExpandable` is `false`, `toggle()`, `expand()`, and `collapse()` are no-ops.
|
|
186
|
+
4. When `isExpandable` is `true`, `aria-expanded` on the trigger is `"true"` when `isExpanded` is `true`, and `"false"` otherwise.
|
|
187
|
+
5. When `isExpandable` is `true`, `aria-controls` on the trigger always matches the `id` of the content region.
|
|
188
|
+
6. When `isExpandable` is `true`, `aria-labelledby` on the content region always matches the `id` of the trigger.
|
|
189
|
+
7. When `isDisabled` is `true`, user-driven interactions (`handleClick`, `handleKeyDown`) do not change `isExpanded`.
|
|
190
|
+
8. Arrow keys (`ArrowDown`/`ArrowRight`) only expand; they never collapse. Arrow keys (`ArrowUp`/`ArrowLeft`) only collapse; they never expand.
|
|
191
|
+
9. `getCardProps()` never produces interactive attributes (`tabindex`, keyboard handlers, `aria-expanded`). Interactive semantics live on the trigger.
|
|
192
|
+
10. `onExpandedChange` is only called when `isExpanded` actually changes value, never on no-op actions.
|
|
193
|
+
|
|
194
|
+
## Adapter Expectations
|
|
195
|
+
|
|
196
|
+
This section defines what UIKit (`cv-card`) binds to from the headless model.
|
|
197
|
+
|
|
198
|
+
### Signals read by adapter
|
|
199
|
+
|
|
200
|
+
| Signal | UIKit usage |
|
|
201
|
+
| ---------------------- | -------------------------------------------------------------------------------------------------------- |
|
|
202
|
+
| `state.isExpandable()` | Determines whether to render the trigger and toggle content visibility; sets `expandable` host attribute |
|
|
203
|
+
| `state.isExpanded()` | Reflects expanded state on host; controls content region visibility |
|
|
204
|
+
| `state.isDisabled()` | Reflects disabled state on host; prevents interaction |
|
|
205
|
+
|
|
206
|
+
### Actions called by adapter
|
|
207
|
+
|
|
208
|
+
| Action | UIKit trigger |
|
|
209
|
+
| -------------------------- | ---------------------------------------------------------------------------- |
|
|
210
|
+
| `actions.toggle()` | Programmatic toggle (e.g., from imperative `toggle()` method on element) |
|
|
211
|
+
| `actions.expand()` | Programmatic expand (e.g., from imperative `expand()` method on element) |
|
|
212
|
+
| `actions.collapse()` | Programmatic collapse (e.g., from imperative `collapse()` method on element) |
|
|
213
|
+
| `actions.setDisabled(v)` | When `disabled` attribute/property changes on the host element |
|
|
214
|
+
| `actions.handleClick()` | Not called directly by adapter; included in `getTriggerProps()` spread |
|
|
215
|
+
| `actions.handleKeyDown(e)` | Not called directly by adapter; included in `getTriggerProps()` spread |
|
|
216
|
+
|
|
217
|
+
### Contracts spread by adapter
|
|
218
|
+
|
|
219
|
+
| Contract | Target element | Notes |
|
|
220
|
+
| ------------------- | ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
221
|
+
| `getCardProps()` | Card root element (`part="base"`) | Spread as attributes on the outermost container |
|
|
222
|
+
| `getTriggerProps()` | Header/trigger element (`part="header"`) | Spread onto the clickable header area; provides `role`, `aria-expanded`, `aria-controls`, `tabindex`, and event handlers. Only spread when `isExpandable` is `true`. |
|
|
223
|
+
| `getContentProps()` | Body/content element (`part="body"`) | Spread onto the content region; provides `id`, `role="region"`, `aria-labelledby`, `hidden`. Only spread when `isExpandable` is `true`. |
|
|
224
|
+
|
|
225
|
+
### Options passed through from UIKit attributes
|
|
226
|
+
|
|
227
|
+
| UIKit attribute | Headless option | Notes |
|
|
228
|
+
| --------------- | --------------- | ---------------------------------------------- |
|
|
229
|
+
| `expandable` | `isExpandable` | Boolean attribute; enables disclosure behavior |
|
|
230
|
+
| `expanded` | `isExpanded` | Boolean attribute; initial expanded state |
|
|
231
|
+
| `disabled` | `isDisabled` | Boolean attribute; prevents interaction |
|
|
232
|
+
|
|
233
|
+
### UIKit-only concerns (NOT in headless)
|
|
234
|
+
|
|
235
|
+
- Visual variants (`elevated`, `outlined`, `filled`) are CSS-only; no headless state
|
|
236
|
+
- Slot layout (`header`, `(default)`, `footer`, `image`) is a rendering concern
|
|
237
|
+
- CSS animations for expand/collapse transitions
|
|
238
|
+
- Lifecycle events (`cv-expand`, `cv-collapse`)
|
|
239
|
+
- Imperative methods (`toggle()`, `expand()`, `collapse()`) that delegate to headless actions
|
|
240
|
+
|
|
241
|
+
## Minimum Test Matrix
|
|
242
|
+
|
|
243
|
+
- default state: `isExpandable = false`, `isExpanded = false`, `isDisabled = false`
|
|
244
|
+
- static card: `getCardProps()` returns empty or minimal object (no interactive attributes)
|
|
245
|
+
- static card: `getTriggerProps()` returns empty object
|
|
246
|
+
- static card: `getContentProps()` returns empty object
|
|
247
|
+
- static card: `toggle()`, `expand()`, `collapse()` are no-ops; `isExpanded` does not change
|
|
248
|
+
- expandable card: `toggle()` toggles `isExpanded` between `true` and `false`
|
|
249
|
+
- expandable card: `expand()` sets `isExpanded` to `true`; no-op if already expanded
|
|
250
|
+
- expandable card: `collapse()` sets `isExpanded` to `false`; no-op if already collapsed
|
|
251
|
+
- expandable card: `handleKeyDown(Enter)` toggles expanded state; calls `preventDefault`
|
|
252
|
+
- expandable card: `handleKeyDown(Space)` toggles expanded state; calls `preventDefault`
|
|
253
|
+
- expandable card: `ArrowDown` / `ArrowRight` expand a collapsed card; no-op on expanded card
|
|
254
|
+
- expandable card: `ArrowUp` / `ArrowLeft` collapse an expanded card; no-op on collapsed card
|
|
255
|
+
- expandable card: arrow keys call `preventDefault`
|
|
256
|
+
- expandable card: all keyboard/click interactions are no-ops when disabled
|
|
257
|
+
- expandable card: `aria-expanded` on trigger reflects `isExpanded`
|
|
258
|
+
- expandable card: `aria-controls` on trigger matches content region `id`
|
|
259
|
+
- expandable card: `aria-labelledby` on content matches trigger `id`
|
|
260
|
+
- expandable card: content region has `hidden = true` when collapsed
|
|
261
|
+
- expandable card: disabled trigger has `tabindex = '-1'` and `aria-disabled = 'true'`
|
|
262
|
+
- `onExpandedChange` fires on actual state transitions, not on no-ops
|
|
263
|
+
- `setDisabled(value)` updates disabled state regardless of expandable mode
|
|
264
|
+
|
|
265
|
+
## ADR-001 Compliance
|
|
266
|
+
|
|
267
|
+
- **Runtime Policy**: Reatom v1000 only; no `@statx/*` in headless core.
|
|
268
|
+
- **Layering**: `core -> interactions -> a11y-contracts -> adapters`; adapters remain thin mappings.
|
|
269
|
+
- **Independence**: No imports from `@project/*`, `apps/*`, or other out-of-package modules.
|
|
270
|
+
- **Verification**: Mandatory adapter integration tests and standalone package test execution.
|
|
271
|
+
|
|
272
|
+
## Out of Scope (Current)
|
|
273
|
+
|
|
274
|
+
- visual variants (`elevated`, `outlined`, `filled`) are UIKit/CSS-only concerns
|
|
275
|
+
- sizes (card is a fluid container; no size state)
|
|
276
|
+
- slot layout and image positioning (rendering concern)
|
|
277
|
+
- clickable/linkable card (entire card as an anchor) — would require a separate interactive pattern
|
|
278
|
+
- card groups, card grids, or masonry layouts
|
|
279
|
+
- animation orchestration for expand/collapse (handled by UIKit visual layer)
|
|
280
|
+
- drag-and-drop reordering of cards
|