@charcuterie/ui 3.11.0 → 3.13.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 (36) hide show
  1. package/dist/Avatar/Avatar.d.ts +163 -0
  2. package/dist/Avatar/Avatar.d.ts.map +1 -0
  3. package/dist/Avatar/Avatar.js +139 -0
  4. package/dist/Avatar/Avatar.js.map +1 -0
  5. package/dist/Board/BoardLaneList.d.ts +29 -0
  6. package/dist/Board/BoardLaneList.d.ts.map +1 -1
  7. package/dist/Board/BoardLaneList.js +2 -1
  8. package/dist/Board/BoardLaneList.js.map +1 -1
  9. package/dist/CopyButton/CopyButton.d.ts +67 -0
  10. package/dist/CopyButton/CopyButton.d.ts.map +1 -0
  11. package/dist/CopyButton/CopyButton.js +76 -0
  12. package/dist/CopyButton/CopyButton.js.map +1 -0
  13. package/dist/CopyButton/copyText.d.ts +23 -0
  14. package/dist/CopyButton/copyText.d.ts.map +1 -0
  15. package/dist/CopyButton/copyText.js +65 -0
  16. package/dist/CopyButton/copyText.js.map +1 -0
  17. package/dist/index.d.ts +5 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +3 -0
  20. package/dist/index.js.map +1 -1
  21. package/package.json +1 -1
  22. package/src/Avatar/Avatar.mdx +92 -0
  23. package/src/Avatar/Avatar.stories.tsx +254 -0
  24. package/src/Avatar/Avatar.test.tsx +143 -0
  25. package/src/Avatar/Avatar.tsx +270 -0
  26. package/src/Board/Board.mdx +26 -0
  27. package/src/Board/Board.stories.tsx +47 -21
  28. package/src/Board/Board.test.tsx +62 -0
  29. package/src/Board/BoardLaneList.tsx +52 -1
  30. package/src/CopyButton/CopyButton.mdx +84 -0
  31. package/src/CopyButton/CopyButton.stories.tsx +111 -0
  32. package/src/CopyButton/CopyButton.test.tsx +227 -0
  33. package/src/CopyButton/CopyButton.tsx +155 -0
  34. package/src/CopyButton/copyText.ts +68 -0
  35. package/src/index.ts +11 -0
  36. package/src/sourceRules.test.ts +15 -1
@@ -0,0 +1,270 @@
1
+ import type { CategoricalIndex } from "@charcuterie/tokens"
2
+ import { getCategoricalIndex } from "@charcuterie/tokens"
3
+ import type {
4
+ ComponentPropsWithRef,
5
+ ReactNode,
6
+ } from "react"
7
+ import { useState } from "react"
8
+
9
+ import { CATEGORICAL_APPEARANCE_CLASS } from "../categoricalStyles.ts"
10
+ import type { IntentAppearance } from "../intentStyles.ts"
11
+ import { toClassName } from "../toClassName.ts"
12
+
13
+ export type AvatarSize = keyof typeof AVATAR_SIZE_CLASS
14
+
15
+ export type AvatarProps = Omit<
16
+ ComponentPropsWithRef<"span">,
17
+ "children"
18
+ > & {
19
+ /**
20
+ * The same three the rest of the categorical family wears, minus
21
+ * `ghost` — a chip with no fill and no border is a floating
22
+ * letter, and the whole job here is to be a **shape** the eye
23
+ * finds in a list.
24
+ */
25
+ appearance?: Exclude<IntentAppearance, "ghost">
26
+ /**
27
+ * The colour, when somebody has chosen one. Omitted, it is derived
28
+ * — see `categoricalKey` — so an avatar always has a colour and
29
+ * never has to be given one.
30
+ */
31
+ categorical?: CategoricalIndex
32
+ /**
33
+ * What the derived colour is hashed from when `categorical` is
34
+ * absent. Defaults to `name`.
35
+ *
36
+ * Pass the **user's id** wherever one exists. `getCategoricalIndex`
37
+ * does no normalization on purpose, so hashing the name means a
38
+ * rename — or a trailing space — silently repaints a person the
39
+ * reader had already learned to recognise. An id does not move.
40
+ */
41
+ categoricalKey?: string
42
+ /**
43
+ * A glyph instead of the initials — a robot for an agent account,
44
+ * a lock for a service account. The library ships **no icons**, so
45
+ * this is the app's own
46
+ * ([decision](../../../docs/decisions/2026-07-29-ship-no-icons-and-no-symbol-glyphs.md)).
47
+ * It is decoration: the name still travels in `aria-label`, and a
48
+ * glyph the font cannot render must not be the only thing
49
+ * carrying who this is.
50
+ */
51
+ icon?: ReactNode
52
+ /**
53
+ * A photo. Optional, and **it is never the only thing** — a
54
+ * request that 404s, a signed URL that expired, or an app that
55
+ * simply has no photos falls back to the initials with no empty
56
+ * box in between.
57
+ */
58
+ imageUrl?: string
59
+ /**
60
+ * The letters, when the derivation is wrong. Derived from `name`
61
+ * otherwise: the first character of each of the first two
62
+ * whitespace-separated words, upper-cased.
63
+ *
64
+ * Worth passing for a name the split gets wrong — a mononym that
65
+ * wants two letters, a name whose family part comes first, a
66
+ * handle with punctuation in it.
67
+ */
68
+ initials?: string
69
+ /**
70
+ * Who this is. Read by assistive technology and shown on hover;
71
+ * **never printed as visible text**, which is the entire point of
72
+ * the component.
73
+ *
74
+ * Nullable, and that is a decision rather than a loose type — see
75
+ * the note on the component about what an unassigned row draws.
76
+ */
77
+ name: string | null | undefined
78
+ size?: AvatarSize
79
+ }
80
+
81
+ /**
82
+ * One rule: **the circle is 1.9 x its own type size**, and `size`
83
+ * picks which step of the type scale that is.
84
+ *
85
+ * 1.9 rather than a tighter number because a circle only offers its
86
+ * full width on the centre line. Two capitals measure about 1.25em
87
+ * in this face, and a chord taken at the cap height of a 1.75em
88
+ * circle is barely wider than that — measured, not guessed.
89
+ *
90
+ * It is em-based rather than a fixed `size-5` / `size-7` / `size-9`,
91
+ * and that is the whole of the design. Every length in this token
92
+ * set moves with the density axis — `--font-size-xs` is 0.88rem
93
+ * compact, 0.94rem at desktop and 1.11rem on the kiosk — so a chip
94
+ * pinned to 20px holds two letters at one density and clips them at
95
+ * the next. `controlStyles.ts` says the same thing about badges and
96
+ * dots: non-control sizing is type-relative on purpose, because a
97
+ * chip that stays 20px while the kiosk scales the prose around it
98
+ * reads as a rendering fault.
99
+ *
100
+ * The hand-rolled avatar this replaces is the worked example. It was
101
+ * `size-5` with `text-[0.625rem]` — a 10px font, which is **not a
102
+ * step of this type scale at any density**, because 20px is not
103
+ * enough room for two characters at the smallest size the tokens
104
+ * actually offer. A component that has to invent a font size to fit
105
+ * its own box has the wrong box.
106
+ */
107
+ const AVATAR_SIZE_CLASS = {
108
+ sm: "size-[1.9em] text-xs",
109
+ md: "size-[1.9em] text-md",
110
+ lg: "size-[1.9em] text-xl",
111
+ } as const
112
+
113
+ /**
114
+ * The first character of each of the first two words.
115
+ *
116
+ * `Array.from` rather than `charAt`, so a name that starts outside
117
+ * the BMP contributes a whole character instead of half a surrogate
118
+ * pair and a replacement glyph.
119
+ */
120
+ const toInitials = (name: string): string =>
121
+ name
122
+ .trim()
123
+ .split(/\s+/)
124
+ .slice(0, 2)
125
+ .map((word) => Array.from(word)[0] ?? "")
126
+ .join("")
127
+ .toLocaleUpperCase()
128
+
129
+ /**
130
+ * A person, as a coloured chip — a glyph or an initial, with the
131
+ * name attached where only assistive technology and a hover can
132
+ * reach it.
133
+ *
134
+ * It exists to delete a printed name. Docket's board put the word
135
+ * *"Owner"* on most of its cards, which is a column of identical
136
+ * text doing the work of a colour; the fleet's boards all want the
137
+ * same chip, and `Board.stories.tsx` had already hand-rolled one
138
+ * inside a story file.
139
+ *
140
+ * ### Unassigned draws **nothing**, and the component owns that
141
+ *
142
+ * `name` accepts `null` and `undefined`, and either renders `null`.
143
+ * A grey *"None"* placeholder is a decision the design system makes
144
+ * against the reader: it is a chip that says a fact nobody needed,
145
+ * repeated down every unassigned row, at the same weight as the
146
+ * chips that mean something.
147
+ *
148
+ * The alternative is a ternary at every call site, and the call
149
+ * sites are the whole problem — a board, a table, a card footer, a
150
+ * detail pane, each written on a different day. The one that forgets
151
+ * is the one that ships the grey circle. Absence is expressed once,
152
+ * here, which is the same call `Board` makes with `onMove`: no
153
+ * handler means no affordance, rather than a disabled one.
154
+ *
155
+ * A consumer that genuinely wants to *say* "unassigned" still can —
156
+ * that is text, or an `EmptyState`, and neither of them is an
157
+ * avatar.
158
+ *
159
+ * ### The name is never visible text
160
+ *
161
+ * `role="img"` with an `aria-label`, exactly as `Swatch` does, so
162
+ * `getByRole("img", { name })` is the one handle whether the chip is
163
+ * showing a photo, a glyph or two letters. `title` carries the same
164
+ * string for a pointer, which is the only channel a person browsing
165
+ * with their eyes has — the letters `KG` are not a name.
166
+ *
167
+ * That is also why there is no `isNameVisible`: an avatar whose name
168
+ * is printed beside it is a name with a decoration in front of it,
169
+ * and the layout that wants one should print the name and skip the
170
+ * chip.
171
+ *
172
+ * ### The colour is derived, never invented
173
+ *
174
+ * `categorical` is the ten-wide non-semantic scale — the same one
175
+ * `Badge` takes — and **not** an `intent`. An intent is a claim the
176
+ * design system makes; a person is not `danger`. With no
177
+ * `categorical` the index comes from `getCategoricalIndex`, which is
178
+ * a pure function of the key, so the same person is the same colour
179
+ * on every machine, every reload, and in a server render. Ten
180
+ * colours means collisions past ten people, and that is a property
181
+ * of the palette rather than a fault in the hash: the colour helps
182
+ * you *scan*, and the accessible name is what identifies.
183
+ *
184
+ * ### There is no `AvatarGroup`
185
+ *
186
+ * Deliberately. A stack of overlapping chips with a `+3` needs
187
+ * answers to which three survive, in what order, and what the
188
+ * overflow announces — and no consumer has asked the question yet. A
189
+ * row of `Avatar`s in a flex box is what a multi-assignee list looks
190
+ * like until one does.
191
+ */
192
+ export const Avatar = ({
193
+ appearance = "soft",
194
+ categorical,
195
+ categoricalKey,
196
+ className,
197
+ icon,
198
+ imageUrl,
199
+ initials,
200
+ name,
201
+ size = "md",
202
+ title,
203
+ ...spanProps
204
+ }: AvatarProps): ReactNode => {
205
+ /**
206
+ * The URL that failed, rather than a `hasFailed` boolean.
207
+ *
208
+ * A boolean has to be *reset* when `imageUrl` changes, which is an
209
+ * effect syncing a prop into state — the shape this library
210
+ * refuses everywhere else
211
+ * ([decision](../../../docs/decisions/2026-07-29-logic-hooks-are-uncontrolled.md)).
212
+ * Storing the URL makes the comparison below derive the answer, so
213
+ * a second photo gets its own attempt and a re-render never
214
+ * resurrects a dead one.
215
+ */
216
+ const [failedImageUrl, setFailedImageUrl] = useState<
217
+ string | undefined
218
+ >(undefined)
219
+
220
+ const hasImageError =
221
+ imageUrl !== undefined && failedImageUrl === imageUrl
222
+
223
+ const trimmedName = name?.trim()
224
+
225
+ if (!trimmedName) {
226
+ return null
227
+ }
228
+
229
+ const resolvedInitials =
230
+ initials ?? toInitials(trimmedName)
231
+
232
+ const resolvedCategorical =
233
+ categorical ??
234
+ getCategoricalIndex(categoricalKey ?? trimmedName)
235
+
236
+ return (
237
+ <span
238
+ {...spanProps}
239
+ aria-label={trimmedName}
240
+ className={toClassName(
241
+ "inline-grid shrink-0 place-items-center overflow-hidden rounded-full border font-medium leading-none",
242
+ AVATAR_SIZE_CLASS[size],
243
+ CATEGORICAL_APPEARANCE_CLASS[resolvedCategorical][
244
+ appearance
245
+ ],
246
+ className,
247
+ )}
248
+ role="img"
249
+ title={title ?? trimmedName}
250
+ >
251
+ {imageUrl !== undefined && !hasImageError ? (
252
+ // `alt=""` on purpose: the wrapper already carries the
253
+ // accessible name, and a second copy on the image is how an
254
+ // avatar gets announced twice.
255
+ <img
256
+ alt=""
257
+ className="size-full object-cover"
258
+ onError={() => {
259
+ setFailedImageUrl(imageUrl)
260
+ }}
261
+ src={imageUrl}
262
+ />
263
+ ) : (
264
+ <span aria-hidden="true">
265
+ {icon ?? resolvedInitials}
266
+ </span>
267
+ )}
268
+ </span>
269
+ )
270
+ }
@@ -108,6 +108,32 @@ An app that already owns a glyph set passes `moveIcon` and buys back ~55px a row
108
108
 
109
109
  <Canvas of={BoardStories.AllVariants} />
110
110
 
111
+ ## A lane heading can be a link
112
+
113
+ <Canvas of={BoardStories.LinkedLaneHeadings} />
114
+
115
+ `BoardLane.href` makes the column title navigate — the "show me only this column" route a
116
+ board screen wants. It is **a link inside the heading, never a heading replaced by a
117
+ link**: the `<h3>` keeps the level the caller passed, keeps its id, and keeps naming the
118
+ lane's `group`, so the screen-reader outline of a linked board is the outline of an
119
+ unlinked one.
120
+
121
+ `label` stays a `string` rather than widening to a `ReactNode`, and that is the trade the
122
+ prop exists to avoid. The same value names the lane in the move menu on every card, in the
123
+ Narrow View's segmented control, in the "+ n more in Todo" line, and in the sentence the
124
+ live region reads after a move. None of those four can put a `ReactNode` in a string, so
125
+ widening `label` would buy one linkable heading at the cost of four places that could no
126
+ longer say which lane they mean.
127
+
128
+ The link inherits the heading's colour and weight. `TextLink` still supplies the cursor,
129
+ the focus ring and the hover underline, which is what says it is a link; a board whose
130
+ three column titles are all accent-coloured competes with the card titles underneath, and
131
+ those are what a reader is actually scanning. The `href` is routed through
132
+ `RouterLinkProvider` like every other one in this library, so an app with a client-side
133
+ router navigates without a full page load.
134
+
135
+ An app that was putting a link in `actions` as a stopgap can take it out.
136
+
111
137
  **No `onMove` means read-only**, all or nothing: no handles, no menus, no drag. A per-card
112
138
  `isMovable` would put the decision in the data, where a card that cannot move looks identical
113
139
  to one nobody has thought about.
@@ -3,6 +3,7 @@ import type { ReactNode } from "react"
3
3
  import { useState } from "react"
4
4
 
5
5
  import { Alert } from "../Alert/Alert.tsx"
6
+ import { Avatar } from "../Avatar/Avatar.tsx"
6
7
  import { Badge } from "../Badge/Badge.tsx"
7
8
  import { Button } from "../Button/Button.tsx"
8
9
  import {
@@ -71,26 +72,6 @@ const Mark = ({
71
72
  </span>
72
73
  )
73
74
 
74
- /**
75
- * An assignee. Two initials and a name nobody has to hover to hear —
76
- * an avatar with no accessible name is a coloured circle, which is
77
- * the fleet's most-repeated a11y defect after the unnamed icon
78
- * button.
79
- */
80
- const Avatar = ({
81
- initials,
82
- name,
83
- }: {
84
- initials: string
85
- name: string
86
- }): ReactNode => (
87
- <span className="grid size-5 shrink-0 place-items-center rounded-full bg-intent-info-surface font-medium text-[0.625rem] text-intent-info-content">
88
- <span aria-hidden="true">{initials}</span>
89
-
90
- <VisuallyHidden>{`Assigned to ${name}`}</VisuallyHidden>
91
- </span>
92
- )
93
-
94
75
  const ProjectChip = ({
95
76
  name,
96
77
  }: {
@@ -184,8 +165,20 @@ const toItem = ({
184
165
  </>
185
166
  ),
186
167
  title,
168
+ /*
169
+ * `Avatar` from this package, not a chip hand-rolled in a story
170
+ * file. This story *had* one — a `size-5` circle painted
171
+ * `intent-info`, with the name in a `VisuallyHidden` — which is
172
+ * exactly the shape three apps were about to write for
173
+ * themselves. The library owns the shape; the story owns the
174
+ * data.
175
+ */
187
176
  trailing: (
188
- <Avatar initials={assignee[0]} name={assignee[1]} />
177
+ <Avatar
178
+ initials={assignee[0]}
179
+ name={assignee[1]}
180
+ size="sm"
181
+ />
189
182
  ),
190
183
  })
191
184
 
@@ -705,3 +698,36 @@ export const InBoardScreen: Story = {
705
698
  </div>
706
699
  ),
707
700
  }
701
+
702
+ /**
703
+ * A lane whose heading goes somewhere — the "show me only this
704
+ * column" route.
705
+ *
706
+ * `href` on the lane, not a `ReactNode` label. The heading stays an
707
+ * `<h3>` at the level the caller asked for, keeps its id, and keeps
708
+ * naming the `group`; the anchor sits **inside** it, so the document
709
+ * outline is the same one a board with no links has.
710
+ *
711
+ * It also reads as the same heading: `TextLink` supplies the cursor,
712
+ * the focus ring and the hover underline, and the colour is
713
+ * inherited. Three accent-coloured column titles would compete with
714
+ * the card titles below them, which are what a reader is scanning.
715
+ */
716
+ export const LinkedLaneHeadings: Story = {
717
+ render: (boardProps) => (
718
+ // The same fixed 72rem the other three-up stories use. Three
719
+ // lanes at once is a measurement of the board's own box, and a
720
+ // story that inherited the canvas width would show three linked
721
+ // headings on one machine and one plus a segmented control on
722
+ // another.
723
+ <Frame inlineSize="72rem">
724
+ <Board
725
+ {...boardProps}
726
+ lanes={LANES.map((lane) => ({
727
+ ...lane,
728
+ href: `/board/${lane.key}`,
729
+ }))}
730
+ />
731
+ </Frame>
732
+ ),
733
+ }
@@ -17,6 +17,7 @@ const {
17
17
  Default,
18
18
  InBoardScreen,
19
19
  Interactive,
20
+ LinkedLaneHeadings,
20
21
  Responsive,
21
22
  } = composeStories(stories)
22
23
 
@@ -308,3 +309,64 @@ test("the board sits under a separate attention banner, and the page is clean",
308
309
 
309
310
  await expectNoAxeViolations(canvasElement)
310
311
  })
312
+
313
+ /**
314
+ * A linkable column title is a **link inside the heading**, never a
315
+ * heading replaced by a link.
316
+ *
317
+ * The distinction is the whole reason `href` is a prop rather than a
318
+ * widened `label`: swap the `<h3>` for an `<a>` and the lane stops
319
+ * having a heading, the `group` loses the element it is named by,
320
+ * and the document outline gains a hole that no gate but a screen
321
+ * reader would notice.
322
+ */
323
+ test("a lane heading with an href is a link inside a real heading", async () => {
324
+ const { canvas } = await mountStory(LinkedLaneHeadings)
325
+
326
+ const board = expectAgentDrivable(canvas, {
327
+ name: "Today",
328
+ role: "region",
329
+ })
330
+
331
+ for (const [laneKey, laneLabel] of [
332
+ ["todo", "Todo"],
333
+ ["in-progress", "In Progress"],
334
+ ["review", "Needs Review"],
335
+ ]) {
336
+ // Still a named group — the heading did not stop being the
337
+ // thing that names it.
338
+ expectAgentDrivable(canvas, {
339
+ name: laneLabel,
340
+ role: "group",
341
+ })
342
+
343
+ const heading = canvas.getByRole("heading", {
344
+ level: 3,
345
+ name: laneLabel,
346
+ })
347
+
348
+ const link = within(heading).getByRole("link", {
349
+ name: laneLabel,
350
+ })
351
+
352
+ await expect(link).toHaveAttribute(
353
+ "href",
354
+ `/board/${laneKey}`,
355
+ )
356
+ }
357
+
358
+ await expectNoAxeViolations(board)
359
+ })
360
+
361
+ /**
362
+ * The lane label is still a string everywhere else it is used, and
363
+ * a linked heading must not have changed that. The move menu, the
364
+ * segmented control and the truncation line all interpolate it.
365
+ */
366
+ test("a linked lane still names itself in the move menu and the overflow line", async () => {
367
+ const { canvas } = await mountStory(LinkedLaneHeadings)
368
+
369
+ await expect(
370
+ canvas.getByText("+ 11 more in Needs Review"),
371
+ ).toBeInTheDocument()
372
+ })
@@ -4,6 +4,7 @@ import type { ReactNode } from "react"
4
4
  import { Badge } from "../Badge/Badge.tsx"
5
5
  import { Button } from "../Button/Button.tsx"
6
6
  import { EmptyState } from "../EmptyState/EmptyState.tsx"
7
+ import { TextLink } from "../TextLink/TextLink.tsx"
7
8
  import { toClassName } from "../toClassName.ts"
8
9
  import type { BoardItem } from "./BoardCard.tsx"
9
10
  import { BoardCard } from "./BoardCard.tsx"
@@ -20,6 +21,23 @@ export type BoardLane = {
20
21
  * requirement and an empty lane is the one a person sees most.
21
22
  */
22
23
  emptyState?: ReactNode
24
+ /**
25
+ * Where the lane's own heading navigates — a route showing this
26
+ * lane and nothing else.
27
+ *
28
+ * **A link inside the heading, never a heading replaced by a
29
+ * link.** The `<h3>` keeps its level, its id and its place in the
30
+ * document outline, so the lane stays a named `group` and the
31
+ * screen-reader outline is unchanged; the anchor sits inside it.
32
+ * Routed through `RouterLinkProvider` like every other `href` in
33
+ * this library, so an app with a client-side router navigates
34
+ * without a full page load and one without it still gets a real
35
+ * `<a href>`.
36
+ *
37
+ * Absent means the heading is plain text, which is what every
38
+ * existing consumer gets.
39
+ */
40
+ href?: string
23
41
  /**
24
42
  * The **true** size of the lane, when `items` is a truncated page
25
43
  * of it. This is what the count badge shows, and the difference
@@ -31,6 +49,18 @@ export type BoardLane = {
31
49
  itemCount?: number
32
50
  items: readonly BoardItem[]
33
51
  key: string
52
+ /**
53
+ * The lane's name, and it stays a **string** rather than widening
54
+ * to a `ReactNode`.
55
+ *
56
+ * It is not only a heading: the same value names the lane in the
57
+ * move menu on every card, in the Narrow View's segmented control,
58
+ * in the "+ n more in Todo" line, and in the sentence the live
59
+ * region reads out after a move. A `ReactNode` is not something
60
+ * any of those four can put in a string, so widening this would
61
+ * trade one linkable heading for four places that can no longer
62
+ * say which lane they mean. `href` buys the link without that.
63
+ */
34
64
  label: string
35
65
  /**
36
66
  * What "+ n more in this lane" does. With no handler the line is
@@ -177,7 +207,28 @@ export const BoardLaneList = ({
177
207
  className="font-semibold text-content-primary text-sm"
178
208
  id={headingId}
179
209
  >
180
- {lane.label}
210
+ {lane.href ? (
211
+ /*
212
+ * `text-inherit` and `font-semibold` on purpose: a
213
+ * linkable column title should read as the same
214
+ * heading it was, not as a blue word. The link
215
+ * announces itself by being a link — the cursor,
216
+ * the focus ring and the hover underline all come
217
+ * from `TextLink` — and a board whose every lane
218
+ * title is accent-coloured competes with the card
219
+ * titles underneath it, which are the things a
220
+ * reader is actually scanning.
221
+ */
222
+ <TextLink
223
+ appearance="standalone"
224
+ className="font-semibold text-inherit"
225
+ href={lane.href}
226
+ >
227
+ {lane.label}
228
+ </TextLink>
229
+ ) : (
230
+ lane.label
231
+ )}
181
232
  </Heading>
182
233
 
183
234
  <Badge
@@ -0,0 +1,84 @@
1
+ import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
2
+
3
+ import * as CopyButtonStories from "./CopyButton.stories";
4
+
5
+ <Meta of={CopyButtonStories} />
6
+
7
+ # CopyButton
8
+
9
+ **A button that puts a string on the clipboard and says so.** Press it, the label turns to
10
+ `Copied`, and the caller is told whether that is true.
11
+
12
+ <Canvas of={CopyButtonStories.Default} />
13
+
14
+ ## Props
15
+
16
+ <Controls />
17
+
18
+ ## Where it came from
19
+
20
+ Four repos spell this shape by hand, and no two agree:
21
+
22
+ | Repo | How it copies | What it does when the clipboard says no |
23
+ | --- | --- | --- |
24
+ | mux-magic (`YamlModal`) | flips its own label on a `setTimeout` | nothing — the label still says `Copied!` |
25
+ | spoolbuddy (`Settings`) | 15 lines inline, with the `execCommand` fallback | a toast |
26
+ | bambuddy (`ArchivesPage`) | a menu item, twice in one file | a toast |
27
+ | mail-sifter | a verification-code banner | **marks the mail done anyway** |
28
+
29
+ The last row is why the failure path is a first-class state here rather than a `catch` that
30
+ shrugs. That consumer files the mail away on a copy, so a copy that did not happen has to be
31
+ distinguishable from one that did.
32
+
33
+ ## The confirmation is the button
34
+
35
+ The press and the proof are the same object, so the eye does not travel. A toast is the
36
+ right shape for something that happened **somewhere else** — a background job, another pane
37
+ — and the wrong shape for the thing directly under the pointer. A consumer that wants a
38
+ toast as well still gets one from `onCopy`.
39
+
40
+ <Canvas of={CopyButtonStories.Confirmed} />
41
+
42
+ ## The label is a word, and so is the failure
43
+
44
+ `Copy` → `Copied`, with the intent moving to `success` underneath it. The colour is not the
45
+ message: a colour change alone is invisible to a screen reader, unreliable for a
46
+ colour-blind reader, and this library [ships no icons](?path=/docs/guides-building-an-app--docs),
47
+ so a tick glyph is not available to carry it either.
48
+
49
+ The refusal says what to do next rather than only that something went wrong.
50
+
51
+ <Canvas of={CopyButtonStories.Refused} />
52
+
53
+ ## Three routes to the clipboard
54
+
55
+ `copyText.ts` tries them in order and never throws:
56
+
57
+ 1. **`navigator.clipboard.writeText`** — the real API, and it exists only in a *secure
58
+ context*. A `devshare` URL and `localhost` are secure; a plain `http://` dev server on
59
+ the LAN is not.
60
+ 2. **A hidden `<textarea>` and `document.execCommand("copy")`** — deprecated, kept because
61
+ it is the only thing that works in that insecure context, and because spoolbuddy already
62
+ carries this exact block inline.
63
+ 3. **Report the failure**, so the caller can hold on to whatever it was about to discard.
64
+
65
+ `copy` is injectable, which is how the `Refused` story above exists and how a host with its
66
+ own clipboard bridge — Electron, a kiosk shell — supplies it.
67
+
68
+ ## Own labels
69
+
70
+ The labels are content. A surface that copies one specific thing should say which thing.
71
+
72
+ <Canvas of={CopyButtonStories.OwnLabels} />
73
+
74
+ <Canvas of={CopyButtonStories.AllAppearances} />
75
+
76
+ ## What it does not do
77
+
78
+ **It does not own the value.** No `children`-as-value, no reading text out of a sibling
79
+ node: the string is a prop, because the thing on screen is regularly not the thing worth
80
+ copying (a masked key, a truncated URL, a code split across two spans).
81
+
82
+ **It does not clear the timer for you and hope.** The confirmation timer is cancelled on
83
+ unmount and on a re-press — two of the four hand-rolled copies set state on an unmounted
84
+ component if you press and navigate away.