@charcuterie/ui 3.10.0 → 3.12.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.
@@ -0,0 +1,111 @@
1
+ import type { Meta, StoryObj } from "@storybook/react"
2
+ import { userEvent, within } from "storybook/test"
3
+
4
+ import {
5
+ StoryCell,
6
+ StoryRow,
7
+ StorySection,
8
+ } from "../board.storyHelpers.tsx"
9
+ import { CopyButton } from "./CopyButton.tsx"
10
+
11
+ const meta = {
12
+ title: "Components/Actions/CopyButton",
13
+ component: CopyButton,
14
+ parameters: { layout: "padded" },
15
+ // The component's own defaults, restated — Storybook does not
16
+ // seed `args` from docgen, so an unstated default shows in the
17
+ // props table with nothing selected in its control.
18
+ args: {
19
+ appearance: "solid",
20
+ confirmDuration: 2000,
21
+ intent: "accent",
22
+ isDisabled: false,
23
+ size: "md",
24
+ value: "335590",
25
+ },
26
+ } satisfies Meta<typeof CopyButton>
27
+
28
+ export default meta
29
+
30
+ type Story = StoryObj<typeof meta>
31
+
32
+ export const Default: Story = {}
33
+
34
+ /**
35
+ * The confirmation, held open. `confirmDuration` is enormous here so
36
+ * the state can be read, hovered and screenshotted — a two-second
37
+ * default is right in an app and useless in a docs page.
38
+ */
39
+ export const Confirmed: Story = {
40
+ args: {
41
+ confirmDuration: 600_000,
42
+ // Injected, not real. A headless browser refuses
43
+ // `navigator.clipboard` outright — the document is not focused —
44
+ // so a story that used the real one would document the *failure*
45
+ // state under a name that promises the success one.
46
+ copy: () => true,
47
+ value: "335590",
48
+ },
49
+ // Driven, not faked: the story presses the button the way a reader
50
+ // would. No assertion — those live in `CopyButton.test.tsx`.
51
+ play: async ({ canvasElement }) => {
52
+ await userEvent.click(
53
+ within(canvasElement).getByRole("button", {
54
+ name: "Copy",
55
+ }),
56
+ )
57
+ },
58
+ }
59
+
60
+ /**
61
+ * The clipboard refused. Injected rather than provoked: a story
62
+ * cannot revoke the permission, and the failure path is the half of
63
+ * this component the hand-rolled copies in the fleet do not have.
64
+ */
65
+ export const Refused: Story = {
66
+ args: {
67
+ confirmDuration: 600_000,
68
+ copy: () => false,
69
+ },
70
+ play: async ({ canvasElement }) => {
71
+ await userEvent.click(
72
+ within(canvasElement).getByRole("button", {
73
+ name: "Copy",
74
+ }),
75
+ )
76
+ },
77
+ }
78
+
79
+ /** Every appearance, so a toolbar's ghost copy is a real option. */
80
+ export const AllAppearances: Story = {
81
+ render: (args) => (
82
+ <StorySection title="Appearances">
83
+ <StoryRow>
84
+ <StoryCell label="solid">
85
+ <CopyButton {...args} appearance="solid" />
86
+ </StoryCell>
87
+ <StoryCell label="soft">
88
+ <CopyButton {...args} appearance="soft" />
89
+ </StoryCell>
90
+ <StoryCell label="outline">
91
+ <CopyButton {...args} appearance="outline" />
92
+ </StoryCell>
93
+ <StoryCell label="ghost">
94
+ <CopyButton {...args} appearance="ghost" />
95
+ </StoryCell>
96
+ </StoryRow>
97
+ </StorySection>
98
+ ),
99
+ }
100
+
101
+ /**
102
+ * A caller's own words. The label is content, so a one-off surface
103
+ * that copies something specific says which thing it copies.
104
+ */
105
+ export const OwnLabels: Story = {
106
+ args: {
107
+ children: "Copy code",
108
+ copiedLabel: "Code copied",
109
+ value: "335590",
110
+ },
111
+ }
@@ -0,0 +1,227 @@
1
+ import {
2
+ composeStories,
3
+ composeStory,
4
+ } from "@storybook/react"
5
+ import {
6
+ expect,
7
+ fn,
8
+ userEvent,
9
+ waitFor,
10
+ } from "storybook/test"
11
+ import { test, vi } from "vitest"
12
+
13
+ import { expectNoAxeViolations } from "../expectNoAxeViolations.testHelpers.ts"
14
+ import { mountStory } from "../mountStory.testHelpers.ts"
15
+ import { expectAgentDrivable } from "../testing/index.ts"
16
+ import meta, * as stories from "./CopyButton.stories.tsx"
17
+ import { copyText } from "./copyText.ts"
18
+
19
+ const { Confirmed, Default, Refused } =
20
+ composeStories(stories)
21
+
22
+ test("is a button an agent can find by role and name", async () => {
23
+ const { canvas } = await mountStory(Default)
24
+
25
+ const button = expectAgentDrivable(canvas, {
26
+ name: "Copy",
27
+ role: "button",
28
+ })
29
+
30
+ // Never `submit`. A copy control inside a form that reloads the
31
+ // page is a bug every repo in the fleet has shipped once.
32
+ await expect(button).toHaveAttribute("type", "button")
33
+ })
34
+
35
+ test("copies the value and confirms in place", async () => {
36
+ const copy = fn(async () => true)
37
+
38
+ const { canvas } = await mountStory(
39
+ composeStory({ args: { copy, value: "335590" } }, meta),
40
+ )
41
+
42
+ await userEvent.click(
43
+ canvas.getByRole("button", { name: "Copy" }),
44
+ )
45
+
46
+ await expect(copy).toHaveBeenCalledWith("335590")
47
+ // The press and its proof are the same object — no toast, no
48
+ // second place to look.
49
+ await expect(
50
+ canvas.getByRole("button", { name: "Copied" }),
51
+ ).toBeVisible()
52
+ })
53
+
54
+ /**
55
+ * The half the four hand-rolled copies in the fleet do not have.
56
+ *
57
+ * A refused clipboard has to *say so*, because the consumer's next
58
+ * move usually destroys the thing that held the value — mail-sifter
59
+ * marks the mail done on a copy.
60
+ */
61
+ test("says so when the clipboard refuses", async () => {
62
+ const onCopy = fn()
63
+
64
+ const { canvas } = await mountStory(
65
+ composeStory(
66
+ { args: { copy: () => false, onCopy } },
67
+ meta,
68
+ ),
69
+ )
70
+
71
+ await userEvent.click(
72
+ canvas.getByRole("button", { name: "Copy" }),
73
+ )
74
+
75
+ await expect(
76
+ canvas.getByRole("button", { name: "Copy failed" }),
77
+ ).toBeVisible()
78
+ await expect(onCopy).toHaveBeenCalledWith(false, "335590")
79
+ })
80
+
81
+ test("tells the caller what happened, both ways", async () => {
82
+ const onCopy = fn()
83
+
84
+ const { canvas } = await mountStory(
85
+ composeStory(
86
+ { args: { copy: async () => true, onCopy } },
87
+ meta,
88
+ ),
89
+ )
90
+
91
+ await userEvent.click(
92
+ canvas.getByRole("button", { name: "Copy" }),
93
+ )
94
+
95
+ await expect(onCopy).toHaveBeenCalledWith(true, "335590")
96
+ })
97
+
98
+ /**
99
+ * A focused button whose own accessible name changes is not
100
+ * reliably announced, so the outcome is written into a live region
101
+ * as well.
102
+ */
103
+ test("announces the outcome in a status region", async () => {
104
+ const { canvas } = await mountStory(Confirmed)
105
+
106
+ await expect(
107
+ canvas.getByRole("status"),
108
+ ).toHaveTextContent("Copied to the clipboard.")
109
+ })
110
+
111
+ test("the failure says what to do instead", async () => {
112
+ const { canvas } = await mountStory(Refused)
113
+
114
+ await expect(
115
+ canvas.getByRole("status"),
116
+ ).toHaveTextContent(
117
+ "The clipboard refused. Select the value and press Control C.",
118
+ )
119
+ })
120
+
121
+ /** The confirmation goes back on its own. */
122
+ test("returns to the resting label", async () => {
123
+ const { canvas } = await mountStory(
124
+ composeStory(
125
+ { args: { confirmDuration: 20, copy: () => true } },
126
+ meta,
127
+ ),
128
+ )
129
+
130
+ await userEvent.click(
131
+ canvas.getByRole("button", { name: "Copy" }),
132
+ )
133
+ await expect(
134
+ canvas.getByRole("button", { name: "Copied" }),
135
+ ).toBeVisible()
136
+
137
+ await waitFor(async () => {
138
+ await expect(
139
+ canvas.getByRole("button", { name: "Copy" }),
140
+ ).toBeVisible()
141
+ })
142
+ })
143
+
144
+ /**
145
+ * A caller's `onClick` runs first and can call it off — the escape
146
+ * hatch for "confirm before this leaves the screen".
147
+ */
148
+ test("a prevented click copies nothing", async () => {
149
+ const copy = fn(() => true)
150
+
151
+ const { canvas } = await mountStory(
152
+ composeStory(
153
+ {
154
+ args: {
155
+ copy,
156
+ onClick: (event: MouseEvent) => {
157
+ event.preventDefault()
158
+ },
159
+ },
160
+ },
161
+ meta,
162
+ ),
163
+ )
164
+
165
+ await userEvent.click(
166
+ canvas.getByRole("button", { name: "Copy" }),
167
+ )
168
+
169
+ await expect(copy).not.toHaveBeenCalled()
170
+ })
171
+
172
+ test("has no axe violations", async () => {
173
+ const { canvasElement } = await mountStory(Default)
174
+
175
+ await expectNoAxeViolations(canvasElement)
176
+ })
177
+
178
+ /**
179
+ * `copyText`'s own contract, exercised here rather than in a
180
+ * node-side `.ts` file, because it is a DOM routine and the `ui`
181
+ * project has no DOM.
182
+ *
183
+ * The real clipboard is unavailable in this chromium — the document
184
+ * is not focused, so `navigator.clipboard.writeText` rejects — which
185
+ * makes this an honest test of the fallback chain rather than of the
186
+ * happy path.
187
+ */
188
+ test("copyText reports failure rather than throwing", async () => {
189
+ const execCommand = vi
190
+ .spyOn(document, "execCommand")
191
+ .mockReturnValue(false)
192
+
193
+ await expect(copyText("335590")).resolves.toBe(false)
194
+
195
+ execCommand.mockRestore()
196
+ })
197
+
198
+ test("copyText falls back to the selection route", async () => {
199
+ const execCommand = vi
200
+ .spyOn(document, "execCommand")
201
+ .mockReturnValue(true)
202
+
203
+ await expect(copyText("335590")).resolves.toBe(true)
204
+ await expect(execCommand).toHaveBeenCalledWith("copy")
205
+
206
+ execCommand.mockRestore()
207
+ })
208
+
209
+ /**
210
+ * The textarea is a means, not a side effect. A leaked one is a
211
+ * focusable, screen-reader-visible node holding the copied value,
212
+ * left in the document for good.
213
+ */
214
+ test("copyText leaves no textarea behind", async () => {
215
+ const execCommand = vi
216
+ .spyOn(document, "execCommand")
217
+ .mockImplementation(() => {
218
+ throw new Error("refused mid-copy")
219
+ })
220
+
221
+ await expect(copyText("335590")).resolves.toBe(false)
222
+ await expect(
223
+ document.querySelectorAll("textarea"),
224
+ ).toHaveLength(0)
225
+
226
+ execCommand.mockRestore()
227
+ })
@@ -0,0 +1,155 @@
1
+ import type { ReactNode } from "react"
2
+ import { useEffect, useRef, useState } from "react"
3
+ import type { ButtonProps } from "../Button/Button.tsx"
4
+ import { Button } from "../Button/Button.tsx"
5
+ import { VisuallyHidden } from "../VisuallyHidden/VisuallyHidden.tsx"
6
+ import { copyText as defaultCopyText } from "./copyText.ts"
7
+
8
+ /** Where the button is in the one action it performs. */
9
+ export type CopyStatus = "copied" | "failed" | "idle"
10
+
11
+ export type CopyButtonProps = Omit<
12
+ ButtonProps,
13
+ "children" | "isLoading" | "loadingLabel" | "onCopy"
14
+ > & {
15
+ /**
16
+ * How long the confirmation stays up, in milliseconds.
17
+ *
18
+ * Long enough to be read after the eye has moved back to the form
19
+ * being filled in, short enough that a second copy of a *different*
20
+ * value is not mistaken for the first one's tick.
21
+ */
22
+ confirmDuration?: number
23
+ /** Resting label. A word, not a glyph — this library ships no icons. */
24
+ children?: ReactNode
25
+ /** Shown while the copy is confirmed. */
26
+ copiedLabel?: ReactNode
27
+ /**
28
+ * The clipboard write, injected. Swapped in tests and in a host
29
+ * that owns its own clipboard bridge (Electron, a kiosk shell).
30
+ */
31
+ copy?: (value: string) => Promise<boolean> | boolean
32
+ /** Shown when the clipboard refused. */
33
+ failedLabel?: ReactNode
34
+ /**
35
+ * Told what actually happened, every press.
36
+ *
37
+ * `isCopied` is the point of the callback rather than a detail: a
38
+ * consumer that files the mail away on a copy must not file it away
39
+ * on a copy the browser refused.
40
+ */
41
+ onCopy?: (isCopied: boolean, value: string) => void
42
+ /** The string that goes on the clipboard. */
43
+ value: string
44
+ }
45
+
46
+ /**
47
+ * A button that puts a string on the clipboard and says so.
48
+ *
49
+ * From a consumer, like `Alert` and `BadgeButton` before it: four
50
+ * repos spell this shape by hand — mux-magic's YAML dialog flips its
51
+ * own label on a `setTimeout`, spoolbuddy carries a 15-line
52
+ * `copyToClipboard` with the `execCommand` fallback inline, bambuddy
53
+ * copies a download link from two menus, and mail-sifter's
54
+ * verification-code banner is the fourth. None of the four agree on
55
+ * what happens when the clipboard says no, and three of them cannot
56
+ * tell.
57
+ *
58
+ * **The confirmation is the button, not a toast.** The press and the
59
+ * proof are the same object, so the eye does not travel; a toast is
60
+ * right for something that happened *elsewhere*. A consumer that
61
+ * wants both still gets `onCopy`.
62
+ *
63
+ * **The label is a word.** `Copy` → `Copied`, and the intent goes
64
+ * with it, because a colour change alone is not a message and a tick
65
+ * glyph is an icon this library does not ship.
66
+ *
67
+ * **It announces.** A screen reader is not reliably told that a
68
+ * focused button's own name changed, so the outcome is also written
69
+ * into a `role="status"` region — and the failure says what to do
70
+ * next rather than only that it went wrong.
71
+ *
72
+ * **The timer is cancelled on unmount and on a re-press**, which is
73
+ * the bug in two of the four hand-rolled copies: press, navigate
74
+ * away, and a `setState` lands on nothing.
75
+ */
76
+ export const CopyButton = ({
77
+ appearance = "solid",
78
+ children = "Copy",
79
+ confirmDuration = 2000,
80
+ copiedLabel = "Copied",
81
+ copy = defaultCopyText,
82
+ failedLabel = "Copy failed",
83
+ intent = "accent",
84
+ onClick,
85
+ onCopy,
86
+ value,
87
+ ...buttonProps
88
+ }: CopyButtonProps): ReactNode => {
89
+ const [status, setStatus] = useState<CopyStatus>("idle")
90
+ const timerRef =
91
+ useRef<ReturnType<typeof setTimeout>>(undefined)
92
+
93
+ // One cleanup covers both the unmount and the re-press: every path
94
+ // that starts a timer clears the previous one first.
95
+ useEffect(
96
+ () => () => {
97
+ clearTimeout(timerRef.current)
98
+ },
99
+ [],
100
+ )
101
+
102
+ const settle = (next: CopyStatus) => {
103
+ setStatus(next)
104
+ clearTimeout(timerRef.current)
105
+ timerRef.current = setTimeout(() => {
106
+ setStatus("idle")
107
+ }, confirmDuration)
108
+ }
109
+
110
+ const handleClick: NonNullable<ButtonProps["onClick"]> = (
111
+ event,
112
+ ) => {
113
+ onClick?.(event)
114
+ if (event.defaultPrevented) return
115
+
116
+ void (async () => {
117
+ const isCopied = await copy(value)
118
+ settle(isCopied ? "copied" : "failed")
119
+ onCopy?.(isCopied, value)
120
+ })()
121
+ }
122
+
123
+ const label =
124
+ status === "copied"
125
+ ? copiedLabel
126
+ : status === "failed"
127
+ ? failedLabel
128
+ : children
129
+
130
+ return (
131
+ <>
132
+ <Button
133
+ {...buttonProps}
134
+ appearance={appearance}
135
+ intent={
136
+ status === "copied"
137
+ ? "success"
138
+ : status === "failed"
139
+ ? "danger"
140
+ : intent
141
+ }
142
+ onClick={handleClick}
143
+ >
144
+ {label}
145
+ </Button>
146
+ <VisuallyHidden role="status">
147
+ {status === "copied"
148
+ ? "Copied to the clipboard."
149
+ : status === "failed"
150
+ ? "The clipboard refused. Select the value and press Control C."
151
+ : ""}
152
+ </VisuallyHidden>
153
+ </>
154
+ )
155
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Put a string on the clipboard, and say whether it landed.
3
+ *
4
+ * Three routes, tried in order, because the fleet runs in all three
5
+ * conditions:
6
+ *
7
+ * 1. **`navigator.clipboard.writeText`.** The real API. It exists
8
+ * only in a *secure context*, which a `devshare` URL and
9
+ * `localhost` both are — and a plain `http://storeman.octen:8080`
10
+ * dev server is not.
11
+ * 2. **A hidden `<textarea>` plus `document.execCommand("copy")`.**
12
+ * Deprecated, and kept anyway: it is the only thing that works in
13
+ * an insecure context, spoolbuddy already hand-rolls exactly this
14
+ * block, and the alternative is a Copy button that silently does
15
+ * nothing on the household's own LAN.
16
+ * 3. **Give up and report it.** A caller that marks mail done on a
17
+ * copy must not mark it done on a copy that never happened.
18
+ *
19
+ * Never throws. The boolean is the whole result, so a call site is an
20
+ * `if` rather than a `try`.
21
+ */
22
+ export const copyText = async (
23
+ value: string,
24
+ ): Promise<boolean> => {
25
+ try {
26
+ // `writeText` rejects on a denied permission and on a document
27
+ // that is not focused — both real, both recoverable below.
28
+ await globalThis.navigator?.clipboard?.writeText(value)
29
+ return true
30
+ } catch {
31
+ // Fall through to the legacy route.
32
+ }
33
+
34
+ return copyBySelection(value)
35
+ }
36
+
37
+ /**
38
+ * The pre-Clipboard-API route.
39
+ *
40
+ * The textarea is positioned off-screen rather than hidden: a
41
+ * `display: none` element has no selection to copy, and
42
+ * `visibility: hidden` is no better. `readOnly` stops the mobile
43
+ * keyboard appearing for the frame it is in the document.
44
+ */
45
+ const copyBySelection = (value: string): boolean => {
46
+ const owner = globalThis.document
47
+ if (!owner?.body) return false
48
+
49
+ const field = owner.createElement("textarea")
50
+ field.value = value
51
+ field.readOnly = true
52
+ field.setAttribute("aria-hidden", "true")
53
+ field.style.position = "fixed"
54
+ field.style.insetInlineStart = "-9999px"
55
+ field.style.top = "0"
56
+
57
+ owner.body.append(field)
58
+
59
+ try {
60
+ field.select()
61
+
62
+ return owner.execCommand("copy")
63
+ } catch {
64
+ return false
65
+ } finally {
66
+ field.remove()
67
+ }
68
+ }
package/src/index.ts CHANGED
@@ -98,6 +98,7 @@ export type {
98
98
  CardSurface,
99
99
  } from "./Card/Card.tsx"
100
100
  export { Card } from "./Card/Card.tsx"
101
+ export type { CardAccentEdge } from "./Card/cardAccentEdge.ts"
101
102
  export type { CheckboxProps } from "./Checkbox/Checkbox.tsx"
102
103
  export { Checkbox } from "./Checkbox/Checkbox.tsx"
103
104
  export type { ColorSchemeSwitcherProps } from "./ColorSchemeSwitcher/ColorSchemeSwitcher.tsx"
@@ -109,6 +110,12 @@ export type {
109
110
  export { ColorSchemeToggle } from "./ColorSchemeToggle/ColorSchemeToggle.tsx"
110
111
  export type { ComboboxProps } from "./Combobox/Combobox.tsx"
111
112
  export { Combobox } from "./Combobox/Combobox.tsx"
113
+ export type {
114
+ CopyButtonProps,
115
+ CopyStatus,
116
+ } from "./CopyButton/CopyButton.tsx"
117
+ export { CopyButton } from "./CopyButton/CopyButton.tsx"
118
+ export { copyText } from "./CopyButton/copyText.ts"
112
119
  export {
113
120
  CATEGORICAL_APPEARANCE_CLASS,
114
121
  CATEGORICAL_CONTENT_CLASS,
@@ -459,7 +459,13 @@ test("the barrel is the only place components are re-exported", async () => {
459
459
  // member of `Badge/` and so stays out of this count and out of the
460
460
  // barrel as a component. Its PROPS type is exported, because a
461
461
  // consumer writing a wrapper needs to name the visual half.
462
- expect(componentNames.length).toBe(53)
462
+ //
463
+ // `CopyButton` — the clipboard write, with a failure state — is
464
+ // +1 -> 54. `copyText.ts` is its member: it stays out of this
465
+ // count by the `<Name>/<Name>.tsx` rule, and it IS exported from
466
+ // the barrel as a function, because a host with its own clipboard
467
+ // bridge needs the fallback chain without the button.
468
+ expect(componentNames.length).toBe(54)
463
469
 
464
470
  for (const name of componentNames) {
465
471
  expect(barrel).toContain(`export { ${name} }`)