@charcuterie/ui 3.14.4 → 3.16.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.
@@ -4,13 +4,40 @@ import {
4
4
  useSinglePicker,
5
5
  } from "@charcuterie/logic"
6
6
  import type { ControlSize } from "@charcuterie/tokens"
7
- import type { ReactNode } from "react"
7
+ import type { CSSProperties, ReactNode } from "react"
8
8
  import { useCallback, useEffect, useRef } from "react"
9
9
 
10
10
  import { toClassName } from "../toClassName.ts"
11
11
  import { RadioGroupOption } from "./RadioGroupOption.tsx"
12
12
 
13
+ /**
14
+ * Which shape the options are drawn in. The control is the same
15
+ * `radiogroup` either way — this is layout, and it is a prop rather
16
+ * than a second component because nothing about the semantics, the
17
+ * keyboard model or the element changes with it.
18
+ *
19
+ * - `row` — the default. Each option is a line of prose beside its
20
+ * radio, stacked down the page. The form-length list.
21
+ * - `tile` — each option is a bordered card carrying a name, an
22
+ * optional line of help and a selected surface, and the group is
23
+ * a grid that gains columns with the CONTAINER's width.
24
+ */
25
+ export type RadioItemShape = "row" | "tile"
26
+
13
27
  export type RadioItem = {
28
+ /**
29
+ * A line of help under the label. Drawn one step down the type
30
+ * ramp in `content-muted`, inside the option's accessible name —
31
+ * a hint a screen reader never reads is a hint half the audience
32
+ * does not have.
33
+ */
34
+ hint?: ReactNode
35
+ /**
36
+ * Leading content, before the name. A `Badge`, an app's own icon,
37
+ * a count. `tile` only: a row already leads with its radio, and a
38
+ * second leading mark either side of it has nowhere to sit.
39
+ */
40
+ icon?: ReactNode
14
41
  /**
15
42
  * A disabled option is simply **not registered** with the roving
16
43
  * group, so the arrow keys skip it without any command in
@@ -24,6 +51,11 @@ export type RadioItem = {
24
51
 
25
52
  export type RadioGroupProps = {
26
53
  className?: string
54
+ /**
55
+ * Draws each option as a card in a grid instead of a row in a
56
+ * stack. Defaults to `row`.
57
+ */
58
+ itemShape?: RadioItemShape
27
59
  items: readonly RadioItem[]
28
60
  /**
29
61
  * Shows which option is chosen at full contrast but refuses to move
@@ -34,6 +66,13 @@ export type RadioGroupProps = {
34
66
  isReadOnly?: boolean
35
67
  /** The group's accessible name. Required. */
36
68
  label: string
69
+ /**
70
+ * The narrowest a tile track may be, in CSS px. `tile` only.
71
+ *
72
+ * It is the grid's floor and not the tile's width: tracks below it
73
+ * are not created, and the ones that are share the row evenly.
74
+ */
75
+ minTileInlineSize?: number
37
76
  onChange?: (selectedValue: string | null) => void
38
77
  /** **Initial** only. Charcuterie owns it from then on. */
39
78
  selectedValue?: string
@@ -46,6 +85,47 @@ const GAP_CLASS: Record<ControlSize, string> = {
46
85
  lg: "gap-2",
47
86
  }
48
87
 
88
+ /**
89
+ * Wider than the row gaps: rows are separated by their leading, and
90
+ * tiles by nothing but the gap, so the same 6px that reads as a list
91
+ * reads as a seam between two cards.
92
+ */
93
+ const TILE_GAP_CLASS: Record<ControlSize, string> = {
94
+ sm: "gap-1.5",
95
+ md: "gap-2",
96
+ lg: "gap-3",
97
+ }
98
+
99
+ /**
100
+ * `auto-fill`, and deliberately not `auto-fit`.
101
+ *
102
+ * `auto-fit` collapses the empty tracks and lets the ones that
103
+ * remain share the whole row, so six tiles in a 2560px container
104
+ * become six 420px slabs — the full-width-row shape in a new
105
+ * costume. `auto-fill` keeps the empty tracks, so a tile stays a
106
+ * tile and the grid simply has room to spare.
107
+ *
108
+ * `min(…, 100%)` is what stops the floor overflowing a container
109
+ * narrower than one tile, which is the Narrow View and is otherwise
110
+ * a horizontal scrollbar on a phone.
111
+ *
112
+ * The floor arrives as a custom property rather than an interpolated
113
+ * class because Tailwind scans source *text* for complete class
114
+ * strings: `` `grid-cols-[…${n}px…]` `` generates nothing, paints
115
+ * nothing and reports nothing. One written-out literal reading
116
+ * `var(--charcuterie-tile-min-inline-size)` covers every width an
117
+ * app can ask for — the same reason `Card`'s accent edge goes
118
+ * through one property.
119
+ *
120
+ * This is **not** `useAdaptiveColumns`, and the difference is the
121
+ * question being asked. That hook buys a column with height, for an
122
+ * unbounded gallery that will scroll; a radio group is a bounded set
123
+ * of options inside a form section, where the only question is how
124
+ * many fit across the box it was given.
125
+ */
126
+ const TILE_COLUMNS_CLASS =
127
+ "grid-cols-[repeat(auto-fill,minmax(min(var(--charcuterie-tile-min-inline-size),100%),1fr))]"
128
+
49
129
  /**
50
130
  * One choice out of many, each on its own row with a label the size
51
131
  * of prose — the stacked sibling of `SegmentedControl`.
@@ -67,12 +147,24 @@ const GAP_CLASS: Record<ControlSize, string> = {
67
147
  * choice from a named set is a radio group, and its arrow keys both
68
148
  * move focus and check — APG's one activation mode, the same reason
69
149
  * `SegmentedControl` has no `manual` prop.
150
+ *
151
+ * ### `itemShape="tile"` is the choice tile
152
+ *
153
+ * A bordered card carrying a name, a line of help and a selected
154
+ * surface, in a grid that gains columns with its container. It is a
155
+ * prop and not a third component because **only the box changes**:
156
+ * the element, the role, the roving tabindex, selection-follows-focus
157
+ * and `isReadOnly` are the ones already tested here. `SegmentedControl`
158
+ * earns its own file by being a different control — one connected
159
+ * strip, no radio affordance; a tile is this control with a border.
70
160
  */
71
161
  export const RadioGroup = ({
72
162
  className,
163
+ itemShape = "row",
73
164
  items,
74
165
  isReadOnly = false,
75
166
  label,
167
+ minTileInlineSize = 200,
76
168
  onChange,
77
169
  selectedValue,
78
170
  size = "md",
@@ -83,6 +175,8 @@ export const RadioGroup = ({
83
175
  new Map<string, HTMLButtonElement>(),
84
176
  )
85
177
 
178
+ const isTile = itemShape === "tile"
179
+
86
180
  const initialValue =
87
181
  selectedValue ??
88
182
  items.find((one) => !one.isDisabled)?.value ??
@@ -152,8 +246,10 @@ export const RadioGroup = ({
152
246
  aria-label={label}
153
247
  aria-readonly={isReadOnly || undefined}
154
248
  className={toClassName(
155
- "flex flex-col",
156
- GAP_CLASS[size],
249
+ isTile
250
+ ? toClassName("grid", TILE_COLUMNS_CLASS)
251
+ : "flex flex-col",
252
+ isTile ? TILE_GAP_CLASS[size] : GAP_CLASS[size],
157
253
  className,
158
254
  )}
159
255
  onKeyDown={(keyEvent) => {
@@ -193,6 +289,13 @@ export const RadioGroup = ({
193
289
  }
194
290
  }}
195
291
  ref={groupRef}
292
+ style={
293
+ isTile
294
+ ? ({
295
+ "--charcuterie-tile-min-inline-size": `${minTileInlineSize}px`,
296
+ } as CSSProperties)
297
+ : undefined
298
+ }
196
299
  role="radiogroup"
197
300
  >
198
301
  {items.map((item) => (
@@ -200,6 +303,7 @@ export const RadioGroup = ({
200
303
  isChecked={checkedValue === item.value}
201
304
  isReadOnly={isReadOnly}
202
305
  item={item}
306
+ itemShape={itemShape}
203
307
  key={item.value}
204
308
  onChoose={chooseOption}
205
309
  registerFocus={focus.register}
@@ -4,12 +4,16 @@ import { useEffect } from "react"
4
4
 
5
5
  import { FOCUS_RING_CLASS } from "../intentStyles.ts"
6
6
  import { toClassName } from "../toClassName.ts"
7
- import type { RadioItem } from "./RadioGroup.tsx"
7
+ import type {
8
+ RadioItem,
9
+ RadioItemShape,
10
+ } from "./RadioGroup.tsx"
8
11
 
9
12
  export type RadioGroupOptionProps = {
10
13
  isChecked: boolean
11
14
  isReadOnly: boolean
12
15
  item: RadioItem
16
+ itemShape: RadioItemShape
13
17
  onChoose: (value: string) => void
14
18
  /** `RovingFocus.register` — membership of the arrow-key group. */
15
19
  registerFocus: (value: string) => () => void
@@ -44,6 +48,36 @@ const TEXT_SIZE_CLASS: Record<ControlSize, string> = {
44
48
  lg: "text-lg",
45
49
  }
46
50
 
51
+ /**
52
+ * One step down from the option's own text, and never below `xs`:
53
+ * a hint that keeps shrinking with the density axis is the 11px
54
+ * caption the 2026-08-10 ramp rebuild exists to have removed.
55
+ */
56
+ const HINT_TEXT_CLASS: Record<ControlSize, string> = {
57
+ sm: "text-xs",
58
+ md: "text-xs",
59
+ lg: "text-sm",
60
+ }
61
+
62
+ /**
63
+ * A tile is a card, so its padding is a card's rather than a
64
+ * control's — enough that the border reads as a box around content
65
+ * and not as an outline on a line of text.
66
+ */
67
+ const TILE_PADDING_CLASS: Record<ControlSize, string> = {
68
+ sm: "px-3 py-2.5",
69
+ md: "px-3.5 py-3",
70
+ lg: "px-4 py-3.5",
71
+ }
72
+
73
+ /**
74
+ * The box, and only the box. Every state below it is a **border and
75
+ * a surface**, never colour alone — the same reason `Stepper`
76
+ * refuses to say "done" with a hue.
77
+ */
78
+ const TILE_BOX_CLASS =
79
+ "rounded-lg border border-border-subtle bg-surface-raised"
80
+
47
81
  /**
48
82
  * Its own file for the same reason `SegmentedOption` is: **both
49
83
  * registrations are effects**, and an effect cannot run in a loop.
@@ -52,11 +86,22 @@ const TEXT_SIZE_CLASS: Record<ControlSize, string> = {
52
86
  * with a background, so the checked dot can be centred by the grid
53
87
  * and sized independently of the ring — a filled `bg-intent-accent`
54
88
  * ring with no inner dot reads as a checkbox, not a radio.
89
+ *
90
+ * ### The tile shape is the same element with a border
91
+ *
92
+ * `itemShape="tile"` swaps `inline-flex` for `flex` — a grid item
93
+ * stretches to its track, and an inline box would sit at
94
+ * max-content inside it — adds the card's padding and border, and
95
+ * moves the ring to the top of a two-line label. The radio dot is
96
+ * still there and is still the thing a screen reader reads: a tile
97
+ * with its border taken away is this row, which is why the two are
98
+ * one component.
55
99
  */
56
100
  export const RadioGroupOption = ({
57
101
  isChecked,
58
102
  isReadOnly,
59
103
  item,
104
+ itemShape,
60
105
  onChoose,
61
106
  registerFocus,
62
107
  registerSelection,
@@ -64,7 +109,15 @@ export const RadioGroupOption = ({
64
109
  tabIndex,
65
110
  trackElement,
66
111
  }: RadioGroupOptionProps): ReactNode => {
67
- const { isDisabled = false, label, value } = item
112
+ const {
113
+ hint,
114
+ icon,
115
+ isDisabled = false,
116
+ label,
117
+ value,
118
+ } = item
119
+
120
+ const isTile = itemShape === "tile"
68
121
 
69
122
  useEffect(
70
123
  () => registerSelection(value),
@@ -84,7 +137,21 @@ export const RadioGroupOption = ({
84
137
  <button
85
138
  aria-checked={isChecked}
86
139
  className={toClassName(
87
- "inline-flex items-center gap-2 rounded-sm text-content-secondary text-start transition-colors duration-(--duration-fast) ease-standard",
140
+ "gap-2 text-content-secondary text-start transition-colors duration-(--duration-fast) ease-standard",
141
+ isTile
142
+ ? toClassName(
143
+ "flex min-w-0 items-start",
144
+ TILE_BOX_CLASS,
145
+ TILE_PADDING_CLASS[size],
146
+ )
147
+ : toClassName(
148
+ "inline-flex rounded-sm",
149
+ // A hint puts a second line under the label, and a ring
150
+ // centred against both sits beside neither.
151
+ hint === undefined
152
+ ? "items-center"
153
+ : "items-start",
154
+ ),
88
155
  TEXT_SIZE_CLASS[size],
89
156
  // Disabled turns the whole option down with `opacity-60`, the
90
157
  // same family treatment as `Checkbox` and `Switch`, rather than
@@ -95,7 +162,18 @@ export const RadioGroupOption = ({
95
162
  ? "cursor-not-allowed opacity-60"
96
163
  : isReadOnly
97
164
  ? "cursor-default"
98
- : "cursor-pointer hover:text-content-primary",
165
+ : isTile
166
+ ? "cursor-pointer hover:border-border-strong"
167
+ : "cursor-pointer hover:text-content-primary",
168
+ // The selected tile carries the accent on its EDGE and lifts
169
+ // its surface. A tile that said "chosen" with a background
170
+ // alone would be the one state a monochrome ePaper build
171
+ // cannot show at all.
172
+ isTile && isChecked
173
+ ? isReadOnly
174
+ ? "border-intent-neutral-solid bg-surface-overlay"
175
+ : "border-intent-accent-solid bg-surface-overlay"
176
+ : "",
99
177
  FOCUS_RING_CLASS,
100
178
  )}
101
179
  disabled={isDisabled}
@@ -142,7 +220,46 @@ export const RadioGroupOption = ({
142
220
  />
143
221
  </span>
144
222
 
145
- <span>{label}</span>
223
+ {hint === undefined && !isTile ? (
224
+ <span>{label}</span>
225
+ ) : (
226
+ // `min-w-0` and `wrap-anywhere` together, because a grid
227
+ // item's automatic minimum is its min-content width: one
228
+ // unbroken token — a path, a URL, a title with no spaces —
229
+ // otherwise sets the track's floor and shoves the grid out
230
+ // of its container.
231
+ <span className="flex min-w-0 flex-col gap-0.5 wrap-anywhere">
232
+ {icon !== undefined && isTile ? (
233
+ // Decorative by construction: the name it sits beside is
234
+ // in the same button, so an announced icon is the label
235
+ // read twice.
236
+ <span aria-hidden className="flex">
237
+ {icon}
238
+ </span>
239
+ ) : null}
240
+
241
+ <span
242
+ className={
243
+ isTile
244
+ ? "font-semibold text-content-primary"
245
+ : undefined
246
+ }
247
+ >
248
+ {label}
249
+ </span>
250
+
251
+ {hint === undefined ? null : (
252
+ <span
253
+ className={toClassName(
254
+ "font-normal text-content-muted",
255
+ HINT_TEXT_CLASS[size],
256
+ )}
257
+ >
258
+ {hint}
259
+ </span>
260
+ )}
261
+ </span>
262
+ )}
146
263
  </button>
147
264
  )
148
265
  }
@@ -82,6 +82,23 @@ test("the last step draws no connector", async () => {
82
82
  await expect(connectors[2]).toBeNull()
83
83
  })
84
84
 
85
+ test("each step carries its key into the DOM, so a re-order can animate", async () => {
86
+ const { canvas } = await mountStory(Default)
87
+
88
+ // React's `key` never reaches the markup. Without this attribute
89
+ // a caller who re-orders `steps` has no way to say which `<li>`
90
+ // is which, and `useFlipList` — which matches on exactly this
91
+ // attribute — would fall back to pairing items by POSITION,
92
+ // measure a delta of zero for every one, and animate nothing.
93
+ await expect(
94
+ canvas
95
+ .getAllByRole("listitem")
96
+ .map((item: HTMLElement) =>
97
+ item.getAttribute("data-flip-key"),
98
+ ),
99
+ ).toEqual(["rip", "tag", "file"])
100
+ })
101
+
85
102
  test("advancing moves which step is current", async () => {
86
103
  const { canvas } = await mountStory(Interactive)
87
104
 
@@ -1,3 +1,4 @@
1
+ import { FLIP_KEY_ATTRIBUTE } from "@charcuterie/logic"
1
2
  import type { ReactNode } from "react"
2
3
 
3
4
  import { toClassName } from "../toClassName.ts"
@@ -138,7 +139,27 @@ export const Stepper = ({
138
139
  const isLast = index === steps.length - 1
139
140
 
140
141
  return (
142
+ /*
143
+ * `data-flip-key` carries `step.key` into the DOM so a
144
+ * re-ordered stepper can animate with `useFlipList`
145
+ * from `@charcuterie/logic`, which matches items on
146
+ * exactly this attribute.
147
+ *
148
+ * Emitted always, not behind a prop. React's `key` is
149
+ * invisible to the DOM, so without this a caller who
150
+ * re-orders `steps` has no way to say which `<li>` is
151
+ * which — and FLIP matched by POSITION pairs every item
152
+ * with whatever now sits where it used to be, measures
153
+ * a delta of zero, and animates nothing. Docket's phase
154
+ * list is the case: the owner reported *"when I move
155
+ * them up and down, there's no animation"*, and the
156
+ * step's identity never reached the markup.
157
+ *
158
+ * A data attribute on a list item costs nothing when
159
+ * nobody reads it, which is why it is not opt-in.
160
+ */
141
161
  <li
162
+ {...{ [FLIP_KEY_ATTRIBUTE]: step.key }}
142
163
  className={toClassName(
143
164
  "relative grid grid-cols-[auto_minmax(0,1fr)] grid-rows-[auto_minmax(0,1fr)] gap-x-3",
144
165
  isHorizontal &&