@charcuterie/ui 3.15.0 → 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.
@@ -71,6 +71,13 @@ export type ComboboxProps = {
71
71
 
72
72
  const AUTO_VIRTUALIZE_THRESHOLD = 100
73
73
 
74
+ /**
75
+ * How many frames the open seed will wait for the panel to be capped
76
+ * before it gives up on centring the chosen row. A few is all
77
+ * floating-ui's `size` middleware needs.
78
+ */
79
+ const SEED_SCROLL_ATTEMPTS = 5
80
+
74
81
  const getOptionText = (item: ListboxItem) =>
75
82
  (
76
83
  item.textValue ??
@@ -288,6 +295,19 @@ export const Combobox = ({
288
295
  // short of it.
289
296
  const pendingSeedIndex = useRef<null | number>(null)
290
297
 
298
+ // Retrying the seed's centring scroll. `useAnchoredOverlay` caps the
299
+ // panel's height by writing `style.maxHeight` straight onto the
300
+ // floating element inside floating-ui's `size` middleware —
301
+ // deliberately outside the `floatingStyles` React manages, so a
302
+ // keystroke cannot wipe it. The cost lands here: it arrives a frame
303
+ // after the open seed, announces itself with no re-render, and until
304
+ // it does the list stands at its full content height with **nothing to
305
+ // scroll**. A seed scroll issued then is dropped without a trace,
306
+ // which is how the first build of this fix highlighted the chosen row
307
+ // and still left it off screen in a real app.
308
+ const [seedScrollAttempt, setSeedScrollAttempt] =
309
+ useState(0)
310
+
291
311
  useEffect(() => {
292
312
  if (!isVisible) {
293
313
  hasSeededOnOpen.current = false
@@ -319,6 +339,8 @@ export const Combobox = ({
319
339
 
320
340
  pendingSeedIndex.current = openSeedIndex
321
341
 
342
+ setSeedScrollAttempt(0)
343
+
322
344
  setActiveIndex(openSeedIndex)
323
345
  }, [
324
346
  isVisible,
@@ -536,6 +558,27 @@ export const Combobox = ({
536
558
  return
537
559
  }
538
560
 
561
+ const list = listElement.current
562
+
563
+ if (
564
+ isSeeding &&
565
+ seedScrollAttempt < SEED_SCROLL_ATTEMPTS &&
566
+ list !== null &&
567
+ list.scrollHeight <= list.clientHeight
568
+ ) {
569
+ // Nothing to scroll yet — the panel is still at its full height.
570
+ // Hold the seed and look again next frame rather than scroll a
571
+ // container that cannot move. Bounded, so a list that genuinely
572
+ // fits its panel stops instead of spinning.
573
+ const frame = requestAnimationFrame(() => {
574
+ setSeedScrollAttempt((attempt) => attempt + 1)
575
+ })
576
+
577
+ return () => {
578
+ cancelAnimationFrame(frame)
579
+ }
580
+ }
581
+
539
582
  pendingSeedIndex.current = null
540
583
 
541
584
  // The open seed centres its row; an arrow move only pulls the next
@@ -567,6 +610,7 @@ export const Combobox = ({
567
610
  isWindowed,
568
611
  listboxId,
569
612
  rowVirtualizer.scrollToIndex,
613
+ seedScrollAttempt,
570
614
  ])
571
615
 
572
616
  // ─── Attached mode: drive the consumer's input ──────────────────────
@@ -47,6 +47,68 @@ at.
47
47
 
48
48
  <Canvas of={RadioGroupStories.AllStates} />
49
49
 
50
+ ## `itemShape="tile"` — the choice tile
51
+
52
+ A bordered card carrying a name, a line of help and a selected surface, laid out in a grid
53
+ that gains columns with its **container**. It is a prop rather than a third component
54
+ because **only the box changes**: the element, the `radio` role, the roving tab stop,
55
+ selection-follows-focus and `isReadOnly` are the ones already tested on the row shape. A
56
+ tile with its border taken away is a row.
57
+
58
+ `SegmentedControl` earns its own file by being a different *control* — one connected strip,
59
+ no radio affordance, options too short to carry a sentence. A tile is this control with a
60
+ border, which is a different reason.
61
+
62
+ <Canvas of={RadioGroupStories.Tiles} />
63
+
64
+ Four apps in the fleet had each hand-painted this shape — ten instances between them — and
65
+ **not one carried `aria-checked`**. Every one was a bare `<button>` or `<a>` whose selection
66
+ was visible only as colour: correct to an eye, silent to a screen reader, and announced as
67
+ neither pressed nor "3 of 6". That is what this prop is for, and it is why the semantics are
68
+ not the part an app should be writing.
69
+
70
+ Eight of the ten had a **second line** under the name, which is also why none of them
71
+ reached for `RadioGroup`: a plain-text `label` could not express it. `hint` can, and it
72
+ renders on a row as readily as on a tile.
73
+
74
+ ### The selected tile is an edge and a surface, never a fill
75
+
76
+ The chosen tile takes the accent on its **border** and lifts to `surface-overlay`; the radio
77
+ dot inside it is unchanged and is still the thing a screen reader reads. A state told in
78
+ colour alone is the one state a monochrome ePaper build cannot show at all — the same reason
79
+ `Stepper` refuses to say "done" with a hue.
80
+
81
+ ### A tile may lead with an icon
82
+
83
+ `icon` is `tile` only — a row already leads with its radio, and a second mark either side of
84
+ it has nowhere to sit — and it is `aria-hidden` by construction, because the name it sits
85
+ above is inside the same button. The library still ships no icons; apps bring their own.
86
+
87
+ <Canvas of={RadioGroupStories.TilesWithIcons} />
88
+
89
+ ## The grid takes its columns from the container
90
+
91
+ `repeat(auto-fill, minmax(min(var(--charcuterie-tile-min-inline-size), 100%), 1fr))`, and
92
+ each of those three parts is load-bearing:
93
+
94
+ - **`auto-fill`, not `auto-fit`.** `auto-fit` collapses the empty tracks and lets the
95
+ survivors share the whole row, so six tiles in a 2560px container become six 420px slabs —
96
+ the full-width-row shape in a new costume. `auto-fill` keeps the empty tracks, so a tile
97
+ stays a tile and the grid simply has room to spare.
98
+ - **`min(…, 100%)`.** Without it the 200px floor overflows a container narrower than one
99
+ tile, which is a horizontal scrollbar in the Narrow View.
100
+ - **A custom property, not an interpolated class.** Tailwind scans source *text* for
101
+ complete class strings, so `` `grid-cols-[…${n}px…]` `` generates nothing, paints nothing
102
+ and reports nothing. `minTileInlineSize` lands in `--charcuterie-tile-min-inline-size`,
103
+ which one written-out literal reads.
104
+
105
+ This is **not** `useAdaptiveColumns`, and the difference is which question is being asked.
106
+ That hook buys a column with **height**, for an unbounded gallery that is going to scroll. A
107
+ radio group is a bounded set of options inside a form section, where the only question is
108
+ how many fit across the box it was handed.
109
+
110
+ <Canvas of={RadioGroupStories.Responsive} />
111
+
50
112
  ## A read-only group shows the choice without moving it
51
113
 
52
114
  `isReadOnly` severs the one link that defines this component — selection follows focus — so
@@ -2,10 +2,16 @@ import type { Meta, StoryObj } from "@storybook/react"
2
2
 
3
3
  import { controlSizeArgType } from "../argTypes.storyHelpers.ts"
4
4
  import {
5
+ ContainerBoard,
5
6
  StoryCell,
6
7
  StoryGrid,
7
8
  StorySection,
8
9
  } from "../board.storyHelpers.tsx"
10
+ import {
11
+ InboxIcon,
12
+ PlayIcon,
13
+ SearchIcon,
14
+ } from "../icons.storyHelpers.tsx"
9
15
  import type { RadioItem } from "./RadioGroup.tsx"
10
16
  import { RadioGroup } from "./RadioGroup.tsx"
11
17
 
@@ -23,6 +29,55 @@ const NAMING_ITEMS: RadioItem[] = [
23
29
  { label: "Use a custom pattern", value: "custom" },
24
30
  ]
25
31
 
32
+ /**
33
+ * The same control with a name and a line of help per option — the
34
+ * shape four apps in the fleet had each hand-painted, and the reason
35
+ * none of them reached for this component.
36
+ */
37
+ const IMPORT_ITEMS: RadioItem[] = [
38
+ {
39
+ hint: "Nothing is left behind at the source.",
40
+ label: "Move the files",
41
+ value: "move",
42
+ },
43
+ {
44
+ hint: "Twice the space, and the source is untouched.",
45
+ label: "Copy the files",
46
+ value: "copy",
47
+ },
48
+ {
49
+ hint: "One copy on disk, two names for it. Same volume only.",
50
+ label: "Hard link",
51
+ value: "link",
52
+ },
53
+ {
54
+ hint: "Catalogue them where they are.",
55
+ label: "Leave in place",
56
+ value: "leave",
57
+ },
58
+ ]
59
+
60
+ const ICON_ITEMS: RadioItem[] = [
61
+ {
62
+ hint: "Everything that arrived since the last run.",
63
+ icon: <InboxIcon />,
64
+ label: "New arrivals",
65
+ value: "new",
66
+ },
67
+ {
68
+ hint: "Whatever the saved query matches today.",
69
+ icon: <SearchIcon />,
70
+ label: "Saved search",
71
+ value: "search",
72
+ },
73
+ {
74
+ hint: "Pick up where the last session stopped.",
75
+ icon: <PlayIcon />,
76
+ label: "Continue",
77
+ value: "continue",
78
+ },
79
+ ]
80
+
26
81
  const meta = {
27
82
  title: "Components/Controls/RadioGroup",
28
83
  component: RadioGroup,
@@ -31,7 +86,12 @@ const meta = {
31
86
  // The component's own defaults, restated — Storybook does not seed
32
87
  // `args` from docgen, so an unstated default shows in the props
33
88
  // table with nothing selected in its control.
34
- args: { isReadOnly: false, size: "md" },
89
+ args: {
90
+ isReadOnly: false,
91
+ itemShape: "row",
92
+ minTileInlineSize: 200,
93
+ size: "md",
94
+ },
35
95
  } satisfies Meta<typeof RadioGroup>
36
96
 
37
97
  export default meta
@@ -61,6 +121,38 @@ export const AllVariants: Story = {
61
121
  ),
62
122
  }
63
123
 
124
+ /**
125
+ * `itemShape="tile"` is the same `radiogroup` in a box: one choice
126
+ * out of several, exclusive, announced as "3 of 6", drawn as cards
127
+ * in a grid that gains columns with its **container**.
128
+ *
129
+ * The selected tile is an accent **edge** and a lifted surface, not
130
+ * a fill — the radio dot inside it is still the thing a screen
131
+ * reader reads, and a state told in colour alone is the one state a
132
+ * monochrome ePaper build cannot show.
133
+ */
134
+ export const Tiles: Story = {
135
+ args: {
136
+ items: IMPORT_ITEMS,
137
+ itemShape: "tile",
138
+ label: "On import",
139
+ },
140
+ }
141
+
142
+ /**
143
+ * A tile may lead with an icon. The library ships none — these are
144
+ * story-only SVGs, exactly what an app passes — and the icon is
145
+ * `aria-hidden` by construction, because the name it sits above is
146
+ * in the same button.
147
+ */
148
+ export const TilesWithIcons: Story = {
149
+ args: {
150
+ items: ICON_ITEMS,
151
+ itemShape: "tile",
152
+ label: "Where to start",
153
+ },
154
+ }
155
+
64
156
  const STATE_ITEMS: RadioItem[] = [
65
157
  { label: "Match the Plex agent", value: "plex" },
66
158
  { label: "Match AniDB titles", value: "anidb" },
@@ -125,11 +217,82 @@ export const AllStates: Story = {
125
217
  selectedValue="anidb"
126
218
  />
127
219
  </StoryCell>
220
+
221
+ <StoryCell align="stretch" label="a hint on a row">
222
+ <RadioGroup
223
+ {...controlProps}
224
+ items={IMPORT_ITEMS}
225
+ label="On import, as rows"
226
+ />
227
+ </StoryCell>
228
+
229
+ <StoryCell
230
+ align="stretch"
231
+ label="tiles, third chosen"
232
+ >
233
+ <RadioGroup
234
+ {...controlProps}
235
+ items={IMPORT_ITEMS}
236
+ itemShape="tile"
237
+ label="On import, as tiles"
238
+ selectedValue="link"
239
+ />
240
+ </StoryCell>
241
+
242
+ <StoryCell
243
+ align="stretch"
244
+ label="tiles, read-only and disabled"
245
+ >
246
+ <RadioGroup
247
+ {...controlProps}
248
+ isReadOnly
249
+ items={[
250
+ ...IMPORT_ITEMS.slice(0, 2),
251
+ {
252
+ hint: "The source volume is read-only.",
253
+ isDisabled: true,
254
+ label: "Hard link",
255
+ value: "link",
256
+ },
257
+ ]}
258
+ itemShape="tile"
259
+ label="On import, read-only tiles"
260
+ selectedValue="copy"
261
+ />
262
+ </StoryCell>
128
263
  </StoryGrid>
129
264
  </StorySection>
130
265
  ),
131
266
  }
132
267
 
268
+ /**
269
+ * Column count comes from the **container**, never the window — the
270
+ * standing rule for any list of cards, and the reason this board
271
+ * varies a wrapper's width rather than the viewport's.
272
+ *
273
+ * At the 200px default floor: one track at 15rem, one at 24rem, two
274
+ * at 34rem. `auto-fill` and deliberately not `auto-fit`, so the
275
+ * tiles keep their size in a container with room to spare instead of
276
+ * stretching to fill it.
277
+ */
278
+ export const Responsive: Story = {
279
+ args: {
280
+ items: IMPORT_ITEMS,
281
+ itemShape: "tile",
282
+ label: "On import",
283
+ },
284
+ render: (controlProps) => (
285
+ <ContainerBoard>
286
+ {(width) => (
287
+ <RadioGroup
288
+ {...controlProps}
289
+ label={`On import at ${width}`}
290
+ />
291
+ )}
292
+ </ContainerBoard>
293
+ ),
294
+ }
295
+
133
296
  /**
134
297
  * The complete keyboard path. Tab enters the group **once** — the
135
298
  * roving-tabindex rule — then the arrow keys move and check
@@ -7,8 +7,14 @@ import { mountStory } from "../mountStory.testHelpers.ts"
7
7
  import { expectAgentDrivable } from "../testing/index.ts"
8
8
  import * as stories from "./RadioGroup.stories.tsx"
9
9
 
10
- const { AllStates, Default, Interactive } =
11
- composeStories(stories)
10
+ const {
11
+ AllStates,
12
+ Default,
13
+ Interactive,
14
+ Responsive,
15
+ Tiles,
16
+ TilesWithIcons,
17
+ } = composeStories(stories)
12
18
 
13
19
  /**
14
20
  * `querySelectorAll` rather than `getAllByRole("radio")`: a board
@@ -230,3 +236,174 @@ test("a read-only group is announced and its choice cannot move", async () => {
230
236
 
231
237
  await expectNoAxeViolations(canvasElement)
232
238
  })
239
+
240
+ /**
241
+ * The tile shape is the same control in a box, so what these assert
242
+ * is that **nothing about the control changed** — role, exclusivity,
243
+ * the roving tab stop and selection-follows-focus are the ones
244
+ * already proven above, driven through the tile story.
245
+ */
246
+ test("a tile group is still a radiogroup of radios", async () => {
247
+ const { canvas, canvasElement } = await mountStory(Tiles)
248
+
249
+ const group = expectAgentDrivable(canvas, {
250
+ name: "On import",
251
+ role: "radiogroup",
252
+ })
253
+
254
+ const options = getOptions(group)
255
+
256
+ await expect(options).toHaveLength(4)
257
+
258
+ await waitFor(() => {
259
+ expect(
260
+ options.filter(
261
+ (one) =>
262
+ one.getAttribute("aria-checked") === "true",
263
+ ),
264
+ ).toHaveLength(1)
265
+ })
266
+
267
+ // Exactly one tab stop — the roving rule survives the layout
268
+ // change, because the layout is all that changed.
269
+ await expect(
270
+ options.filter((one) => one.tabIndex === 0),
271
+ ).toHaveLength(1)
272
+
273
+ await expectNoAxeViolations(canvasElement)
274
+ })
275
+
276
+ test("a tile's hint is part of the option's accessible name", async () => {
277
+ const { canvas } = await mountStory(Tiles)
278
+
279
+ // The reason the hint is rendered inside the button rather than
280
+ // beside it: a hint a screen reader never reads is a hint half the
281
+ // audience does not have. `getByRole` computes the name from
282
+ // contents, so this query only passes if it is in there.
283
+ expectAgentDrivable(canvas, {
284
+ name: "Hard link One copy on disk, two names for it. Same volume only.",
285
+ role: "radio",
286
+ })
287
+ })
288
+
289
+ test("a tile icon is decoration and stays out of the name", async () => {
290
+ const { canvas, canvasElement } =
291
+ await mountStory(TilesWithIcons)
292
+
293
+ // Named by its words alone. An icon that joined the name would
294
+ // make this query fail, which is the whole reason it is
295
+ // `aria-hidden`.
296
+ expectAgentDrivable(canvas, {
297
+ name: "New arrivals Everything that arrived since the last run.",
298
+ role: "radio",
299
+ })
300
+
301
+ await expectNoAxeViolations(canvasElement)
302
+ })
303
+
304
+ test("the arrow keys still move and check inside a tile grid", async () => {
305
+ const { canvas } = await mountStory(Tiles)
306
+
307
+ const options = getOptions(
308
+ expectAgentDrivable(canvas, {
309
+ name: "On import",
310
+ role: "radiogroup",
311
+ }),
312
+ )
313
+
314
+ options[0]?.focus()
315
+
316
+ // ArrowRight in a grid is `focus.next`, exactly as ArrowDown is in
317
+ // a stack. APG gives a radio group one traversal order, and a grid
318
+ // does not earn a second one.
319
+ await userEvent.keyboard("{ArrowRight}")
320
+
321
+ await waitFor(() => {
322
+ expect(options[1]).toHaveAttribute(
323
+ "aria-checked",
324
+ "true",
325
+ )
326
+ })
327
+ })
328
+
329
+ /**
330
+ * The three things about the box that fail *silently*, so all three
331
+ * are read off `getComputedStyle` in a real browser rather than off
332
+ * a class name. A class-name assertion would have passed while the
333
+ * grid was one column, because the class really was in the DOM.
334
+ */
335
+ test("a tile carries a border, and the chosen one carries the accent", async () => {
336
+ const { canvas } = await mountStory(Tiles)
337
+
338
+ const options = getOptions(
339
+ expectAgentDrivable(canvas, {
340
+ name: "On import",
341
+ role: "radiogroup",
342
+ }),
343
+ )
344
+
345
+ const [first, second] = options as [
346
+ HTMLButtonElement,
347
+ HTMLButtonElement,
348
+ ]
349
+
350
+ await waitFor(() => {
351
+ expect(first).toHaveAttribute("aria-checked", "true")
352
+ })
353
+
354
+ const chosen = getComputedStyle(first)
355
+
356
+ const other = getComputedStyle(second)
357
+
358
+ await expect(
359
+ Number.parseFloat(chosen.borderTopWidth),
360
+ ).toBeGreaterThan(0)
361
+
362
+ // A border and a surface, never colour alone — but the two tiles
363
+ // must still differ, or "chosen" is told by the radio dot only.
364
+ await expect(chosen.borderTopColor).not.toBe(
365
+ other.borderTopColor,
366
+ )
367
+
368
+ await expect(chosen.backgroundColor).not.toBe(
369
+ other.backgroundColor,
370
+ )
371
+ })
372
+
373
+ test("the column count comes from the container, not the window", async () => {
374
+ const { canvas } = await mountStory(Responsive)
375
+
376
+ const getColumnCount = (name: string) =>
377
+ getComputedStyle(
378
+ expectAgentDrivable(canvas, {
379
+ name,
380
+ role: "radiogroup",
381
+ }),
382
+ ).gridTemplateColumns.split(" ").length
383
+
384
+ // One viewport, three container widths, and the answer changes
385
+ // with the container. Every panel is the same window.
386
+ await expect(getColumnCount("On import at 15rem")).toBe(1)
387
+
388
+ await expect(getColumnCount("On import at 34rem")).toBe(2)
389
+ })
390
+
391
+ test("a tile grid never overflows a container narrower than one tile", async () => {
392
+ const { canvas } = await mountStory(Responsive)
393
+
394
+ const group = expectAgentDrivable(canvas, {
395
+ name: "On import at 15rem",
396
+ role: "radiogroup",
397
+ })
398
+
399
+ // 15rem is 240px and the tile floor is 200px, so this passes
400
+ // either way — the case that matters is the `min(…, 100%)`, which
401
+ // is what stops a 200px floor from becoming a horizontal
402
+ // scrollbar in the Narrow View.
403
+ await expect(
404
+ group.getBoundingClientRect().width,
405
+ ).toBeLessThanOrEqual(
406
+ (group.parentElement?.getBoundingClientRect().width ??
407
+ 0) + 1,
408
+ )
409
+ })