@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,293 @@
|
|
|
1
|
+
# CopyButton Component Contract
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
`CopyButton` is a headless contract for a button that copies a value to the system clipboard. It manages a three-phase feedback cycle (`idle` -> `success`/`error` -> `idle`), supports both synchronous string values and async getters for lazy/sensitive data, and provides complete ARIA semantics including a live-region status announcement.
|
|
6
|
+
|
|
7
|
+
## Component Files
|
|
8
|
+
|
|
9
|
+
- `src/copy-button/index.ts` - model and public `createCopyButton` API
|
|
10
|
+
- `src/copy-button/copy-button.test.ts` - unit behavior tests
|
|
11
|
+
|
|
12
|
+
## Options (`CreateCopyButtonOptions`)
|
|
13
|
+
|
|
14
|
+
| Option | Type | Default | Description |
|
|
15
|
+
| ------------------ | --------------------------------------------------------- | --------------------- | -------------------------------------------------------------------------------------- |
|
|
16
|
+
| `value` | `string \| (() => Promise<string>)` | `''` | Text to copy, or async getter for lazy/sensitive values |
|
|
17
|
+
| `feedbackDuration` | `number` | `1500` | Milliseconds to show success/error before reverting to idle (clamped >= 0) |
|
|
18
|
+
| `isDisabled` | `boolean` | `false` | Whether the button starts in a disabled state |
|
|
19
|
+
| `ariaLabel` | `string \| undefined` | `undefined` | Accessible label for the button (e.g., `'Copy password'`) |
|
|
20
|
+
| `onCopy` | `(value: string) => void \| undefined` | `undefined` | Called on successful copy with the resolved value |
|
|
21
|
+
| `onError` | `(error: unknown) => void \| undefined` | `undefined` | Called when copy fails with the error |
|
|
22
|
+
| `clipboard` | `{ writeText(text: string): Promise<void> } \| undefined` | `navigator.clipboard` | Injectable clipboard adapter for testing and environments without native clipboard API |
|
|
23
|
+
|
|
24
|
+
## Type Definitions
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
type CopyButtonStatus = 'idle' | 'success' | 'error'
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Public API
|
|
31
|
+
|
|
32
|
+
### `createCopyButton(options?: CreateCopyButtonOptions): CopyButtonModel`
|
|
33
|
+
|
|
34
|
+
### State (signal-backed)
|
|
35
|
+
|
|
36
|
+
| Signal | Type | Derived? | Description |
|
|
37
|
+
| -------------------- | ----------------------------------------- | -------- | ------------------------------------------------------------------------------------------------ |
|
|
38
|
+
| `status()` | `Atom<CopyButtonStatus>` | No | Current feedback phase: `'idle'`, `'success'`, or `'error'` |
|
|
39
|
+
| `isDisabled()` | `Atom<boolean>` | No | Whether the button is disabled |
|
|
40
|
+
| `isCopying()` | `Computed<boolean>` | Yes | `true` while the async copy operation is in-flight (between invocation and clipboard resolution) |
|
|
41
|
+
| `feedbackDuration()` | `Atom<number>` | No | Milliseconds to display success/error before reverting to idle |
|
|
42
|
+
| `value()` | `Atom<string \| (() => Promise<string>)>` | No | Current value or async getter |
|
|
43
|
+
| `isIdle()` | `Computed<boolean>` | Yes | Derived: `status() === 'idle'` |
|
|
44
|
+
| `isSuccess()` | `Computed<boolean>` | Yes | Derived: `status() === 'success'` |
|
|
45
|
+
| `isError()` | `Computed<boolean>` | Yes | Derived: `status() === 'error'` |
|
|
46
|
+
| `isUnavailable()` | `Computed<boolean>` | Yes | Derived: `isDisabled() \|\| isCopying()` — the button cannot be activated |
|
|
47
|
+
|
|
48
|
+
### Actions
|
|
49
|
+
|
|
50
|
+
| Action | Signature | Description |
|
|
51
|
+
| --------------------- | ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
52
|
+
| `copy` | `() => Promise<void>` | Triggers the full copy cycle: resolve value, write to clipboard, transition to success/error, schedule revert. No-op if `isUnavailable()` is `true`. |
|
|
53
|
+
| `setDisabled` | `(v: boolean) => void` | Sets the disabled state |
|
|
54
|
+
| `setFeedbackDuration` | `(v: number) => void` | Sets feedback duration (clamped >= 0) |
|
|
55
|
+
| `setValue` | `(v: string \| (() => Promise<string>)) => void` | Sets the value or async getter |
|
|
56
|
+
| `reset` | `() => void` | Forces status back to `'idle'`, cancels any pending revert timer, clears `isCopying` |
|
|
57
|
+
|
|
58
|
+
### Contracts
|
|
59
|
+
|
|
60
|
+
| Contract | Return type | Description |
|
|
61
|
+
| ------------------------------ | ----------------- | ---------------------------------------------------------------------------- |
|
|
62
|
+
| `getButtonProps()` | `CopyButtonProps` | Ready-to-spread ARIA attribute map and event handlers for the button element |
|
|
63
|
+
| `getStatusProps()` | `CopyStatusProps` | Ready-to-spread attributes for the live-region status announcement element |
|
|
64
|
+
| `getIconContainerProps(which)` | `CopyIconProps` | Ready-to-spread attributes for each icon container based on current status |
|
|
65
|
+
|
|
66
|
+
#### `CopyButtonProps` shape
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
{
|
|
70
|
+
role: 'button'
|
|
71
|
+
tabindex: '0' | '-1' // '0' when interactive, '-1' when unavailable
|
|
72
|
+
'aria-disabled': 'true' | 'false' // reflects isUnavailable()
|
|
73
|
+
'aria-label'?: string // from options.ariaLabel; updates to include status feedback
|
|
74
|
+
onClick: (e: Event) => void // calls copy()
|
|
75
|
+
onKeyDown: (e: KeyboardEvent) => void // Enter triggers copy()
|
|
76
|
+
onKeyUp: (e: KeyboardEvent) => void // Space triggers copy()
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
**`aria-label` resolution:**
|
|
81
|
+
|
|
82
|
+
- If `options.ariaLabel` is set and status is `'idle'`: returns `ariaLabel` as-is
|
|
83
|
+
- If `options.ariaLabel` is set and status is `'success'`: returns `'Copied'`
|
|
84
|
+
- If `options.ariaLabel` is set and status is `'error'`: returns `'Copy failed'`
|
|
85
|
+
- If `options.ariaLabel` is not set: `aria-label` is omitted (consumer provides labeling externally)
|
|
86
|
+
|
|
87
|
+
#### `CopyStatusProps` shape
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
{
|
|
91
|
+
role: 'status'
|
|
92
|
+
'aria-live': 'polite'
|
|
93
|
+
'aria-atomic': 'true'
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
This region is always present in the DOM. Its text content is managed by the adapter:
|
|
98
|
+
|
|
99
|
+
- `'idle'`: empty or hidden
|
|
100
|
+
- `'success'`: "Copied" (or localized equivalent)
|
|
101
|
+
- `'error'`: "Copy failed" (or localized equivalent)
|
|
102
|
+
|
|
103
|
+
#### `CopyIconProps` shape
|
|
104
|
+
|
|
105
|
+
`getIconContainerProps(which: 'copy' | 'success' | 'error')` returns:
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
{
|
|
109
|
+
'aria-hidden': 'true' // icons are always decorative
|
|
110
|
+
hidden?: boolean // true when this icon is NOT the active one for current status
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Visibility logic:
|
|
115
|
+
|
|
116
|
+
- `which === 'copy'`: visible when `status() === 'idle'`
|
|
117
|
+
- `which === 'success'`: visible when `status() === 'success'`
|
|
118
|
+
- `which === 'error'`: visible when `status() === 'error'`
|
|
119
|
+
|
|
120
|
+
## APG and A11y Contract
|
|
121
|
+
|
|
122
|
+
- role: `button`
|
|
123
|
+
- required attributes:
|
|
124
|
+
- `aria-disabled`: reflects `isUnavailable()` (`isDisabled || isCopying`)
|
|
125
|
+
- `tabindex`: `0` when interactive, `-1` when unavailable
|
|
126
|
+
- status announcement:
|
|
127
|
+
- A sibling `role="status"` live region with `aria-live="polite"` and `aria-atomic="true"` announces copy outcome to assistive technology
|
|
128
|
+
- focus management:
|
|
129
|
+
- The button is in the page tab sequence when interactive (`tabindex="0"`)
|
|
130
|
+
- Focus is not moved programmatically after copy; the button retains focus
|
|
131
|
+
- keyboard interaction:
|
|
132
|
+
- `Enter`: triggers copy on `keydown`
|
|
133
|
+
- `Space`: triggers copy on `keyup`
|
|
134
|
+
|
|
135
|
+
## Keyboard Contract
|
|
136
|
+
|
|
137
|
+
| Key | Event | Guard | Effect |
|
|
138
|
+
| ------- | --------- | ------------------ | ----------------------------------------- |
|
|
139
|
+
| `Enter` | `keydown` | `!isUnavailable()` | Calls `copy()` |
|
|
140
|
+
| `Space` | `keyup` | `!isUnavailable()` | Calls `copy()` |
|
|
141
|
+
| `Space` | `keydown` | any | `preventDefault()` to prevent page scroll |
|
|
142
|
+
|
|
143
|
+
## Behavior Contract
|
|
144
|
+
|
|
145
|
+
- **Copy cycle**: calling `copy()` executes the following sequence:
|
|
146
|
+
1. Guard: if `isUnavailable()` is `true`, return immediately (no-op)
|
|
147
|
+
2. Set internal `isCopying` tracking flag to `true`
|
|
148
|
+
3. Resolve value: if `value` is a function, `await value()`; otherwise use the string directly. If the getter throws, transition to `'error'` and skip clipboard write.
|
|
149
|
+
4. Write to clipboard via `clipboard.writeText(resolvedValue)`
|
|
150
|
+
5. On success: set `status` to `'success'`, call `onCopy(resolvedValue)` callback
|
|
151
|
+
6. On failure: set `status` to `'error'`, call `onError(error)` callback
|
|
152
|
+
7. Clear `isCopying` flag
|
|
153
|
+
8. Schedule revert: after `feedbackDuration` ms, set `status` back to `'idle'` (only if status has not been changed by another action, e.g., `reset()`)
|
|
154
|
+
- **Timer management**: only one revert timer is active at a time. A new `copy()` call cancels any pending revert timer before starting a new cycle.
|
|
155
|
+
- **Reset**: `reset()` cancels any pending revert timer, clears `isCopying`, and forces `status` to `'idle'`.
|
|
156
|
+
- **Zero feedback duration**: when `feedbackDuration` is `0`, the revert to idle happens synchronously (via `setTimeout(fn, 0)`).
|
|
157
|
+
- **Disabled state**: when `isDisabled` is `true`, `copy()` is a no-op. `setDisabled`, `setValue`, `setFeedbackDuration`, and `reset` remain callable.
|
|
158
|
+
|
|
159
|
+
## Transitions Table
|
|
160
|
+
|
|
161
|
+
| Event / Action | Guard | Current Status | Next Status | Side Effects |
|
|
162
|
+
| --------------------------------------------- | ------------------------------- | ------------------- | ------------------- | -------------------------------------------------------------------------- |
|
|
163
|
+
| `copy()` | `isUnavailable()` | any | no change | no-op |
|
|
164
|
+
| `copy()` (value resolves, clipboard succeeds) | `!isUnavailable()` | `idle` | `success` | `isCopying`: true -> false; `onCopy(value)` called; revert timer scheduled |
|
|
165
|
+
| `copy()` (value resolves, clipboard fails) | `!isUnavailable()` | `idle` | `error` | `isCopying`: true -> false; `onError(err)` called; revert timer scheduled |
|
|
166
|
+
| `copy()` (async getter throws) | `!isUnavailable()` | `idle` | `error` | `isCopying`: true -> false; `onError(err)` called; revert timer scheduled |
|
|
167
|
+
| `copy()` during feedback | `!isUnavailable()` | `success` / `error` | `success` / `error` | Previous revert timer cancelled; new cycle begins |
|
|
168
|
+
| revert timer fires | status unchanged since schedule | `success` / `error` | `idle` | timer reference cleared |
|
|
169
|
+
| `reset()` | none | any | `idle` | Pending revert timer cancelled; `isCopying` cleared |
|
|
170
|
+
| `setDisabled(true)` | none | any | no status change | `isDisabled` = true; `isUnavailable` recomputes |
|
|
171
|
+
| `setDisabled(false)` | none | any | no status change | `isDisabled` = false; `isUnavailable` recomputes |
|
|
172
|
+
| `setValue(v)` | none | any | no status change | `value` updated |
|
|
173
|
+
| `setFeedbackDuration(v)` | none | any | no status change | `feedbackDuration` updated (clamped >= 0); does not affect running timers |
|
|
174
|
+
|
|
175
|
+
### Derived state reactions
|
|
176
|
+
|
|
177
|
+
| State Change | Derived Signal | Recomputation |
|
|
178
|
+
| ----------------------------------- | -------------------------------- | ---------------------------------------------------- |
|
|
179
|
+
| `status` changes | `isIdle`, `isSuccess`, `isError` | Recomputed from `status()` |
|
|
180
|
+
| `isDisabled` or `isCopying` changes | `isUnavailable` | Recomputed as `isDisabled() \|\| isCopying()` |
|
|
181
|
+
| `status` changes | `getButtonProps()` | `aria-disabled`, `tabindex`, `aria-label` recomputed |
|
|
182
|
+
| `status` changes | `getIconContainerProps(which)` | `hidden` recomputed per icon |
|
|
183
|
+
|
|
184
|
+
## Invariants
|
|
185
|
+
|
|
186
|
+
1. `status` must be one of `'idle'`, `'success'`, or `'error'` at all times.
|
|
187
|
+
2. `isUnavailable` must be `true` whenever `isDisabled` or `isCopying` is `true`.
|
|
188
|
+
3. `copy()` must be a no-op when `isUnavailable()` is `true` — no clipboard write, no state change, no callbacks.
|
|
189
|
+
4. At most one revert timer may be active at any time. Starting a new copy cycle or calling `reset()` cancels the previous timer.
|
|
190
|
+
5. `isCopying` must be `true` only during the async window between copy invocation and clipboard resolution; it must be `false` at all other times.
|
|
191
|
+
6. `aria-disabled` must reflect `isUnavailable()`, not just `isDisabled()`.
|
|
192
|
+
7. `tabindex` must be `'0'` when the button is interactive, `'-1'` when `isUnavailable()` is `true`.
|
|
193
|
+
8. `getIconContainerProps(which)` must set `hidden` to `true` for exactly two of the three icon types at any time (only the icon matching current `status` is visible).
|
|
194
|
+
9. `onCopy` must only be called on successful clipboard write; `onError` must only be called on failure.
|
|
195
|
+
10. `feedbackDuration` must be clamped to `>= 0`. Negative values are treated as `0`.
|
|
196
|
+
11. After `reset()`, `status` must be `'idle'`, `isCopying` must be `false`, and no revert timer may be pending.
|
|
197
|
+
12. The status live region (`getStatusProps()`) must always have `role="status"`, `aria-live="polite"`, and `aria-atomic="true"` regardless of component state.
|
|
198
|
+
|
|
199
|
+
## Adapter Expectations
|
|
200
|
+
|
|
201
|
+
This section defines what UIKit (`cv-copy-button`) binds to from the headless model.
|
|
202
|
+
|
|
203
|
+
### Signals read by adapter
|
|
204
|
+
|
|
205
|
+
| Signal | UIKit usage |
|
|
206
|
+
| ----------------------- | ----------------------------------------------------------------------------------------------------- |
|
|
207
|
+
| `state.status()` | Maps to `status` host attribute for CSS state styling; drives icon visibility and status text content |
|
|
208
|
+
| `state.isDisabled()` | Maps to `disabled` host attribute; reflects native disabled styling |
|
|
209
|
+
| `state.isCopying()` | Maps to `copying` host attribute for loading indicator styling (e.g., spinner) |
|
|
210
|
+
| `state.isUnavailable()` | Used to determine if click/keyboard should be suppressed; reflected in ARIA via contracts |
|
|
211
|
+
| `state.isIdle()` | Controls visibility of the copy icon slot |
|
|
212
|
+
| `state.isSuccess()` | Controls visibility of the success icon slot |
|
|
213
|
+
| `state.isError()` | Controls visibility of the error icon slot |
|
|
214
|
+
|
|
215
|
+
### Actions called by adapter
|
|
216
|
+
|
|
217
|
+
| Action | UIKit trigger |
|
|
218
|
+
| -------------------------------- | ------------------------------------------------------------------------------------------------------- |
|
|
219
|
+
| `actions.copy()` | Internal: called from headless contract handlers (click, Enter, Space) — adapter does NOT call directly |
|
|
220
|
+
| `actions.setDisabled(v)` | When `disabled` attribute/property changes on the host element |
|
|
221
|
+
| `actions.setFeedbackDuration(v)` | When `feedback-duration` attribute/property changes on the host element |
|
|
222
|
+
| `actions.setValue(v)` | When `value` property changes on the host element |
|
|
223
|
+
| `actions.reset()` | Programmatic API; exposed as a method on the custom element |
|
|
224
|
+
|
|
225
|
+
### Contracts spread by adapter
|
|
226
|
+
|
|
227
|
+
| Contract | Target element | Notes |
|
|
228
|
+
| ---------------------------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
|
|
229
|
+
| `getButtonProps()` | Inner button element (`part="base"`) | Spread as attributes; provides `role`, `aria-disabled`, `tabindex`, `aria-label`, `onClick`, `onKeyDown`, `onKeyUp` |
|
|
230
|
+
| `getStatusProps()` | Status live region element (`part="status"`) | Spread as attributes; provides `role="status"`, `aria-live`, `aria-atomic` |
|
|
231
|
+
| `getIconContainerProps('copy')` | Copy icon container (`part="copy-icon"`) | Spread as attributes; provides `aria-hidden`, `hidden` |
|
|
232
|
+
| `getIconContainerProps('success')` | Success icon container (`part="success-icon"`) | Spread as attributes; provides `aria-hidden`, `hidden` |
|
|
233
|
+
| `getIconContainerProps('error')` | Error icon container (`part="error-icon"`) | Spread as attributes; provides `aria-hidden`, `hidden` |
|
|
234
|
+
|
|
235
|
+
### Options passed through from UIKit attributes
|
|
236
|
+
|
|
237
|
+
| UIKit attribute | Headless option | Notes |
|
|
238
|
+
| ------------------- | ------------------ | ---------------------------------------------------------------------------------------------------- |
|
|
239
|
+
| `value` | `value` | Property-only (not reflected as attribute for security); accepts `string \| (() => Promise<string>)` |
|
|
240
|
+
| `feedback-duration` | `feedbackDuration` | Numeric attribute, defaults to `1500` |
|
|
241
|
+
| `disabled` | `isDisabled` | Boolean attribute |
|
|
242
|
+
| `aria-label` | `ariaLabel` | Labeling |
|
|
243
|
+
|
|
244
|
+
### UIKit-only concerns (NOT in headless)
|
|
245
|
+
|
|
246
|
+
- Icon rendering (slot content for `copy-icon`, `success-icon`, `error-icon`)
|
|
247
|
+
- Pulse/scale animation on copy activation
|
|
248
|
+
- CSS custom properties for sizing (`--cv-copy-button-size`)
|
|
249
|
+
- Slotted content variant (showing slot content until hover)
|
|
250
|
+
- `cv-copy` success event dispatched on host element with `{ detail: { value } }`
|
|
251
|
+
- `cv-error` error event dispatched on host element with `{ detail: { error } }`
|
|
252
|
+
|
|
253
|
+
## Minimum Test Matrix
|
|
254
|
+
|
|
255
|
+
- `copy()` writes resolved value to clipboard and transitions status to `'success'`
|
|
256
|
+
- `copy()` transitions status to `'error'` when clipboard.writeText rejects
|
|
257
|
+
- `copy()` transitions status to `'error'` when async value getter throws
|
|
258
|
+
- `copy()` is a no-op when `isDisabled` is `true`
|
|
259
|
+
- `copy()` is a no-op when `isCopying` is `true` (re-entrant guard)
|
|
260
|
+
- `isCopying` is `true` during async resolution, `false` after
|
|
261
|
+
- status reverts to `'idle'` after `feedbackDuration` ms
|
|
262
|
+
- `reset()` forces status to `'idle'` and cancels pending revert timer
|
|
263
|
+
- `reset()` clears `isCopying` if currently in-flight
|
|
264
|
+
- `getButtonProps()` returns correct `role`, `tabindex`, `aria-disabled` for all states
|
|
265
|
+
- `getButtonProps()` onClick calls `copy()`
|
|
266
|
+
- `getButtonProps()` keyboard: Enter on keydown triggers copy, Space on keyup triggers copy
|
|
267
|
+
- `getStatusProps()` always returns `role="status"`, `aria-live="polite"`, `aria-atomic="true"`
|
|
268
|
+
- `getIconContainerProps('copy')` is visible only when idle
|
|
269
|
+
- `getIconContainerProps('success')` is visible only when success
|
|
270
|
+
- `getIconContainerProps('error')` is visible only when error
|
|
271
|
+
- `onCopy` callback fires on success with resolved value
|
|
272
|
+
- `onError` callback fires on failure with error
|
|
273
|
+
- new `copy()` cancels previous revert timer
|
|
274
|
+
- `feedbackDuration` of `0` still reverts (async via setTimeout 0)
|
|
275
|
+
- `setFeedbackDuration` clamps negative values to `0`
|
|
276
|
+
- async value getter is supported (function returning Promise<string>)
|
|
277
|
+
- injectable `clipboard` adapter is used when provided
|
|
278
|
+
|
|
279
|
+
## ADR-001 Compliance
|
|
280
|
+
|
|
281
|
+
- **Runtime Policy**: Reatom v1000 only; no `@statx/*` in headless core.
|
|
282
|
+
- **Layering**: `core -> interactions -> a11y-contracts -> adapters`; adapters remain thin mappings.
|
|
283
|
+
- **Independence**: No imports from `@project/*`, `apps/*`, or other out-of-package modules.
|
|
284
|
+
- **Verification**: Mandatory adapter integration tests and standalone package test execution.
|
|
285
|
+
|
|
286
|
+
## Out of Scope (Current)
|
|
287
|
+
|
|
288
|
+
- Built-in tooltip integration (composable externally)
|
|
289
|
+
- Auto-wipe of clipboard after timeout (domain concern, handled by consumer)
|
|
290
|
+
- Copy-to-clipboard permission prompts or fallback strategies (e.g., `document.execCommand`)
|
|
291
|
+
- Animation orchestration for icon transitions
|
|
292
|
+
- Internationalization of status announcement text (adapter responsibility)
|
|
293
|
+
- Multiple value formats (e.g., HTML, rich text) — only plain text via `clipboard.writeText`
|