@charcuterie/ui 3.31.0 → 3.33.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.
Files changed (44) hide show
  1. package/README.md +14 -0
  2. package/dist/ActionTiles/ActionTiles.d.ts +115 -0
  3. package/dist/ActionTiles/ActionTiles.d.ts.map +1 -0
  4. package/dist/ActionTiles/ActionTiles.js +115 -0
  5. package/dist/ActionTiles/ActionTiles.js.map +1 -0
  6. package/dist/AdaptiveGrid/useAdaptiveColumns.d.ts +16 -0
  7. package/dist/AdaptiveGrid/useAdaptiveColumns.d.ts.map +1 -1
  8. package/dist/AdaptiveGrid/useAdaptiveColumns.js +1 -0
  9. package/dist/AdaptiveGrid/useAdaptiveColumns.js.map +1 -1
  10. package/dist/RadioGroup/RadioGroup.d.ts +10 -0
  11. package/dist/RadioGroup/RadioGroup.d.ts.map +1 -1
  12. package/dist/RadioGroup/RadioGroup.js +12 -39
  13. package/dist/RadioGroup/RadioGroup.js.map +1 -1
  14. package/dist/RadioGroup/RadioGroupOption.d.ts.map +1 -1
  15. package/dist/RadioGroup/RadioGroupOption.js +3 -33
  16. package/dist/RadioGroup/RadioGroupOption.js.map +1 -1
  17. package/dist/VirtualizedGrid/VirtualizedGrid.d.ts +35 -0
  18. package/dist/VirtualizedGrid/VirtualizedGrid.d.ts.map +1 -1
  19. package/dist/VirtualizedGrid/VirtualizedGrid.js +37 -1
  20. package/dist/VirtualizedGrid/VirtualizedGrid.js.map +1 -1
  21. package/dist/index.d.ts +2 -0
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +1 -0
  24. package/dist/index.js.map +1 -1
  25. package/dist/tileStyles.d.ts +83 -0
  26. package/dist/tileStyles.d.ts.map +1 -0
  27. package/dist/tileStyles.js +98 -0
  28. package/dist/tileStyles.js.map +1 -0
  29. package/package.json +1 -1
  30. package/src/ActionTiles/ActionTiles.mdx +131 -0
  31. package/src/ActionTiles/ActionTiles.stories.tsx +311 -0
  32. package/src/ActionTiles/ActionTiles.test.tsx +292 -0
  33. package/src/ActionTiles/ActionTiles.tsx +292 -0
  34. package/src/AdaptiveGrid/useAdaptiveColumns.ts +17 -0
  35. package/src/RadioGroup/RadioGroup.mdx +7 -0
  36. package/src/RadioGroup/RadioGroup.tsx +16 -42
  37. package/src/RadioGroup/RadioGroupOption.tsx +8 -38
  38. package/src/VirtualizedGrid/VirtualizedGrid.mdx +21 -0
  39. package/src/VirtualizedGrid/VirtualizedGrid.stories.tsx +64 -0
  40. package/src/VirtualizedGrid/VirtualizedGrid.test.tsx +122 -3
  41. package/src/VirtualizedGrid/VirtualizedGrid.tsx +37 -1
  42. package/src/index.ts +5 -0
  43. package/src/sourceRules.test.ts +9 -1
  44. package/src/tileStyles.ts +118 -0
@@ -1,5 +1,5 @@
1
1
  import { composeStories } from "@storybook/react"
2
- import { expect, waitFor } from "storybook/test"
2
+ import { expect, userEvent, waitFor } from "storybook/test"
3
3
  import { test } from "vitest"
4
4
 
5
5
  import { mountStory } from "../mountStory.testHelpers.ts"
@@ -31,10 +31,17 @@ import * as stories from "./VirtualizedGrid.stories.tsx"
31
31
  * and "list, 40 items" on a list of 2,000.
32
32
  * - **An empty list reserves no space.** The state a filter lands
33
33
  * on.
34
+ * - **A hidden panel measures nothing, and revealing it does not
35
+ * scroll.** The regression that took Docket's Triage tab bar off
36
+ * the top of the screen.
34
37
  */
35
38
 
36
- const { AllStates, Default, InShellScrollRegion } =
37
- composeStories(stories)
39
+ const {
40
+ AllStates,
41
+ Default,
42
+ InHiddenTabPanel,
43
+ InShellScrollRegion,
44
+ } = composeStories(stories)
38
45
 
39
46
  const getLists = (canvasElement: HTMLElement) => [
40
47
  ...canvasElement.querySelectorAll<HTMLElement>("ul"),
@@ -205,6 +212,118 @@ test("a grid inside Shell follows Main's one scroll region", async () => {
205
212
  ).toBe("0px")
206
213
  })
207
214
 
215
+ test("a grid inside a hidden panel mounts nothing and reveals without scrolling", async () => {
216
+ const { canvasElement } = await mountStory(
217
+ InHiddenTabPanel,
218
+ )
219
+
220
+ const main =
221
+ canvasElement.querySelector<HTMLElement>("main")
222
+
223
+ if (!main) {
224
+ throw new Error("The tab story has no Main element.")
225
+ }
226
+
227
+ await waitFor(async () => {
228
+ await expect(
229
+ getRowIndexes(canvasElement).length,
230
+ ).toBeGreaterThan(0)
231
+ })
232
+
233
+ const hiddenPanel = canvasElement.querySelector(
234
+ '[role="tabpanel"][hidden]',
235
+ )
236
+
237
+ if (!hiddenPanel) {
238
+ throw new Error("Both tab panels are showing.")
239
+ }
240
+
241
+ /*
242
+ * The direct observable, and the one the fix is: `display: none`
243
+ * gives an element no box, so nothing inside it can be measured.
244
+ * A grid that mounted its rows anyway would record a 0px-wide
245
+ * container and 0px-tall rows as facts.
246
+ */
247
+ /*
248
+ * THE ASSERTION THAT DISCRIMINATES, and it is the cause rather
249
+ * than the symptom on purpose.
250
+ *
251
+ * `display: none` gives an element no box, so nothing inside it
252
+ * can be measured. A grid that mounted its rows anyway records a
253
+ * 0px-wide container and 0px-tall rows as facts, and the
254
+ * virtualizer then reads the correction as a resize and
255
+ * compensates the scroll position for it.
256
+ *
257
+ * Whether that compensation actually lands is a RACE — between
258
+ * the `ResizeObserver` delivering the real row heights and the
259
+ * layout effect correcting `scrollMargin`. In Docket it landed
260
+ * and moved `Main` by 266px, 190px and 1,190px on three
261
+ * different switches; in this story it does not land at all. So
262
+ * the symptom is not what is asserted here. This is: with
263
+ * nothing mounted, the race has no entrants.
264
+ */
265
+ await expect(
266
+ hiddenPanel.querySelectorAll("ul").length,
267
+ ).toBe(0)
268
+
269
+ const tabs = [
270
+ ...canvasElement.querySelectorAll<HTMLElement>(
271
+ '[role="tab"]',
272
+ ),
273
+ ]
274
+
275
+ const nextTab = tabs.find(
276
+ (tab) => tab.getAttribute("aria-selected") === "false",
277
+ )
278
+
279
+ if (!nextTab) {
280
+ throw new Error("No unselected tab to switch to.")
281
+ }
282
+
283
+ await expect(main.scrollTop).toBe(0)
284
+
285
+ await userEvent.click(nextTab)
286
+
287
+ /**
288
+ * The list inside the panel that is SHOWING. Scoping matters:
289
+ * without the gate the hidden panel still holds a `<ul>`, and a
290
+ * query that took the first one in the canvas would watch the
291
+ * panel that was just put away and settle before the revealed
292
+ * one had measured anything.
293
+ */
294
+ const getShownList = () => {
295
+ const shown = canvasElement.querySelector(
296
+ '[role="tabpanel"]:not([hidden])',
297
+ )
298
+
299
+ const list = shown?.querySelector<HTMLElement>("ul")
300
+
301
+ if (!list) {
302
+ throw new Error("The shown panel has no grid.")
303
+ }
304
+
305
+ return list
306
+ }
307
+
308
+ // Wait for the revealed grid to have MEASURED, not merely to
309
+ // have mounted — the adjustment, when it happens, happens on the
310
+ // virtualizer's resize path.
311
+ await waitFor(async () => {
312
+ await expect(
313
+ Number(
314
+ getShownList().style.paddingBlockEnd.replace(
315
+ "px",
316
+ "",
317
+ ),
318
+ ),
319
+ ).toBeGreaterThan(0)
320
+ })
321
+
322
+ // The symptom, kept as a guard rather than as the proof. It is
323
+ // free, and it is the sentence a reader of this file wants.
324
+ await expect(main.scrollTop).toBe(0)
325
+ })
326
+
208
327
  test("a screen reader is told the real length", async () => {
209
328
  const { canvasElement } = await mountStory(Default)
210
329
 
@@ -157,6 +157,41 @@ export type VirtualizedGridProps<Item> = {
157
157
  * answer: the browser's `Ctrl+F` only searches what is mounted, and
158
158
  * so does "select all". A page that needs either owes its users a
159
159
  * search field of its own.
160
+ *
161
+ * ### Nothing is mounted until there is a box to measure
162
+ *
163
+ * `display: none` gives an element no boxes at all, so every
164
+ * measurement taken through one reads zero — and zero is the
165
+ * absence of a measurement, not a measurement of nothing. A grid
166
+ * inside a `hidden` tab panel, a collapsed disclosure or a closed
167
+ * `<details>` therefore measures a 0px container (one column) and
168
+ * 0px-tall rows, and the virtualizer stores those as facts.
169
+ *
170
+ * Revealing the panel corrects all of them at once, and the
171
+ * virtualizer reads a correction as a **resize**. That is the one
172
+ * event it is entitled to move the scroll position for: an item
173
+ * above the viewport that grows must be compensated for, or the
174
+ * page slides under the reader. So it compensates — for a growth
175
+ * that never happened — and scrolls the shared scroll owner down
176
+ * by roughly the distance from the top of the region to the top of
177
+ * the grid.
178
+ *
179
+ * Whether the compensation lands is a race between the
180
+ * `ResizeObserver` delivering the real row heights and the layout
181
+ * effect correcting `scrollMargin`, so the symptom is intermittent
182
+ * and its size varies. Docket's Triage bar is where it was found:
183
+ * one switch moved `Main` 266px, another 190px, another 1,190px,
184
+ * and the tab bar went off the top of the screen. The cause is not
185
+ * the tab bar, and fixing it there would have left every other
186
+ * host of a hidden grid broken.
187
+ *
188
+ * `isLaidOut` is the gate. While the container has no box the
189
+ * windowed grid is not mounted, so there is nothing to measure
190
+ * wrongly and no virtualizer to mislead; it mounts when the box
191
+ * arrives and measures once, correctly. The cost is that a
192
+ * revealed grid re-measures from its estimate instead of resuming
193
+ * a cache — the same thing a routed tab does, and the reason a
194
+ * routed tab never had this bug.
160
195
  */
161
196
  export const VirtualizedGrid = <Item,>({
162
197
  blockSizeResolver,
@@ -227,7 +262,8 @@ export const VirtualizedGrid = <Item,>({
227
262
  )}
228
263
  ref={layout.containerRef}
229
264
  >
230
- {scrollOwner?.kind === "element" ? (
265
+ {!layout.isLaidOut ? null : scrollOwner?.kind ===
266
+ "element" ? (
231
267
  <ElementGrid
232
268
  {...gridProps}
233
269
  scrollOwner={scrollOwner}
package/src/index.ts CHANGED
@@ -37,6 +37,11 @@ export type {
37
37
  AccordionProps,
38
38
  } from "./Accordion/Accordion.tsx"
39
39
  export { Accordion } from "./Accordion/Accordion.tsx"
40
+ export type {
41
+ ActionTileItem,
42
+ ActionTilesProps,
43
+ } from "./ActionTiles/ActionTiles.tsx"
44
+ export { ActionTiles } from "./ActionTiles/ActionTiles.tsx"
40
45
  export type { AdaptiveGridProps } from "./AdaptiveGrid/AdaptiveGrid.tsx"
41
46
  export { AdaptiveGrid } from "./AdaptiveGrid/AdaptiveGrid.tsx"
42
47
  export type { ColumnChoice } from "./AdaptiveGrid/chooseColumns.ts"
@@ -537,7 +537,15 @@ test("the barrel is the only place components are re-exported", async () => {
537
537
  // active-path rule an app's tests want to assert directly, and
538
538
  // `useNavLayout.ts`, because the app has to read the width rule to
539
539
  // know which corner to render into.
540
- expect(componentNames.length).toBe(61)
540
+ //
541
+ // `ActionTiles` is +1 -> 62. It is `RadioGroup itemShape="tile"`
542
+ // with the radio taken away: the box is shared through
543
+ // `tileStyles.ts`, and nothing else is. The 2026-08-25 record that
544
+ // made the choice tile a `RadioGroup` shape named this case under
545
+ // what it deliberately does not cover — a tile that navigates is
546
+ // not a radio, and giving one `aria-checked` would be worse than
547
+ // the paint it replaced.
548
+ expect(componentNames.length).toBe(62)
541
549
 
542
550
  for (const name of componentNames) {
543
551
  expect(barrel).toContain(`export { ${name} }`)
@@ -0,0 +1,118 @@
1
+ /**
2
+ * The tile shape, shared by the two components that draw it.
3
+ *
4
+ * A tile is a bordered card carrying a name and a line of help, laid
5
+ * out in a grid that gains columns with its CONTAINER. `RadioGroup`
6
+ * draws one per option when `itemShape="tile"`; `ActionTiles` draws
7
+ * one per action. The two must be the **same box** — a page that puts
8
+ * "which activity" beside "what would you like to do" would otherwise
9
+ * show two cards with different padding, different corners and
10
+ * different gaps, which is the drift this file exists to make
11
+ * impossible.
12
+ *
13
+ * Same argument as `CONTROL_BASE_CLASS`, which `Button` and
14
+ * `ButtonLink` share for the same reason: "identical" spelled as two
15
+ * copies of one string is a promise that survives exactly one edit.
16
+ *
17
+ * These were `RadioGroup`'s private constants until `ActionTiles`
18
+ * shipped. Nothing about their values changed on the way out.
19
+ */
20
+
21
+ import type { ControlSize } from "@charcuterie/tokens"
22
+
23
+ /**
24
+ * A tile is a card, so its padding is a card's rather than a
25
+ * control's — enough that the border reads as a box around content
26
+ * and not as an outline on a line of text.
27
+ *
28
+ * This is also the whole reason `ActionTiles` exists rather than a
29
+ * `Button` with a `className`: a `Button` is sized by
30
+ * `h-(--control-height-md)` and carries **no block padding at all**,
31
+ * so an app that reaches for `height: auto` to let one wrap gets a
32
+ * two-line card with `padding: 0` down its block axis and no error
33
+ * anywhere. QueuePilot's queue-type chooser shipped exactly that.
34
+ */
35
+ export const TILE_PADDING_CLASS: Record<
36
+ ControlSize,
37
+ string
38
+ > = {
39
+ sm: "px-3 py-2.5",
40
+ md: "px-3.5 py-3",
41
+ lg: "px-4 py-3.5",
42
+ }
43
+
44
+ /**
45
+ * The box, and only the box. Every state layered over it is a
46
+ * **border and a surface**, never colour alone — the same reason
47
+ * `Stepper` refuses to say "done" with a hue.
48
+ */
49
+ export const TILE_BOX_CLASS =
50
+ "rounded-lg border border-border-subtle bg-surface-raised"
51
+
52
+ export const TILE_TEXT_SIZE_CLASS: Record<
53
+ ControlSize,
54
+ string
55
+ > = {
56
+ sm: "text-sm",
57
+ md: "text-md",
58
+ lg: "text-lg",
59
+ }
60
+
61
+ /**
62
+ * One step down from the tile's own text, and never below `xs`: a
63
+ * hint that keeps shrinking with the density axis is the 11px
64
+ * caption the 2026-08-10 ramp rebuild exists to have removed.
65
+ */
66
+ export const TILE_HINT_TEXT_CLASS: Record<
67
+ ControlSize,
68
+ string
69
+ > = {
70
+ sm: "text-xs",
71
+ md: "text-xs",
72
+ lg: "text-sm",
73
+ }
74
+
75
+ /**
76
+ * Wider than a list's row gaps: rows are separated by their leading,
77
+ * and tiles by nothing but the gap, so the same 6px that reads as a
78
+ * list reads as a seam between two cards.
79
+ */
80
+ export const TILE_GAP_CLASS: Record<ControlSize, string> = {
81
+ sm: "gap-1.5",
82
+ md: "gap-2",
83
+ lg: "gap-3",
84
+ }
85
+
86
+ /**
87
+ * `auto-fill`, and deliberately not `auto-fit`.
88
+ *
89
+ * `auto-fit` collapses the empty tracks and lets the ones that
90
+ * remain share the whole row, so six tiles in a 2560px container
91
+ * become six 420px slabs — the full-width-row shape in a new
92
+ * costume. `auto-fill` keeps the empty tracks, so a tile stays a
93
+ * tile and the grid simply has room to spare.
94
+ *
95
+ * `min(…, 100%)` is what stops the floor overflowing a container
96
+ * narrower than one tile, which is the Narrow View and is otherwise
97
+ * a horizontal scrollbar on a phone.
98
+ *
99
+ * The floor arrives as a custom property rather than an interpolated
100
+ * class because Tailwind scans source *text* for complete class
101
+ * strings: `` `grid-cols-[…${n}px…]` `` generates nothing, paints
102
+ * nothing and reports nothing. One written-out literal reading
103
+ * `var(--charcuterie-tile-min-inline-size)` covers every width an
104
+ * app can ask for — the same reason `Card`'s accent edge goes
105
+ * through one property.
106
+ *
107
+ * This is **not** `useAdaptiveColumns`, and the difference is the
108
+ * question being asked. That hook buys a column with height, for an
109
+ * unbounded gallery that will scroll; a tile set is a bounded group
110
+ * inside a form section or a first step, where the only question is
111
+ * how many fit across the box it was given.
112
+ */
113
+ export const TILE_COLUMNS_CLASS =
114
+ "grid-cols-[repeat(auto-fill,minmax(min(var(--charcuterie-tile-min-inline-size),100%),1fr))]"
115
+
116
+ /** The property `TILE_COLUMNS_CLASS` reads its floor from. */
117
+ export const TILE_MIN_INLINE_SIZE_PROPERTY =
118
+ "--charcuterie-tile-min-inline-size"