@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.
- package/dist/VirtualizedGrid/ElementGrid.d.ts +7 -0
- package/dist/VirtualizedGrid/ElementGrid.d.ts.map +1 -0
- package/dist/VirtualizedGrid/ElementGrid.js +19 -0
- package/dist/VirtualizedGrid/ElementGrid.js.map +1 -0
- package/dist/VirtualizedGrid/GridRows.d.ts +4 -0
- package/dist/VirtualizedGrid/GridRows.d.ts.map +1 -0
- package/dist/VirtualizedGrid/GridRows.js +40 -0
- package/dist/VirtualizedGrid/GridRows.js.map +1 -0
- package/dist/VirtualizedGrid/VirtualizedGrid.d.ts +6 -8
- package/dist/VirtualizedGrid/VirtualizedGrid.d.ts.map +1 -1
- package/dist/VirtualizedGrid/VirtualizedGrid.js +30 -87
- package/dist/VirtualizedGrid/VirtualizedGrid.js.map +1 -1
- package/dist/VirtualizedGrid/WindowGrid.d.ts +3 -0
- package/dist/VirtualizedGrid/WindowGrid.d.ts.map +1 -0
- package/dist/VirtualizedGrid/WindowGrid.js +20 -0
- package/dist/VirtualizedGrid/WindowGrid.js.map +1 -0
- package/dist/VirtualizedGrid/virtualizedGridScroll.d.ts +25 -0
- package/dist/VirtualizedGrid/virtualizedGridScroll.d.ts.map +1 -0
- package/dist/VirtualizedGrid/virtualizedGridScroll.js +50 -0
- package/dist/VirtualizedGrid/virtualizedGridScroll.js.map +1 -0
- package/dist/VirtualizedGrid/virtualizedGridTypes.d.ts +27 -0
- package/dist/VirtualizedGrid/virtualizedGridTypes.d.ts.map +1 -0
- package/dist/VirtualizedGrid/virtualizedGridTypes.js +2 -0
- package/dist/VirtualizedGrid/virtualizedGridTypes.js.map +1 -0
- package/package.json +1 -1
- package/src/VirtualizedGrid/ElementGrid.tsx +45 -0
- package/src/VirtualizedGrid/GridRows.tsx +90 -0
- package/src/VirtualizedGrid/VirtualizedGrid.mdx +9 -8
- package/src/VirtualizedGrid/VirtualizedGrid.stories.tsx +26 -0
- package/src/VirtualizedGrid/VirtualizedGrid.test.tsx +60 -1
- package/src/VirtualizedGrid/VirtualizedGrid.tsx +42 -142
- package/src/VirtualizedGrid/WindowGrid.tsx +39 -0
- package/src/VirtualizedGrid/virtualizedGridScroll.ts +69 -0
- 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
|
-
* ###
|
|
126
|
+
* ### It follows the page's existing scroll owner
|
|
127
127
|
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
* `
|
|
132
|
-
*
|
|
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":"
|
|
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 {
|
|
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
|
-
* ###
|
|
63
|
+
* ### It follows the page's existing scroll owner
|
|
63
64
|
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
* `
|
|
68
|
-
*
|
|
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
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
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
|
|
116
|
-
const [scrollMargin, setScrollMargin] = useState(0);
|
|
99
|
+
const [scrollOwner, setScrollOwner] = useState(null);
|
|
117
100
|
useLayoutEffect(() => {
|
|
118
|
-
const element =
|
|
101
|
+
const element = layout.containerRef.current;
|
|
119
102
|
if (!element) {
|
|
120
103
|
return;
|
|
121
104
|
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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":";
|
|
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 @@
|
|
|
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 @@
|
|
|
1
|
+
{"version":3,"file":"virtualizedGridTypes.js","sourceRoot":"","sources":["../../src/VirtualizedGrid/virtualizedGridTypes.ts"],"names":[],"mappings":""}
|
package/package.json
CHANGED
|
@@ -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
|
|
50
|
+
## It follows the page's scroll region
|
|
51
51
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
58
|
-
|
|
59
|
-
wrong and every row is offset by the
|
|
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 } =
|
|
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
|
-
* ###
|
|
143
|
+
* ### It follows the page's existing scroll owner
|
|
144
144
|
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
* `
|
|
149
|
-
*
|
|
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
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
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
|
|
216
|
-
|
|
197
|
+
const [scrollOwner, setScrollOwner] =
|
|
198
|
+
useState<ScrollOwner | null>(null)
|
|
217
199
|
|
|
218
200
|
useLayoutEffect(() => {
|
|
219
|
-
const element =
|
|
201
|
+
const element = layout.containerRef.current
|
|
220
202
|
|
|
221
203
|
if (!element) {
|
|
222
204
|
return
|
|
223
205
|
}
|
|
224
206
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
setScrollMargin((current) =>
|
|
229
|
-
current === next ? current : next,
|
|
230
|
-
)
|
|
231
|
-
})
|
|
207
|
+
setScrollOwner(findScrollOwner(element))
|
|
208
|
+
}, [layout.containerRef])
|
|
232
209
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
+
}
|