@charcuterie/ui 4.0.0 → 4.2.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/README.md +3 -2
- package/dist/ActionTiles/ActionTiles.d.ts +66 -8
- package/dist/ActionTiles/ActionTiles.d.ts.map +1 -1
- package/dist/ActionTiles/ActionTiles.js +88 -11
- package/dist/ActionTiles/ActionTiles.js.map +1 -1
- package/dist/PortraitTiles/PortraitAvatar.d.ts +21 -0
- package/dist/PortraitTiles/PortraitAvatar.d.ts.map +1 -0
- package/dist/PortraitTiles/PortraitAvatar.js +29 -0
- package/dist/PortraitTiles/PortraitAvatar.js.map +1 -0
- package/dist/PortraitTiles/PortraitTiles.d.ts +162 -0
- package/dist/PortraitTiles/PortraitTiles.d.ts.map +1 -0
- package/dist/PortraitTiles/PortraitTiles.js +263 -0
- package/dist/PortraitTiles/PortraitTiles.js.map +1 -0
- package/dist/PortraitTiles/portraitColour.d.ts +49 -0
- package/dist/PortraitTiles/portraitColour.d.ts.map +1 -0
- package/dist/PortraitTiles/portraitColour.js +55 -0
- package/dist/PortraitTiles/portraitColour.js.map +1 -0
- package/dist/RouterLink/UnstyledLink.d.ts +17 -0
- package/dist/RouterLink/UnstyledLink.d.ts.map +1 -0
- package/dist/RouterLink/UnstyledLink.js +24 -0
- package/dist/RouterLink/UnstyledLink.js.map +1 -0
- package/dist/categoricalStyles.d.ts +27 -0
- package/dist/categoricalStyles.d.ts.map +1 -1
- package/dist/categoricalStyles.js +49 -0
- package/dist/categoricalStyles.js.map +1 -1
- package/dist/index.d.ts +6 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/tileStyles.d.ts +15 -0
- package/dist/tileStyles.d.ts.map +1 -1
- package/dist/tileStyles.js +26 -1
- package/dist/tileStyles.js.map +1 -1
- package/package.json +2 -2
- package/src/ActionTiles/ActionTiles.mdx +82 -8
- package/src/ActionTiles/ActionTiles.stories.tsx +107 -2
- package/src/ActionTiles/ActionTiles.test.tsx +205 -5
- package/src/ActionTiles/ActionTiles.tsx +274 -110
- package/src/PortraitTiles/PortraitAvatar.tsx +49 -0
- package/src/PortraitTiles/PortraitTiles.mdx +153 -0
- package/src/PortraitTiles/PortraitTiles.stories.tsx +403 -0
- package/src/PortraitTiles/PortraitTiles.test.tsx +399 -0
- package/src/PortraitTiles/PortraitTiles.tsx +577 -0
- package/src/PortraitTiles/portraitColour.test.ts +39 -0
- package/src/PortraitTiles/portraitColour.ts +67 -0
- package/src/RadioGroup/RadioGroup.stories.tsx +39 -0
- package/src/RouterLink/RouterLink.mdx +25 -4
- package/src/RouterLink/UnstyledLink.stories.tsx +57 -0
- package/src/RouterLink/UnstyledLink.test.tsx +40 -0
- package/src/RouterLink/UnstyledLink.tsx +34 -0
- package/src/categoricalStyles.ts +57 -0
- package/src/index.ts +11 -0
- package/src/sourceRules.test.ts +17 -4
- package/src/tileStyles.ts +30 -1
|
@@ -1,10 +1,24 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
CategoricalIndex,
|
|
3
|
+
ControlSize,
|
|
4
|
+
} from "@charcuterie/tokens"
|
|
5
|
+
import {
|
|
6
|
+
CATEGORICAL_INDEX_COUNT,
|
|
7
|
+
CATEGORICAL_INDEXES,
|
|
8
|
+
} from "@charcuterie/tokens"
|
|
2
9
|
import type { CSSProperties, ReactNode } from "react"
|
|
3
10
|
|
|
11
|
+
import { getAccentEdgeClassName } from "../Card/cardAccentEdge.ts"
|
|
12
|
+
import {
|
|
13
|
+
CATEGORICAL_CONTENT_CLASS,
|
|
14
|
+
CATEGORICAL_HOVER_BORDER_CLASS,
|
|
15
|
+
CATEGORICAL_HOVER_CLASS,
|
|
16
|
+
} from "../categoricalStyles.ts"
|
|
4
17
|
import { FOCUS_RING_CLASS } from "../intentStyles.ts"
|
|
5
18
|
import { useRouterLink } from "../RouterLink/RouterLinkProvider.tsx"
|
|
6
19
|
import { getIsRoutedHref } from "../RouterLink/routerLink.ts"
|
|
7
20
|
import {
|
|
21
|
+
TILE_ACCENT_EDGE_PADDING_CLASS,
|
|
8
22
|
TILE_BOX_CLASS,
|
|
9
23
|
TILE_COLUMNS_CLASS,
|
|
10
24
|
TILE_GAP_CLASS,
|
|
@@ -17,6 +31,20 @@ import { toClassName } from "../toClassName.ts"
|
|
|
17
31
|
import { VisuallyHidden } from "../VisuallyHidden/VisuallyHidden.tsx"
|
|
18
32
|
|
|
19
33
|
export type ActionTileItem = {
|
|
34
|
+
/**
|
|
35
|
+
* Which of the ten hues this tile wears, overriding the one its
|
|
36
|
+
* position would have given it.
|
|
37
|
+
*
|
|
38
|
+
* Reach for it when a tile's colour means something outside the
|
|
39
|
+
* set — a destination that is red everywhere else in the app, a
|
|
40
|
+
* pair that must not drift apart when a third tile is inserted
|
|
41
|
+
* between them. An overridden tile keeps its hue and the rest go
|
|
42
|
+
* on walking the palette in order, so naming one does not force
|
|
43
|
+
* naming all of them.
|
|
44
|
+
*
|
|
45
|
+
* Ignored when the set is `accent="none"`.
|
|
46
|
+
*/
|
|
47
|
+
categorical?: CategoricalIndex
|
|
20
48
|
/**
|
|
21
49
|
* A line of help under the name. Drawn one step down the type ramp
|
|
22
50
|
* in `content-muted`, **inside** the tile — so `getByRole` computes
|
|
@@ -36,10 +64,15 @@ export type ActionTileItem = {
|
|
|
36
64
|
*/
|
|
37
65
|
href?: string
|
|
38
66
|
/**
|
|
39
|
-
* Leading content,
|
|
40
|
-
* count.
|
|
67
|
+
* Leading content, drawn **beside** the name rather than above it.
|
|
68
|
+
* A glyph, a `Badge`, a count.
|
|
69
|
+
*
|
|
70
|
+
* Takes the tile's hue, so an app passes a `stroke="currentColor"`
|
|
71
|
+
* path and states no colour of its own. Size is the app's: around
|
|
72
|
+
* 22px at `md` and 30px at `lg` reads the way the fleet's
|
|
73
|
+
* hand-rolled tool cards did.
|
|
41
74
|
*
|
|
42
|
-
* `aria-hidden` by construction: the name it sits
|
|
75
|
+
* `aria-hidden` by construction: the name it sits beside is inside
|
|
43
76
|
* the same tile, so an announced icon is the label read twice.
|
|
44
77
|
*/
|
|
45
78
|
icon?: ReactNode
|
|
@@ -57,7 +90,23 @@ export type ActionTileItem = {
|
|
|
57
90
|
value: string
|
|
58
91
|
}
|
|
59
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Where a tile's colour comes from.
|
|
95
|
+
*
|
|
96
|
+
* - `auto` — the set walks the ten-hue categorical palette in
|
|
97
|
+
* order, so eight tiles get eight colours and no call site does
|
|
98
|
+
* any work. An item may still name its own with `categorical`.
|
|
99
|
+
* - `none` — no bar, no hue, and the neutral `border-strong`
|
|
100
|
+
* hover every other box uses.
|
|
101
|
+
*/
|
|
102
|
+
export type ActionTilesAccent = "auto" | "none"
|
|
103
|
+
|
|
60
104
|
export type ActionTilesProps = {
|
|
105
|
+
/**
|
|
106
|
+
* Whether the set colours itself. `auto` by default — see
|
|
107
|
+
* {@link ActionTilesAccent}.
|
|
108
|
+
*/
|
|
109
|
+
accent?: ActionTilesAccent
|
|
61
110
|
className?: string
|
|
62
111
|
items: readonly ActionTileItem[]
|
|
63
112
|
/** The set's accessible name. Required. */
|
|
@@ -77,9 +126,51 @@ export type ActionTilesProps = {
|
|
|
77
126
|
}
|
|
78
127
|
|
|
79
128
|
/**
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
129
|
+
* The gap between the icon and the name it sits beside.
|
|
130
|
+
*
|
|
131
|
+
* Local to this component rather than in `tileStyles.ts`, because a
|
|
132
|
+
* radio tile's inner layout is a control beside a label and has no
|
|
133
|
+
* head row to space. The shared file owns the BOX; this is what goes
|
|
134
|
+
* in it.
|
|
135
|
+
*/
|
|
136
|
+
const HEAD_GAP_CLASS: Record<ControlSize, string> = {
|
|
137
|
+
sm: "gap-2",
|
|
138
|
+
md: "gap-2.5",
|
|
139
|
+
lg: "gap-3",
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The gap between the head row and the hint under it. */
|
|
143
|
+
const STACK_GAP_CLASS: Record<ControlSize, string> = {
|
|
144
|
+
sm: "gap-1",
|
|
145
|
+
md: "gap-1",
|
|
146
|
+
lg: "gap-2",
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The hue a tile wears when it did not name one: its position in
|
|
151
|
+
* the set, wrapping at ten.
|
|
152
|
+
*
|
|
153
|
+
* Position rather than `getCategoricalIndex(value)`. That helper
|
|
154
|
+
* hashes a key so a row keeps its colour forever across a list
|
|
155
|
+
* nobody controls the order of — right for Docket's labels, wrong
|
|
156
|
+
* here, because a hash of two tile values can collide and hand a
|
|
157
|
+
* two-tile set the same colour twice. A tile set is short, ordered,
|
|
158
|
+
* and written out in the source; the eye reads it as a row, so the
|
|
159
|
+
* palette is walked as one.
|
|
160
|
+
*/
|
|
161
|
+
const getTileCategorical = (
|
|
162
|
+
item: ActionTileItem,
|
|
163
|
+
position: number,
|
|
164
|
+
): CategoricalIndex =>
|
|
165
|
+
item.categorical ??
|
|
166
|
+
(CATEGORICAL_INDEXES[
|
|
167
|
+
position % CATEGORICAL_INDEX_COUNT
|
|
168
|
+
] as CategoricalIndex)
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* A set of ACTIONS drawn as tiles — a bordered card carrying a
|
|
172
|
+
* colour bar, an icon beside its name, and a line of help, in a
|
|
173
|
+
* grid that gains columns with its container.
|
|
83
174
|
*
|
|
84
175
|
* This is `RadioGroup`'s `itemShape="tile"` with the radio taken
|
|
85
176
|
* away. The
|
|
@@ -108,6 +199,30 @@ export type ActionTilesProps = {
|
|
|
108
199
|
* why QueuePilot's "Queue type" chooser is here and its Tonight
|
|
109
200
|
* activity filter is not.
|
|
110
201
|
*
|
|
202
|
+
* ### The colour is the point, and it is not decoration
|
|
203
|
+
*
|
|
204
|
+
* The first version of this component was neutral, and the owner's
|
|
205
|
+
* verdict on it was *"very boring, not colorful"*. Three apps had
|
|
206
|
+
* already answered the question themselves — mux-magic, Gallery
|
|
207
|
+
* Downloader and points-market all colour their tiles by hand — so
|
|
208
|
+
* the paint is now the library's and every one of them can stop.
|
|
209
|
+
*
|
|
210
|
+
* A tile wears a **bar down its leading edge**, drawn by `Card`'s
|
|
211
|
+
* accent-edge pseudo-element so a tile and a card on the same page
|
|
212
|
+
* are the same bar rather than two that nearly match. Its icon takes
|
|
213
|
+
* the same hue, and the box hovers in it too. The hue comes from the
|
|
214
|
+
* ten-wide categorical palette, taken **in order**, which is why a
|
|
215
|
+
* set of eight needs no colour props at all. See
|
|
216
|
+
* [the tile paint record](../../../../docs/decisions/2026-09-02-an-action-tile-is-coloured-and-the-icon-sits-beside-the-name.md)
|
|
217
|
+
* for the four other paints that were drawn and rejected.
|
|
218
|
+
*
|
|
219
|
+
* `categorical` and not `intent`, for the reason
|
|
220
|
+
* [`cardAccentEdge.ts`](../Card/cardAccentEdge.ts) gives: an intent
|
|
221
|
+
* is a *claim* — `danger` says what happens if you press the thing —
|
|
222
|
+
* and "Builder" beside "Jobs" is making no claim about either. Ten
|
|
223
|
+
* numbered hues say only "these are different from each other",
|
|
224
|
+
* which is exactly what a tile set means.
|
|
225
|
+
*
|
|
111
226
|
* ### Why this is not a `Button` with a `className`
|
|
112
227
|
*
|
|
113
228
|
* A `Button` is sized by `h-(--control-height-md)` and carries **no
|
|
@@ -130,6 +245,7 @@ export type ActionTilesProps = {
|
|
|
130
245
|
* ability to Tab to the second tile.
|
|
131
246
|
*/
|
|
132
247
|
export const ActionTiles = ({
|
|
248
|
+
accent = "auto",
|
|
133
249
|
className,
|
|
134
250
|
items,
|
|
135
251
|
label,
|
|
@@ -157,8 +273,8 @@ export const ActionTiles = ({
|
|
|
157
273
|
} as CSSProperties
|
|
158
274
|
}
|
|
159
275
|
>
|
|
160
|
-
{items.map(
|
|
161
|
-
|
|
276
|
+
{items.map((item, position) => {
|
|
277
|
+
const {
|
|
162
278
|
hint,
|
|
163
279
|
href,
|
|
164
280
|
icon,
|
|
@@ -166,127 +282,175 @@ export const ActionTiles = ({
|
|
|
166
282
|
isExternal = false,
|
|
167
283
|
label: tileLabel,
|
|
168
284
|
value,
|
|
169
|
-
}
|
|
170
|
-
const tileClassName = toClassName(
|
|
171
|
-
// `flex`, not `inline-flex`: a grid item stretches to its
|
|
172
|
-
// track, and an inline box would sit at max-content
|
|
173
|
-
// inside it. `min-w-0` with `wrap-anywhere` below,
|
|
174
|
-
// because a grid item's automatic minimum is its
|
|
175
|
-
// min-content width — one unbroken token would otherwise
|
|
176
|
-
// set the track's floor and shove the grid out of its
|
|
177
|
-
// container.
|
|
178
|
-
"flex min-w-0 flex-col gap-0.5 text-start wrap-anywhere transition-colors duration-(--duration-fast) ease-standard",
|
|
179
|
-
TILE_BOX_CLASS,
|
|
180
|
-
TILE_PADDING_CLASS[size],
|
|
181
|
-
TILE_TEXT_SIZE_CLASS[size],
|
|
182
|
-
// Disabled turns the whole tile down with `opacity-60`,
|
|
183
|
-
// the same family treatment as `Checkbox`, `Switch` and
|
|
184
|
-
// the radio tile, rather than fading the border to a
|
|
185
|
-
// token that disappeared on a pale theme.
|
|
186
|
-
isDisabled
|
|
187
|
-
? "cursor-not-allowed opacity-60"
|
|
188
|
-
: "cursor-pointer hover:border-border-strong",
|
|
189
|
-
FOCUS_RING_CLASS,
|
|
190
|
-
)
|
|
285
|
+
} = item
|
|
191
286
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
{icon}
|
|
197
|
-
</span>
|
|
198
|
-
)}
|
|
287
|
+
const categorical =
|
|
288
|
+
accent === "none"
|
|
289
|
+
? null
|
|
290
|
+
: getTileCategorical(item, position)
|
|
199
291
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
292
|
+
const tileClassName = toClassName(
|
|
293
|
+
// `flex`, not `inline-flex`: a grid item stretches to its
|
|
294
|
+
// track, and an inline box would sit at max-content
|
|
295
|
+
// inside it. `min-w-0` with `wrap-anywhere` below,
|
|
296
|
+
// because a grid item's automatic minimum is its
|
|
297
|
+
// min-content width — one unbroken token would otherwise
|
|
298
|
+
// set the track's floor and shove the grid out of its
|
|
299
|
+
// container.
|
|
300
|
+
"flex min-w-0 flex-col text-start wrap-anywhere transition-colors duration-(--duration-fast) ease-standard",
|
|
301
|
+
STACK_GAP_CLASS[size],
|
|
302
|
+
TILE_BOX_CLASS,
|
|
303
|
+
TILE_PADDING_CLASS[size],
|
|
304
|
+
TILE_TEXT_SIZE_CLASS[size],
|
|
305
|
+
// The bar is an overlay and takes no space in the box
|
|
306
|
+
// model, so the tile makes room for it or the text lands
|
|
307
|
+
// on top of it.
|
|
308
|
+
categorical === null
|
|
309
|
+
? null
|
|
310
|
+
: toClassName(
|
|
311
|
+
getAccentEdgeClassName({ categorical }),
|
|
312
|
+
TILE_ACCENT_EDGE_PADDING_CLASS[size],
|
|
313
|
+
),
|
|
314
|
+
// Disabled turns the whole tile down with `opacity-60`,
|
|
315
|
+
// the same family treatment as `Checkbox`, `Switch` and
|
|
316
|
+
// the radio tile, rather than fading the border to a
|
|
317
|
+
// token that disappeared on a pale theme. It also drops
|
|
318
|
+
// every hover class: a tile that cannot be pressed must
|
|
319
|
+
// not light up under the pointer.
|
|
320
|
+
isDisabled
|
|
321
|
+
? "cursor-not-allowed opacity-60"
|
|
322
|
+
: toClassName(
|
|
323
|
+
"cursor-pointer",
|
|
324
|
+
categorical === null
|
|
325
|
+
? "hover:border-border-strong"
|
|
326
|
+
: toClassName(
|
|
327
|
+
CATEGORICAL_HOVER_BORDER_CLASS[
|
|
328
|
+
categorical
|
|
329
|
+
],
|
|
330
|
+
CATEGORICAL_HOVER_CLASS[categorical]
|
|
331
|
+
.ghost,
|
|
332
|
+
),
|
|
333
|
+
),
|
|
334
|
+
FOCUS_RING_CLASS,
|
|
335
|
+
)
|
|
203
336
|
|
|
204
|
-
|
|
337
|
+
const content = (
|
|
338
|
+
<>
|
|
339
|
+
{/* The head row. The icon sits BESIDE the name rather
|
|
340
|
+
than above it, which is how all three apps that had
|
|
341
|
+
grown this card by hand drew it, and how the owner
|
|
342
|
+
asked for it back: "I like how Mux-Magic looks today
|
|
343
|
+
with the icon to the left of the title." */}
|
|
344
|
+
<span
|
|
345
|
+
className={toClassName(
|
|
346
|
+
"flex min-w-0 items-center",
|
|
347
|
+
HEAD_GAP_CLASS[size],
|
|
348
|
+
)}
|
|
349
|
+
>
|
|
350
|
+
{icon === undefined ? null : (
|
|
205
351
|
<span
|
|
352
|
+
aria-hidden
|
|
206
353
|
className={toClassName(
|
|
207
|
-
"
|
|
208
|
-
|
|
354
|
+
"flex shrink-0",
|
|
355
|
+
categorical === null
|
|
356
|
+
? null
|
|
357
|
+
: CATEGORICAL_CONTENT_CLASS[
|
|
358
|
+
categorical
|
|
359
|
+
],
|
|
209
360
|
)}
|
|
210
361
|
>
|
|
211
|
-
{
|
|
362
|
+
{icon}
|
|
212
363
|
</span>
|
|
213
364
|
)}
|
|
214
365
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
) : null}
|
|
220
|
-
</>
|
|
221
|
-
)
|
|
222
|
-
|
|
223
|
-
if (href !== undefined && !isDisabled) {
|
|
224
|
-
// An external destination or a fragment is never the
|
|
225
|
-
// router's — pushing `https://…` or `#credits` onto the
|
|
226
|
-
// history stack navigates the SPA to a route that does
|
|
227
|
-
// not exist. Same destination test as `ButtonLink`.
|
|
228
|
-
const LinkElement =
|
|
229
|
-
isExternal || !getIsRoutedHref(href)
|
|
230
|
-
? "a"
|
|
231
|
-
: RouterLink
|
|
366
|
+
<span className="min-w-0 font-semibold text-content-primary">
|
|
367
|
+
{tileLabel}
|
|
368
|
+
</span>
|
|
369
|
+
</span>
|
|
232
370
|
|
|
233
|
-
|
|
234
|
-
<
|
|
235
|
-
className={
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
// `noopener` closes the `window.opener` hole, and
|
|
240
|
-
// `noreferrer` is what keeps a private app's URL out
|
|
241
|
-
// of the destination's logs.
|
|
242
|
-
rel={
|
|
243
|
-
isExternal
|
|
244
|
-
? "noopener noreferrer"
|
|
245
|
-
: undefined
|
|
246
|
-
}
|
|
247
|
-
target={isExternal ? "_blank" : undefined}
|
|
371
|
+
{hint === undefined ? null : (
|
|
372
|
+
<span
|
|
373
|
+
className={toClassName(
|
|
374
|
+
"font-normal text-content-muted",
|
|
375
|
+
TILE_HINT_TEXT_CLASS[size],
|
|
376
|
+
)}
|
|
248
377
|
>
|
|
249
|
-
{
|
|
250
|
-
</
|
|
251
|
-
)
|
|
252
|
-
}
|
|
378
|
+
{hint}
|
|
379
|
+
</span>
|
|
380
|
+
)}
|
|
253
381
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
>
|
|
270
|
-
{content}
|
|
271
|
-
</a>
|
|
272
|
-
)
|
|
273
|
-
}
|
|
382
|
+
{isExternal && href !== undefined ? (
|
|
383
|
+
<VisuallyHidden>{newTabLabel}</VisuallyHidden>
|
|
384
|
+
) : null}
|
|
385
|
+
</>
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
if (href !== undefined && !isDisabled) {
|
|
389
|
+
// An external destination or a fragment is never the
|
|
390
|
+
// router's — pushing `https://…` or `#credits` onto the
|
|
391
|
+
// history stack navigates the SPA to a route that does
|
|
392
|
+
// not exist. Same destination test as `ButtonLink`.
|
|
393
|
+
const LinkElement =
|
|
394
|
+
isExternal || !getIsRoutedHref(href)
|
|
395
|
+
? "a"
|
|
396
|
+
: RouterLink
|
|
274
397
|
|
|
275
398
|
return (
|
|
276
|
-
<
|
|
399
|
+
<LinkElement
|
|
277
400
|
className={tileClassName}
|
|
278
|
-
|
|
401
|
+
href={href}
|
|
279
402
|
key={value}
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
403
|
+
// `noreferrer` alongside `noopener` on purpose:
|
|
404
|
+
// `noopener` closes the `window.opener` hole, and
|
|
405
|
+
// `noreferrer` is what keeps a private app's URL out
|
|
406
|
+
// of the destination's logs.
|
|
407
|
+
rel={
|
|
408
|
+
isExternal
|
|
409
|
+
? "noopener noreferrer"
|
|
410
|
+
: undefined
|
|
411
|
+
}
|
|
412
|
+
target={isExternal ? "_blank" : undefined}
|
|
284
413
|
>
|
|
285
414
|
{content}
|
|
286
|
-
</
|
|
415
|
+
</LinkElement>
|
|
287
416
|
)
|
|
288
|
-
}
|
|
289
|
-
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (href !== undefined) {
|
|
420
|
+
// `isDisabled` drops `href` entirely rather than shipping
|
|
421
|
+
// a focusable anchor that silently ignores clicks — an
|
|
422
|
+
// anchor with no `href` is inert by the platform's own
|
|
423
|
+
// rules, and the explicit `role`/`aria-disabled` keeps it
|
|
424
|
+
// *announced* as a link that is currently unavailable
|
|
425
|
+
// rather than vanishing from the tree. `ButtonLink`'s
|
|
426
|
+
// rule, unchanged.
|
|
427
|
+
return (
|
|
428
|
+
// biome-ignore lint/a11y/useValidAnchor: dropping `href` is the point. A disabled anchor that keeps one is focusable and silently ignores clicks; without one it is inert and out of the tab order by the platform's own rules, and the explicit `role`/`aria-disabled` keeps it announced rather than removed from the tree. `ButtonLink` makes the same call.
|
|
429
|
+
<a
|
|
430
|
+
aria-disabled="true"
|
|
431
|
+
className={tileClassName}
|
|
432
|
+
key={value}
|
|
433
|
+
role="link"
|
|
434
|
+
>
|
|
435
|
+
{content}
|
|
436
|
+
</a>
|
|
437
|
+
)
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
return (
|
|
441
|
+
<button
|
|
442
|
+
className={tileClassName}
|
|
443
|
+
disabled={isDisabled}
|
|
444
|
+
key={value}
|
|
445
|
+
onClick={() => {
|
|
446
|
+
onChoose?.(value)
|
|
447
|
+
}}
|
|
448
|
+
type="button"
|
|
449
|
+
>
|
|
450
|
+
{content}
|
|
451
|
+
</button>
|
|
452
|
+
)
|
|
453
|
+
})}
|
|
290
454
|
</div>
|
|
291
455
|
)
|
|
292
456
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { ReactNode } from "react"
|
|
2
|
+
import { useState } from "react"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The face, and the one piece of this component that holds state.
|
|
6
|
+
*
|
|
7
|
+
* A portrait grid is the shape that cannot absorb a broken image:
|
|
8
|
+
* a missing `<img>` is a torn hole where a person's face was, next
|
|
9
|
+
* to nine that rendered. `onError` is the only signal a browser
|
|
10
|
+
* gives for a 404, a wrong content type, or a file the user's
|
|
11
|
+
* network stripped — so the fallback the caller already supplied for
|
|
12
|
+
* "no picture" covers "no picture *today*" as well.
|
|
13
|
+
*
|
|
14
|
+
* Its own component because the state is per portrait. Hoisting it
|
|
15
|
+
* to a `Record<string, boolean>` in the set would re-render every
|
|
16
|
+
* tile whenever any one image failed.
|
|
17
|
+
*/
|
|
18
|
+
export const PortraitAvatar = ({
|
|
19
|
+
className,
|
|
20
|
+
imageSrc,
|
|
21
|
+
initials,
|
|
22
|
+
}: {
|
|
23
|
+
className: string
|
|
24
|
+
imageSrc: string | undefined
|
|
25
|
+
initials: ReactNode
|
|
26
|
+
}): ReactNode => {
|
|
27
|
+
const [isImageBroken, setIsImageBroken] = useState(false)
|
|
28
|
+
|
|
29
|
+
if (imageSrc === undefined || isImageBroken) {
|
|
30
|
+
return (
|
|
31
|
+
<span aria-hidden className={className}>
|
|
32
|
+
{initials}
|
|
33
|
+
</span>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<img
|
|
39
|
+
// The name is beside it, inside the same tile, so an alt here
|
|
40
|
+
// is the name announced twice.
|
|
41
|
+
alt=""
|
|
42
|
+
className={className}
|
|
43
|
+
onError={() => {
|
|
44
|
+
setIsImageBroken(true)
|
|
45
|
+
}}
|
|
46
|
+
src={imageSrc}
|
|
47
|
+
/>
|
|
48
|
+
)
|
|
49
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
|
|
2
|
+
|
|
3
|
+
import * as PortraitTilesStories from "./PortraitTiles.stories";
|
|
4
|
+
|
|
5
|
+
<Meta of={PortraitTilesStories} />
|
|
6
|
+
|
|
7
|
+
# PortraitTiles
|
|
8
|
+
|
|
9
|
+
A set of **people** — or of anything else picked by its face — drawn as round pictures with a
|
|
10
|
+
name and one big number.
|
|
11
|
+
|
|
12
|
+
points-market's "Who's shopping?" is the shape this is taken from. It had been hand-rolled
|
|
13
|
+
there since the app was built, and it is the third tile shape the fleet grew on its own,
|
|
14
|
+
after mux-magic's and Gallery Downloader's "Pick a tool" cards.
|
|
15
|
+
|
|
16
|
+
<Canvas of={PortraitTilesStories.Default} />
|
|
17
|
+
|
|
18
|
+
## Props
|
|
19
|
+
|
|
20
|
+
<Controls />
|
|
21
|
+
|
|
22
|
+
## Why this is not an `ActionTiles` variant
|
|
23
|
+
|
|
24
|
+
The two answer different questions.
|
|
25
|
+
|
|
26
|
+
- [`ActionTiles`](/docs/components-actions-actiontiles--docs) asks **what do you want to
|
|
27
|
+
do** — a name, a line of help, an icon that illustrates a verb.
|
|
28
|
+
- This asks **who is this** — a face, and one number that decides it. There is no
|
|
29
|
+
description, because a person is not explained.
|
|
30
|
+
|
|
31
|
+
Collapsing them would mean one component whose `icon` is sometimes a 20px glyph beside the
|
|
32
|
+
title and sometimes a 144px circle above it, with `hint` meaning "what this does" in one mode
|
|
33
|
+
and "points" in the other. That is two components wearing one name.
|
|
34
|
+
|
|
35
|
+
What they **do** share is the box: `tileStyles.ts` holds the border, the radius, the surface
|
|
36
|
+
and the `auto-fill` grid, and both read it. A page carrying both does not show two different
|
|
37
|
+
cards.
|
|
38
|
+
|
|
39
|
+
## It scales, and the scaling is the point
|
|
40
|
+
|
|
41
|
+
Two axes carry it, and neither is a pixel count in an app.
|
|
42
|
+
|
|
43
|
+
**The container.** `layout="auto"` is a row while the set is narrow and a column once it has
|
|
44
|
+
room, off a `cq-sm` (24rem) query on the set itself. Nothing reads the viewport, so a picker
|
|
45
|
+
in a 320px sidebar on a 2560px monitor gets the row form — which is right, and which a media
|
|
46
|
+
query would have got wrong.
|
|
47
|
+
|
|
48
|
+
**The density.** Every length is a token, so `data-density="kiosk"` grows the type ramp
|
|
49
|
+
underneath it: `text-2xl` is 30px at `comfortable` and 38px at `kiosk`. points-market runs
|
|
50
|
+
`kiosk` today, and that is where its big numbers come from.
|
|
51
|
+
|
|
52
|
+
<Canvas of={PortraitTilesStories.Responsive} />
|
|
53
|
+
|
|
54
|
+
The container and the element querying it **cannot be the same box** — a container query
|
|
55
|
+
matches descendants only. The set renders a wrapper for that reason, the same arrangement
|
|
56
|
+
`EmptyState` uses, and `className` stays on the outer element where a consumer expects it.
|
|
57
|
+
|
|
58
|
+
<Canvas of={PortraitTilesStories.AllVariants} />
|
|
59
|
+
|
|
60
|
+
## The colour is the person's, not the position's
|
|
61
|
+
|
|
62
|
+
A portrait takes the next hue from the ten-wide
|
|
63
|
+
[categorical palette](/docs/foundations-colour--docs) when it names none, which costs a call
|
|
64
|
+
site nothing and is the right default for a list assembled on the fly.
|
|
65
|
+
|
|
66
|
+
It is the **wrong** default for a household. Add a fifth member and the first four change
|
|
67
|
+
colour — and their colour was the thing the picker was being read by, faster than the names
|
|
68
|
+
were. Any set whose subjects persist should store a `categorical` per subject and pass it,
|
|
69
|
+
the same call Docket makes for a project.
|
|
70
|
+
|
|
71
|
+
`categorical` and not `intent`: an intent is a claim the design system makes — `danger` says
|
|
72
|
+
what happens if you press the thing — and a person is not a warning.
|
|
73
|
+
|
|
74
|
+
<Canvas of={PortraitTilesStories.NamedHues} />
|
|
75
|
+
|
|
76
|
+
### When the colour is not ours to choose
|
|
77
|
+
|
|
78
|
+
Some colours are not a design decision at all. points-market colours each child to match the
|
|
79
|
+
**NFC card they physically tap**, by a settled household decision, so the picker on the wall
|
|
80
|
+
and the card in their hand have to agree. Ten audited hues cannot promise that, and
|
|
81
|
+
re-theming the colour would be re-theming the card.
|
|
82
|
+
|
|
83
|
+
That is what `color` is for, and it is the same line
|
|
84
|
+
[`Swatch`](/docs/components-data-swatch--docs) draws: a colour that arrived from the world is
|
|
85
|
+
data, not a token. It reaches the paint through custom properties on the tile, because
|
|
86
|
+
Tailwind generates its CSS at build time from source text and a colour out of a database is
|
|
87
|
+
not in the source.
|
|
88
|
+
|
|
89
|
+
Three things happen to that one colour, and only the first uses it raw.
|
|
90
|
+
|
|
91
|
+
- The **fill** is the colour, exactly.
|
|
92
|
+
- The **initials** are black or white, whichever the fill can carry — which is why the third
|
|
93
|
+
portrait below has white letters and the other two have black. Pass six-digit hex:
|
|
94
|
+
`getReadableTextColour` measures hex and nothing else, and falls back to white on anything
|
|
95
|
+
it cannot parse rather than throwing inside your render.
|
|
96
|
+
- The **number** is the colour mixed toward the scheme's own text colour, so a pale card
|
|
97
|
+
colour is still readable on a pale surface — and, in the dark scheme, is pulled the other
|
|
98
|
+
way instead.
|
|
99
|
+
|
|
100
|
+
Reach for this arm only when the colour is a **fact about the subject**. A colour that is
|
|
101
|
+
merely a choice is `categorical`, and it gets re-theming and a contrast guarantee for free.
|
|
102
|
+
|
|
103
|
+
<Canvas of={PortraitTilesStories.DataColours} />
|
|
104
|
+
|
|
105
|
+
## A picture, and what happens when it 404s
|
|
106
|
+
|
|
107
|
+
`imageSrc` draws a photo; `initials` draws a letter on the hue's solid fill. A portrait grid
|
|
108
|
+
is the shape that cannot absorb a broken image — a missing `<img>` is a torn hole where a
|
|
109
|
+
face was, next to three that loaded — so a load error falls back to the initials the caller
|
|
110
|
+
already supplied.
|
|
111
|
+
|
|
112
|
+
The `<img>` is `alt=""` on purpose. The name is beside it inside the same tile, so an alt
|
|
113
|
+
here is the name announced twice.
|
|
114
|
+
|
|
115
|
+
<Canvas of={PortraitTilesStories.WithPictures} />
|
|
116
|
+
|
|
117
|
+
## The keyboard is the platform's
|
|
118
|
+
|
|
119
|
+
`href` makes a portrait a real `<a href>`, routed through the injected `RouterLink` for a
|
|
120
|
+
same-origin destination — so middle-click, ctrl-click, "open in a new tab" and "copy link
|
|
121
|
+
address" come from the element rather than from the paint. Without one it is a `<button>`
|
|
122
|
+
that calls `onChoose`. A set may mix the two.
|
|
123
|
+
|
|
124
|
+
Tab reaches **each** portrait. There is no roving tabindex, for the same reason `ActionTiles`
|
|
125
|
+
has none: a radio group borrows one tab stop because exactly one of its options is true at a
|
|
126
|
+
time, and nothing here is true.
|
|
127
|
+
|
|
128
|
+
The group takes a required `label` naming the set — "Who's shopping?" — and `stat` is drawn
|
|
129
|
+
inside the tile with its `hint` so both are part of the accessible name. "1,240" on its own
|
|
130
|
+
tells a screen-reader user nothing.
|
|
131
|
+
|
|
132
|
+
<Canvas of={PortraitTilesStories.AllStates} />
|
|
133
|
+
|
|
134
|
+
## The lift, and who does not get it
|
|
135
|
+
|
|
136
|
+
Hovering raises the portrait and casts a shadow. It is the one piece of motion in this
|
|
137
|
+
library, kept because it is the thing the owner pointed at in points-market when this moved
|
|
138
|
+
in here.
|
|
139
|
+
|
|
140
|
+
`motion-reduce:hover:translate-y-0` turns the movement off for anyone who has asked the OS
|
|
141
|
+
for less. The shadow and the coloured border stay, so the hover is still legible without it.
|
|
142
|
+
|
|
143
|
+
<Canvas of={PortraitTilesStories.Interactive} />
|
|
144
|
+
|
|
145
|
+
## What this deliberately does not do
|
|
146
|
+
|
|
147
|
+
- **Hold a selection.** Nothing here is checked; the page moved on. A picker that keeps a
|
|
148
|
+
value is `RadioGroup itemShape="tile"`.
|
|
149
|
+
- **Guarantee contrast on a `color` you supply.** The palette arm is audited in both schemes;
|
|
150
|
+
the data arm cannot be, because the colour is not ours. The initials are picked to suit the
|
|
151
|
+
fill and the number is pulled toward readable, and that is the whole of what it can promise.
|
|
152
|
+
- **Crop or focus a face.** `object-fit: cover` centres, and that is all. An app that needs a
|
|
153
|
+
focal point owns the crop.
|