@charcuterie/ui 3.7.0 → 3.8.1

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.
@@ -0,0 +1,340 @@
1
+ import { useWindowVirtualizer } from "@tanstack/react-virtual"
2
+ import type { ReactNode } from "react"
3
+ import {
4
+ useCallback,
5
+ useLayoutEffect,
6
+ useRef,
7
+ useState,
8
+ } from "react"
9
+
10
+ import { getContentMaxInlineSize } from "../AdaptiveGrid/chooseColumns.ts"
11
+ import type { BlockSizeResolver } from "../AdaptiveGrid/useAdaptiveColumns.ts"
12
+ import { useAdaptiveColumns } from "../AdaptiveGrid/useAdaptiveColumns.ts"
13
+ import { toClassName } from "../toClassName.ts"
14
+ import { DEFAULT_GRID_GAP_PX } from "./gridGap.ts"
15
+
16
+ export type VirtualizedGridProps<Item> = {
17
+ /**
18
+ * Where the available block size is read from. Defaults to the
19
+ * viewport, which is the only thing that can answer "will this
20
+ * scroll".
21
+ */
22
+ blockSizeResolver?: BlockSizeResolver
23
+ /**
24
+ * Everything down the page that is not this grid, in CSS px —
25
+ * header, rails, filter rows. Subtracted before asking how many
26
+ * items stack.
27
+ */
28
+ chromeBlockSize?: number
29
+ className?: string
30
+ /**
31
+ * Draw this many columns instead of the automatic answer — the
32
+ * manual override, for a page that hoisted `useAdaptiveColumns`
33
+ * itself.
34
+ */
35
+ columns?: number
36
+ /** The gap between tracks and rows, in CSS px. */
37
+ gap?: number
38
+ /**
39
+ * A stable identity per item. Without one the key is the index,
40
+ * which is wrong the moment the list is filtered or an item
41
+ * leaves the middle — React reuses the mounted subtree and the
42
+ * wrong tile inherits the wrong open menu.
43
+ */
44
+ getItemKey?: (
45
+ item: Item,
46
+ index: number,
47
+ ) => string | number
48
+ /**
49
+ * One row's block size in CSS px, as a starting guess.
50
+ *
51
+ * Unlike `AdaptiveGrid`'s, this is an *estimate* and not a
52
+ * commitment: every mounted row is measured and the estimate
53
+ * corrected. It still wants to be close, because it is what the
54
+ * scrollbar is sized from before anything has been measured — a
55
+ * bad guess makes the bar jump under the thumb on first scroll.
56
+ */
57
+ itemBlockSize: number
58
+ /** Every item, mounted or not. */
59
+ items: readonly Item[]
60
+ /**
61
+ * An accessible name for the list. Worth giving: a windowed list
62
+ * is one whose length a reader cannot count for itself.
63
+ */
64
+ label?: string
65
+ /** The most columns the automatic answer will take. */
66
+ maxColumns?: number
67
+ /** The narrowest a column may be, in CSS px. */
68
+ minColumnInlineSize?: number
69
+ /**
70
+ * How many rows to mount above and below the viewport.
71
+ *
72
+ * Four rather than `@tanstack/react-virtual`'s default of one:
73
+ * these are media tiles whose images load over the network, and
74
+ * a row that mounts at the moment it becomes visible shows a
75
+ * blank box while the image arrives. Overscan buys the load a
76
+ * head start.
77
+ */
78
+ overscanRows?: number
79
+ /** Draw one item. */
80
+ renderItem: (item: Item, index: number) => ReactNode
81
+ /**
82
+ * The `localStorage` key a manual column choice is remembered
83
+ * under. Namespace it to the app; omit it and nothing is
84
+ * written.
85
+ */
86
+ storageKey?: string
87
+ }
88
+
89
+ /**
90
+ * `AdaptiveGrid`'s layout, with only the rows you can see mounted.
91
+ *
92
+ * The fleet's grids render every item they are given. That is the
93
+ * right default, and it stops being right somewhere in the low
94
+ * thousands: QueuePilot's Pending page put **2,162** tiles on one
95
+ * screen — 19,933 DOM nodes, 2,162 `<img>`, 4,371 `<button>`, 7.8
96
+ * seconds to settle, and roughly 43fps while scrolling on a machine
97
+ * with no GPU to lose. Nothing about that page was wrong except how
98
+ * much of it existed at once.
99
+ *
100
+ * ### It is the same grid, and deliberately so
101
+ *
102
+ * Columns come from `useAdaptiveColumns` — the identical
103
+ * height-first rule `AdaptiveGrid` uses, with the identical two-box
104
+ * structure protecting it (an uncapped outer box is measured, an
105
+ * inner box carries the cap). Windowing is the only difference. A
106
+ * second column heuristic that windowed lists followed and flow
107
+ * lists did not would be a fleet where the same content lays out
108
+ * two ways depending on how many of it there are.
109
+ *
110
+ * ### The window is padding, not absolute positioning
111
+ *
112
+ * The obvious build — absolutely position a row per visible row —
113
+ * is the one this does not do, and the reason is the markup. A
114
+ * `<ul>` whose children are positioning wrappers is not a list any
115
+ * more; the cards stop being `<li>`, and the structure a screen
116
+ * reader walks becomes a fiction maintained by ARIA roles.
117
+ *
118
+ * So there are no row elements at all. One real CSS grid holds the
119
+ * visible items as ordinary `<li>` children, and the thousands of
120
+ * rows above and below it are two numbers:
121
+ * `padding-block-start` for everything scrolled past and
122
+ * `padding-block-end` for everything still to come. The grid then
123
+ * does its own job — tracks on the inline axis, rows sized to
124
+ * their tallest item — instead of being reimplemented in
125
+ * `transform`s.
126
+ *
127
+ * ### How a row gets measured without existing
128
+ *
129
+ * A CSS grid stretches every cell in a row to the row's height
130
+ * (`align-items: stretch`, which is the default and is not
131
+ * overridden here). So **any** cell in a row is exactly as tall as
132
+ * the row, and measuring the first one measures the row. That is
133
+ * what `data-index` and the `measureElement` ref are doing on the
134
+ * leading cell of each row, and it is why tile heights are allowed
135
+ * to vary — a two-line title, a wrapped pair of buttons, a badge
136
+ * only some items carry.
137
+ *
138
+ * The row gap sits between two measured boxes and so is in
139
+ * neither. `measureRow` adds it back, which keeps the virtualizer's
140
+ * idea of the distance from one row to the next and the browser's
141
+ * the same number.
142
+ *
143
+ * ### Why the window and not a scroll box of its own
144
+ *
145
+ * `useWindowVirtualizer`, so the page scrolls the way every other
146
+ * page in the fleet scrolls — the header leaves, the scrollbar is
147
+ * the browser's, `Ctrl+End` works. A component that grew its own
148
+ * `overflow: auto` well would trap the wheel and strand the page
149
+ * header above it. The cost is `scrollMargin`: the virtualizer has
150
+ * to be told how far down the document the grid starts, which is
151
+ * measured below.
152
+ *
153
+ * ### What windowing costs, and what is done about it
154
+ *
155
+ * The true length is stated rather than implied — `aria-setsize` on
156
+ * every cell carries the full count and `aria-posinset` its real
157
+ * position, so a reader announces "3 of 2,162" instead of "3 of
158
+ * 40". Two costs have no fix at this layer and are the caller's to
159
+ * answer: the browser's `Ctrl+F` only searches what is mounted, and
160
+ * so does "select all". A page that needs either owes its users a
161
+ * search field of its own.
162
+ */
163
+ export const VirtualizedGrid = <Item,>({
164
+ blockSizeResolver,
165
+ chromeBlockSize,
166
+ className,
167
+ columns,
168
+ gap = DEFAULT_GRID_GAP_PX,
169
+ getItemKey,
170
+ itemBlockSize,
171
+ items,
172
+ label,
173
+ maxColumns,
174
+ minColumnInlineSize,
175
+ overscanRows = 4,
176
+ renderItem,
177
+ storageKey,
178
+ }: VirtualizedGridProps<Item>): ReactNode => {
179
+ const layout = useAdaptiveColumns({
180
+ blockSizeResolver,
181
+ chromeBlockSize,
182
+ itemBlockSize,
183
+ itemCount: items.length,
184
+ maxAutoColumns: maxColumns,
185
+ minColumnInlineSize,
186
+ storageKey,
187
+ })
188
+
189
+ const drawnColumns = columns ?? layout.columns
190
+
191
+ // Folded from what is DRAWN rather than read off the hook, so an
192
+ // override widens the page cap with it — the same rule
193
+ // `AdaptiveGrid` follows, and wrong in the same way if it does
194
+ // not.
195
+ const contentMaxInlineSize = getContentMaxInlineSize({
196
+ columns: drawnColumns,
197
+ })
198
+
199
+ const rowCount = Math.ceil(items.length / drawnColumns)
200
+
201
+ /**
202
+ * How far down the document the grid starts.
203
+ *
204
+ * `useWindowVirtualizer` measures against the document, so
205
+ * without this every row is placed as though the grid began at
206
+ * the top of the page, and the whole list sits shifted by the
207
+ * height of the header above it.
208
+ *
209
+ * State and not a ref, because it feeds `scrollMargin` and a
210
+ * change has to re-render. Measured on every commit rather than
211
+ * on a dependency list: a filter row that grows a line moves the
212
+ * grid down the page, and nothing in this component's own props
213
+ * would report that.
214
+ */
215
+ const gridElement = useRef<HTMLUListElement>(null)
216
+ const [scrollMargin, setScrollMargin] = useState(0)
217
+
218
+ useLayoutEffect(() => {
219
+ const element = gridElement.current
220
+
221
+ if (!element) {
222
+ return
223
+ }
224
+
225
+ const next =
226
+ element.getBoundingClientRect().top + window.scrollY
227
+
228
+ setScrollMargin((current) =>
229
+ current === next ? current : next,
230
+ )
231
+ })
232
+
233
+ /**
234
+ * A row's height, plus the gap that follows it.
235
+ *
236
+ * The gap is added here rather than left to the estimate because
237
+ * it is real distance the next row starts after: leave it out
238
+ * and the virtualizer's offsets drift by one gap per row, which
239
+ * on a list of two thousand is a scrollbar off by half a page.
240
+ */
241
+ const measureRow = useCallback(
242
+ (element: Element) =>
243
+ element.getBoundingClientRect().height + gap,
244
+ [gap],
245
+ )
246
+
247
+ const rowVirtualizer = useWindowVirtualizer({
248
+ count: rowCount,
249
+ estimateSize: () => itemBlockSize + gap,
250
+ measureElement: measureRow,
251
+ overscan: overscanRows,
252
+ scrollMargin,
253
+ })
254
+
255
+ const virtualRows = rowVirtualizer.getVirtualItems()
256
+
257
+ const firstRow = virtualRows[0]
258
+ const lastRow = virtualRows[virtualRows.length - 1]
259
+
260
+ // Everything scrolled past, and everything still to come. The two
261
+ // numbers that stand in for the rows that do not exist.
262
+ const paddingBlockStart = firstRow?.start ?? 0
263
+
264
+ const paddingBlockEnd = lastRow
265
+ ? Math.max(
266
+ 0,
267
+ rowVirtualizer.getTotalSize() - lastRow.end,
268
+ )
269
+ : 0
270
+
271
+ return (
272
+ <div
273
+ className={toClassName(
274
+ "flex justify-center",
275
+ className,
276
+ )}
277
+ ref={layout.containerRef}
278
+ >
279
+ <ul
280
+ aria-label={label}
281
+ className="grid w-full list-none p-0 [&>*]:min-w-0"
282
+ ref={gridElement}
283
+ style={{
284
+ gap: `${gap}px`,
285
+ gridTemplateColumns: `repeat(${drawnColumns}, minmax(0, 1fr))`,
286
+ maxInlineSize: contentMaxInlineSize,
287
+ // Offset by `scrollMargin` because `start` is measured
288
+ // from the top of the document, and this padding is
289
+ // measured from the top of the grid.
290
+ paddingBlockEnd: `${paddingBlockEnd}px`,
291
+ paddingBlockStart: `${Math.max(0, paddingBlockStart - scrollMargin)}px`,
292
+ }}
293
+ >
294
+ {virtualRows.flatMap((row) => {
295
+ const firstIndex = row.index * drawnColumns
296
+
297
+ return items
298
+ .slice(firstIndex, firstIndex + drawnColumns)
299
+ .map((item, offset) => {
300
+ const index = firstIndex + offset
301
+
302
+ return (
303
+ <li
304
+ aria-posinset={index + 1}
305
+ aria-setsize={items.length}
306
+ // `data-index` names the ROW, so it goes on
307
+ // every cell in it — they all sit in that row
308
+ // and the attribute is true of all of them.
309
+ //
310
+ // Putting it only on the measured cell is the
311
+ // version that looks tidier and warns on every
312
+ // resize: the virtualizer keeps a
313
+ // `ResizeObserver` on whatever it measured and
314
+ // re-reads the attribute whenever that node
315
+ // changes size. Let the node stop being the
316
+ // leading cell — a column count change is all it
317
+ // takes — and it is still observed, now with no
318
+ // attribute to resolve.
319
+ data-index={row.index}
320
+ key={getItemKey?.(item, index) ?? index}
321
+ // Measured on the leading cell alone. A CSS grid
322
+ // stretches every cell to its row's height, so
323
+ // one is enough — and measuring all of them
324
+ // would have the virtualizer record the same row
325
+ // once per column.
326
+ ref={
327
+ offset === 0
328
+ ? rowVirtualizer.measureElement
329
+ : undefined
330
+ }
331
+ >
332
+ {renderItem(item, index)}
333
+ </li>
334
+ )
335
+ })
336
+ })}
337
+ </ul>
338
+ </div>
339
+ )
340
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The gap between tracks and between rows in a wrapping grid, in
3
+ * CSS px.
4
+ *
5
+ * `AdaptiveGrid` spells this `gap-4` and never has to know the
6
+ * number: it lays out in flow, so the browser owns the spacing.
7
+ * `VirtualizedGrid` does have to know it. The space between two
8
+ * rows falls outside the box either of them measures, and the rows
9
+ * above and below the window are arithmetic rather than layout, so
10
+ * a gap the component cannot name is a gap its scrollbar is wrong
11
+ * by — once per row, which on a list of two thousand is half a
12
+ * page.
13
+ *
14
+ * `4` on Tailwind's spacing scale is `1rem`, and `1rem` is 16px at
15
+ * the fleet's root size. Exported so a caller measuring its own
16
+ * `itemBlockSize` off a running page can account for it rather
17
+ * than rediscovering the number.
18
+ */
19
+ export const DEFAULT_GRID_GAP_PX = 16
package/src/index.ts CHANGED
@@ -384,5 +384,8 @@ export { Toolbar } from "./Toolbar/Toolbar.tsx"
384
384
  export type { TooltipProps } from "./Tooltip/Tooltip.tsx"
385
385
  export { Tooltip } from "./Tooltip/Tooltip.tsx"
386
386
  export { toClassName } from "./toClassName.ts"
387
+ export { DEFAULT_GRID_GAP_PX } from "./VirtualizedGrid/gridGap.ts"
388
+ export type { VirtualizedGridProps } from "./VirtualizedGrid/VirtualizedGrid.tsx"
389
+ export { VirtualizedGrid } from "./VirtualizedGrid/VirtualizedGrid.tsx"
387
390
  export type { VisuallyHiddenProps } from "./VisuallyHidden/VisuallyHidden.tsx"
388
391
  export { VisuallyHidden } from "./VisuallyHidden/VisuallyHidden.tsx"
@@ -437,7 +437,16 @@ test("the barrel is the only place components are re-exported", async () => {
437
437
  // `useBoardDrag.ts` are its members and stay out of this count and
438
438
  // the barrel by the `<Name>/<Name>.tsx` rule, exactly as
439
439
  // `ToolbarSlot` and `QueryBuilderRow` do.
440
- expect(componentNames.length).toBe(50)
440
+ //
441
+ // `VirtualizedGrid` — `AdaptiveGrid`'s layout with only the
442
+ // visible rows mounted — is +1 -> 51. It **calls**
443
+ // `useAdaptiveColumns` rather than forking it, so the two grids
444
+ // cannot answer the column question differently; windowing is the
445
+ // whole of the difference. `gridGap.ts` is its member and stays
446
+ // out of this count by the `<Name>/<Name>.tsx` rule, while still
447
+ // being exported from the barrel — a consumer measuring its own
448
+ // `itemBlockSize` needs the number the rows are spaced by.
449
+ expect(componentNames.length).toBe(51)
441
450
 
442
451
  for (const name of componentNames) {
443
452
  expect(barrel).toContain(`export { ${name} }`)