@chromvoid/headless-ui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (191) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +99 -0
  3. package/dist/a11y-contracts/index.d.ts +23 -0
  4. package/dist/a11y-contracts/index.js +1 -0
  5. package/dist/accordion/index.d.ts +78 -0
  6. package/dist/accordion/index.js +264 -0
  7. package/dist/adapters/index.d.ts +9 -0
  8. package/dist/adapters/index.js +1 -0
  9. package/dist/alert/index.d.ts +33 -0
  10. package/dist/alert/index.js +54 -0
  11. package/dist/alert-dialog/index.d.ts +69 -0
  12. package/dist/alert-dialog/index.js +94 -0
  13. package/dist/badge/index.d.ts +48 -0
  14. package/dist/badge/index.js +89 -0
  15. package/dist/breadcrumb/index.d.ts +55 -0
  16. package/dist/breadcrumb/index.js +77 -0
  17. package/dist/button/index.d.ts +46 -0
  18. package/dist/button/index.js +86 -0
  19. package/dist/callout/index.d.ts +41 -0
  20. package/dist/callout/index.js +63 -0
  21. package/dist/card/index.d.ts +54 -0
  22. package/dist/card/index.js +103 -0
  23. package/dist/carousel/index.d.ts +98 -0
  24. package/dist/carousel/index.js +243 -0
  25. package/dist/checkbox/index.d.ts +50 -0
  26. package/dist/checkbox/index.js +87 -0
  27. package/dist/combobox/index.d.ts +114 -0
  28. package/dist/combobox/index.js +431 -0
  29. package/dist/command-palette/index.d.ts +73 -0
  30. package/dist/command-palette/index.js +147 -0
  31. package/dist/context-menu/index.d.ts +111 -0
  32. package/dist/context-menu/index.js +372 -0
  33. package/dist/copy-button/index.d.ts +62 -0
  34. package/dist/copy-button/index.js +183 -0
  35. package/dist/core/index.d.ts +20 -0
  36. package/dist/core/index.js +2 -0
  37. package/dist/core/selection.d.ts +5 -0
  38. package/dist/core/selection.js +39 -0
  39. package/dist/core/value-range.d.ts +49 -0
  40. package/dist/core/value-range.js +134 -0
  41. package/dist/date-picker/index.d.ts +210 -0
  42. package/dist/date-picker/index.js +895 -0
  43. package/dist/dialog/index.d.ts +95 -0
  44. package/dist/dialog/index.js +153 -0
  45. package/dist/disclosure/index.d.ts +52 -0
  46. package/dist/disclosure/index.js +159 -0
  47. package/dist/drawer/index.d.ts +30 -0
  48. package/dist/drawer/index.js +39 -0
  49. package/dist/feed/index.d.ts +77 -0
  50. package/dist/feed/index.js +260 -0
  51. package/dist/grid/index.d.ts +103 -0
  52. package/dist/grid/index.js +415 -0
  53. package/dist/index.d.ts +51 -0
  54. package/dist/index.js +51 -0
  55. package/dist/input/index.d.ts +86 -0
  56. package/dist/input/index.js +156 -0
  57. package/dist/interactions/composite-navigation.d.ts +69 -0
  58. package/dist/interactions/composite-navigation.js +169 -0
  59. package/dist/interactions/index.d.ts +15 -0
  60. package/dist/interactions/index.js +4 -0
  61. package/dist/interactions/keyboard-intents.d.ts +16 -0
  62. package/dist/interactions/keyboard-intents.js +33 -0
  63. package/dist/interactions/overlay-focus.d.ts +40 -0
  64. package/dist/interactions/overlay-focus.js +93 -0
  65. package/dist/interactions/typeahead.d.ts +20 -0
  66. package/dist/interactions/typeahead.js +41 -0
  67. package/dist/landmarks/index.d.ts +39 -0
  68. package/dist/landmarks/index.js +58 -0
  69. package/dist/link/index.d.ts +34 -0
  70. package/dist/link/index.js +39 -0
  71. package/dist/listbox/index.d.ts +92 -0
  72. package/dist/listbox/index.js +337 -0
  73. package/dist/menu/index.d.ts +132 -0
  74. package/dist/menu/index.js +541 -0
  75. package/dist/menu-button/index.d.ts +71 -0
  76. package/dist/menu-button/index.js +121 -0
  77. package/dist/meter/index.d.ts +45 -0
  78. package/dist/meter/index.js +106 -0
  79. package/dist/number/index.d.ts +113 -0
  80. package/dist/number/index.js +252 -0
  81. package/dist/popover/index.d.ts +70 -0
  82. package/dist/popover/index.js +126 -0
  83. package/dist/progress/index.d.ts +49 -0
  84. package/dist/progress/index.js +79 -0
  85. package/dist/radio-group/index.d.ts +61 -0
  86. package/dist/radio-group/index.js +150 -0
  87. package/dist/select/index.d.ts +92 -0
  88. package/dist/select/index.js +239 -0
  89. package/dist/sidebar/index.d.ts +74 -0
  90. package/dist/sidebar/index.js +186 -0
  91. package/dist/slider/index.d.ts +61 -0
  92. package/dist/slider/index.js +150 -0
  93. package/dist/slider-multi-thumb/index.d.ts +70 -0
  94. package/dist/slider-multi-thumb/index.js +222 -0
  95. package/dist/spinbutton/index.d.ts +75 -0
  96. package/dist/spinbutton/index.js +214 -0
  97. package/dist/spinner/index.d.ts +1 -0
  98. package/dist/spinner/index.js +1 -0
  99. package/dist/spinner/spinner.d.ts +23 -0
  100. package/dist/spinner/spinner.js +25 -0
  101. package/dist/switch/index.d.ts +40 -0
  102. package/dist/switch/index.js +61 -0
  103. package/dist/table/index.d.ts +117 -0
  104. package/dist/table/index.js +377 -0
  105. package/dist/tabs/index.d.ts +63 -0
  106. package/dist/tabs/index.js +174 -0
  107. package/dist/textarea/index.d.ts +68 -0
  108. package/dist/textarea/index.js +137 -0
  109. package/dist/toast/index.d.ts +67 -0
  110. package/dist/toast/index.js +145 -0
  111. package/dist/toolbar/index.d.ts +59 -0
  112. package/dist/toolbar/index.js +139 -0
  113. package/dist/tooltip/index.d.ts +52 -0
  114. package/dist/tooltip/index.js +169 -0
  115. package/dist/treegrid/index.d.ts +101 -0
  116. package/dist/treegrid/index.js +463 -0
  117. package/dist/treeview/index.d.ts +68 -0
  118. package/dist/treeview/index.js +370 -0
  119. package/dist/window-splitter/index.d.ts +65 -0
  120. package/dist/window-splitter/index.js +204 -0
  121. package/package.json +92 -0
  122. package/specs/ADR-001-headless-architecture.md +461 -0
  123. package/specs/ADR-002-repo-release-model.md +108 -0
  124. package/specs/ADR-003-public-api-versioning.md +136 -0
  125. package/specs/ADR-004-focus-selection-policy.md +117 -0
  126. package/specs/IMPLEMENTATION-ROADMAP.md +237 -0
  127. package/specs/ISSUE-BACKLOG.md +681 -0
  128. package/specs/RELEASE-CANDIDATE.md +30 -0
  129. package/specs/components/accordion.md +130 -0
  130. package/specs/components/alert-dialog.md +72 -0
  131. package/specs/components/alert.md +65 -0
  132. package/specs/components/badge.md +220 -0
  133. package/specs/components/breadcrumb.md +74 -0
  134. package/specs/components/button.md +115 -0
  135. package/specs/components/callout.md +195 -0
  136. package/specs/components/card.md +280 -0
  137. package/specs/components/carousel.md +140 -0
  138. package/specs/components/checkbox.md +172 -0
  139. package/specs/components/combobox.md +423 -0
  140. package/specs/components/command-palette.md +92 -0
  141. package/specs/components/context-menu.md +556 -0
  142. package/specs/components/copy-button.md +293 -0
  143. package/specs/components/date-picker.md +400 -0
  144. package/specs/components/dialog.md +298 -0
  145. package/specs/components/disclosure.md +257 -0
  146. package/specs/components/drawer.md +353 -0
  147. package/specs/components/feed.md +265 -0
  148. package/specs/components/grid.md +186 -0
  149. package/specs/components/input.md +254 -0
  150. package/specs/components/landmarks.md +136 -0
  151. package/specs/components/link.md +134 -0
  152. package/specs/components/listbox.md +351 -0
  153. package/specs/components/menu-button.md +76 -0
  154. package/specs/components/menu.md +623 -0
  155. package/specs/components/meter.md +149 -0
  156. package/specs/components/number.md +393 -0
  157. package/specs/components/popover.md +252 -0
  158. package/specs/components/progress.md +188 -0
  159. package/specs/components/radio-group.md +151 -0
  160. package/specs/components/select.md +144 -0
  161. package/specs/components/sidebar.md +321 -0
  162. package/specs/components/slider-multi-thumb.md +78 -0
  163. package/specs/components/slider.md +84 -0
  164. package/specs/components/spinbutton.md +140 -0
  165. package/specs/components/spinner.md +132 -0
  166. package/specs/components/switch.md +175 -0
  167. package/specs/components/table.md +403 -0
  168. package/specs/components/tabs.md +265 -0
  169. package/specs/components/textarea.md +185 -0
  170. package/specs/components/toast.md +198 -0
  171. package/specs/components/toolbar.md +278 -0
  172. package/specs/components/tooltip.md +252 -0
  173. package/specs/components/treegrid.md +281 -0
  174. package/specs/components/treeview.md +91 -0
  175. package/specs/components/window-splitter.md +297 -0
  176. package/specs/ops/git-shard-sync.md +107 -0
  177. package/specs/ops/release-checklist.md +76 -0
  178. package/specs/release/GAP-TO-GREEN-ISSUES.md +88 -0
  179. package/specs/release/api-freeze-candidate.md +54 -0
  180. package/specs/release/changelog-automation.md +76 -0
  181. package/specs/release/changelog.generated.md +53 -0
  182. package/specs/release/changelog.patch.generated.md +46 -0
  183. package/specs/release/consumer-integration.md +53 -0
  184. package/specs/release/migration-notes-pre-v1.md +40 -0
  185. package/specs/release/mvp-changelog.md +57 -0
  186. package/specs/release/release-notes-template.md +61 -0
  187. package/specs/release/release-rehearsal.md +113 -0
  188. package/specs/release/semver-deprecation-dry-run.md +89 -0
  189. package/specs/release/shard-release-drill-report.md +50 -0
  190. package/specs/release/shard-release-follow-ups.md +31 -0
  191. package/specs/signals.md +208 -0
@@ -0,0 +1,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