@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
@@ -0,0 +1,292 @@
1
+ import type { ControlSize } from "@charcuterie/tokens"
2
+ import type { CSSProperties, ReactNode } from "react"
3
+
4
+ import { FOCUS_RING_CLASS } from "../intentStyles.ts"
5
+ import { useRouterLink } from "../RouterLink/RouterLinkProvider.tsx"
6
+ import { getIsRoutedHref } from "../RouterLink/routerLink.ts"
7
+ import {
8
+ TILE_BOX_CLASS,
9
+ TILE_COLUMNS_CLASS,
10
+ TILE_GAP_CLASS,
11
+ TILE_HINT_TEXT_CLASS,
12
+ TILE_MIN_INLINE_SIZE_PROPERTY,
13
+ TILE_PADDING_CLASS,
14
+ TILE_TEXT_SIZE_CLASS,
15
+ } from "../tileStyles.ts"
16
+ import { toClassName } from "../toClassName.ts"
17
+ import { VisuallyHidden } from "../VisuallyHidden/VisuallyHidden.tsx"
18
+
19
+ export type ActionTileItem = {
20
+ /**
21
+ * A line of help under the name. Drawn one step down the type ramp
22
+ * in `content-muted`, **inside** the tile — so `getByRole` computes
23
+ * it into the accessible name. A hint a screen reader never reads
24
+ * is a hint half the audience does not have.
25
+ */
26
+ hint?: ReactNode
27
+ /**
28
+ * Makes this tile a real `<a href>`: middle-click, ctrl-click,
29
+ * "open in new tab", "copy link address" and the status bar all
30
+ * come from the element rather than from the paint. Routed through
31
+ * the injected `RouterLink` when the destination is same-origin and
32
+ * not a fragment.
33
+ *
34
+ * Without one, the tile is a `<button>` that calls the set's
35
+ * `onChoose`. A set may mix the two.
36
+ */
37
+ href?: string
38
+ /**
39
+ * Leading content, above the name. A `Badge`, an app's own icon, a
40
+ * count.
41
+ *
42
+ * `aria-hidden` by construction: the name it sits above is inside
43
+ * the same tile, so an announced icon is the label read twice.
44
+ */
45
+ icon?: ReactNode
46
+ isDisabled?: boolean
47
+ /** Opens in a new tab, and says so to a screen reader. `href` only. */
48
+ isExternal?: boolean
49
+ label: ReactNode
50
+ /**
51
+ * Identity. The React key, and what `onChoose` is handed.
52
+ *
53
+ * Required even on a tile that navigates, so a set can mix the two
54
+ * without one half losing its key — the same reason `RadioItem`
55
+ * carries one.
56
+ */
57
+ value: string
58
+ }
59
+
60
+ export type ActionTilesProps = {
61
+ className?: string
62
+ items: readonly ActionTileItem[]
63
+ /** The set's accessible name. Required. */
64
+ label: string
65
+ /**
66
+ * The narrowest a tile track may be, in CSS px.
67
+ *
68
+ * It is the grid's floor and not the tile's width: tracks below it
69
+ * are not created, and the ones that are share the row evenly.
70
+ */
71
+ minTileInlineSize?: number
72
+ /** Fired by a tile that carries no `href`. */
73
+ onChoose?: (value: string) => void
74
+ /** What `isExternal` announces. Not shown. */
75
+ newTabLabel?: string
76
+ size?: ControlSize
77
+ }
78
+
79
+ /**
80
+ * A set of ACTIONS drawn as tiles — a bordered card carrying a name
81
+ * and a line of help, in a grid that gains columns with its
82
+ * container.
83
+ *
84
+ * This is `RadioGroup`'s `itemShape="tile"` with the radio taken
85
+ * away. The
86
+ * [2026-08-25 record](../../../../docs/decisions/2026-08-25-a-choice-tile-is-a-radiogroup-shape-not-a-third-component.md)
87
+ * that made the choice tile a `RadioGroup` shape named this case
88
+ * under *"What this deliberately does not cover"* — mux-magic's "Pick
89
+ * a tool" tiles are `<a href>` and points-market's are `<Link>`, and
90
+ * *"a link is not a radio, and giving one `aria-checked` would be
91
+ * worse than the paint it replaced"*. So the box is shared through
92
+ * `tileStyles.ts` and nothing else is.
93
+ *
94
+ * ### Which of the two you want
95
+ *
96
+ * Ask what a press does.
97
+ *
98
+ * - It **records a value** the user will then submit, or that
99
+ * filters what is below it — `RadioGroup itemShape="tile"`. The
100
+ * set announces "3 of 6", the arrow keys move the choice, and one
101
+ * tile stays selected.
102
+ * - It **goes somewhere or starts something** — this. Nothing stays
103
+ * selected, because nothing was chosen: the page moved on. Tab
104
+ * reaches each tile, Enter and Space press it, and a `href` tile
105
+ * is a real anchor.
106
+ *
107
+ * A first step that opens the next step is the second kind, which is
108
+ * why QueuePilot's "Queue type" chooser is here and its Tonight
109
+ * activity filter is not.
110
+ *
111
+ * ### Why this is not a `Button` with a `className`
112
+ *
113
+ * A `Button` is sized by `h-(--control-height-md)` and carries **no
114
+ * block padding at all** — one line of text, by contract. An app that
115
+ * wants a wrapping two-line card reaches for `height: auto`, which
116
+ * deletes the height and leaves `padding: 0` down the block axis:
117
+ * a title flush against the top border and a description flush
118
+ * against the bottom one, with nothing to report it. That shipped in
119
+ * QueuePilot and is what this component exists to have made
120
+ * impossible.
121
+ *
122
+ * ### `role="group"`, and no roving tabindex
123
+ *
124
+ * These are ordinary buttons and anchors, so the platform already
125
+ * owns the keyboard: Tab reaches each one, Enter and Space press it,
126
+ * and a link opens in a new tab on ctrl-click. A radio group borrows
127
+ * one tab stop and moves the choice with the arrow keys because
128
+ * exactly one of its options is true at a time; nothing here is
129
+ * true, so imposing that model would only cost a keyboard user the
130
+ * ability to Tab to the second tile.
131
+ */
132
+ export const ActionTiles = ({
133
+ className,
134
+ items,
135
+ label,
136
+ minTileInlineSize = 200,
137
+ newTabLabel = "(opens in a new tab)",
138
+ onChoose,
139
+ size = "md",
140
+ }: ActionTilesProps): ReactNode => {
141
+ const RouterLink = useRouterLink()
142
+
143
+ return (
144
+ // 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 buttons and links that is not a form. Same call `Menu`'s group, `AccordionSection` and `BoardLaneList` already made.
145
+ <div
146
+ aria-label={label}
147
+ className={toClassName(
148
+ "grid",
149
+ TILE_COLUMNS_CLASS,
150
+ TILE_GAP_CLASS[size],
151
+ className,
152
+ )}
153
+ role="group"
154
+ style={
155
+ {
156
+ [TILE_MIN_INLINE_SIZE_PROPERTY]: `${minTileInlineSize}px`,
157
+ } as CSSProperties
158
+ }
159
+ >
160
+ {items.map(
161
+ ({
162
+ hint,
163
+ href,
164
+ icon,
165
+ isDisabled = false,
166
+ isExternal = false,
167
+ label: tileLabel,
168
+ 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
+ )
191
+
192
+ const content = (
193
+ <>
194
+ {icon === undefined ? null : (
195
+ <span aria-hidden className="flex">
196
+ {icon}
197
+ </span>
198
+ )}
199
+
200
+ <span className="font-semibold text-content-primary">
201
+ {tileLabel}
202
+ </span>
203
+
204
+ {hint === undefined ? null : (
205
+ <span
206
+ className={toClassName(
207
+ "font-normal text-content-muted",
208
+ TILE_HINT_TEXT_CLASS[size],
209
+ )}
210
+ >
211
+ {hint}
212
+ </span>
213
+ )}
214
+
215
+ {isExternal && href !== undefined ? (
216
+ <VisuallyHidden>
217
+ {newTabLabel}
218
+ </VisuallyHidden>
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
232
+
233
+ return (
234
+ <LinkElement
235
+ className={tileClassName}
236
+ href={href}
237
+ key={value}
238
+ // `noreferrer` alongside `noopener` on purpose:
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}
248
+ >
249
+ {content}
250
+ </LinkElement>
251
+ )
252
+ }
253
+
254
+ if (href !== undefined) {
255
+ // `isDisabled` drops `href` entirely rather than shipping
256
+ // a focusable anchor that silently ignores clicks — an
257
+ // anchor with no `href` is inert by the platform's own
258
+ // rules, and the explicit `role`/`aria-disabled` keeps it
259
+ // *announced* as a link that is currently unavailable
260
+ // rather than vanishing from the tree. `ButtonLink`'s
261
+ // rule, unchanged.
262
+ return (
263
+ // 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.
264
+ <a
265
+ aria-disabled="true"
266
+ className={tileClassName}
267
+ key={value}
268
+ role="link"
269
+ >
270
+ {content}
271
+ </a>
272
+ )
273
+ }
274
+
275
+ return (
276
+ <button
277
+ className={tileClassName}
278
+ disabled={isDisabled}
279
+ key={value}
280
+ onClick={() => {
281
+ onChoose?.(value)
282
+ }}
283
+ type="button"
284
+ >
285
+ {content}
286
+ </button>
287
+ )
288
+ },
289
+ )}
290
+ </div>
291
+ )
292
+ }
@@ -240,6 +240,22 @@ export const useAdaptiveColumns = <
240
240
  * ⚠️ Never the element `contentMaxInlineSize` is applied to.
241
241
  */
242
242
  containerRef: RefObject<ContainerElement | null>
243
+ /**
244
+ * Has the container been given a box yet?
245
+ *
246
+ * False in two situations that look identical from here and are
247
+ * both "no measurement has happened": the frame before
248
+ * `ResizeObserver` delivers its first record, and any time the
249
+ * container sits inside a `display: none` ancestor — which is
250
+ * what the `hidden` attribute is, and what a tab panel, a
251
+ * collapsed disclosure and a closed `<details>` all use.
252
+ *
253
+ * The distinction matters because a zero inline size is not a
254
+ * measurement of zero. It is the absence of one, and a consumer
255
+ * that treats it as a number lays out for a 0px container and
256
+ * then reads the correction as a change.
257
+ */
258
+ isLaidOut: boolean
243
259
  /** What was picked, `"auto"` included. */
244
260
  choice: ColumnChoice
245
261
  setChoice: (choice: ColumnChoice) => void
@@ -350,6 +366,7 @@ export const useAdaptiveColumns = <
350
366
  gutterInlineSize,
351
367
  singleInlineSize,
352
368
  }),
369
+ isLaidOut: availableInlineSize > 0,
353
370
  setChoice,
354
371
  }
355
372
  }
@@ -59,6 +59,13 @@ tile with its border taken away is a row.
59
59
  no radio affordance, options too short to carry a sentence. A tile is this control with a
60
60
  border, which is a different reason.
61
61
 
62
+ [`ActionTiles`](/docs/components-actions-actiontiles--docs) is the same tile that **acts**
63
+ rather than holds a value, and it *does* earn its own file: invert every clause of the
64
+ paragraph above — no `radio` role, no roving tabindex, no selection-follows-focus, no
65
+ `isReadOnly` — and the box is all that is left in common. The two read it from one
66
+ `tileStyles.ts`. Ask what a press does: records a value something below reads, or goes
67
+ somewhere and is done.
68
+
62
69
  <Canvas of={RadioGroupStories.Tiles} />
63
70
 
64
71
  Four apps in the fleet had each hand-painted this shape — ten instances between them — and
@@ -7,6 +7,11 @@ import type { ControlSize } from "@charcuterie/tokens"
7
7
  import type { CSSProperties, ReactNode } from "react"
8
8
  import { useCallback, useEffect, useRef } from "react"
9
9
 
10
+ import {
11
+ TILE_COLUMNS_CLASS,
12
+ TILE_GAP_CLASS,
13
+ TILE_MIN_INLINE_SIZE_PROPERTY,
14
+ } from "../tileStyles.ts"
10
15
  import { toClassName } from "../toClassName.ts"
11
16
  import { RadioGroupOption } from "./RadioGroupOption.tsx"
12
17
 
@@ -85,47 +90,6 @@ const GAP_CLASS: Record<ControlSize, string> = {
85
90
  lg: "gap-2",
86
91
  }
87
92
 
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
-
129
93
  /**
130
94
  * One choice out of many, each on its own row with a label the size
131
95
  * of prose — the stacked sibling of `SegmentedControl`.
@@ -157,6 +121,16 @@ const TILE_COLUMNS_CLASS =
157
121
  * and `isReadOnly` are the ones already tested here. `SegmentedControl`
158
122
  * earns its own file by being a different control — one connected
159
123
  * strip, no radio affordance; a tile is this control with a border.
124
+ *
125
+ * ### A tile that ACTS is `ActionTiles`
126
+ *
127
+ * Same box, read from `tileStyles.ts`, and nothing else in common:
128
+ * no `radio` role, no roving tabindex, no selection to follow focus,
129
+ * no read-only. Ask what a press does. It records a value something
130
+ * below reads — this. It goes somewhere or starts something, and
131
+ * nothing stays selected — `ActionTiles`. Inverting every clause of
132
+ * the paragraph above is what earned that one its own file
133
+ * (`docs/decisions/2026-09-01-a-tile-that-acts-is-its-own-component-and-shares-only-the-box.md`).
160
134
  */
161
135
  export const RadioGroup = ({
162
136
  className,
@@ -292,7 +266,7 @@ export const RadioGroup = ({
292
266
  style={
293
267
  isTile
294
268
  ? ({
295
- "--charcuterie-tile-min-inline-size": `${minTileInlineSize}px`,
269
+ [TILE_MIN_INLINE_SIZE_PROPERTY]: `${minTileInlineSize}px`,
296
270
  } as CSSProperties)
297
271
  : undefined
298
272
  }
@@ -3,6 +3,12 @@ import type { ReactNode } from "react"
3
3
  import { useEffect } from "react"
4
4
 
5
5
  import { FOCUS_RING_CLASS } from "../intentStyles.ts"
6
+ import {
7
+ TILE_BOX_CLASS,
8
+ TILE_HINT_TEXT_CLASS,
9
+ TILE_PADDING_CLASS,
10
+ TILE_TEXT_SIZE_CLASS,
11
+ } from "../tileStyles.ts"
6
12
  import { toClassName } from "../toClassName.ts"
7
13
  import type {
8
14
  RadioItem,
@@ -42,42 +48,6 @@ const DOT_SIZE_CLASS: Record<ControlSize, string> = {
42
48
  lg: "size-2.5",
43
49
  }
44
50
 
45
- const TEXT_SIZE_CLASS: Record<ControlSize, string> = {
46
- sm: "text-sm",
47
- md: "text-md",
48
- lg: "text-lg",
49
- }
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
-
81
51
  /**
82
52
  * Its own file for the same reason `SegmentedOption` is: **both
83
53
  * registrations are effects**, and an effect cannot run in a loop.
@@ -152,7 +122,7 @@ export const RadioGroupOption = ({
152
122
  ? "items-center"
153
123
  : "items-start",
154
124
  ),
155
- TEXT_SIZE_CLASS[size],
125
+ TILE_TEXT_SIZE_CLASS[size],
156
126
  // Disabled turns the whole option down with `opacity-60`, the
157
127
  // same family treatment as `Checkbox` and `Switch`, rather than
158
128
  // fading the ring to a token that disappeared on a pale theme.
@@ -252,7 +222,7 @@ export const RadioGroupOption = ({
252
222
  <span
253
223
  className={toClassName(
254
224
  "font-normal text-content-muted",
255
- HINT_TEXT_CLASS[size],
225
+ TILE_HINT_TEXT_CLASS[size],
256
226
  )}
257
227
  >
258
228
  {hint}
@@ -59,6 +59,27 @@ wrong and every row is offset by the chrome above it.
59
59
 
60
60
  <Canvas of={VirtualizedGridStories.InShellScrollRegion} />
61
61
 
62
+ ## It waits for a box before it measures anything
63
+
64
+ `display: none` gives an element no boxes, and the `hidden` attribute is `display: none`.
65
+ So a grid inside a hidden tab panel, a collapsed disclosure or a closed `<details>` cannot
66
+ be measured at all: its container reads 0px wide and every mounted row reads 0px tall.
67
+
68
+ That is the absence of a measurement, not a measurement of zero, and the difference is not
69
+ academic. Revealing the panel corrects every one of those numbers at once, and a
70
+ virtualizer is entitled to read a correction as a **resize** — the one event it may move
71
+ the scroll position for. It then compensates for growth that never happened and scrolls the
72
+ page. Docket's Triage bar is where this was found: switching tabs moved `Main` by up to
73
+ 1,190px and took the tab bar off the top of the screen.
74
+
75
+ So the grid mounts nothing until its container has a box. Put a grid in a tab panel and the
76
+ panel holds no rows while it is put away; the rows arrive with the box and are measured once,
77
+ correctly. The cost is that a revealed grid re-measures from its estimate rather than
78
+ resuming a cache, which is exactly what a routed tab already does.
79
+
80
+ <Canvas of={VirtualizedGridStories.InHiddenTabPanel} />
81
+
82
+
62
83
  ## The column rule is inherited, not reimplemented
63
84
 
64
85
  <Canvas of={VirtualizedGridStories.AllVariants} />
@@ -10,6 +10,7 @@ import { Header } from "../Header/Header.tsx"
10
10
  import { Main } from "../Main/Main.tsx"
11
11
  import { SegmentedControl } from "../SegmentedControl/SegmentedControl.tsx"
12
12
  import { Shell } from "../Shell/Shell.tsx"
13
+ import { Tabs } from "../Tabs/Tabs.tsx"
13
14
  import { VirtualizedGrid } from "./VirtualizedGrid.tsx"
14
15
 
15
16
  /**
@@ -130,6 +131,69 @@ export const InShellScrollRegion: Story = {
130
131
  ),
131
132
  }
132
133
 
134
+ /**
135
+ * Two grids in a tab bar, one of them hidden — the shape that found
136
+ * the scroll bug.
137
+ *
138
+ * A `hidden` panel is `display: none`, so the grid inside it has no
139
+ * box: its container measures 0px wide (one column) and every
140
+ * mounted row measures 0px tall. Revealing the panel corrects all
141
+ * of that at once, and a virtualizer is entitled to read a
142
+ * correction as a resize and compensate the scroll position for it.
143
+ *
144
+ * Docket's Triage bar is where it showed: clicking a tab scrolled
145
+ * `Main` down 266px and put the tab bar off the top of the screen.
146
+ * So the claim this story exists to hold is one number — **switching
147
+ * tabs leaves `Main.scrollTop` where it was.**
148
+ *
149
+ * The chrome above the grid is not decoration either. The false
150
+ * adjustment is roughly the distance from the top of the scroll
151
+ * region to the top of the grid, so a story with the grid flush
152
+ * against the top of `Main` would have had nothing to measure and
153
+ * would have passed while the bug was still there.
154
+ */
155
+ export const InHiddenTabPanel: Story = {
156
+ parameters: { layout: "fullscreen" },
157
+ render: (gridProps) => (
158
+ <Shell contentWidth="full">
159
+ <Header heading="Bays" />
160
+
161
+ <Main>
162
+ <p className="text-content-secondary text-sm">
163
+ Switching tabs must not move the page.
164
+ </p>
165
+
166
+ <Tabs
167
+ label="Bay views"
168
+ tabs={[
169
+ {
170
+ content: (
171
+ <VirtualizedGrid
172
+ {...gridProps}
173
+ label="Loaded bays"
174
+ />
175
+ ),
176
+ key: "loaded",
177
+ label: "Loaded",
178
+ },
179
+ {
180
+ content: (
181
+ <VirtualizedGrid
182
+ {...gridProps}
183
+ items={MANY_ITEMS.slice(0, 48)}
184
+ label="Reserved bays"
185
+ />
186
+ ),
187
+ key: "reserved",
188
+ label: "Reserved",
189
+ },
190
+ ]}
191
+ />
192
+ </Main>
193
+ </Shell>
194
+ ),
195
+ }
196
+
133
197
  /**
134
198
  * The same 2,000 items at three window heights.
135
199
  *