@charcuterie/ui 3.28.0 → 3.28.2

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 (46) hide show
  1. package/dist/Main/Main.js +1 -1
  2. package/dist/Main/Main.js.map +1 -1
  3. package/dist/Rail/Rail.js +1 -1
  4. package/dist/Rail/Rail.js.map +1 -1
  5. package/dist/Shell/Shell.js +1 -1
  6. package/dist/Shell/Shell.js.map +1 -1
  7. package/dist/VirtualizedGrid/ElementGrid.d.ts +7 -0
  8. package/dist/VirtualizedGrid/ElementGrid.d.ts.map +1 -0
  9. package/dist/VirtualizedGrid/ElementGrid.js +19 -0
  10. package/dist/VirtualizedGrid/ElementGrid.js.map +1 -0
  11. package/dist/VirtualizedGrid/GridRows.d.ts +4 -0
  12. package/dist/VirtualizedGrid/GridRows.d.ts.map +1 -0
  13. package/dist/VirtualizedGrid/GridRows.js +40 -0
  14. package/dist/VirtualizedGrid/GridRows.js.map +1 -0
  15. package/dist/VirtualizedGrid/VirtualizedGrid.d.ts +6 -8
  16. package/dist/VirtualizedGrid/VirtualizedGrid.d.ts.map +1 -1
  17. package/dist/VirtualizedGrid/VirtualizedGrid.js +30 -87
  18. package/dist/VirtualizedGrid/VirtualizedGrid.js.map +1 -1
  19. package/dist/VirtualizedGrid/WindowGrid.d.ts +3 -0
  20. package/dist/VirtualizedGrid/WindowGrid.d.ts.map +1 -0
  21. package/dist/VirtualizedGrid/WindowGrid.js +20 -0
  22. package/dist/VirtualizedGrid/WindowGrid.js.map +1 -0
  23. package/dist/VirtualizedGrid/virtualizedGridScroll.d.ts +25 -0
  24. package/dist/VirtualizedGrid/virtualizedGridScroll.d.ts.map +1 -0
  25. package/dist/VirtualizedGrid/virtualizedGridScroll.js +50 -0
  26. package/dist/VirtualizedGrid/virtualizedGridScroll.js.map +1 -0
  27. package/dist/VirtualizedGrid/virtualizedGridTypes.d.ts +27 -0
  28. package/dist/VirtualizedGrid/virtualizedGridTypes.d.ts.map +1 -0
  29. package/dist/VirtualizedGrid/virtualizedGridTypes.js +2 -0
  30. package/dist/VirtualizedGrid/virtualizedGridTypes.js.map +1 -0
  31. package/package.json +1 -1
  32. package/src/Main/Main.tsx +1 -1
  33. package/src/Rail/Rail.tsx +1 -1
  34. package/src/Shell/Shell.mdx +8 -0
  35. package/src/Shell/Shell.stories.tsx +24 -0
  36. package/src/Shell/Shell.test.tsx +29 -0
  37. package/src/Shell/Shell.tsx +1 -1
  38. package/src/VirtualizedGrid/ElementGrid.tsx +45 -0
  39. package/src/VirtualizedGrid/GridRows.tsx +90 -0
  40. package/src/VirtualizedGrid/VirtualizedGrid.mdx +9 -8
  41. package/src/VirtualizedGrid/VirtualizedGrid.stories.tsx +26 -0
  42. package/src/VirtualizedGrid/VirtualizedGrid.test.tsx +60 -1
  43. package/src/VirtualizedGrid/VirtualizedGrid.tsx +42 -142
  44. package/src/VirtualizedGrid/WindowGrid.tsx +39 -0
  45. package/src/VirtualizedGrid/virtualizedGridScroll.ts +69 -0
  46. package/src/VirtualizedGrid/virtualizedGridTypes.ts +32 -0
@@ -16,6 +16,7 @@ const {
16
16
  Default,
17
17
  Interactive,
18
18
  Responsive,
19
+ Scrollable,
19
20
  WithBothRails,
20
21
  WithStartRail,
21
22
  } = composeStories(stories)
@@ -152,6 +153,34 @@ test("the shell fills the viewport at desktop width without overflowing it", asy
152
153
  await expectNoHorizontalScroll()
153
154
  })
154
155
 
156
+ test("only main scrolls vertically, keeping the rail available", async () => {
157
+ await setViewport(DESKTOP)
158
+
159
+ const { canvas, canvasElement } =
160
+ await mountStory(Scrollable)
161
+
162
+ const shell =
163
+ canvasElement.firstElementChild as HTMLElement
164
+ const main = canvas.getByRole("main")
165
+ const rail = canvas.getByRole("navigation", {
166
+ name: "Sections",
167
+ })
168
+
169
+ await expect(shell.clientHeight).toBe(DESKTOP.height)
170
+ await expect(main.scrollHeight).toBeGreaterThan(
171
+ main.clientHeight,
172
+ )
173
+ await expect(
174
+ globalThis.getComputedStyle(main).overflowY,
175
+ ).toBe("auto")
176
+
177
+ main.scrollTo({ top: main.scrollHeight })
178
+
179
+ await expect(main.scrollTop).toBeGreaterThan(0)
180
+ await expect(rail.scrollTop).toBe(0)
181
+ await expectNoAxeViolations(canvasElement)
182
+ })
183
+
155
184
  test("the shell is one banner, one main, and two uniquely-named rails", async () => {
156
185
  const { canvas, canvasElement } =
157
186
  await mountStory(WithBothRails)
@@ -140,7 +140,7 @@ export const Shell = ({
140
140
  <div
141
141
  {...divProps}
142
142
  className={toClassName(
143
- "relative grid min-h-dvh grid-cols-1 grid-rows-[auto_auto_1fr_auto] overflow-x-clip bg-surface-base text-content-primary md:grid-cols-[auto_minmax(0,1fr)_auto] md:grid-rows-[auto_1fr]",
143
+ "relative grid h-dvh grid-cols-1 grid-rows-[auto_auto_minmax(0,1fr)_auto] overflow-x-clip bg-surface-base text-content-primary md:grid-cols-[auto_minmax(0,1fr)_auto] md:grid-rows-[auto_minmax(0,1fr)]",
144
144
  className,
145
145
  )}
146
146
  >
@@ -0,0 +1,45 @@
1
+ import { useVirtualizer } from "@tanstack/react-virtual"
2
+ import { useCallback } from "react"
3
+
4
+ import { GridRows } from "./GridRows.tsx"
5
+ import { useScrollMargin } from "./virtualizedGridScroll.ts"
6
+ import type {
7
+ GridWindowProps,
8
+ ScrollOwner,
9
+ } from "./virtualizedGridTypes.ts"
10
+
11
+ export const ElementGrid = <Item,>(
12
+ props: GridWindowProps<Item> & {
13
+ scrollOwner: Extract<ScrollOwner, { kind: "element" }>
14
+ },
15
+ ) => {
16
+ const { gridElement, scrollMargin } = useScrollMargin(
17
+ props.scrollOwner,
18
+ )
19
+
20
+ const measureRow = useCallback(
21
+ (element: Element) =>
22
+ element.getBoundingClientRect().height + props.gap,
23
+ [props.gap],
24
+ )
25
+
26
+ const rowVirtualizer = useVirtualizer({
27
+ count: props.rowCount,
28
+ estimateSize: () => props.itemBlockSize + props.gap,
29
+ getScrollElement: () => props.scrollOwner.element,
30
+ measureElement: measureRow,
31
+ overscan: props.overscanRows,
32
+ scrollMargin,
33
+ })
34
+
35
+ return (
36
+ <GridRows
37
+ {...props}
38
+ gridElement={gridElement}
39
+ measureElement={rowVirtualizer.measureElement}
40
+ scrollMargin={scrollMargin}
41
+ totalSize={rowVirtualizer.getTotalSize()}
42
+ virtualRows={rowVirtualizer.getVirtualItems()}
43
+ />
44
+ )
45
+ }
@@ -0,0 +1,90 @@
1
+ import { getContentMaxInlineSize } from "../AdaptiveGrid/chooseColumns.ts"
2
+ import type { GridRowsProps } from "./virtualizedGridTypes.ts"
3
+
4
+ /** The shared markup. Only the scroll observer differs between the two owners. */
5
+ export const GridRows = <Item,>({
6
+ drawnColumns,
7
+ gap,
8
+ getItemKey,
9
+ gridElement,
10
+ items,
11
+ label,
12
+ measureElement,
13
+ renderItem,
14
+ scrollMargin,
15
+ totalSize,
16
+ virtualRows,
17
+ }: GridRowsProps<Item>) => {
18
+ const firstRow = virtualRows[0]
19
+ const lastRow = virtualRows[virtualRows.length - 1]
20
+
21
+ // Everything scrolled past, and everything still to come. The two
22
+ // numbers that stand in for the rows that do not exist.
23
+ const paddingBlockStart = firstRow?.start ?? 0
24
+
25
+ const paddingBlockEnd = lastRow
26
+ ? Math.max(0, totalSize - lastRow.end)
27
+ : 0
28
+
29
+ return (
30
+ <ul
31
+ aria-label={label}
32
+ className="grid w-full list-none p-0 [&>*]:min-w-0"
33
+ ref={gridElement}
34
+ style={{
35
+ gap: `${gap}px`,
36
+ gridTemplateColumns: `repeat(${drawnColumns}, minmax(0, 1fr))`,
37
+ maxInlineSize: getContentMaxInlineSize({
38
+ columns: drawnColumns,
39
+ }),
40
+ // Offset by `scrollMargin` because `start` is measured
41
+ // from the top of the scroll owner, and this padding is
42
+ // measured from the top of the grid.
43
+ paddingBlockEnd: `${paddingBlockEnd}px`,
44
+ paddingBlockStart: `${Math.max(0, paddingBlockStart - scrollMargin)}px`,
45
+ }}
46
+ >
47
+ {virtualRows.flatMap((row) => {
48
+ const firstIndex = row.index * drawnColumns
49
+
50
+ return items
51
+ .slice(firstIndex, firstIndex + drawnColumns)
52
+ .map((item, offset) => {
53
+ const index = firstIndex + offset
54
+
55
+ return (
56
+ <li
57
+ aria-posinset={index + 1}
58
+ aria-setsize={items.length}
59
+ // `data-index` names the ROW, so it goes on
60
+ // every cell in it — they all sit in that row
61
+ // and the attribute is true of all of them.
62
+ //
63
+ // Putting it only on the measured cell is the
64
+ // version that looks tidier and warns on every
65
+ // resize: the virtualizer keeps a
66
+ // `ResizeObserver` on whatever it measured and
67
+ // re-reads the attribute whenever that node
68
+ // changes size. Let the node stop being the
69
+ // leading cell — a column count change is all it
70
+ // takes — and it is still observed, now with no
71
+ // attribute to resolve.
72
+ data-index={row.index}
73
+ key={getItemKey?.(item, index) ?? index}
74
+ // Measured on the leading cell alone. A CSS grid
75
+ // stretches every cell to its row's height, so
76
+ // one is enough — and measuring all of them
77
+ // would have the virtualizer record the same row
78
+ // once per column.
79
+ ref={
80
+ offset === 0 ? measureElement : undefined
81
+ }
82
+ >
83
+ {renderItem(item, index)}
84
+ </li>
85
+ )
86
+ })
87
+ })}
88
+ </ul>
89
+ )
90
+ }
@@ -47,16 +47,17 @@ commitment — every mounted row is measured and the estimate corrected. It stil
47
47
  be close, because it is what the scrollbar is sized from before anything has been
48
48
  measured. A bad guess makes the bar jump under the thumb on the first scroll.
49
49
 
50
- ## It scrolls the page, not a box of its own
50
+ ## It follows the page's scroll region
51
51
 
52
- `useWindowVirtualizer`, so the page scrolls the way every other page in the fleet
53
- scrolls — the header leaves, the scrollbar is the browser's, `Ctrl+End` works. A
54
- component that grew its own `overflow: auto` well would trap the wheel and strand the
55
- page header above it.
52
+ The grid never creates a scroll box. It follows the nearest ancestor whose vertical
53
+ overflow is `auto` or `scroll`. Inside `Shell`, that is `Main`, so the header and rail stay
54
+ available while the grid scrolls. A standalone grid falls back to the browser window.
56
55
 
57
- The cost is that the grid has to know how far down the document it starts, which it
58
- measures in a layout effect and feeds to the virtualizer as `scrollMargin`. Get that
59
- wrong and every row is offset by the height of the header above it.
56
+ The cost is that the grid has to know how far down its scroll region it starts. It measures
57
+ that offset in a layout effect and feeds it to the virtualizer as `scrollMargin`. Get that
58
+ wrong and every row is offset by the chrome above it.
59
+
60
+ <Canvas of={VirtualizedGridStories.InShellScrollRegion} />
60
61
 
61
62
  ## The column rule is inherited, not reimplemented
62
63
 
@@ -3,9 +3,13 @@ import { useState } from "react"
3
3
 
4
4
  import { getColumnChoices } from "../AdaptiveGrid/chooseColumns.ts"
5
5
  import type { BlockSizeResolver } from "../AdaptiveGrid/useAdaptiveColumns.ts"
6
+ import { Button } from "../Button/Button.tsx"
6
7
  import { StorySection } from "../board.storyHelpers.tsx"
7
8
  import { Card } from "../Card/Card.tsx"
9
+ import { Header } from "../Header/Header.tsx"
10
+ import { Main } from "../Main/Main.tsx"
8
11
  import { SegmentedControl } from "../SegmentedControl/SegmentedControl.tsx"
12
+ import { Shell } from "../Shell/Shell.tsx"
9
13
  import { VirtualizedGrid } from "./VirtualizedGrid.tsx"
10
14
 
11
15
  /**
@@ -104,6 +108,28 @@ type Story = StoryObj<typeof meta>
104
108
  */
105
109
  export const Default: Story = {}
106
110
 
111
+ /**
112
+ * The app-shell contract: `Main` is the one vertical scroll region,
113
+ * and the grid follows it rather than adding document height.
114
+ *
115
+ * This is QueuePilot's Pending shape. It is a separate story because
116
+ * a standalone grid still follows the browser window and both paths
117
+ * need a real consumer.
118
+ */
119
+ export const InShellScrollRegion: Story = {
120
+ parameters: { layout: "fullscreen" },
121
+ render: (gridProps) => (
122
+ <Shell contentWidth="full">
123
+ <Header heading="Pending" />
124
+
125
+ <Main>
126
+ <Button onClick={() => {}}>Mark all seen</Button>
127
+ <VirtualizedGrid {...gridProps} />
128
+ </Main>
129
+ </Shell>
130
+ ),
131
+ }
132
+
107
133
  /**
108
134
  * The same 2,000 items at three window heights.
109
135
  *
@@ -33,7 +33,8 @@ import * as stories from "./VirtualizedGrid.stories.tsx"
33
33
  * on.
34
34
  */
35
35
 
36
- const { AllStates, Default } = composeStories(stories)
36
+ const { AllStates, Default, InShellScrollRegion } =
37
+ composeStories(stories)
37
38
 
38
39
  const getLists = (canvasElement: HTMLElement) => [
39
40
  ...canvasElement.querySelectorAll<HTMLElement>("ul"),
@@ -146,6 +147,64 @@ test("scrolling changes which rows exist", async () => {
146
147
  }
147
148
  })
148
149
 
150
+ test("a grid inside Shell follows Main's one scroll region", async () => {
151
+ const { canvasElement } = await mountStory(
152
+ InShellScrollRegion,
153
+ )
154
+ const main =
155
+ canvasElement.querySelector<HTMLElement>("main")
156
+
157
+ if (!main) {
158
+ throw new Error("The shell story has no Main element.")
159
+ }
160
+
161
+ await waitFor(async () => {
162
+ await expect(
163
+ getRowIndexes(canvasElement).length,
164
+ ).toBeGreaterThan(0)
165
+ })
166
+
167
+ await expect(getComputedStyle(main).overflowY).toBe(
168
+ "auto",
169
+ )
170
+ await expect(main.scrollHeight).toBeGreaterThan(
171
+ main.clientHeight,
172
+ )
173
+
174
+ const before = getRowIndexes(canvasElement)
175
+
176
+ main.scrollTop = 6000
177
+ main.dispatchEvent(new Event("scroll"))
178
+
179
+ await waitFor(async () => {
180
+ await expect(
181
+ getRowIndexes(canvasElement).some((index) =>
182
+ before.includes(index),
183
+ ),
184
+ ).toBe(false)
185
+ })
186
+
187
+ // The last row must replace the virtual spacer. This is the
188
+ // reported failure's visible half: the old window observer left
189
+ // tens of thousands of blank pixels below the first mounted rows
190
+ // when Main scrolled.
191
+ main.scrollTop = main.scrollHeight
192
+ main.dispatchEvent(new Event("scroll"))
193
+
194
+ await waitFor(async () => {
195
+ await expect(
196
+ getCells(canvasElement).some(
197
+ (cell) =>
198
+ cell.getAttribute("aria-posinset") === "2000",
199
+ ),
200
+ ).toBe(true)
201
+ })
202
+
203
+ await expect(
204
+ getList(canvasElement).style.paddingBlockEnd,
205
+ ).toBe("0px")
206
+ })
207
+
149
208
  test("a screen reader is told the real length", async () => {
150
209
  const { canvasElement } = await mountStory(Default)
151
210
 
@@ -1,17 +1,17 @@
1
- import { useWindowVirtualizer } from "@tanstack/react-virtual"
2
1
  import type { ReactNode } from "react"
3
- import {
4
- useCallback,
5
- useLayoutEffect,
6
- useRef,
7
- useState,
8
- } from "react"
2
+ import { useLayoutEffect, useState } from "react"
9
3
 
10
- import { getContentMaxInlineSize } from "../AdaptiveGrid/chooseColumns.ts"
11
4
  import type { BlockSizeResolver } from "../AdaptiveGrid/useAdaptiveColumns.ts"
12
5
  import { useAdaptiveColumns } from "../AdaptiveGrid/useAdaptiveColumns.ts"
13
6
  import { toClassName } from "../toClassName.ts"
7
+ import { ElementGrid } from "./ElementGrid.tsx"
14
8
  import { DEFAULT_GRID_GAP_PX } from "./gridGap.ts"
9
+ import { findScrollOwner } from "./virtualizedGridScroll.ts"
10
+ import type {
11
+ GridWindowProps,
12
+ ScrollOwner,
13
+ } from "./virtualizedGridTypes.ts"
14
+ import { WindowGrid } from "./WindowGrid.tsx"
15
15
 
16
16
  export type VirtualizedGridProps<Item> = {
17
17
  /**
@@ -140,15 +140,13 @@ export type VirtualizedGridProps<Item> = {
140
140
  * idea of the distance from one row to the next and the browser's
141
141
  * the same number.
142
142
  *
143
- * ### Why the window and not a scroll box of its own
143
+ * ### It follows the page's existing scroll owner
144
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.
145
+ * A standalone grid follows the browser window. Inside `Shell`, it
146
+ * follows `Main`, which is the shell's one vertical scroll region.
147
+ * It never creates a scroll box of its own. The cost is
148
+ * `scrollMargin`: the virtualizer has to be told how far down its
149
+ * scroll owner the grid starts, which is measured below.
152
150
  *
153
151
  * ### What windowing costs, and what is done about it
154
152
  *
@@ -188,85 +186,38 @@ export const VirtualizedGrid = <Item,>({
188
186
 
189
187
  const drawnColumns = columns ?? layout.columns
190
188
 
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
189
  const rowCount = Math.ceil(items.length / drawnColumns)
200
190
 
201
191
  /**
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.
192
+ * Main owns vertical scrolling inside an app Shell. Older pages
193
+ * and a standalone grid still use the window. Resolve the owner
194
+ * from the actual tree rather than making every app wire a ref
195
+ * between two Charcuterie components.
214
196
  */
215
- const gridElement = useRef<HTMLUListElement>(null)
216
- const [scrollMargin, setScrollMargin] = useState(0)
197
+ const [scrollOwner, setScrollOwner] =
198
+ useState<ScrollOwner | null>(null)
217
199
 
218
200
  useLayoutEffect(() => {
219
- const element = gridElement.current
201
+ const element = layout.containerRef.current
220
202
 
221
203
  if (!element) {
222
204
  return
223
205
  }
224
206
 
225
- const next =
226
- element.getBoundingClientRect().top + window.scrollY
227
-
228
- setScrollMargin((current) =>
229
- current === next ? current : next,
230
- )
231
- })
207
+ setScrollOwner(findScrollOwner(element))
208
+ }, [layout.containerRef])
232
209
 
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
210
+ const gridProps: GridWindowProps<Item> = {
211
+ drawnColumns,
212
+ gap,
213
+ getItemKey,
214
+ itemBlockSize,
215
+ items,
216
+ label,
217
+ overscanRows,
218
+ renderItem,
219
+ rowCount,
220
+ }
270
221
 
271
222
  return (
272
223
  <div
@@ -276,65 +227,14 @@ export const VirtualizedGrid = <Item,>({
276
227
  )}
277
228
  ref={layout.containerRef}
278
229
  >
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>
230
+ {scrollOwner?.kind === "element" ? (
231
+ <ElementGrid
232
+ {...gridProps}
233
+ scrollOwner={scrollOwner}
234
+ />
235
+ ) : scrollOwner?.kind === "window" ? (
236
+ <WindowGrid {...gridProps} />
237
+ ) : null}
338
238
  </div>
339
239
  )
340
240
  }
@@ -0,0 +1,39 @@
1
+ import { useWindowVirtualizer } from "@tanstack/react-virtual"
2
+ import { useCallback } from "react"
3
+
4
+ import { GridRows } from "./GridRows.tsx"
5
+ import { useScrollMargin } from "./virtualizedGridScroll.ts"
6
+ import type { GridWindowProps } from "./virtualizedGridTypes.ts"
7
+
8
+ export const WindowGrid = <Item,>(
9
+ props: GridWindowProps<Item>,
10
+ ) => {
11
+ const { gridElement, scrollMargin } = useScrollMargin({
12
+ kind: "window",
13
+ })
14
+
15
+ const measureRow = useCallback(
16
+ (element: Element) =>
17
+ element.getBoundingClientRect().height + props.gap,
18
+ [props.gap],
19
+ )
20
+
21
+ const rowVirtualizer = useWindowVirtualizer({
22
+ count: props.rowCount,
23
+ estimateSize: () => props.itemBlockSize + props.gap,
24
+ measureElement: measureRow,
25
+ overscan: props.overscanRows,
26
+ scrollMargin,
27
+ })
28
+
29
+ return (
30
+ <GridRows
31
+ {...props}
32
+ gridElement={gridElement}
33
+ measureElement={rowVirtualizer.measureElement}
34
+ scrollMargin={scrollMargin}
35
+ totalSize={rowVirtualizer.getTotalSize()}
36
+ virtualRows={rowVirtualizer.getVirtualItems()}
37
+ />
38
+ )
39
+ }
@@ -0,0 +1,69 @@
1
+ import { useLayoutEffect, useRef, useState } from "react"
2
+
3
+ import type { ScrollOwner } from "./virtualizedGridTypes.ts"
4
+
5
+ /**
6
+ * The closest ancestor that owns vertical scrolling.
7
+ *
8
+ * The computed value is enough. An `overflow-y:auto` ancestor can
9
+ * be shorter than its contents only AFTER this grid installs its
10
+ * virtual spacer, so requiring `scrollHeight > clientHeight` here
11
+ * would miss the owner on the first commit and create the exact
12
+ * document scrollbar this lookup prevents.
13
+ */
14
+ export const findScrollOwner = (
15
+ element: HTMLElement,
16
+ ): ScrollOwner => {
17
+ let ancestor = element.parentElement
18
+
19
+ while (ancestor) {
20
+ const overflow = getComputedStyle(ancestor).overflowY
21
+
22
+ if (overflow === "auto" || overflow === "scroll") {
23
+ return { element: ancestor, kind: "element" }
24
+ }
25
+
26
+ ancestor = ancestor.parentElement
27
+ }
28
+
29
+ return { kind: "window" }
30
+ }
31
+
32
+ /**
33
+ * The offset from the scroll owner's content origin to the grid.
34
+ *
35
+ * Both formulas stay stable while their owner scrolls: the grid's
36
+ * viewport-relative top falls by the same amount that `scrollY` or
37
+ * `scrollTop` rises. The element path subtracts its border because
38
+ * a scroll offset starts at the padding edge, not at the outer
39
+ * border returned by `getBoundingClientRect()`.
40
+ */
41
+ export const useScrollMargin = (
42
+ scrollOwner: ScrollOwner,
43
+ ) => {
44
+ const gridElement = useRef<HTMLUListElement>(null)
45
+ const [scrollMargin, setScrollMargin] = useState(0)
46
+
47
+ useLayoutEffect(() => {
48
+ const element = gridElement.current
49
+
50
+ if (!element) {
51
+ return
52
+ }
53
+
54
+ const gridTop = element.getBoundingClientRect().top
55
+ const next =
56
+ scrollOwner.kind === "window"
57
+ ? gridTop + window.scrollY
58
+ : gridTop -
59
+ scrollOwner.element.getBoundingClientRect().top -
60
+ scrollOwner.element.clientTop +
61
+ scrollOwner.element.scrollTop
62
+
63
+ setScrollMargin((current) =>
64
+ current === next ? current : next,
65
+ )
66
+ })
67
+
68
+ return { gridElement, scrollMargin }
69
+ }