@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
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CategoricalIndex,
|
|
3
|
+
ControlSize,
|
|
4
|
+
} from "@charcuterie/tokens"
|
|
5
|
+
import {
|
|
6
|
+
CATEGORICAL_INDEX_COUNT,
|
|
7
|
+
CATEGORICAL_INDEXES,
|
|
8
|
+
} from "@charcuterie/tokens"
|
|
9
|
+
import type { CSSProperties, ReactNode } from "react"
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
CATEGORICAL_APPEARANCE_CLASS,
|
|
13
|
+
CATEGORICAL_CONTENT_CLASS,
|
|
14
|
+
CATEGORICAL_HOVER_BORDER_CLASS,
|
|
15
|
+
CATEGORICAL_RING_CLASS,
|
|
16
|
+
} from "../categoricalStyles.ts"
|
|
17
|
+
import { FOCUS_RING_CLASS } from "../intentStyles.ts"
|
|
18
|
+
import { useRouterLink } from "../RouterLink/RouterLinkProvider.tsx"
|
|
19
|
+
import { getIsRoutedHref } from "../RouterLink/routerLink.ts"
|
|
20
|
+
import {
|
|
21
|
+
TILE_BOX_CLASS,
|
|
22
|
+
TILE_COLUMNS_CLASS,
|
|
23
|
+
TILE_MIN_INLINE_SIZE_PROPERTY,
|
|
24
|
+
} from "../tileStyles.ts"
|
|
25
|
+
import { toClassName } from "../toClassName.ts"
|
|
26
|
+
import { VisuallyHidden } from "../VisuallyHidden/VisuallyHidden.tsx"
|
|
27
|
+
import { PortraitAvatar } from "./PortraitAvatar.tsx"
|
|
28
|
+
import { getPortraitColourProperties } from "./portraitColour.ts"
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Where one portrait's colour comes from. Two arms, and the fleet
|
|
32
|
+
* has both — the same split
|
|
33
|
+
* [`cardAccentEdge.ts`](../Card/cardAccentEdge.ts) already draws,
|
|
34
|
+
* for the same reason.
|
|
35
|
+
*
|
|
36
|
+
* - **`categorical`** — one of the ten audited hues. A colour the
|
|
37
|
+
* library owns, re-themed by `data-variant`, gated in both
|
|
38
|
+
* schemes. Right whenever the colour is a *choice from what we
|
|
39
|
+
* offered*.
|
|
40
|
+
* - **`color`** — any CSS colour, from data. Right when the colour
|
|
41
|
+
* **arrived from the world** and re-theming it would be lying
|
|
42
|
+
* about a physical object. points-market is exactly this: each
|
|
43
|
+
* kid's colour matches the NFC card they tap, by the workspace's
|
|
44
|
+
* 2026-07-20 `kid-identity-colors-match-nfc-cards` record. Ten
|
|
45
|
+
* palette hues cannot represent that, and swapping them in would
|
|
46
|
+
* have made the app disagree with the cards on the fridge.
|
|
47
|
+
*
|
|
48
|
+
* Neither is a default. A portrait that names no colour takes the
|
|
49
|
+
* next `categorical` hue by position.
|
|
50
|
+
*/
|
|
51
|
+
type PortraitTileColour =
|
|
52
|
+
| { categorical?: CategoricalIndex; color?: never }
|
|
53
|
+
| { categorical?: never; color: string }
|
|
54
|
+
|
|
55
|
+
export type PortraitTileItem = PortraitTileColour & {
|
|
56
|
+
/**
|
|
57
|
+
* The quiet line under the number, naming its unit — "points",
|
|
58
|
+
* "episodes left", "due today".
|
|
59
|
+
*
|
|
60
|
+
* Inside the tile, so it is part of the accessible name. "1,240"
|
|
61
|
+
* on its own tells a screen-reader user nothing.
|
|
62
|
+
*/
|
|
63
|
+
hint?: ReactNode
|
|
64
|
+
/** Makes the portrait a real `<a href>`. Same rules as `ActionTiles`. */
|
|
65
|
+
href?: string
|
|
66
|
+
/**
|
|
67
|
+
* A picture of the subject. Falls back to {@link initials} when
|
|
68
|
+
* absent, and **also** when the file 404s — a broken portrait grid
|
|
69
|
+
* is the one failure this shape cannot absorb.
|
|
70
|
+
*/
|
|
71
|
+
imageSrc?: string
|
|
72
|
+
/**
|
|
73
|
+
* Drawn in the hue's solid fill when there is no picture. One or
|
|
74
|
+
* two characters; longer strings are the caller's problem to
|
|
75
|
+
* shorten, because "which letters" is a naming question this
|
|
76
|
+
* component cannot answer (a mononym, a family that shares an
|
|
77
|
+
* initial).
|
|
78
|
+
*/
|
|
79
|
+
initials?: string
|
|
80
|
+
isDisabled?: boolean
|
|
81
|
+
/** Opens in a new tab, and says so to a screen reader. `href` only. */
|
|
82
|
+
isExternal?: boolean
|
|
83
|
+
/** The name. */
|
|
84
|
+
label: ReactNode
|
|
85
|
+
/**
|
|
86
|
+
* The one number this portrait is chosen by — a balance, a count,
|
|
87
|
+
* a streak. Optional: a picker that is only ever "who are you"
|
|
88
|
+
* passes none and the tile closes up around the name.
|
|
89
|
+
*/
|
|
90
|
+
stat?: ReactNode
|
|
91
|
+
/** Identity. The React key, and what `onChoose` is handed. */
|
|
92
|
+
value: string
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* How a portrait arranges itself.
|
|
97
|
+
*
|
|
98
|
+
* - `auto` — a **row** while the set is narrow, a **column** once
|
|
99
|
+
* it has room. The switch is a container query on the set, not a
|
|
100
|
+
* media query: a picker in a 320px sidebar is a list of rows on a
|
|
101
|
+
* 2560px monitor, and the same picker filling a phone is a column
|
|
102
|
+
* per screenful.
|
|
103
|
+
* - `row` / `column` — fixed, for a layout that has already decided.
|
|
104
|
+
*/
|
|
105
|
+
export type PortraitTilesLayout = "auto" | "column" | "row"
|
|
106
|
+
|
|
107
|
+
export type PortraitTilesProps = {
|
|
108
|
+
className?: string
|
|
109
|
+
items: readonly PortraitTileItem[]
|
|
110
|
+
/** The set's accessible name. Required. */
|
|
111
|
+
label: string
|
|
112
|
+
layout?: PortraitTilesLayout
|
|
113
|
+
/** The narrowest a portrait track may be, in CSS px. */
|
|
114
|
+
minTileInlineSize?: number
|
|
115
|
+
/** What `isExternal` announces. Not shown. */
|
|
116
|
+
newTabLabel?: string
|
|
117
|
+
/** Fired by a portrait that carries no `href`. */
|
|
118
|
+
onChoose?: (value: string) => void
|
|
119
|
+
size?: ControlSize
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The two forms, spelled as complete class strings rather than
|
|
124
|
+
* assembled.
|
|
125
|
+
*
|
|
126
|
+
* `cq-sm` is 24rem. Below it the set is one narrow column — a phone,
|
|
127
|
+
* a sidebar — and a 144px face would push the number off the bottom
|
|
128
|
+
* of the fold; above it there is room for the shape points-market
|
|
129
|
+
* built, which is what its kiosk screen shows today.
|
|
130
|
+
*
|
|
131
|
+
* The `auto` strings carry BOTH forms, because a container query
|
|
132
|
+
* variant is a media query in the stylesheet and cannot be chosen in
|
|
133
|
+
* JavaScript. `layout="row"` and `layout="column"` exist so a caller
|
|
134
|
+
* that already knows can skip the query rather than fight it.
|
|
135
|
+
*/
|
|
136
|
+
const TILE_LAYOUT_CLASS: Record<
|
|
137
|
+
PortraitTilesLayout,
|
|
138
|
+
string
|
|
139
|
+
> = {
|
|
140
|
+
auto: "flex-row items-center text-start cq-sm:flex-col cq-sm:items-center cq-sm:text-center",
|
|
141
|
+
column: "flex-col items-center text-center",
|
|
142
|
+
row: "flex-row items-center text-start",
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const TEXT_LAYOUT_CLASS: Record<
|
|
146
|
+
PortraitTilesLayout,
|
|
147
|
+
string
|
|
148
|
+
> = {
|
|
149
|
+
auto: "items-start cq-sm:items-center",
|
|
150
|
+
column: "items-center",
|
|
151
|
+
row: "items-start",
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const TILE_PADDING_CLASS: Record<
|
|
155
|
+
PortraitTilesLayout,
|
|
156
|
+
string
|
|
157
|
+
> = {
|
|
158
|
+
auto: "p-4 cq-sm:p-6",
|
|
159
|
+
column: "p-6",
|
|
160
|
+
row: "p-4",
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Every length here moves together, and none of them is a pixel
|
|
165
|
+
* count in an app.
|
|
166
|
+
*
|
|
167
|
+
* The face grows when the set does, and the type ramp does the rest
|
|
168
|
+
* on its own — `text-2xl` is 30px at `comfortable` and 38px at
|
|
169
|
+
* `kiosk`, which is how points-market's wall-mounted picker gets its
|
|
170
|
+
* size back without a single override. That the density axis already
|
|
171
|
+
* carries this is the reason the owner's "it is meant to scale" does
|
|
172
|
+
* not need a fourth size here.
|
|
173
|
+
*/
|
|
174
|
+
const AVATAR_SIZE_CLASS: Record<
|
|
175
|
+
ControlSize,
|
|
176
|
+
Record<PortraitTilesLayout, string>
|
|
177
|
+
> = {
|
|
178
|
+
sm: {
|
|
179
|
+
auto: "size-12 cq-sm:size-20",
|
|
180
|
+
column: "size-20",
|
|
181
|
+
row: "size-12",
|
|
182
|
+
},
|
|
183
|
+
md: {
|
|
184
|
+
auto: "size-16 cq-sm:size-28",
|
|
185
|
+
column: "size-28",
|
|
186
|
+
row: "size-16",
|
|
187
|
+
},
|
|
188
|
+
lg: {
|
|
189
|
+
auto: "size-20 cq-sm:size-36",
|
|
190
|
+
column: "size-36",
|
|
191
|
+
row: "size-20",
|
|
192
|
+
},
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The initials, sized to the circle they sit in.
|
|
197
|
+
*
|
|
198
|
+
* Its own map rather than a `calc()` off the avatar size, because
|
|
199
|
+
* the size arrives as a Tailwind class and there is no number here
|
|
200
|
+
* to compute from. Inheriting the tile's text size — which is what
|
|
201
|
+
* the first version did — put a 17px letter in the middle of a 144px
|
|
202
|
+
* circle and looked like a bug in the picture loader.
|
|
203
|
+
*
|
|
204
|
+
* The ramp stops at `2xl`, so the largest column avatar is
|
|
205
|
+
* deliberately a letter with air around it rather than a letter that
|
|
206
|
+
* fills the disc. Going past the ramp for one glyph would be the
|
|
207
|
+
* hardcoded length every other size in this file avoids.
|
|
208
|
+
*/
|
|
209
|
+
const AVATAR_TEXT_CLASS: Record<
|
|
210
|
+
ControlSize,
|
|
211
|
+
Record<PortraitTilesLayout, string>
|
|
212
|
+
> = {
|
|
213
|
+
sm: {
|
|
214
|
+
auto: "text-md cq-sm:text-xl",
|
|
215
|
+
column: "text-xl",
|
|
216
|
+
row: "text-md",
|
|
217
|
+
},
|
|
218
|
+
md: {
|
|
219
|
+
auto: "text-lg cq-sm:text-2xl",
|
|
220
|
+
column: "text-2xl",
|
|
221
|
+
row: "text-lg",
|
|
222
|
+
},
|
|
223
|
+
lg: {
|
|
224
|
+
auto: "text-xl cq-sm:text-2xl",
|
|
225
|
+
column: "text-2xl",
|
|
226
|
+
row: "text-xl",
|
|
227
|
+
},
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const NAME_TEXT_CLASS: Record<ControlSize, string> = {
|
|
231
|
+
sm: "text-sm",
|
|
232
|
+
md: "text-md",
|
|
233
|
+
lg: "text-lg",
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const STAT_TEXT_CLASS: Record<
|
|
237
|
+
ControlSize,
|
|
238
|
+
Record<PortraitTilesLayout, string>
|
|
239
|
+
> = {
|
|
240
|
+
sm: {
|
|
241
|
+
auto: "text-lg cq-sm:text-xl",
|
|
242
|
+
column: "text-xl",
|
|
243
|
+
row: "text-lg",
|
|
244
|
+
},
|
|
245
|
+
md: {
|
|
246
|
+
auto: "text-xl cq-sm:text-2xl",
|
|
247
|
+
column: "text-2xl",
|
|
248
|
+
row: "text-xl",
|
|
249
|
+
},
|
|
250
|
+
lg: {
|
|
251
|
+
auto: "text-2xl cq-sm:text-2xl",
|
|
252
|
+
column: "text-2xl",
|
|
253
|
+
row: "text-2xl",
|
|
254
|
+
},
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const GAP_CLASS: Record<ControlSize, string> = {
|
|
258
|
+
sm: "gap-3",
|
|
259
|
+
md: "gap-4",
|
|
260
|
+
lg: "gap-5",
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Positional hue, wrapping at ten. Same rule `ActionTiles` uses.
|
|
265
|
+
*
|
|
266
|
+
* `null` when the portrait brought its own `color` — there is no
|
|
267
|
+
* palette index to fall back to in that arm, and picking one anyway
|
|
268
|
+
* would paint a second, unrelated hue onto the same tile.
|
|
269
|
+
*/
|
|
270
|
+
const getPortraitCategorical = (
|
|
271
|
+
item: PortraitTileItem,
|
|
272
|
+
position: number,
|
|
273
|
+
): CategoricalIndex | null =>
|
|
274
|
+
item.color === undefined
|
|
275
|
+
? (item.categorical ??
|
|
276
|
+
(CATEGORICAL_INDEXES[
|
|
277
|
+
position % CATEGORICAL_INDEX_COUNT
|
|
278
|
+
] as CategoricalIndex))
|
|
279
|
+
: null
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* The classes that read the four custom properties, spelled whole.
|
|
283
|
+
*
|
|
284
|
+
* Tailwind scans SOURCE TEXT for complete class strings, so these
|
|
285
|
+
* cannot be built from a variable — and the failure is silent CSS
|
|
286
|
+
* that never gets generated rather than an error anywhere.
|
|
287
|
+
*/
|
|
288
|
+
const COLOUR_TILE_HOVER_CLASS =
|
|
289
|
+
"hover:border-(--charcuterie-portrait-fill)"
|
|
290
|
+
|
|
291
|
+
const COLOUR_AVATAR_CLASS =
|
|
292
|
+
"bg-(--charcuterie-portrait-fill) text-(--charcuterie-portrait-initials) ring-(--charcuterie-portrait-halo) inset-ring-1 inset-ring-black/10"
|
|
293
|
+
|
|
294
|
+
const COLOUR_STAT_CLASS =
|
|
295
|
+
"text-(--charcuterie-portrait-stat)"
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* A set of PEOPLE — or of anything else picked by its face — drawn
|
|
299
|
+
* as round pictures with a name and one big number.
|
|
300
|
+
*
|
|
301
|
+
* points-market's "Who's shopping?" is the shape this is taken from,
|
|
302
|
+
* and it is deliberately **not** an `ActionTiles` variant. The two
|
|
303
|
+
* answer different questions:
|
|
304
|
+
*
|
|
305
|
+
* - `ActionTiles` asks *what do you want to do* — a name, a line of
|
|
306
|
+
* help, an icon that illustrates the verb.
|
|
307
|
+
* - This asks *who is this* — a face, and one number that decides
|
|
308
|
+
* it. There is no description, because a person is not explained.
|
|
309
|
+
*
|
|
310
|
+
* Collapsing them would mean one component whose icon is sometimes a
|
|
311
|
+
* 20px glyph beside the title and sometimes a 144px circle above it,
|
|
312
|
+
* with `hint` meaning "what this does" in one mode and "points" in
|
|
313
|
+
* the other. That is two components wearing one name, which is the
|
|
314
|
+
* mistake the
|
|
315
|
+
* [2026-08-25 record](../../../../docs/decisions/2026-08-25-a-choice-tile-is-a-radiogroup-shape-not-a-third-component.md)
|
|
316
|
+
* warns about from the other direction.
|
|
317
|
+
*
|
|
318
|
+
* What they DO share is the box — `TILE_BOX_CLASS` and the same
|
|
319
|
+
* `auto-fill` grid — so a page carrying both does not show two
|
|
320
|
+
* different cards.
|
|
321
|
+
*
|
|
322
|
+
* ### It scales, and the scaling is the point
|
|
323
|
+
*
|
|
324
|
+
* The owner's one requirement when this moved into the library:
|
|
325
|
+
* *"that one is meant to scale. I don't wanna lose that
|
|
326
|
+
* functionality."* Two axes carry it.
|
|
327
|
+
*
|
|
328
|
+
* 1. **The container.** `layout="auto"` is a row while the set is
|
|
329
|
+
* narrow and a column once it has room, off a `cq-sm` query on
|
|
330
|
+
* the set itself. Nothing reads the viewport, so the same picker
|
|
331
|
+
* works in a sidebar and on a wall.
|
|
332
|
+
* 2. **The density.** Every length is a token, so `data-density="kiosk"`
|
|
333
|
+
* grows the type ramp and the control heights under it. points-market
|
|
334
|
+
* runs `kiosk` today; that is where its big numbers come from,
|
|
335
|
+
* and it keeps them.
|
|
336
|
+
*
|
|
337
|
+
* ### The colour is the person's, not the position's
|
|
338
|
+
*
|
|
339
|
+
* A positional hue is the default because it costs a call site
|
|
340
|
+
* nothing. It is also the wrong default for a household: add a
|
|
341
|
+
* fifth member and the first four change colour, which is exactly
|
|
342
|
+
* the identity the picker was using to be fast. Any set whose
|
|
343
|
+
* subjects persist should name its colour per subject.
|
|
344
|
+
*
|
|
345
|
+
* There are two ways to name one, and the difference is where the
|
|
346
|
+
* colour came from:
|
|
347
|
+
*
|
|
348
|
+
* - **`categorical`** — an index into the ten audited hues. A
|
|
349
|
+
* choice from what the library offered, so the library keeps
|
|
350
|
+
* owning it: re-themed by `data-variant`, contrast-gated in both
|
|
351
|
+
* schemes. The same call Docket makes for a project.
|
|
352
|
+
* - **`color`** — any CSS colour, straight from data. For a colour
|
|
353
|
+
* the system does **not** own and cannot re-theme without lying:
|
|
354
|
+
* points-market's kids are coloured to match the NFC cards they
|
|
355
|
+
* tap, so the picker and the cards on the fridge have to agree,
|
|
356
|
+
* and ten palette hues cannot promise that.
|
|
357
|
+
*
|
|
358
|
+
* The second arm is the narrow one. Reach for it only when the
|
|
359
|
+
* colour is a fact about the subject rather than a decision about
|
|
360
|
+
* the design — the same line `Swatch` draws, for the same reason.
|
|
361
|
+
*/
|
|
362
|
+
export const PortraitTiles = ({
|
|
363
|
+
className,
|
|
364
|
+
items,
|
|
365
|
+
label,
|
|
366
|
+
layout = "auto",
|
|
367
|
+
minTileInlineSize = 200,
|
|
368
|
+
newTabLabel = "(opens in a new tab)",
|
|
369
|
+
onChoose,
|
|
370
|
+
size = "md",
|
|
371
|
+
}: PortraitTilesProps): ReactNode => {
|
|
372
|
+
const RouterLink = useRouterLink()
|
|
373
|
+
|
|
374
|
+
return (
|
|
375
|
+
// The container and the thing querying it cannot be the same
|
|
376
|
+
// element — a container query matches descendants only, so
|
|
377
|
+
// `@container cq-sm:flex-col` on one box silently never fires
|
|
378
|
+
// and the layout stays a row forever with nothing to report it.
|
|
379
|
+
// `EmptyState` hit this first and its comment says the same.
|
|
380
|
+
<div className={toClassName("@container", className)}>
|
|
381
|
+
{/* biome-ignore lint/a11y/useSemanticElements: the semantic element for `group` is `<fieldset>`, which is a form-control grouping — it drags `<legend>` semantics, a border and form-reset behaviour onto a set of links that is not a form. Same call `ActionTiles` and `Menu`'s group already made. */}
|
|
382
|
+
<div
|
|
383
|
+
aria-label={label}
|
|
384
|
+
className={toClassName(
|
|
385
|
+
"grid",
|
|
386
|
+
TILE_COLUMNS_CLASS,
|
|
387
|
+
GAP_CLASS[size],
|
|
388
|
+
)}
|
|
389
|
+
role="group"
|
|
390
|
+
style={
|
|
391
|
+
{
|
|
392
|
+
[TILE_MIN_INLINE_SIZE_PROPERTY]: `${minTileInlineSize}px`,
|
|
393
|
+
} as CSSProperties
|
|
394
|
+
}
|
|
395
|
+
>
|
|
396
|
+
{items.map((item, position) => {
|
|
397
|
+
const {
|
|
398
|
+
hint,
|
|
399
|
+
href,
|
|
400
|
+
imageSrc,
|
|
401
|
+
initials,
|
|
402
|
+
isDisabled = false,
|
|
403
|
+
isExternal = false,
|
|
404
|
+
label: tileLabel,
|
|
405
|
+
stat,
|
|
406
|
+
value,
|
|
407
|
+
} = item
|
|
408
|
+
|
|
409
|
+
const categorical = getPortraitCategorical(
|
|
410
|
+
item,
|
|
411
|
+
position,
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
const { color } = item
|
|
415
|
+
|
|
416
|
+
const tileClassName = toClassName(
|
|
417
|
+
"flex min-w-0 wrap-anywhere transition-[background-color,border-color,box-shadow,transform] duration-(--duration-fast) ease-standard",
|
|
418
|
+
TILE_BOX_CLASS,
|
|
419
|
+
TILE_LAYOUT_CLASS[layout],
|
|
420
|
+
TILE_PADDING_CLASS[layout],
|
|
421
|
+
GAP_CLASS[size],
|
|
422
|
+
isDisabled
|
|
423
|
+
? "cursor-not-allowed opacity-60"
|
|
424
|
+
: toClassName(
|
|
425
|
+
"cursor-pointer hover:shadow-medium hover:-translate-y-1",
|
|
426
|
+
// The lift is the one piece of motion in this
|
|
427
|
+
// library, kept because it is what the owner
|
|
428
|
+
// pointed at in points-market — and turned off
|
|
429
|
+
// for anyone who has asked the OS for less. The
|
|
430
|
+
// shadow and the border stay, so the hover is
|
|
431
|
+
// still legible without it.
|
|
432
|
+
"motion-reduce:hover:translate-y-0",
|
|
433
|
+
categorical === null
|
|
434
|
+
? COLOUR_TILE_HOVER_CLASS
|
|
435
|
+
: CATEGORICAL_HOVER_BORDER_CLASS[
|
|
436
|
+
categorical
|
|
437
|
+
],
|
|
438
|
+
),
|
|
439
|
+
FOCUS_RING_CLASS,
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
const avatarClassName = toClassName(
|
|
443
|
+
"flex shrink-0 items-center justify-center overflow-hidden rounded-full object-cover font-display font-semibold ring-4",
|
|
444
|
+
AVATAR_SIZE_CLASS[size][layout],
|
|
445
|
+
AVATAR_TEXT_CLASS[size][layout],
|
|
446
|
+
categorical === null
|
|
447
|
+
? COLOUR_AVATAR_CLASS
|
|
448
|
+
: toClassName(
|
|
449
|
+
CATEGORICAL_APPEARANCE_CLASS[categorical]
|
|
450
|
+
.solid,
|
|
451
|
+
CATEGORICAL_RING_CLASS[categorical],
|
|
452
|
+
),
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
// The one sanctioned escape hatch for a runtime value,
|
|
456
|
+
// and the same one `Swatch` uses: a colour out of a
|
|
457
|
+
// database cannot be a class, because Tailwind generates
|
|
458
|
+
// its CSS at build time from source text.
|
|
459
|
+
const tileStyle =
|
|
460
|
+
color === undefined
|
|
461
|
+
? undefined
|
|
462
|
+
: (getPortraitColourProperties(
|
|
463
|
+
color,
|
|
464
|
+
) as CSSProperties)
|
|
465
|
+
|
|
466
|
+
const content = (
|
|
467
|
+
<>
|
|
468
|
+
<PortraitAvatar
|
|
469
|
+
className={avatarClassName}
|
|
470
|
+
imageSrc={imageSrc}
|
|
471
|
+
initials={initials}
|
|
472
|
+
/>
|
|
473
|
+
|
|
474
|
+
<span
|
|
475
|
+
className={toClassName(
|
|
476
|
+
"flex min-w-0 flex-col",
|
|
477
|
+
TEXT_LAYOUT_CLASS[layout],
|
|
478
|
+
)}
|
|
479
|
+
>
|
|
480
|
+
<span
|
|
481
|
+
className={toClassName(
|
|
482
|
+
"font-semibold text-content-primary",
|
|
483
|
+
NAME_TEXT_CLASS[size],
|
|
484
|
+
)}
|
|
485
|
+
>
|
|
486
|
+
{tileLabel}
|
|
487
|
+
</span>
|
|
488
|
+
|
|
489
|
+
{stat === undefined ? null : (
|
|
490
|
+
<span
|
|
491
|
+
className={toClassName(
|
|
492
|
+
"font-bold tabular-nums",
|
|
493
|
+
STAT_TEXT_CLASS[size][layout],
|
|
494
|
+
categorical === null
|
|
495
|
+
? COLOUR_STAT_CLASS
|
|
496
|
+
: CATEGORICAL_CONTENT_CLASS[
|
|
497
|
+
categorical
|
|
498
|
+
],
|
|
499
|
+
)}
|
|
500
|
+
>
|
|
501
|
+
{stat}
|
|
502
|
+
</span>
|
|
503
|
+
)}
|
|
504
|
+
|
|
505
|
+
{hint === undefined ? null : (
|
|
506
|
+
<span className="text-xs text-content-muted">
|
|
507
|
+
{hint}
|
|
508
|
+
</span>
|
|
509
|
+
)}
|
|
510
|
+
</span>
|
|
511
|
+
|
|
512
|
+
{isExternal && href !== undefined ? (
|
|
513
|
+
<VisuallyHidden>
|
|
514
|
+
{newTabLabel}
|
|
515
|
+
</VisuallyHidden>
|
|
516
|
+
) : null}
|
|
517
|
+
</>
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
if (href !== undefined && !isDisabled) {
|
|
521
|
+
const LinkElement =
|
|
522
|
+
isExternal || !getIsRoutedHref(href)
|
|
523
|
+
? "a"
|
|
524
|
+
: RouterLink
|
|
525
|
+
|
|
526
|
+
return (
|
|
527
|
+
<LinkElement
|
|
528
|
+
className={tileClassName}
|
|
529
|
+
href={href}
|
|
530
|
+
key={value}
|
|
531
|
+
rel={
|
|
532
|
+
isExternal
|
|
533
|
+
? "noopener noreferrer"
|
|
534
|
+
: undefined
|
|
535
|
+
}
|
|
536
|
+
style={tileStyle}
|
|
537
|
+
target={isExternal ? "_blank" : undefined}
|
|
538
|
+
>
|
|
539
|
+
{content}
|
|
540
|
+
</LinkElement>
|
|
541
|
+
)
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
if (href !== undefined) {
|
|
545
|
+
return (
|
|
546
|
+
// 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 by the platform's own rules, and the explicit `role`/`aria-disabled` keeps it announced. `ActionTiles` and `ButtonLink` make the same call.
|
|
547
|
+
<a
|
|
548
|
+
aria-disabled="true"
|
|
549
|
+
className={tileClassName}
|
|
550
|
+
key={value}
|
|
551
|
+
role="link"
|
|
552
|
+
style={tileStyle}
|
|
553
|
+
>
|
|
554
|
+
{content}
|
|
555
|
+
</a>
|
|
556
|
+
)
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
return (
|
|
560
|
+
<button
|
|
561
|
+
className={tileClassName}
|
|
562
|
+
disabled={isDisabled}
|
|
563
|
+
key={value}
|
|
564
|
+
onClick={() => {
|
|
565
|
+
onChoose?.(value)
|
|
566
|
+
}}
|
|
567
|
+
style={tileStyle}
|
|
568
|
+
type="button"
|
|
569
|
+
>
|
|
570
|
+
{content}
|
|
571
|
+
</button>
|
|
572
|
+
)
|
|
573
|
+
})}
|
|
574
|
+
</div>
|
|
575
|
+
</div>
|
|
576
|
+
)
|
|
577
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { expect, test } from "vitest"
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
getPortraitColourProperties,
|
|
5
|
+
PORTRAIT_FILL_PROPERTY,
|
|
6
|
+
PORTRAIT_HALO_PROPERTY,
|
|
7
|
+
PORTRAIT_INITIALS_PROPERTY,
|
|
8
|
+
PORTRAIT_STAT_PROPERTY,
|
|
9
|
+
} from "./portraitColour.ts"
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Four properties out of one colour, and each of them is read by a
|
|
13
|
+
* class that Tailwind generated from a literal. A wrong name here is
|
|
14
|
+
* not an error anywhere — the property is simply never read and the
|
|
15
|
+
* portrait paints with no fill at all, which looks like an image
|
|
16
|
+
* that failed to load.
|
|
17
|
+
*/
|
|
18
|
+
test("one colour becomes the four properties the paint reads", () => {
|
|
19
|
+
const properties = getPortraitColourProperties("#8FD3F4")
|
|
20
|
+
|
|
21
|
+
expect(properties[PORTRAIT_FILL_PROPERTY]).toBe("#8FD3F4")
|
|
22
|
+
|
|
23
|
+
// Pale fill, so the letters go dark. `getReadableTextColour` owns
|
|
24
|
+
// that choice and `contrast.test.ts` covers it.
|
|
25
|
+
expect(properties[PORTRAIT_INITIALS_PROPERTY]).toBe(
|
|
26
|
+
"#000000",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
// A wash of the hue, standing in for `categorical-N-surface`, and
|
|
30
|
+
// the number pulled toward the scheme's own text colour so it
|
|
31
|
+
// moves with the scheme instead of staying pale on a pale surface.
|
|
32
|
+
expect(properties[PORTRAIT_HALO_PROPERTY]).toBe(
|
|
33
|
+
"color-mix(in srgb, #8FD3F4 28%, transparent)",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
expect(properties[PORTRAIT_STAT_PROPERTY]).toBe(
|
|
37
|
+
"color-mix(in srgb, #8FD3F4 62%, var(--color-content-primary))",
|
|
38
|
+
)
|
|
39
|
+
})
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The paint for a portrait whose colour came from DATA rather than
|
|
3
|
+
* from the palette.
|
|
4
|
+
*
|
|
5
|
+
* `Swatch` already argues the case this file serves: a colour that
|
|
6
|
+
* "arrived from the world" — a sticker, an extracted album accent,
|
|
7
|
+
* the NFC card a child taps — is not a token, so it cannot be
|
|
8
|
+
* re-themed and cannot be a Tailwind class. It reaches the paint as
|
|
9
|
+
* custom properties on the tile, and the classes read those
|
|
10
|
+
* properties.
|
|
11
|
+
*
|
|
12
|
+
* Four properties rather than one, because the same hue has to do
|
|
13
|
+
* four jobs and only one of them can use it raw. Each is named for
|
|
14
|
+
* the PART it paints, following `--charcuterie-accent-edge` — a
|
|
15
|
+
* property called `-colour` would say only what type it holds, which
|
|
16
|
+
* is the one thing already obvious from where it is used:
|
|
17
|
+
*
|
|
18
|
+
* - the **fill** behind the initials — the colour itself, exactly;
|
|
19
|
+
* - the **initials** on top of it — black or white, whichever the
|
|
20
|
+
* fill can actually carry;
|
|
21
|
+
* - the **halo** around the face — a transparent wash of the hue,
|
|
22
|
+
* standing in for what `categorical-N-surface` is on the palette
|
|
23
|
+
* side;
|
|
24
|
+
* - the **number** — the hue pulled toward the scheme's own text
|
|
25
|
+
* colour, so a pale card colour stays readable on a pale surface
|
|
26
|
+
* and a dark one stays readable on a dark surface. Mixing toward
|
|
27
|
+
* `content-primary` moves lightness and keeps hue, which is the
|
|
28
|
+
* smallest change that keeps the number *this person's colour*
|
|
29
|
+
* while remaining a number somebody can read.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { getReadableTextColour } from "@charcuterie/tokens"
|
|
33
|
+
|
|
34
|
+
export const PORTRAIT_FILL_PROPERTY =
|
|
35
|
+
"--charcuterie-portrait-fill"
|
|
36
|
+
|
|
37
|
+
export const PORTRAIT_INITIALS_PROPERTY =
|
|
38
|
+
"--charcuterie-portrait-initials"
|
|
39
|
+
|
|
40
|
+
export const PORTRAIT_HALO_PROPERTY =
|
|
41
|
+
"--charcuterie-portrait-halo"
|
|
42
|
+
|
|
43
|
+
export const PORTRAIT_STAT_PROPERTY =
|
|
44
|
+
"--charcuterie-portrait-stat"
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The four properties for one portrait.
|
|
48
|
+
*
|
|
49
|
+
* `srgb` rather than `oklab` for both mixes, matching `Swatch`'s
|
|
50
|
+
* existing `color-mix(in srgb, …)` — one interpolation space across
|
|
51
|
+
* the library, so two components handed the same colour do not
|
|
52
|
+
* produce two different washes of it.
|
|
53
|
+
*
|
|
54
|
+
* The initials colour is `getReadableTextColour`, which lives in
|
|
55
|
+
* `@charcuterie/tokens` beside the contrast maths rather than here.
|
|
56
|
+
* It is total on purpose: it runs inside a render, on a colour out
|
|
57
|
+
* of a database, and a throw would blank the whole picker.
|
|
58
|
+
*/
|
|
59
|
+
export const getPortraitColourProperties = (
|
|
60
|
+
color: string,
|
|
61
|
+
): Record<string, string> => ({
|
|
62
|
+
[PORTRAIT_FILL_PROPERTY]: color,
|
|
63
|
+
[PORTRAIT_HALO_PROPERTY]: `color-mix(in srgb, ${color} 28%, transparent)`,
|
|
64
|
+
[PORTRAIT_INITIALS_PROPERTY]:
|
|
65
|
+
getReadableTextColour(color),
|
|
66
|
+
[PORTRAIT_STAT_PROPERTY]: `color-mix(in srgb, ${color} 62%, var(--color-content-primary))`,
|
|
67
|
+
})
|