@charcuterie/ui 3.7.0 → 3.8.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.
- package/dist/VirtualizedGrid/VirtualizedGrid.d.ts +147 -0
- package/dist/VirtualizedGrid/VirtualizedGrid.d.ts.map +1 -0
- package/dist/VirtualizedGrid/VirtualizedGrid.js +177 -0
- package/dist/VirtualizedGrid/VirtualizedGrid.js.map +1 -0
- package/dist/VirtualizedGrid/gridGap.d.ts +20 -0
- package/dist/VirtualizedGrid/gridGap.d.ts.map +1 -0
- package/dist/VirtualizedGrid/gridGap.js +20 -0
- package/dist/VirtualizedGrid/gridGap.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/VirtualizedGrid/VirtualizedGrid.mdx +110 -0
- package/src/VirtualizedGrid/VirtualizedGrid.stories.tsx +258 -0
- package/src/VirtualizedGrid/VirtualizedGrid.test.tsx +182 -0
- package/src/VirtualizedGrid/VirtualizedGrid.tsx +340 -0
- package/src/VirtualizedGrid/gridGap.ts +19 -0
- package/src/index.ts +3 -0
- package/src/sourceRules.test.ts +10 -1
|
@@ -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"
|
package/src/sourceRules.test.ts
CHANGED
|
@@ -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
|
-
|
|
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} }`)
|