@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.
- package/dist/Avatar/Avatar.d.ts +163 -0
- package/dist/Avatar/Avatar.d.ts.map +1 -0
- package/dist/Avatar/Avatar.js +139 -0
- package/dist/Avatar/Avatar.js.map +1 -0
- package/dist/Board/BoardLaneList.d.ts +29 -0
- package/dist/Board/BoardLaneList.d.ts.map +1 -1
- package/dist/Board/BoardLaneList.js +2 -1
- package/dist/Board/BoardLaneList.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/Avatar/Avatar.mdx +92 -0
- package/src/Avatar/Avatar.stories.tsx +254 -0
- package/src/Avatar/Avatar.test.tsx +143 -0
- package/src/Avatar/Avatar.tsx +270 -0
- package/src/Board/Board.mdx +26 -0
- package/src/Board/Board.stories.tsx +47 -21
- package/src/Board/Board.test.tsx +62 -0
- package/src/Board/BoardLaneList.tsx +52 -1
- package/src/index.ts +5 -0
- package/src/sourceRules.test.ts +9 -1
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react"
|
|
2
|
+
|
|
3
|
+
import { categoricalArgType } from "../argTypes.storyHelpers.ts"
|
|
4
|
+
import {
|
|
5
|
+
StoryCell,
|
|
6
|
+
StoryGrid,
|
|
7
|
+
StoryRow,
|
|
8
|
+
StorySection,
|
|
9
|
+
} from "../board.storyHelpers.tsx"
|
|
10
|
+
import { Card } from "../Card/Card.tsx"
|
|
11
|
+
import { Avatar } from "./Avatar.tsx"
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* **Invented, every one of them.** The component was built for a
|
|
15
|
+
* household task board, so its real fixture is a list of the people
|
|
16
|
+
* who live there — which must never reach a published repo and
|
|
17
|
+
* doubly never a committed screenshot, because a PNG is opaque to
|
|
18
|
+
* every grep and no text scrub can redact it.
|
|
19
|
+
*
|
|
20
|
+
* So this is a fictional studio's roster: two-word names, a mononym,
|
|
21
|
+
* a name whose first character is outside the BMP, and a
|
|
22
|
+
* non-human account.
|
|
23
|
+
*/
|
|
24
|
+
const ROSTER = [
|
|
25
|
+
{ id: "user-1", name: "Ada Lovelace" },
|
|
26
|
+
{ id: "user-2", name: "Wren Okonkwo" },
|
|
27
|
+
{ id: "user-3", name: "Bo" },
|
|
28
|
+
{ id: "user-4", name: "Ines Marchetti" },
|
|
29
|
+
{ id: "user-5", name: "Tomas Ericsson" },
|
|
30
|
+
] as const
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A photo that needs no network and no fixture file. The initials
|
|
34
|
+
* behind it are what the reader sees the moment it is absent, which
|
|
35
|
+
* is the state that actually matters.
|
|
36
|
+
*/
|
|
37
|
+
const PORTRAIT_URL = `data:image/svg+xml;utf8,${encodeURIComponent(
|
|
38
|
+
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" fill="#2F6F4E"/><circle cx="32" cy="24" r="12" fill="#F2E8CF"/><rect x="12" y="40" width="40" height="28" rx="14" fill="#F2E8CF"/></svg>',
|
|
39
|
+
)}`
|
|
40
|
+
|
|
41
|
+
/** 404s, which is what makes the fallback real rather than mocked. */
|
|
42
|
+
const MISSING_PORTRAIT_URL =
|
|
43
|
+
"/charcuterie-missing-portrait.png"
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A stand-in for an app's own glyph set. The library ships none, so
|
|
47
|
+
* a story that wants one has to bring it — and a letter is the safe
|
|
48
|
+
* choice in a sandbox whose font may lack the symbol.
|
|
49
|
+
*/
|
|
50
|
+
const AgentGlyph = () => <span>{"AI"}</span>
|
|
51
|
+
|
|
52
|
+
const meta = {
|
|
53
|
+
title: "Components/Data/Avatar",
|
|
54
|
+
component: Avatar,
|
|
55
|
+
parameters: { layout: "padded" },
|
|
56
|
+
argTypes: { categorical: categoricalArgType },
|
|
57
|
+
args: {
|
|
58
|
+
appearance: "soft",
|
|
59
|
+
categorical: undefined,
|
|
60
|
+
categoricalKey: undefined,
|
|
61
|
+
icon: undefined,
|
|
62
|
+
imageUrl: undefined,
|
|
63
|
+
initials: undefined,
|
|
64
|
+
name: "Ada Lovelace",
|
|
65
|
+
size: "md",
|
|
66
|
+
},
|
|
67
|
+
} satisfies Meta<typeof Avatar>
|
|
68
|
+
|
|
69
|
+
export default meta
|
|
70
|
+
|
|
71
|
+
type Story = StoryObj<typeof meta>
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Two letters on a colour nobody chose. The name is on the chip for
|
|
75
|
+
* a screen reader and on hover for a pointer, and it is not printed
|
|
76
|
+
* anywhere — `getByRole("img", { name: "Ada Lovelace" })` resolves.
|
|
77
|
+
*/
|
|
78
|
+
export const Default: Story = {}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The three appearances, and the ten colours the scale holds.
|
|
82
|
+
*
|
|
83
|
+
* `soft` is the default because a lane of solid chips competes with
|
|
84
|
+
* the card titles beside them. `solid` is for a chip that is the
|
|
85
|
+
* only colour in its row; `outline` is the quiet one.
|
|
86
|
+
*/
|
|
87
|
+
export const AllVariants: Story = {
|
|
88
|
+
render: (avatarProps) => (
|
|
89
|
+
<StorySection title="The colour is a hash of the key, so the same person is the same colour on every machine and every reload.">
|
|
90
|
+
<StoryGrid columns={3}>
|
|
91
|
+
{(["soft", "outline", "solid"] as const).map(
|
|
92
|
+
(appearance) => (
|
|
93
|
+
<StoryCell key={appearance} label={appearance}>
|
|
94
|
+
<StoryRow>
|
|
95
|
+
{ROSTER.map((person) => (
|
|
96
|
+
<Avatar
|
|
97
|
+
{...avatarProps}
|
|
98
|
+
appearance={appearance}
|
|
99
|
+
categoricalKey={person.id}
|
|
100
|
+
key={person.id}
|
|
101
|
+
name={person.name}
|
|
102
|
+
/>
|
|
103
|
+
))}
|
|
104
|
+
</StoryRow>
|
|
105
|
+
</StoryCell>
|
|
106
|
+
),
|
|
107
|
+
)}
|
|
108
|
+
</StoryGrid>
|
|
109
|
+
</StorySection>
|
|
110
|
+
),
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Everything the chip can be showing, including the two that are
|
|
115
|
+
* easy to get wrong: a photo that does not load, and nobody at all.
|
|
116
|
+
*/
|
|
117
|
+
export const AllStates: Story = {
|
|
118
|
+
render: (avatarProps) => (
|
|
119
|
+
<StoryGrid columns={2}>
|
|
120
|
+
<StoryCell label="sizes">
|
|
121
|
+
<StoryRow>
|
|
122
|
+
<Avatar {...avatarProps} size="sm" />
|
|
123
|
+
|
|
124
|
+
<Avatar {...avatarProps} size="md" />
|
|
125
|
+
|
|
126
|
+
<Avatar {...avatarProps} size="lg" />
|
|
127
|
+
</StoryRow>
|
|
128
|
+
</StoryCell>
|
|
129
|
+
|
|
130
|
+
<StoryCell label="a mononym gets one letter">
|
|
131
|
+
<Avatar {...avatarProps} name="Bo" />
|
|
132
|
+
</StoryCell>
|
|
133
|
+
|
|
134
|
+
<StoryCell label="a glyph instead of the letters">
|
|
135
|
+
<Avatar
|
|
136
|
+
{...avatarProps}
|
|
137
|
+
icon={<AgentGlyph />}
|
|
138
|
+
name="Agentic"
|
|
139
|
+
/>
|
|
140
|
+
</StoryCell>
|
|
141
|
+
|
|
142
|
+
<StoryCell label="a chosen colour beats the hash">
|
|
143
|
+
<StoryRow>
|
|
144
|
+
<Avatar
|
|
145
|
+
{...avatarProps}
|
|
146
|
+
categorical={4}
|
|
147
|
+
name="Wren Okonkwo"
|
|
148
|
+
/>
|
|
149
|
+
|
|
150
|
+
<Avatar
|
|
151
|
+
{...avatarProps}
|
|
152
|
+
categorical={9}
|
|
153
|
+
name="Ines Marchetti"
|
|
154
|
+
/>
|
|
155
|
+
</StoryRow>
|
|
156
|
+
</StoryCell>
|
|
157
|
+
|
|
158
|
+
<StoryCell label="a photo, clipped to the circle">
|
|
159
|
+
<Avatar
|
|
160
|
+
{...avatarProps}
|
|
161
|
+
imageUrl={PORTRAIT_URL}
|
|
162
|
+
name="Tomas Ericsson"
|
|
163
|
+
size="lg"
|
|
164
|
+
/>
|
|
165
|
+
</StoryCell>
|
|
166
|
+
|
|
167
|
+
<StoryCell label="a photo that 404s falls back to the letters">
|
|
168
|
+
<Avatar
|
|
169
|
+
{...avatarProps}
|
|
170
|
+
imageUrl={MISSING_PORTRAIT_URL}
|
|
171
|
+
name="Kit Sandoval"
|
|
172
|
+
size="lg"
|
|
173
|
+
/>
|
|
174
|
+
</StoryCell>
|
|
175
|
+
|
|
176
|
+
<StoryCell label="unassigned draws nothing at all">
|
|
177
|
+
{/*
|
|
178
|
+
* There is no chip in this cell, and that is the assertion.
|
|
179
|
+
* A grey "None" placeholder is a fact nobody needed,
|
|
180
|
+
* repeated down every unassigned row at the same weight as
|
|
181
|
+
* the chips that mean something.
|
|
182
|
+
*/}
|
|
183
|
+
<Avatar {...avatarProps} name={null} />
|
|
184
|
+
</StoryCell>
|
|
185
|
+
</StoryGrid>
|
|
186
|
+
),
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The placement it was built for: a row per task, the chip answering
|
|
191
|
+
* "whose is this?" without spending a column on the word.
|
|
192
|
+
*
|
|
193
|
+
* The middle row is unassigned. It draws nothing, so the row is
|
|
194
|
+
* shorter by exactly one chip and says nothing false.
|
|
195
|
+
*/
|
|
196
|
+
export const InAList: Story = {
|
|
197
|
+
render: () => (
|
|
198
|
+
<Card heading="Today">
|
|
199
|
+
<ul className="flex list-none flex-col gap-2 p-0">
|
|
200
|
+
{[
|
|
201
|
+
{
|
|
202
|
+
assignee: ROSTER[0],
|
|
203
|
+
title:
|
|
204
|
+
"Fold the second scheduler into the broker",
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
assignee: undefined,
|
|
208
|
+
title: "Fingerprint duplicate uploads",
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
assignee: ROSTER[1],
|
|
212
|
+
title: "One composed Storybook for the fleet",
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
assignee: ROSTER[3],
|
|
216
|
+
title: "Retire the legacy rest_command bridge",
|
|
217
|
+
},
|
|
218
|
+
].map((task) => (
|
|
219
|
+
<li
|
|
220
|
+
/*
|
|
221
|
+
* The chip's slot is **reserved by the layout**, in `em`
|
|
222
|
+
* of the row's own type, and the unassigned row simply
|
|
223
|
+
* leaves it empty. That is the composition the "draws
|
|
224
|
+
* nothing" rule asks for: a grid column keeps every
|
|
225
|
+
* title on one start edge without the design system
|
|
226
|
+
* inventing a grey placeholder to hold the space open.
|
|
227
|
+
*
|
|
228
|
+
* The wrapper around the chip is what makes it work. A
|
|
229
|
+
* component that returns `null` contributes **no grid
|
|
230
|
+
* item**, so without it the title of an unassigned row
|
|
231
|
+
* slides into the first column — which is the one way
|
|
232
|
+
* this composition goes wrong, and it goes wrong
|
|
233
|
+
* silently.
|
|
234
|
+
*/
|
|
235
|
+
className="grid grid-cols-[2em_1fr] items-center gap-3"
|
|
236
|
+
key={task.title}
|
|
237
|
+
>
|
|
238
|
+
<span className="justify-self-center">
|
|
239
|
+
<Avatar
|
|
240
|
+
categoricalKey={task.assignee?.id}
|
|
241
|
+
name={task.assignee?.name}
|
|
242
|
+
size="sm"
|
|
243
|
+
/>
|
|
244
|
+
</span>
|
|
245
|
+
|
|
246
|
+
<span className="text-content-secondary text-sm">
|
|
247
|
+
{task.title}
|
|
248
|
+
</span>
|
|
249
|
+
</li>
|
|
250
|
+
))}
|
|
251
|
+
</ul>
|
|
252
|
+
</Card>
|
|
253
|
+
),
|
|
254
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { composeStories } from "@storybook/react"
|
|
2
|
+
import { expect, waitFor } from "storybook/test"
|
|
3
|
+
import { test } from "vitest"
|
|
4
|
+
|
|
5
|
+
import { expectNoAxeViolations } from "../expectNoAxeViolations.testHelpers.ts"
|
|
6
|
+
import { mountStory } from "../mountStory.testHelpers.ts"
|
|
7
|
+
import { expectAgentDrivable } from "../testing/index.ts"
|
|
8
|
+
import * as stories from "./Avatar.stories.tsx"
|
|
9
|
+
|
|
10
|
+
const { AllStates, AllVariants, Default, InAList } =
|
|
11
|
+
composeStories(stories)
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The whole contract in one test: the name reaches assistive
|
|
15
|
+
* technology and a pointer, and reaches the page as text **nowhere
|
|
16
|
+
* at all**. The component exists to delete a printed "Owner" from
|
|
17
|
+
* every card on a board, so a name that leaks back into the DOM as
|
|
18
|
+
* text is the one regression that matters.
|
|
19
|
+
*/
|
|
20
|
+
test("the name is readable by an agent and never printed", async () => {
|
|
21
|
+
const { canvas } = await mountStory(Default)
|
|
22
|
+
|
|
23
|
+
const avatar = expectAgentDrivable(canvas, {
|
|
24
|
+
name: "Ada Lovelace",
|
|
25
|
+
role: "img",
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
await expect(avatar).toHaveAttribute(
|
|
29
|
+
"title",
|
|
30
|
+
"Ada Lovelace",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
// The letters are what is on screen, and they are hidden from the
|
|
34
|
+
// accessibility tree so the chip is announced once.
|
|
35
|
+
await expect(canvas.getByText("AL")).toHaveAttribute(
|
|
36
|
+
"aria-hidden",
|
|
37
|
+
"true",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
await expect(
|
|
41
|
+
canvas.queryByText("Ada Lovelace"),
|
|
42
|
+
).toBeNull()
|
|
43
|
+
|
|
44
|
+
await expectNoAxeViolations(avatar)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Unassigned draws nothing — not a grey placeholder, not an empty
|
|
49
|
+
* circle, not a whitespace-sized gap that shifts the row.
|
|
50
|
+
*
|
|
51
|
+
* Asserted through the story rather than by rendering the component
|
|
52
|
+
* directly, because the failure this prevents is a *layout* one: a
|
|
53
|
+
* row with no assignee has to look like a row with no assignee.
|
|
54
|
+
*/
|
|
55
|
+
test("an unassigned avatar renders nothing at all", async () => {
|
|
56
|
+
const { canvas } = await mountStory(AllStates)
|
|
57
|
+
|
|
58
|
+
// Nine chips are storied and a tenth is `name={null}`.
|
|
59
|
+
await expect(canvas.getAllByRole("img")).toHaveLength(9)
|
|
60
|
+
|
|
61
|
+
await expect(canvas.queryByText("None")).toBeNull()
|
|
62
|
+
|
|
63
|
+
// And the same in the list placement: four rows, three chips.
|
|
64
|
+
const list = await mountStory(InAList)
|
|
65
|
+
|
|
66
|
+
await expect(
|
|
67
|
+
list.canvas.getAllByRole("listitem"),
|
|
68
|
+
).toHaveLength(4)
|
|
69
|
+
|
|
70
|
+
await expect(
|
|
71
|
+
list.canvas.getAllByRole("img"),
|
|
72
|
+
).toHaveLength(3)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A photo is never the only thing. An expired signed URL, a 404, or
|
|
77
|
+
* an app with no photos at all lands on the initials rather than on
|
|
78
|
+
* a broken-image glyph — which is what a hand-rolled avatar shows,
|
|
79
|
+
* because nobody writes the `onError` until they see it.
|
|
80
|
+
*/
|
|
81
|
+
test("a photo that fails falls back to the initials", async () => {
|
|
82
|
+
const { canvas } = await mountStory(AllStates)
|
|
83
|
+
|
|
84
|
+
const broken = expectAgentDrivable(canvas, {
|
|
85
|
+
name: "Kit Sandoval",
|
|
86
|
+
role: "img",
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
await waitFor(async () => {
|
|
90
|
+
await expect(broken.querySelector("img")).toBeNull()
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
await expect(broken).toHaveTextContent("KS")
|
|
94
|
+
|
|
95
|
+
// The photo that *does* load stays a photo, so the fallback is not
|
|
96
|
+
// simply "the image never renders".
|
|
97
|
+
const loaded = expectAgentDrivable(canvas, {
|
|
98
|
+
name: "Tomas Ericsson",
|
|
99
|
+
role: "img",
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
await expect(loaded.querySelector("img")).not.toBeNull()
|
|
103
|
+
|
|
104
|
+
// The image carries no name of its own — the chip already has one,
|
|
105
|
+
// and a second copy is how an avatar gets announced twice.
|
|
106
|
+
await expect(loaded.querySelector("img")).toHaveAttribute(
|
|
107
|
+
"alt",
|
|
108
|
+
"",
|
|
109
|
+
)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The colour is a pure function of the key, so the same person is
|
|
114
|
+
* the same colour in every appearance, on every machine, and in a
|
|
115
|
+
* server render. A colour assigned at random on first render is a
|
|
116
|
+
* different colour on the next reload, which is the failure mode
|
|
117
|
+
* `getCategoricalIndex` exists to prevent.
|
|
118
|
+
*/
|
|
119
|
+
test("one person is one colour, and a chosen index wins", async () => {
|
|
120
|
+
const { canvas } = await mountStory(AllVariants)
|
|
121
|
+
|
|
122
|
+
const indexes = new Set(
|
|
123
|
+
canvas
|
|
124
|
+
.getAllByRole("img", { name: "Ada Lovelace" })
|
|
125
|
+
.map(
|
|
126
|
+
(avatar: HTMLElement) =>
|
|
127
|
+
/categorical-(\d+)-/.exec(avatar.className)?.[1],
|
|
128
|
+
),
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
await expect(indexes.size).toBe(1)
|
|
132
|
+
|
|
133
|
+
await expect(indexes.has(undefined)).toBe(false)
|
|
134
|
+
|
|
135
|
+
// A stored pick is not a hash — it is passed, and it wins.
|
|
136
|
+
const states = await mountStory(AllStates)
|
|
137
|
+
|
|
138
|
+
await expect(
|
|
139
|
+
states.canvas.getByRole("img", {
|
|
140
|
+
name: "Wren Okonkwo",
|
|
141
|
+
}),
|
|
142
|
+
).toHaveClass("bg-categorical-4-surface")
|
|
143
|
+
})
|
|
@@ -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
|
+
}
|
package/src/Board/Board.mdx
CHANGED
|
@@ -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.
|