@charcuterie/ui 3.28.1 → 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 (34) hide show
  1. package/dist/VirtualizedGrid/ElementGrid.d.ts +7 -0
  2. package/dist/VirtualizedGrid/ElementGrid.d.ts.map +1 -0
  3. package/dist/VirtualizedGrid/ElementGrid.js +19 -0
  4. package/dist/VirtualizedGrid/ElementGrid.js.map +1 -0
  5. package/dist/VirtualizedGrid/GridRows.d.ts +4 -0
  6. package/dist/VirtualizedGrid/GridRows.d.ts.map +1 -0
  7. package/dist/VirtualizedGrid/GridRows.js +40 -0
  8. package/dist/VirtualizedGrid/GridRows.js.map +1 -0
  9. package/dist/VirtualizedGrid/VirtualizedGrid.d.ts +6 -8
  10. package/dist/VirtualizedGrid/VirtualizedGrid.d.ts.map +1 -1
  11. package/dist/VirtualizedGrid/VirtualizedGrid.js +30 -87
  12. package/dist/VirtualizedGrid/VirtualizedGrid.js.map +1 -1
  13. package/dist/VirtualizedGrid/WindowGrid.d.ts +3 -0
  14. package/dist/VirtualizedGrid/WindowGrid.d.ts.map +1 -0
  15. package/dist/VirtualizedGrid/WindowGrid.js +20 -0
  16. package/dist/VirtualizedGrid/WindowGrid.js.map +1 -0
  17. package/dist/VirtualizedGrid/virtualizedGridScroll.d.ts +25 -0
  18. package/dist/VirtualizedGrid/virtualizedGridScroll.d.ts.map +1 -0
  19. package/dist/VirtualizedGrid/virtualizedGridScroll.js +50 -0
  20. package/dist/VirtualizedGrid/virtualizedGridScroll.js.map +1 -0
  21. package/dist/VirtualizedGrid/virtualizedGridTypes.d.ts +27 -0
  22. package/dist/VirtualizedGrid/virtualizedGridTypes.d.ts.map +1 -0
  23. package/dist/VirtualizedGrid/virtualizedGridTypes.js +2 -0
  24. package/dist/VirtualizedGrid/virtualizedGridTypes.js.map +1 -0
  25. package/package.json +1 -1
  26. package/src/VirtualizedGrid/ElementGrid.tsx +45 -0
  27. package/src/VirtualizedGrid/GridRows.tsx +90 -0
  28. package/src/VirtualizedGrid/VirtualizedGrid.mdx +9 -8
  29. package/src/VirtualizedGrid/VirtualizedGrid.stories.tsx +26 -0
  30. package/src/VirtualizedGrid/VirtualizedGrid.test.tsx +60 -1
  31. package/src/VirtualizedGrid/VirtualizedGrid.tsx +42 -142
  32. package/src/VirtualizedGrid/WindowGrid.tsx +39 -0
  33. package/src/VirtualizedGrid/virtualizedGridScroll.ts +69 -0
  34. package/src/VirtualizedGrid/virtualizedGridTypes.ts +32 -0
@@ -0,0 +1,7 @@
1
+ import type { GridWindowProps, ScrollOwner } from "./virtualizedGridTypes.ts";
2
+ export declare const ElementGrid: <Item>(props: GridWindowProps<Item> & {
3
+ scrollOwner: Extract<ScrollOwner, {
4
+ kind: "element";
5
+ }>;
6
+ }) => import("react").JSX.Element;
7
+ //# sourceMappingURL=ElementGrid.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ElementGrid.d.ts","sourceRoot":"","sources":["../../src/VirtualizedGrid/ElementGrid.tsx"],"names":[],"mappings":"AAKA,OAAO,KAAK,EACV,eAAe,EACf,WAAW,EACZ,MAAM,2BAA2B,CAAA;AAElC,eAAO,MAAM,WAAW,GAAI,IAAI,EAC9B,OAAO,eAAe,CAAC,IAAI,CAAC,GAAG;IAC7B,WAAW,EAAE,OAAO,CAAC,WAAW,EAAE;QAAE,IAAI,EAAE,SAAS,CAAA;KAAE,CAAC,CAAA;CACvD,gCA+BF,CAAA"}
@@ -0,0 +1,19 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useVirtualizer } from "@tanstack/react-virtual";
3
+ import { useCallback } from "react";
4
+ import { GridRows } from "./GridRows.js";
5
+ import { useScrollMargin } from "./virtualizedGridScroll.js";
6
+ export const ElementGrid = (props) => {
7
+ const { gridElement, scrollMargin } = useScrollMargin(props.scrollOwner);
8
+ const measureRow = useCallback((element) => element.getBoundingClientRect().height + props.gap, [props.gap]);
9
+ const rowVirtualizer = useVirtualizer({
10
+ count: props.rowCount,
11
+ estimateSize: () => props.itemBlockSize + props.gap,
12
+ getScrollElement: () => props.scrollOwner.element,
13
+ measureElement: measureRow,
14
+ overscan: props.overscanRows,
15
+ scrollMargin,
16
+ });
17
+ return (_jsx(GridRows, { ...props, gridElement: gridElement, measureElement: rowVirtualizer.measureElement, scrollMargin: scrollMargin, totalSize: rowVirtualizer.getTotalSize(), virtualRows: rowVirtualizer.getVirtualItems() }));
18
+ };
19
+ //# sourceMappingURL=ElementGrid.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ElementGrid.js","sourceRoot":"","sources":["../../src/VirtualizedGrid/ElementGrid.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,yBAAyB,CAAA;AACxD,OAAO,EAAE,WAAW,EAAE,MAAM,OAAO,CAAA;AAEnC,OAAO,EAAE,QAAQ,EAAE,MAAM,eAAgB,CAAA;AACzC,OAAO,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAA;AAM5D,MAAM,CAAC,MAAM,WAAW,GAAG,CACzB,KAEC,EACD,EAAE;IACF,MAAM,EAAE,WAAW,EAAE,YAAY,EAAE,GAAG,eAAe,CACnD,KAAK,CAAC,WAAW,CAClB,CAAA;IAED,MAAM,UAAU,GAAG,WAAW,CAC5B,CAAC,OAAgB,EAAE,EAAE,CACnB,OAAO,CAAC,qBAAqB,EAAE,CAAC,MAAM,GAAG,KAAK,CAAC,GAAG,EACpD,CAAC,KAAK,CAAC,GAAG,CAAC,CACZ,CAAA;IAED,MAAM,cAAc,GAAG,cAAc,CAAC;QACpC,KAAK,EAAE,KAAK,CAAC,QAAQ;QACrB,YAAY,EAAE,GAAG,EAAE,CAAC,KAAK,CAAC,aAAa,GAAG,KAAK,CAAC,GAAG;QACnD,gBAAgB,EAAE,GAAG,EAAE,CAAC,KAAK,CAAC,WAAW,CAAC,OAAO;QACjD,cAAc,EAAE,UAAU;QAC1B,QAAQ,EAAE,KAAK,CAAC,YAAY;QAC5B,YAAY;KACb,CAAC,CAAA;IAEF,OAAO,CACL,KAAC,QAAQ,OACH,KAAK,EACT,WAAW,EAAE,WAAW,EACxB,cAAc,EAAE,cAAc,CAAC,cAAc,EAC7C,YAAY,EAAE,YAAY,EAC1B,SAAS,EAAE,cAAc,CAAC,YAAY,EAAE,EACxC,WAAW,EAAE,cAAc,CAAC,eAAe,EAAE,GAC7C,CACH,CAAA;AACH,CAAC,CAAA"}
@@ -0,0 +1,4 @@
1
+ import type { GridRowsProps } from "./virtualizedGridTypes.ts";
2
+ /** The shared markup. Only the scroll observer differs between the two owners. */
3
+ export declare const GridRows: <Item>({ drawnColumns, gap, getItemKey, gridElement, items, label, measureElement, renderItem, scrollMargin, totalSize, virtualRows, }: GridRowsProps<Item>) => import("react").JSX.Element;
4
+ //# sourceMappingURL=GridRows.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"GridRows.d.ts","sourceRoot":"","sources":["../../src/VirtualizedGrid/GridRows.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,2BAA2B,CAAA;AAE9D,kFAAkF;AAClF,eAAO,MAAM,QAAQ,GAAI,IAAI,EAAG,iIAY7B,aAAa,CAAC,IAAI,CAAC,gCAyErB,CAAA"}
@@ -0,0 +1,40 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { getContentMaxInlineSize } from "../AdaptiveGrid/chooseColumns.js";
3
+ /** The shared markup. Only the scroll observer differs between the two owners. */
4
+ export const GridRows = ({ drawnColumns, gap, getItemKey, gridElement, items, label, measureElement, renderItem, scrollMargin, totalSize, virtualRows, }) => {
5
+ const firstRow = virtualRows[0];
6
+ const lastRow = virtualRows[virtualRows.length - 1];
7
+ // Everything scrolled past, and everything still to come. The two
8
+ // numbers that stand in for the rows that do not exist.
9
+ const paddingBlockStart = firstRow?.start ?? 0;
10
+ const paddingBlockEnd = lastRow
11
+ ? Math.max(0, totalSize - lastRow.end)
12
+ : 0;
13
+ return (_jsx("ul", { "aria-label": label, className: "grid w-full list-none p-0 [&>*]:min-w-0", ref: gridElement, style: {
14
+ gap: `${gap}px`,
15
+ gridTemplateColumns: `repeat(${drawnColumns}, minmax(0, 1fr))`,
16
+ maxInlineSize: getContentMaxInlineSize({
17
+ columns: drawnColumns,
18
+ }),
19
+ // Offset by `scrollMargin` because `start` is measured
20
+ // from the top of the scroll owner, and this padding is
21
+ // measured from the top of the grid.
22
+ paddingBlockEnd: `${paddingBlockEnd}px`,
23
+ paddingBlockStart: `${Math.max(0, paddingBlockStart - scrollMargin)}px`,
24
+ }, children: virtualRows.flatMap((row) => {
25
+ const firstIndex = row.index * drawnColumns;
26
+ return items
27
+ .slice(firstIndex, firstIndex + drawnColumns)
28
+ .map((item, offset) => {
29
+ const index = firstIndex + offset;
30
+ return (_jsx("li", { "aria-posinset": index + 1, "aria-setsize": items.length, "data-index": row.index,
31
+ // Measured on the leading cell alone. A CSS grid
32
+ // stretches every cell to its row's height, so
33
+ // one is enough — and measuring all of them
34
+ // would have the virtualizer record the same row
35
+ // once per column.
36
+ ref: offset === 0 ? measureElement : undefined, children: renderItem(item, index) }, getItemKey?.(item, index) ?? index));
37
+ });
38
+ }) }));
39
+ };
40
+ //# sourceMappingURL=GridRows.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"GridRows.js","sourceRoot":"","sources":["../../src/VirtualizedGrid/GridRows.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,uBAAuB,EAAE,MAAM,kCAAkC,CAAA;AAG1E,kFAAkF;AAClF,MAAM,CAAC,MAAM,QAAQ,GAAG,CAAQ,EAC9B,YAAY,EACZ,GAAG,EACH,UAAU,EACV,WAAW,EACX,KAAK,EACL,KAAK,EACL,cAAc,EACd,UAAU,EACV,YAAY,EACZ,SAAS,EACT,WAAW,GACS,EAAE,EAAE;IACxB,MAAM,QAAQ,GAAG,WAAW,CAAC,CAAC,CAAC,CAAA;IAC/B,MAAM,OAAO,GAAG,WAAW,CAAC,WAAW,CAAC,MAAM,GAAG,CAAC,CAAC,CAAA;IAEnD,kEAAkE;IAClE,wDAAwD;IACxD,MAAM,iBAAiB,GAAG,QAAQ,EAAE,KAAK,IAAI,CAAC,CAAA;IAE9C,MAAM,eAAe,GAAG,OAAO;QAC7B,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,SAAS,GAAG,OAAO,CAAC,GAAG,CAAC;QACtC,CAAC,CAAC,CAAC,CAAA;IAEL,OAAO,CACL,2BACc,KAAK,EACjB,SAAS,EAAC,yCAAyC,EACnD,GAAG,EAAE,WAAW,EAChB,KAAK,EAAE;YACL,GAAG,EAAE,GAAG,GAAG,IAAI;YACf,mBAAmB,EAAE,UAAU,YAAY,mBAAmB;YAC9D,aAAa,EAAE,uBAAuB,CAAC;gBACrC,OAAO,EAAE,YAAY;aACtB,CAAC;YACF,uDAAuD;YACvD,wDAAwD;YACxD,qCAAqC;YACrC,eAAe,EAAE,GAAG,eAAe,IAAI;YACvC,iBAAiB,EAAE,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,iBAAiB,GAAG,YAAY,CAAC,IAAI;SACxE,YAEA,WAAW,CAAC,OAAO,CAAC,CAAC,GAAG,EAAE,EAAE;YAC3B,MAAM,UAAU,GAAG,GAAG,CAAC,KAAK,GAAG,YAAY,CAAA;YAE3C,OAAO,KAAK;iBACT,KAAK,CAAC,UAAU,EAAE,UAAU,GAAG,YAAY,CAAC;iBAC5C,GAAG,CAAC,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE;gBACpB,MAAM,KAAK,GAAG,UAAU,GAAG,MAAM,CAAA;gBAEjC,OAAO,CACL,8BACiB,KAAK,GAAG,CAAC,kBACV,KAAK,CAAC,MAAM,gBAcd,GAAG,CAAC,KAAK;oBAErB,iDAAiD;oBACjD,+CAA+C;oBAC/C,4CAA4C;oBAC5C,iDAAiD;oBACjD,mBAAmB;oBACnB,GAAG,EACD,MAAM,KAAK,CAAC,CAAC,CAAC,CAAC,cAAc,CAAC,CAAC,CAAC,SAAS,YAG1C,UAAU,CAAC,IAAI,EAAE,KAAK,CAAC,IAVnB,UAAU,EAAE,CAAC,IAAI,EAAE,KAAK,CAAC,IAAI,KAAK,CAWpC,CACN,CAAA;YACH,CAAC,CAAC,CAAA;QACN,CAAC,CAAC,GACC,CACN,CAAA;AACH,CAAC,CAAA"}
@@ -123,15 +123,13 @@ export type VirtualizedGridProps<Item> = {
123
123
  * idea of the distance from one row to the next and the browser's
124
124
  * the same number.
125
125
  *
126
- * ### Why the window and not a scroll box of its own
126
+ * ### It follows the page's existing scroll owner
127
127
  *
128
- * `useWindowVirtualizer`, so the page scrolls the way every other
129
- * page in the fleet scrolls — the header leaves, the scrollbar is
130
- * the browser's, `Ctrl+End` works. A component that grew its own
131
- * `overflow: auto` well would trap the wheel and strand the page
132
- * header above it. The cost is `scrollMargin`: the virtualizer has
133
- * to be told how far down the document the grid starts, which is
134
- * measured below.
128
+ * A standalone grid follows the browser window. Inside `Shell`, it
129
+ * follows `Main`, which is the shell's one vertical scroll region.
130
+ * It never creates a scroll box of its own. The cost is
131
+ * `scrollMargin`: the virtualizer has to be told how far down its
132
+ * scroll owner the grid starts, which is measured below.
135
133
  *
136
134
  * ### What windowing costs, and what is done about it
137
135
  *
@@ -1 +1 @@
1
- {"version":3,"file":"VirtualizedGrid.d.ts","sourceRoot":"","sources":["../../src/VirtualizedGrid/VirtualizedGrid.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAStC,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,uCAAuC,CAAA;AAK9E,MAAM,MAAM,oBAAoB,CAAC,IAAI,IAAI;IACvC;;;;OAIG;IACH,iBAAiB,CAAC,EAAE,iBAAiB,CAAA;IACrC;;;;OAIG;IACH,eAAe,CAAC,EAAE,MAAM,CAAA;IACxB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB;;;;OAIG;IACH,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,kDAAkD;IAClD,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ;;;;;OAKG;IACH,UAAU,CAAC,EAAE,CACX,IAAI,EAAE,IAAI,EACV,KAAK,EAAE,MAAM,KACV,MAAM,GAAG,MAAM,CAAA;IACpB;;;;;;;;OAQG;IACH,aAAa,EAAE,MAAM,CAAA;IACrB,kCAAkC;IAClC,KAAK,EAAE,SAAS,IAAI,EAAE,CAAA;IACtB;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,uDAAuD;IACvD,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,gDAAgD;IAChD,mBAAmB,CAAC,EAAE,MAAM,CAAA;IAC5B;;;;;;;;OAQG;IACH,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,qBAAqB;IACrB,UAAU,EAAE,CAAC,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,MAAM,KAAK,SAAS,CAAA;IACpD;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAA;CACpB,CAAA;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyEG;AACH,eAAO,MAAM,eAAe,GAAI,IAAI,EAAG,kLAepC,oBAAoB,CAAC,IAAI,CAAC,KAAG,SAkK/B,CAAA"}
1
+ {"version":3,"file":"VirtualizedGrid.d.ts","sourceRoot":"","sources":["../../src/VirtualizedGrid/VirtualizedGrid.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAGtC,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,uCAAuC,CAAA;AAY9E,MAAM,MAAM,oBAAoB,CAAC,IAAI,IAAI;IACvC;;;;OAIG;IACH,iBAAiB,CAAC,EAAE,iBAAiB,CAAA;IACrC;;;;OAIG;IACH,eAAe,CAAC,EAAE,MAAM,CAAA;IACxB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB;;;;OAIG;IACH,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,kDAAkD;IAClD,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ;;;;;OAKG;IACH,UAAU,CAAC,EAAE,CACX,IAAI,EAAE,IAAI,EACV,KAAK,EAAE,MAAM,KACV,MAAM,GAAG,MAAM,CAAA;IACpB;;;;;;;;OAQG;IACH,aAAa,EAAE,MAAM,CAAA;IACrB,kCAAkC;IAClC,KAAK,EAAE,SAAS,IAAI,EAAE,CAAA;IACtB;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,uDAAuD;IACvD,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,gDAAgD;IAChD,mBAAmB,CAAC,EAAE,MAAM,CAAA;IAC5B;;;;;;;;OAQG;IACH,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,qBAAqB;IACrB,UAAU,EAAE,CAAC,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,MAAM,KAAK,SAAS,CAAA;IACpD;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAA;CACpB,CAAA;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuEG;AACH,eAAO,MAAM,eAAe,GAAI,IAAI,EAAG,kLAepC,oBAAoB,CAAC,IAAI,CAAC,KAAG,SAgE/B,CAAA"}
@@ -1,10 +1,11 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { useWindowVirtualizer } from "@tanstack/react-virtual";
3
- import { useCallback, useLayoutEffect, useRef, useState, } from "react";
4
- import { getContentMaxInlineSize } from "../AdaptiveGrid/chooseColumns.js";
2
+ import { useLayoutEffect, useState } from "react";
5
3
  import { useAdaptiveColumns } from "../AdaptiveGrid/useAdaptiveColumns.js";
6
4
  import { toClassName } from "../toClassName.js";
5
+ import { ElementGrid } from "./ElementGrid.js";
7
6
  import { DEFAULT_GRID_GAP_PX } from "./gridGap.js";
7
+ import { findScrollOwner } from "./virtualizedGridScroll.js";
8
+ import { WindowGrid } from "./WindowGrid.js";
8
9
  /**
9
10
  * `AdaptiveGrid`'s layout, with only the rows you can see mounted.
10
11
  *
@@ -59,15 +60,13 @@ import { DEFAULT_GRID_GAP_PX } from "./gridGap.js";
59
60
  * idea of the distance from one row to the next and the browser's
60
61
  * the same number.
61
62
  *
62
- * ### Why the window and not a scroll box of its own
63
+ * ### It follows the page's existing scroll owner
63
64
  *
64
- * `useWindowVirtualizer`, so the page scrolls the way every other
65
- * page in the fleet scrolls — the header leaves, the scrollbar is
66
- * the browser's, `Ctrl+End` works. A component that grew its own
67
- * `overflow: auto` well would trap the wheel and strand the page
68
- * header above it. The cost is `scrollMargin`: the virtualizer has
69
- * to be told how far down the document the grid starts, which is
70
- * measured below.
65
+ * A standalone grid follows the browser window. Inside `Shell`, it
66
+ * follows `Main`, which is the shell's one vertical scroll region.
67
+ * It never creates a scroll box of its own. The cost is
68
+ * `scrollMargin`: the virtualizer has to be told how far down its
69
+ * scroll owner the grid starts, which is measured below.
71
70
  *
72
71
  * ### What windowing costs, and what is done about it
73
72
  *
@@ -90,88 +89,32 @@ export const VirtualizedGrid = ({ blockSizeResolver, chromeBlockSize, className,
90
89
  storageKey,
91
90
  });
92
91
  const drawnColumns = columns ?? layout.columns;
93
- // Folded from what is DRAWN rather than read off the hook, so an
94
- // override widens the page cap with it — the same rule
95
- // `AdaptiveGrid` follows, and wrong in the same way if it does
96
- // not.
97
- const contentMaxInlineSize = getContentMaxInlineSize({
98
- columns: drawnColumns,
99
- });
100
92
  const rowCount = Math.ceil(items.length / drawnColumns);
101
93
  /**
102
- * How far down the document the grid starts.
103
- *
104
- * `useWindowVirtualizer` measures against the document, so
105
- * without this every row is placed as though the grid began at
106
- * the top of the page, and the whole list sits shifted by the
107
- * height of the header above it.
108
- *
109
- * State and not a ref, because it feeds `scrollMargin` and a
110
- * change has to re-render. Measured on every commit rather than
111
- * on a dependency list: a filter row that grows a line moves the
112
- * grid down the page, and nothing in this component's own props
113
- * would report that.
94
+ * Main owns vertical scrolling inside an app Shell. Older pages
95
+ * and a standalone grid still use the window. Resolve the owner
96
+ * from the actual tree rather than making every app wire a ref
97
+ * between two Charcuterie components.
114
98
  */
115
- const gridElement = useRef(null);
116
- const [scrollMargin, setScrollMargin] = useState(0);
99
+ const [scrollOwner, setScrollOwner] = useState(null);
117
100
  useLayoutEffect(() => {
118
- const element = gridElement.current;
101
+ const element = layout.containerRef.current;
119
102
  if (!element) {
120
103
  return;
121
104
  }
122
- const next = element.getBoundingClientRect().top + window.scrollY;
123
- setScrollMargin((current) => current === next ? current : next);
124
- });
125
- /**
126
- * A row's height, plus the gap that follows it.
127
- *
128
- * The gap is added here rather than left to the estimate because
129
- * it is real distance the next row starts after: leave it out
130
- * and the virtualizer's offsets drift by one gap per row, which
131
- * on a list of two thousand is a scrollbar off by half a page.
132
- */
133
- const measureRow = useCallback((element) => element.getBoundingClientRect().height + gap, [gap]);
134
- const rowVirtualizer = useWindowVirtualizer({
135
- count: rowCount,
136
- estimateSize: () => itemBlockSize + gap,
137
- measureElement: measureRow,
138
- overscan: overscanRows,
139
- scrollMargin,
140
- });
141
- const virtualRows = rowVirtualizer.getVirtualItems();
142
- const firstRow = virtualRows[0];
143
- const lastRow = virtualRows[virtualRows.length - 1];
144
- // Everything scrolled past, and everything still to come. The two
145
- // numbers that stand in for the rows that do not exist.
146
- const paddingBlockStart = firstRow?.start ?? 0;
147
- const paddingBlockEnd = lastRow
148
- ? Math.max(0, rowVirtualizer.getTotalSize() - lastRow.end)
149
- : 0;
150
- return (_jsx("div", { className: toClassName("flex justify-center", className), ref: layout.containerRef, children: _jsx("ul", { "aria-label": label, className: "grid w-full list-none p-0 [&>*]:min-w-0", ref: gridElement, style: {
151
- gap: `${gap}px`,
152
- gridTemplateColumns: `repeat(${drawnColumns}, minmax(0, 1fr))`,
153
- maxInlineSize: contentMaxInlineSize,
154
- // Offset by `scrollMargin` because `start` is measured
155
- // from the top of the document, and this padding is
156
- // measured from the top of the grid.
157
- paddingBlockEnd: `${paddingBlockEnd}px`,
158
- paddingBlockStart: `${Math.max(0, paddingBlockStart - scrollMargin)}px`,
159
- }, children: virtualRows.flatMap((row) => {
160
- const firstIndex = row.index * drawnColumns;
161
- return items
162
- .slice(firstIndex, firstIndex + drawnColumns)
163
- .map((item, offset) => {
164
- const index = firstIndex + offset;
165
- return (_jsx("li", { "aria-posinset": index + 1, "aria-setsize": items.length, "data-index": row.index,
166
- // Measured on the leading cell alone. A CSS grid
167
- // stretches every cell to its row's height, so
168
- // one is enough — and measuring all of them
169
- // would have the virtualizer record the same row
170
- // once per column.
171
- ref: offset === 0
172
- ? rowVirtualizer.measureElement
173
- : undefined, children: renderItem(item, index) }, getItemKey?.(item, index) ?? index));
174
- });
175
- }) }) }));
105
+ setScrollOwner(findScrollOwner(element));
106
+ }, [layout.containerRef]);
107
+ const gridProps = {
108
+ drawnColumns,
109
+ gap,
110
+ getItemKey,
111
+ itemBlockSize,
112
+ items,
113
+ label,
114
+ overscanRows,
115
+ renderItem,
116
+ rowCount,
117
+ };
118
+ return (_jsx("div", { className: toClassName("flex justify-center", className), ref: layout.containerRef, children: scrollOwner?.kind === "element" ? (_jsx(ElementGrid, { ...gridProps, scrollOwner: scrollOwner })) : scrollOwner?.kind === "window" ? (_jsx(WindowGrid, { ...gridProps })) : null }));
176
119
  };
177
120
  //# sourceMappingURL=VirtualizedGrid.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"VirtualizedGrid.js","sourceRoot":"","sources":["../../src/VirtualizedGrid/VirtualizedGrid.tsx"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"VirtualizedGrid.js","sourceRoot":"","sources":["../../src/VirtualizedGrid/VirtualizedGrid.tsx"],"names":[],"mappings":";AACA,OAAO,EAAE,eAAe,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAA;AAGjD,OAAO,EAAE,kBAAkB,EAAE,MAAM,uCAAuC,CAAA;AAC1E,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAA;AAC/C,OAAO,EAAE,WAAW,EAAE,MAAM,kBAAmB,CAAA;AAC/C,OAAO,EAAE,mBAAmB,EAAE,MAAM,cAAc,CAAA;AAClD,OAAO,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAA;AAK5D,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAkB,CAAA;AA2E7C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuEG;AACH,MAAM,CAAC,MAAM,eAAe,GAAG,CAAQ,EACrC,iBAAiB,EACjB,eAAe,EACf,SAAS,EACT,OAAO,EACP,GAAG,GAAG,mBAAmB,EACzB,UAAU,EACV,aAAa,EACb,KAAK,EACL,KAAK,EACL,UAAU,EACV,mBAAmB,EACnB,YAAY,GAAG,CAAC,EAChB,UAAU,EACV,UAAU,GACiB,EAAa,EAAE;IAC1C,MAAM,MAAM,GAAG,kBAAkB,CAAC;QAChC,iBAAiB;QACjB,eAAe;QACf,aAAa;QACb,SAAS,EAAE,KAAK,CAAC,MAAM;QACvB,cAAc,EAAE,UAAU;QAC1B,mBAAmB;QACnB,UAAU;KACX,CAAC,CAAA;IAEF,MAAM,YAAY,GAAG,OAAO,IAAI,MAAM,CAAC,OAAO,CAAA;IAE9C,MAAM,QAAQ,GAAG,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,MAAM,GAAG,YAAY,CAAC,CAAA;IAEvD;;;;;OAKG;IACH,MAAM,CAAC,WAAW,EAAE,cAAc,CAAC,GACjC,QAAQ,CAAqB,IAAI,CAAC,CAAA;IAEpC,eAAe,CAAC,GAAG,EAAE;QACnB,MAAM,OAAO,GAAG,MAAM,CAAC,YAAY,CAAC,OAAO,CAAA;QAE3C,IAAI,CAAC,OAAO,EAAE,CAAC;YACb,OAAM;QACR,CAAC;QAED,cAAc,CAAC,eAAe,CAAC,OAAO,CAAC,CAAC,CAAA;IAC1C,CAAC,EAAE,CAAC,MAAM,CAAC,YAAY,CAAC,CAAC,CAAA;IAEzB,MAAM,SAAS,GAA0B;QACvC,YAAY;QACZ,GAAG;QACH,UAAU;QACV,aAAa;QACb,KAAK;QACL,KAAK;QACL,YAAY;QACZ,UAAU;QACV,QAAQ;KACT,CAAA;IAED,OAAO,CACL,cACE,SAAS,EAAE,WAAW,CACpB,qBAAqB,EACrB,SAAS,CACV,EACD,GAAG,EAAE,MAAM,CAAC,YAAY,YAEvB,WAAW,EAAE,IAAI,KAAK,SAAS,CAAC,CAAC,CAAC,CACjC,KAAC,WAAW,OACN,SAAS,EACb,WAAW,EAAE,WAAW,GACxB,CACH,CAAC,CAAC,CAAC,WAAW,EAAE,IAAI,KAAK,QAAQ,CAAC,CAAC,CAAC,CACnC,KAAC,UAAU,OAAK,SAAS,GAAI,CAC9B,CAAC,CAAC,CAAC,IAAI,GACJ,CACP,CAAA;AACH,CAAC,CAAA"}
@@ -0,0 +1,3 @@
1
+ import type { GridWindowProps } from "./virtualizedGridTypes.ts";
2
+ export declare const WindowGrid: <Item>(props: GridWindowProps<Item>) => import("react").JSX.Element;
3
+ //# sourceMappingURL=WindowGrid.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"WindowGrid.d.ts","sourceRoot":"","sources":["../../src/VirtualizedGrid/WindowGrid.tsx"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,2BAA2B,CAAA;AAEhE,eAAO,MAAM,UAAU,GAAI,IAAI,EAC7B,OAAO,eAAe,CAAC,IAAI,CAAC,gCA8B7B,CAAA"}
@@ -0,0 +1,20 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useWindowVirtualizer } from "@tanstack/react-virtual";
3
+ import { useCallback } from "react";
4
+ import { GridRows } from "./GridRows.js";
5
+ import { useScrollMargin } from "./virtualizedGridScroll.js";
6
+ export const WindowGrid = (props) => {
7
+ const { gridElement, scrollMargin } = useScrollMargin({
8
+ kind: "window",
9
+ });
10
+ const measureRow = useCallback((element) => element.getBoundingClientRect().height + props.gap, [props.gap]);
11
+ const rowVirtualizer = useWindowVirtualizer({
12
+ count: props.rowCount,
13
+ estimateSize: () => props.itemBlockSize + props.gap,
14
+ measureElement: measureRow,
15
+ overscan: props.overscanRows,
16
+ scrollMargin,
17
+ });
18
+ return (_jsx(GridRows, { ...props, gridElement: gridElement, measureElement: rowVirtualizer.measureElement, scrollMargin: scrollMargin, totalSize: rowVirtualizer.getTotalSize(), virtualRows: rowVirtualizer.getVirtualItems() }));
19
+ };
20
+ //# sourceMappingURL=WindowGrid.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"WindowGrid.js","sourceRoot":"","sources":["../../src/VirtualizedGrid/WindowGrid.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,oBAAoB,EAAE,MAAM,yBAAyB,CAAA;AAC9D,OAAO,EAAE,WAAW,EAAE,MAAM,OAAO,CAAA;AAEnC,OAAO,EAAE,QAAQ,EAAE,MAAM,eAAgB,CAAA;AACzC,OAAO,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAA;AAG5D,MAAM,CAAC,MAAM,UAAU,GAAG,CACxB,KAA4B,EAC5B,EAAE;IACF,MAAM,EAAE,WAAW,EAAE,YAAY,EAAE,GAAG,eAAe,CAAC;QACpD,IAAI,EAAE,QAAQ;KACf,CAAC,CAAA;IAEF,MAAM,UAAU,GAAG,WAAW,CAC5B,CAAC,OAAgB,EAAE,EAAE,CACnB,OAAO,CAAC,qBAAqB,EAAE,CAAC,MAAM,GAAG,KAAK,CAAC,GAAG,EACpD,CAAC,KAAK,CAAC,GAAG,CAAC,CACZ,CAAA;IAED,MAAM,cAAc,GAAG,oBAAoB,CAAC;QAC1C,KAAK,EAAE,KAAK,CAAC,QAAQ;QACrB,YAAY,EAAE,GAAG,EAAE,CAAC,KAAK,CAAC,aAAa,GAAG,KAAK,CAAC,GAAG;QACnD,cAAc,EAAE,UAAU;QAC1B,QAAQ,EAAE,KAAK,CAAC,YAAY;QAC5B,YAAY;KACb,CAAC,CAAA;IAEF,OAAO,CACL,KAAC,QAAQ,OACH,KAAK,EACT,WAAW,EAAE,WAAW,EACxB,cAAc,EAAE,cAAc,CAAC,cAAc,EAC7C,YAAY,EAAE,YAAY,EAC1B,SAAS,EAAE,cAAc,CAAC,YAAY,EAAE,EACxC,WAAW,EAAE,cAAc,CAAC,eAAe,EAAE,GAC7C,CACH,CAAA;AACH,CAAC,CAAA"}
@@ -0,0 +1,25 @@
1
+ import type { ScrollOwner } from "./virtualizedGridTypes.ts";
2
+ /**
3
+ * The closest ancestor that owns vertical scrolling.
4
+ *
5
+ * The computed value is enough. An `overflow-y:auto` ancestor can
6
+ * be shorter than its contents only AFTER this grid installs its
7
+ * virtual spacer, so requiring `scrollHeight > clientHeight` here
8
+ * would miss the owner on the first commit and create the exact
9
+ * document scrollbar this lookup prevents.
10
+ */
11
+ export declare const findScrollOwner: (element: HTMLElement) => ScrollOwner;
12
+ /**
13
+ * The offset from the scroll owner's content origin to the grid.
14
+ *
15
+ * Both formulas stay stable while their owner scrolls: the grid's
16
+ * viewport-relative top falls by the same amount that `scrollY` or
17
+ * `scrollTop` rises. The element path subtracts its border because
18
+ * a scroll offset starts at the padding edge, not at the outer
19
+ * border returned by `getBoundingClientRect()`.
20
+ */
21
+ export declare const useScrollMargin: (scrollOwner: ScrollOwner) => {
22
+ gridElement: import("react").RefObject<HTMLUListElement | null>;
23
+ scrollMargin: number;
24
+ };
25
+ //# sourceMappingURL=virtualizedGridScroll.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"virtualizedGridScroll.d.ts","sourceRoot":"","sources":["../../src/VirtualizedGrid/virtualizedGridScroll.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,2BAA2B,CAAA;AAE5D;;;;;;;;GAQG;AACH,eAAO,MAAM,eAAe,GAC1B,SAAS,WAAW,KACnB,WAcF,CAAA;AAED;;;;;;;;GAQG;AACH,eAAO,MAAM,eAAe,GAC1B,aAAa,WAAW;;;CA2BzB,CAAA"}
@@ -0,0 +1,50 @@
1
+ import { useLayoutEffect, useRef, useState } from "react";
2
+ /**
3
+ * The closest ancestor that owns vertical scrolling.
4
+ *
5
+ * The computed value is enough. An `overflow-y:auto` ancestor can
6
+ * be shorter than its contents only AFTER this grid installs its
7
+ * virtual spacer, so requiring `scrollHeight > clientHeight` here
8
+ * would miss the owner on the first commit and create the exact
9
+ * document scrollbar this lookup prevents.
10
+ */
11
+ export const findScrollOwner = (element) => {
12
+ let ancestor = element.parentElement;
13
+ while (ancestor) {
14
+ const overflow = getComputedStyle(ancestor).overflowY;
15
+ if (overflow === "auto" || overflow === "scroll") {
16
+ return { element: ancestor, kind: "element" };
17
+ }
18
+ ancestor = ancestor.parentElement;
19
+ }
20
+ return { kind: "window" };
21
+ };
22
+ /**
23
+ * The offset from the scroll owner's content origin to the grid.
24
+ *
25
+ * Both formulas stay stable while their owner scrolls: the grid's
26
+ * viewport-relative top falls by the same amount that `scrollY` or
27
+ * `scrollTop` rises. The element path subtracts its border because
28
+ * a scroll offset starts at the padding edge, not at the outer
29
+ * border returned by `getBoundingClientRect()`.
30
+ */
31
+ export const useScrollMargin = (scrollOwner) => {
32
+ const gridElement = useRef(null);
33
+ const [scrollMargin, setScrollMargin] = useState(0);
34
+ useLayoutEffect(() => {
35
+ const element = gridElement.current;
36
+ if (!element) {
37
+ return;
38
+ }
39
+ const gridTop = element.getBoundingClientRect().top;
40
+ const next = scrollOwner.kind === "window"
41
+ ? gridTop + window.scrollY
42
+ : gridTop -
43
+ scrollOwner.element.getBoundingClientRect().top -
44
+ scrollOwner.element.clientTop +
45
+ scrollOwner.element.scrollTop;
46
+ setScrollMargin((current) => current === next ? current : next);
47
+ });
48
+ return { gridElement, scrollMargin };
49
+ };
50
+ //# sourceMappingURL=virtualizedGridScroll.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"virtualizedGridScroll.js","sourceRoot":"","sources":["../../src/VirtualizedGrid/virtualizedGridScroll.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAA;AAIzD;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,eAAe,GAAG,CAC7B,OAAoB,EACP,EAAE;IACf,IAAI,QAAQ,GAAG,OAAO,CAAC,aAAa,CAAA;IAEpC,OAAO,QAAQ,EAAE,CAAC;QAChB,MAAM,QAAQ,GAAG,gBAAgB,CAAC,QAAQ,CAAC,CAAC,SAAS,CAAA;QAErD,IAAI,QAAQ,KAAK,MAAM,IAAI,QAAQ,KAAK,QAAQ,EAAE,CAAC;YACjD,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,IAAI,EAAE,SAAS,EAAE,CAAA;QAC/C,CAAC;QAED,QAAQ,GAAG,QAAQ,CAAC,aAAa,CAAA;IACnC,CAAC;IAED,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,CAAA;AAC3B,CAAC,CAAA;AAED;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,eAAe,GAAG,CAC7B,WAAwB,EACxB,EAAE;IACF,MAAM,WAAW,GAAG,MAAM,CAAmB,IAAI,CAAC,CAAA;IAClD,MAAM,CAAC,YAAY,EAAE,eAAe,CAAC,GAAG,QAAQ,CAAC,CAAC,CAAC,CAAA;IAEnD,eAAe,CAAC,GAAG,EAAE;QACnB,MAAM,OAAO,GAAG,WAAW,CAAC,OAAO,CAAA;QAEnC,IAAI,CAAC,OAAO,EAAE,CAAC;YACb,OAAM;QACR,CAAC;QAED,MAAM,OAAO,GAAG,OAAO,CAAC,qBAAqB,EAAE,CAAC,GAAG,CAAA;QACnD,MAAM,IAAI,GACR,WAAW,CAAC,IAAI,KAAK,QAAQ;YAC3B,CAAC,CAAC,OAAO,GAAG,MAAM,CAAC,OAAO;YAC1B,CAAC,CAAC,OAAO;gBACP,WAAW,CAAC,OAAO,CAAC,qBAAqB,EAAE,CAAC,GAAG;gBAC/C,WAAW,CAAC,OAAO,CAAC,SAAS;gBAC7B,WAAW,CAAC,OAAO,CAAC,SAAS,CAAA;QAEnC,eAAe,CAAC,CAAC,OAAO,EAAE,EAAE,CAC1B,OAAO,KAAK,IAAI,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,IAAI,CAClC,CAAA;IACH,CAAC,CAAC,CAAA;IAEF,OAAO,EAAE,WAAW,EAAE,YAAY,EAAE,CAAA;AACtC,CAAC,CAAA"}
@@ -0,0 +1,27 @@
1
+ import type { VirtualItem } from "@tanstack/react-virtual";
2
+ import type { ReactNode, RefObject } from "react";
3
+ export type ScrollOwner = {
4
+ kind: "element";
5
+ element: HTMLElement;
6
+ } | {
7
+ kind: "window";
8
+ };
9
+ export type GridWindowProps<Item> = {
10
+ drawnColumns: number;
11
+ gap: number;
12
+ getItemKey?: (item: Item, index: number) => string | number;
13
+ itemBlockSize: number;
14
+ items: readonly Item[];
15
+ label?: string;
16
+ overscanRows: number;
17
+ renderItem: (item: Item, index: number) => ReactNode;
18
+ rowCount: number;
19
+ };
20
+ export type GridRowsProps<Item> = Omit<GridWindowProps<Item>, "itemBlockSize" | "overscanRows" | "rowCount"> & {
21
+ gridElement: RefObject<HTMLUListElement | null>;
22
+ measureElement: (element: Element | null) => void;
23
+ scrollMargin: number;
24
+ totalSize: number;
25
+ virtualRows: VirtualItem[];
26
+ };
27
+ //# sourceMappingURL=virtualizedGridTypes.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"virtualizedGridTypes.d.ts","sourceRoot":"","sources":["../../src/VirtualizedGrid/virtualizedGridTypes.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAA;AAC1D,OAAO,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAEjD,MAAM,MAAM,WAAW,GACnB;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,OAAO,EAAE,WAAW,CAAA;CAAE,GACzC;IAAE,IAAI,EAAE,QAAQ,CAAA;CAAE,CAAA;AAEtB,MAAM,MAAM,eAAe,CAAC,IAAI,IAAI;IAClC,YAAY,EAAE,MAAM,CAAA;IACpB,GAAG,EAAE,MAAM,CAAA;IACX,UAAU,CAAC,EAAE,CACX,IAAI,EAAE,IAAI,EACV,KAAK,EAAE,MAAM,KACV,MAAM,GAAG,MAAM,CAAA;IACpB,aAAa,EAAE,MAAM,CAAA;IACrB,KAAK,EAAE,SAAS,IAAI,EAAE,CAAA;IACtB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,YAAY,EAAE,MAAM,CAAA;IACpB,UAAU,EAAE,CAAC,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,MAAM,KAAK,SAAS,CAAA;IACpD,QAAQ,EAAE,MAAM,CAAA;CACjB,CAAA;AAED,MAAM,MAAM,aAAa,CAAC,IAAI,IAAI,IAAI,CACpC,eAAe,CAAC,IAAI,CAAC,EACrB,eAAe,GAAG,cAAc,GAAG,UAAU,CAC9C,GAAG;IACF,WAAW,EAAE,SAAS,CAAC,gBAAgB,GAAG,IAAI,CAAC,CAAA;IAC/C,cAAc,EAAE,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,KAAK,IAAI,CAAA;IACjD,YAAY,EAAE,MAAM,CAAA;IACpB,SAAS,EAAE,MAAM,CAAA;IACjB,WAAW,EAAE,WAAW,EAAE,CAAA;CAC3B,CAAA"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=virtualizedGridTypes.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"virtualizedGridTypes.js","sourceRoot":"","sources":["../../src/VirtualizedGrid/virtualizedGridTypes.ts"],"names":[],"mappings":""}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@charcuterie/ui",
3
- "version": "3.28.1",
3
+ "version": "3.28.2",
4
4
  "description": "The Charcuterie components. Tokens for look, @charcuterie/logic for state, ARIA that agents can actually drive.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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
+ }
@@ -0,0 +1,32 @@
1
+ import type { VirtualItem } from "@tanstack/react-virtual"
2
+ import type { ReactNode, RefObject } from "react"
3
+
4
+ export type ScrollOwner =
5
+ | { kind: "element"; element: HTMLElement }
6
+ | { kind: "window" }
7
+
8
+ export type GridWindowProps<Item> = {
9
+ drawnColumns: number
10
+ gap: number
11
+ getItemKey?: (
12
+ item: Item,
13
+ index: number,
14
+ ) => string | number
15
+ itemBlockSize: number
16
+ items: readonly Item[]
17
+ label?: string
18
+ overscanRows: number
19
+ renderItem: (item: Item, index: number) => ReactNode
20
+ rowCount: number
21
+ }
22
+
23
+ export type GridRowsProps<Item> = Omit<
24
+ GridWindowProps<Item>,
25
+ "itemBlockSize" | "overscanRows" | "rowCount"
26
+ > & {
27
+ gridElement: RefObject<HTMLUListElement | null>
28
+ measureElement: (element: Element | null) => void
29
+ scrollMargin: number
30
+ totalSize: number
31
+ virtualRows: VirtualItem[]
32
+ }