@charcuterie/ui 3.12.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.
@@ -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
package/src/index.ts CHANGED
@@ -63,6 +63,11 @@ export type {
63
63
  AlertSize,
64
64
  } from "./Alert/Alert.tsx"
65
65
  export { Alert } from "./Alert/Alert.tsx"
66
+ export type {
67
+ AvatarProps,
68
+ AvatarSize,
69
+ } from "./Avatar/Avatar.tsx"
70
+ export { Avatar } from "./Avatar/Avatar.tsx"
66
71
  export type { BadgeProps } from "./Badge/Badge.tsx"
67
72
  export { Badge } from "./Badge/Badge.tsx"
68
73
  export type { BadgeShapeProps } from "./Badge/useBadgeShape.tsx"
@@ -465,7 +465,15 @@ test("the barrel is the only place components are re-exported", async () => {
465
465
  // count by the `<Name>/<Name>.tsx` rule, and it IS exported from
466
466
  // the barrel as a function, because a host with its own clipboard
467
467
  // bridge needs the fallback chain without the button.
468
- expect(componentNames.length).toBe(54)
468
+ //
469
+ // `Avatar` — a person as a coloured chip, so a board stops
470
+ // printing the same name on every card — is +1 -> 55. It has no
471
+ // members: the initials derivation is a private function in
472
+ // `Avatar.tsx`, and the colour it falls back to is
473
+ // `getCategoricalIndex`, which already lives in
474
+ // `@charcuterie/tokens` because a Node service and a Satori render
475
+ // both need to answer the colour question with no React in scope.
476
+ expect(componentNames.length).toBe(55)
469
477
 
470
478
  for (const name of componentNames) {
471
479
  expect(barrel).toContain(`export { ${name} }`)