@versini/ui-datagrid 3.1.2 → 4.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -18
- package/dist/197.js +56 -34
- package/dist/430.js +1 -1
- package/dist/799.js +1 -1
- package/dist/91.js +1 -1
- package/dist/DataGrid/DataGridTypes.d.ts +52 -0
- package/dist/DataGrid/index.js +53 -9
- package/dist/DataGridAnimated/AnimatedWrapper.d.ts +0 -1
- package/dist/DataGridAnimated/index.js +16 -3
- package/dist/DataGridBody/index.js +1 -1
- package/dist/DataGridCell/index.js +1 -1
- package/dist/DataGridCellSort/index.js +1 -1
- package/dist/DataGridConstants/index.js +1 -1
- package/dist/DataGridFooter/index.js +4 -2
- package/dist/DataGridHeader/index.js +4 -2
- package/dist/DataGridInfinite/DataGridInfiniteBody.d.ts +32 -43
- package/dist/DataGridInfinite/index.js +287 -223
- package/dist/DataGridRow/index.js +5 -2
- package/dist/DataGridSorting/index.js +1 -1
- package/package.json +3 -2
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/*!
|
|
2
|
-
@versini/ui-datagrid
|
|
2
|
+
@versini/ui-datagrid v4.0.1
|
|
3
3
|
© 2026 gizmette.com
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { jsx } from "react/jsx-runtime";
|
|
7
|
-
import {
|
|
6
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
7
|
+
import { elementScroll, observeElementOffset, observeElementRect, observeWindowOffset, observeWindowRect, useVirtualizer, windowScroll } from "@tanstack/react-virtual";
|
|
8
|
+
import { useContext, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
8
9
|
import { DataGridContext } from "../430.js";
|
|
9
10
|
import { CellWrapper } from "../799.js";
|
|
10
11
|
import { getBodyClass, useColumnMeasurement } from "../197.js";
|
|
@@ -17,291 +18,319 @@ import { getBodyClass, useColumnMeasurement } from "../197.js";
|
|
|
17
18
|
|
|
18
19
|
|
|
19
20
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
let parent = element.parentElement;
|
|
29
|
-
while(parent){
|
|
30
|
-
const style = getComputedStyle(parent);
|
|
31
|
-
const overflowY = style.overflowY;
|
|
32
|
-
if (overflowY === "auto" || overflowY === "scroll") {
|
|
33
|
-
return parent;
|
|
34
|
-
}
|
|
35
|
-
parent = parent.parentElement;
|
|
36
|
-
}
|
|
37
|
-
return null;
|
|
38
|
-
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
const DEFAULT_OVERSCAN = 8;
|
|
24
|
+
const DEFAULT_ESTIMATED_ROW_HEIGHT = 44;
|
|
25
|
+
const DEFAULT_COMPACT_ESTIMATED_ROW_HEIGHT = 29;
|
|
26
|
+
// Fallback used to restore the normal window after a jump when the browser does
|
|
27
|
+
// not fire `scrollend` (e.g. Safari < 17.4) or the target was already centered.
|
|
28
|
+
const JUMP_SETTLE_FALLBACK_MS = 1000;
|
|
39
29
|
/**
|
|
40
|
-
* A
|
|
30
|
+
* A virtualized DataGrid body for large datasets.
|
|
41
31
|
*
|
|
42
|
-
*
|
|
43
|
-
* -
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
32
|
+
* Only the rows near the viewport stay mounted (windowing via
|
|
33
|
+
* `@tanstack/react-virtual`), so the DOM stays small and scrolling stays smooth
|
|
34
|
+
* at any row count. Rows are rendered in normal grid flow between two full-span
|
|
35
|
+
* spacer elements, so `columns`/subgrid (including intrinsic `auto` tracks) keep
|
|
36
|
+
* working without any consumer change. The virtualizer windows against the
|
|
37
|
+
* DataGrid's own scroll container (created by `maxHeight` or a sticky
|
|
38
|
+
* header/footer), or the page (window scroll) when the grid has neither.
|
|
39
|
+
*
|
|
40
|
+
* Because off-screen rows are removed from the DOM, the grid exposes
|
|
41
|
+
* `aria-rowcount`/`aria-rowindex` and keeps keyboard focus on the grid if a
|
|
42
|
+
* focused row recycles out. Browser find-in-page and uncontrolled per-row state
|
|
43
|
+
* do not persist across recycling (inherent to DOM virtualization).
|
|
47
44
|
*
|
|
48
45
|
* @example
|
|
49
46
|
* ```tsx
|
|
50
|
-
* const infiniteBodyRef = useRef<DataGridInfiniteBodyRef>(null);
|
|
51
|
-
*
|
|
52
|
-
* // Jump to a specific row
|
|
53
|
-
* const handleJumpToRow = () => {
|
|
54
|
-
* infiniteBodyRef.current?.scrollToIndex(134);
|
|
55
|
-
* };
|
|
56
|
-
*
|
|
57
47
|
* <DataGrid maxHeight="400px" stickyHeader>
|
|
58
|
-
* <DataGridHeader caption={`Showing ${
|
|
48
|
+
* <DataGridHeader caption={`Showing ${reached} of ${data.length}`}>
|
|
59
49
|
* <DataGridRow>
|
|
60
50
|
* <DataGridCell>Name</DataGridCell>
|
|
61
|
-
* <DataGridCell>Role</DataGridCell>
|
|
62
51
|
* </DataGridRow>
|
|
63
52
|
* </DataGridHeader>
|
|
64
53
|
*
|
|
65
|
-
* <DataGridInfiniteBody
|
|
66
|
-
* ref={infiniteBodyRef}
|
|
67
|
-
* data={largeData}
|
|
68
|
-
* batchSize={25}
|
|
69
|
-
* onVisibleCountChange={(count) => setVisibleCount(count)}
|
|
70
|
-
* >
|
|
54
|
+
* <DataGridInfiniteBody data={largeData} estimatedRowHeight={56}>
|
|
71
55
|
* {(row) => (
|
|
72
56
|
* <DataGridRow key={row.id}>
|
|
73
57
|
* <DataGridCell>{row.name}</DataGridCell>
|
|
74
|
-
* <DataGridCell>{row.role}</DataGridCell>
|
|
75
58
|
* </DataGridRow>
|
|
76
59
|
* )}
|
|
77
60
|
* </DataGridInfiniteBody>
|
|
78
61
|
* </DataGrid>
|
|
79
62
|
* ```
|
|
80
63
|
*
|
|
81
|
-
*/ function DataGridInfiniteBody({ data = [], children: renderRow,
|
|
64
|
+
*/ function DataGridInfiniteBody({ data = [], children: renderRow, overscan = DEFAULT_OVERSCAN, estimatedRowHeight, onVisibleCountChange, className, noData = false, noDataText, ref }) {
|
|
82
65
|
const ctx = useContext(DataGridContext);
|
|
83
66
|
const bodyRef = useRef(null);
|
|
84
|
-
|
|
85
|
-
|
|
67
|
+
// A zero-height grid item at the list start. The display:contents body has no
|
|
68
|
+
// box, so we measure this real element to find the list's scroll-margin.
|
|
69
|
+
const sentinelRef = useRef(null);
|
|
86
70
|
const totalItems = data.length;
|
|
87
|
-
const
|
|
88
|
-
const
|
|
89
|
-
const hasMore = visibleCount < totalItems;
|
|
71
|
+
const headerRows = ctx.headerRows ?? 0;
|
|
72
|
+
const footerRows = ctx.footerRows ?? 0;
|
|
90
73
|
/**
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
74
|
+
* Default the row-height estimate from the grid's density when the consumer
|
|
75
|
+
* doesn't set it: `compact` rows are much shorter (~29px vs ~44px), and an
|
|
76
|
+
* estimate far from the real height hurts scrollbar accuracy and scrollToIndex.
|
|
77
|
+
*/ const resolvedEstimatedRowHeight = estimatedRowHeight ?? (ctx.compact ? DEFAULT_COMPACT_ESTIMATED_ROW_HEIGHT : DEFAULT_ESTIMATED_ROW_HEIGHT);
|
|
78
|
+
const [scrollMargin, setScrollMargin] = useState(0);
|
|
79
|
+
/**
|
|
80
|
+
* Jump-to-row state. A programmatic `scrollToIndex` first widens the
|
|
81
|
+
* virtualized window (`jump.overscan`) so every row between the current view
|
|
82
|
+
* and the target is mounted, then lets the browser natively smooth-scroll to
|
|
83
|
+
* the real target element. Scrolling over real, laid-out rows lands exactly and
|
|
84
|
+
* glides smoothly — the virtualizer never re-estimates the target offset
|
|
85
|
+
* mid-flight (which is what made the jump stutter near the target). The window
|
|
86
|
+
* is restored once the scroll settles, so the path rows recycle back out.
|
|
87
|
+
*
|
|
88
|
+
* `seq` makes every request a fresh object: two jumps that happen to compute
|
|
89
|
+
* the same `overscan` still re-run the jump effect (a plain number would be a
|
|
90
|
+
* no-op setState and the second jump would be dropped). `handledSeqRef` records
|
|
91
|
+
* the last seq the effect acted on, so it scrolls once per request.
|
|
92
|
+
*/ const [jump, setJump] = useState(null);
|
|
93
|
+
const jumpSeqRef = useRef(0);
|
|
94
|
+
const handledSeqRef = useRef(-1);
|
|
95
|
+
/**
|
|
96
|
+
* The scroll target is provided by the parent DataGrid: its internal bounded
|
|
97
|
+
* scroller (sticky / maxHeight), or the page (window scroll) when it owns
|
|
98
|
+
* none. This avoids fragile DOM-walking that can mistake an unbounded
|
|
99
|
+
* `overflow-x:auto` wrapper for a real vertical scroller.
|
|
100
|
+
*/ const useWindow = ctx.pageScroll === true;
|
|
101
|
+
const scrollEl = useWindow ? null : ctx.scrollContainer ?? null;
|
|
102
|
+
const resolved = useWindow || scrollEl !== null;
|
|
103
|
+
// Measure the list's offset within the scroller (= sticky-header padding /
|
|
104
|
+
// inline-header height / page offset). Keeps the window origin correct.
|
|
105
|
+
useLayoutEffect(()=>{
|
|
106
|
+
if (!resolved || noData) {
|
|
98
107
|
return;
|
|
99
108
|
}
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
if (row) {
|
|
105
|
-
row.scrollIntoView({
|
|
106
|
-
behavior: "smooth",
|
|
107
|
-
block: "center"
|
|
108
|
-
});
|
|
109
|
+
const compute = ()=>{
|
|
110
|
+
const node = sentinelRef.current;
|
|
111
|
+
if (!node) {
|
|
112
|
+
return;
|
|
109
113
|
}
|
|
114
|
+
const top = node.getBoundingClientRect().top;
|
|
115
|
+
const next = useWindow ? Math.max(0, Math.round(top + window.scrollY)) : Math.max(0, Math.round(top - scrollEl.getBoundingClientRect().top + scrollEl.scrollTop));
|
|
116
|
+
setScrollMargin((prev)=>prev === next ? prev : next);
|
|
117
|
+
};
|
|
118
|
+
compute();
|
|
119
|
+
const target = useWindow ? document.documentElement : scrollEl;
|
|
120
|
+
if (!target) {
|
|
121
|
+
return;
|
|
110
122
|
}
|
|
111
|
-
|
|
123
|
+
const observer = new ResizeObserver(compute);
|
|
124
|
+
observer.observe(target);
|
|
125
|
+
return ()=>observer.disconnect();
|
|
126
|
+
}, [
|
|
127
|
+
resolved,
|
|
128
|
+
useWindow,
|
|
129
|
+
scrollEl,
|
|
130
|
+
noData
|
|
131
|
+
]);
|
|
132
|
+
const virtualizer = useVirtualizer({
|
|
133
|
+
count: totalItems,
|
|
134
|
+
// In window mode the scroll element IS `window` (the window observers read
|
|
135
|
+
// window.innerHeight / window.scrollY off it); cast bridges the type since
|
|
136
|
+
// useVirtualizer's element constraint excludes Window.
|
|
137
|
+
getScrollElement: ()=>useWindow ? typeof window !== "undefined" ? window : null : scrollEl,
|
|
138
|
+
estimateSize: ()=>resolvedEstimatedRowHeight,
|
|
139
|
+
// While a jump is in flight, `jump.overscan` widens the window to mount the
|
|
140
|
+
// rows along the path; otherwise the consumer's (or default) overscan.
|
|
141
|
+
overscan: jump?.overscan ?? overscan,
|
|
142
|
+
scrollMargin,
|
|
143
|
+
// React 19: let React batch the scroll-driven updates natively.
|
|
144
|
+
useFlushSync: false,
|
|
145
|
+
// Window vs element observers are selected to match getScrollElement; the
|
|
146
|
+
// casts bridge the Element|Window union react-virtual can't narrow here.
|
|
147
|
+
observeElementRect: useWindow ? observeWindowRect : observeElementRect,
|
|
148
|
+
observeElementOffset: useWindow ? observeWindowOffset : observeElementOffset,
|
|
149
|
+
scrollToFn: useWindow ? windowScroll : elementScroll,
|
|
150
|
+
// Window mode: seed the offset from the live scroll position (mirrors
|
|
151
|
+
// useWindowVirtualizer). Without this, on mount the virtualizer's offset is
|
|
152
|
+
// 0, so it scrolls an already-scrolled page to the top and computes the
|
|
153
|
+
// wrong initial window.
|
|
154
|
+
initialOffset: useWindow ? ()=>typeof window !== "undefined" ? window.scrollY : 0 : undefined
|
|
155
|
+
});
|
|
156
|
+
const virtualItems = virtualizer.getVirtualItems();
|
|
157
|
+
const totalSize = virtualizer.getTotalSize();
|
|
158
|
+
const lastVisibleIndex = virtualItems.length > 0 ? virtualItems[virtualItems.length - 1].index : -1;
|
|
112
159
|
/**
|
|
113
|
-
*
|
|
160
|
+
* Dev-only: warn about scroll-container setups that silently break
|
|
161
|
+
* virtualization — windowing against the page while a bounded ancestor is
|
|
162
|
+
* actually the scroller (only the first screen renders), or an internal
|
|
163
|
+
* scroller that isn't height-bounded (every row mounts). Runs after paint so
|
|
164
|
+
* the measurements are real; stripped from production builds.
|
|
114
165
|
*/ useEffect(()=>{
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
166
|
+
// Treat a missing `process` (a non-Node runtime whose bundler didn't replace
|
|
167
|
+
// this expression) as production, so reading NODE_ENV can never throw.
|
|
168
|
+
const isProduction = typeof process === "undefined" || process.env.NODE_ENV === "production";
|
|
169
|
+
if (isProduction || noData || totalItems === 0 || typeof window === "undefined") {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (useWindow) {
|
|
173
|
+
let el = bodyRef.current?.parentElement ?? null;
|
|
174
|
+
while(el && el !== document.body && el !== document.documentElement){
|
|
175
|
+
const { overflowY } = getComputedStyle(el);
|
|
176
|
+
if ((overflowY === "auto" || overflowY === "scroll") && el.scrollHeight > el.clientHeight) {
|
|
177
|
+
console.warn("[DataGridInfiniteBody] Virtualizing against the page (window scroll), but " + "the grid is inside a scrollable container, so only the first screen of " + "rows will render. Set `maxHeight` on the DataGrid to scroll inside it.");
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
el = el.parentElement;
|
|
124
181
|
}
|
|
182
|
+
} else if (scrollEl && scrollEl.clientHeight > window.innerHeight && scrollEl.scrollHeight <= scrollEl.clientHeight + 1) {
|
|
183
|
+
console.warn("[DataGridInfiniteBody] The scroll container is not height-bounded, so every " + "row mounts and virtualization has no effect. Set `maxHeight` on the DataGrid.");
|
|
125
184
|
}
|
|
126
185
|
}, [
|
|
127
|
-
|
|
128
|
-
|
|
186
|
+
useWindow,
|
|
187
|
+
scrollEl,
|
|
188
|
+
noData,
|
|
189
|
+
totalItems
|
|
129
190
|
]);
|
|
130
191
|
/**
|
|
131
|
-
* Expose
|
|
192
|
+
* Expose scrollToIndex via ref. Rather than letting the virtualizer scroll to
|
|
193
|
+
* an estimated offset (which re-corrects as rows measure, stuttering near the
|
|
194
|
+
* target), we widen the window to mount every row from the current view to the
|
|
195
|
+
* target, then a layout effect natively smooth-scrolls to the real element.
|
|
196
|
+
* Works for any index, mounted or not.
|
|
132
197
|
*/ useImperativeHandle(ref, ()=>({
|
|
133
198
|
scrollToIndex: (index)=>{
|
|
134
199
|
if (index < 0 || index >= totalItems) {
|
|
135
|
-
console.warn(`scrollToIndex: index ${index} is out of bounds (0-${totalItems - 1})`);
|
|
200
|
+
console.warn(`scrollToIndex: index ${index} is out of bounds${totalItems === 0 ? " (the grid is empty)" : ` (0-${totalItems - 1})`}`);
|
|
136
201
|
return;
|
|
137
202
|
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
203
|
+
const items = virtualizer.getVirtualItems();
|
|
204
|
+
const first = items.length > 0 ? items[0].index : 0;
|
|
205
|
+
const last = items.length > 0 ? items[items.length - 1].index : 0;
|
|
206
|
+
// Overscan is symmetric, so widen by the larger gap to either edge of
|
|
207
|
+
// the current window; that guarantees the target (and the rows between)
|
|
208
|
+
// mount. The jump effect scrolls once they render, then restores the
|
|
209
|
+
// normal window.
|
|
210
|
+
const reach = Math.max(index - first, last - index, 0);
|
|
211
|
+
jumpSeqRef.current += 1;
|
|
212
|
+
setJump({
|
|
213
|
+
index,
|
|
214
|
+
overscan: reach + overscan,
|
|
215
|
+
seq: jumpSeqRef.current
|
|
216
|
+
});
|
|
149
217
|
}
|
|
150
218
|
}), [
|
|
151
219
|
totalItems,
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
scrollToRowElement
|
|
220
|
+
virtualizer,
|
|
221
|
+
overscan
|
|
155
222
|
]);
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
223
|
+
/**
|
|
224
|
+
* Perform a pending jump once the widened window has mounted the target row
|
|
225
|
+
* (react-virtual recomputes the window synchronously, so it is mounted in the
|
|
226
|
+
* commit that follows the `jump` change), then restore the normal window after
|
|
227
|
+
* the scroll settles. `scrollend` fires when the native smooth scroll finishes;
|
|
228
|
+
* the timeout is a fallback for browsers without it and for the no-op case
|
|
229
|
+
* (target already centered, so no scroll — and thus no `scrollend` — fires).
|
|
230
|
+
*/ useLayoutEffect(()=>{
|
|
231
|
+
if (jump === null || handledSeqRef.current === jump.seq) {
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
handledSeqRef.current = jump.seq;
|
|
235
|
+
const rowEl = bodyRef.current?.querySelector(`[data-index="${jump.index}"]`);
|
|
236
|
+
if (!rowEl) {
|
|
237
|
+
// The widened window should always mount the target; if it somehow did
|
|
238
|
+
// not, don't stay stuck in widened mode — restore the normal window so
|
|
239
|
+
// later jumps aren't dropped.
|
|
240
|
+
setJump(null);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
const reduceMotion = typeof window !== "undefined" && !!window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
|
|
244
|
+
rowEl.scrollIntoView({
|
|
245
|
+
behavior: reduceMotion ? "auto" : "smooth",
|
|
246
|
+
block: "center"
|
|
247
|
+
});
|
|
248
|
+
const scroller = useWindow ? typeof window !== "undefined" ? window : null : scrollEl;
|
|
249
|
+
const settle = ()=>setJump(null);
|
|
250
|
+
scroller?.addEventListener("scrollend", settle, {
|
|
251
|
+
once: true
|
|
252
|
+
});
|
|
253
|
+
const timer = setTimeout(settle, JUMP_SETTLE_FALLBACK_MS);
|
|
254
|
+
return ()=>{
|
|
255
|
+
clearTimeout(timer);
|
|
256
|
+
scroller?.removeEventListener("scrollend", settle);
|
|
257
|
+
};
|
|
159
258
|
}, [
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
259
|
+
jump,
|
|
260
|
+
useWindow,
|
|
261
|
+
scrollEl
|
|
163
262
|
]);
|
|
164
|
-
|
|
263
|
+
/**
|
|
264
|
+
* Report a monotonic high-water mark via onVisibleCountChange: the furthest
|
|
265
|
+
* row index reached + 1 (resets when the dataset length changes).
|
|
266
|
+
*/ const highWaterRef = useRef(0);
|
|
267
|
+
const prevTotalRef = useRef(totalItems);
|
|
165
268
|
useEffect(()=>{
|
|
166
|
-
|
|
269
|
+
if (prevTotalRef.current !== totalItems) {
|
|
270
|
+
prevTotalRef.current = totalItems;
|
|
271
|
+
highWaterRef.current = 0;
|
|
272
|
+
}
|
|
273
|
+
const hw = Math.min(lastVisibleIndex + 1, totalItems);
|
|
274
|
+
if (hw > highWaterRef.current) {
|
|
275
|
+
highWaterRef.current = hw;
|
|
276
|
+
onVisibleCountChange?.(hw, totalItems);
|
|
277
|
+
}
|
|
167
278
|
}, [
|
|
168
|
-
|
|
279
|
+
lastVisibleIndex,
|
|
169
280
|
totalItems,
|
|
170
281
|
onVisibleCountChange
|
|
171
282
|
]);
|
|
172
283
|
/**
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
if (
|
|
178
|
-
|
|
284
|
+
* Report the total row count to the grid for aria-rowcount (header + body +
|
|
285
|
+
* footer rows), and clear it when this body unmounts. Empty/noData grids
|
|
286
|
+
* report nothing, so they render no aria-rowcount and no focus sentinel.
|
|
287
|
+
*/ useEffect(()=>{
|
|
288
|
+
if (noData || totalItems === 0) {
|
|
289
|
+
ctx.setRowCount?.(undefined);
|
|
290
|
+
return;
|
|
179
291
|
}
|
|
292
|
+
ctx.setRowCount?.(totalItems + headerRows + footerRows);
|
|
293
|
+
return ()=>ctx.setRowCount?.(undefined);
|
|
180
294
|
}, [
|
|
181
|
-
|
|
182
|
-
totalItems
|
|
295
|
+
noData,
|
|
296
|
+
totalItems,
|
|
297
|
+
headerRows,
|
|
298
|
+
footerRows,
|
|
299
|
+
ctx.setRowCount
|
|
183
300
|
]);
|
|
184
301
|
/**
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
if (node && hasMore) {
|
|
197
|
-
const root = findScrollableAncestor(node);
|
|
198
|
-
observerRef.current = new IntersectionObserver(handleIntersection, {
|
|
199
|
-
root,
|
|
200
|
-
rootMargin
|
|
201
|
-
});
|
|
202
|
-
observerRef.current.observe(node);
|
|
302
|
+
* Focus preservation: when a focused per-row control recycles out of the DOM,
|
|
303
|
+
* focus would fall to document.body. Track the focused row index; if it
|
|
304
|
+
* unmounts and focus drops to the body, move focus to the grid sentinel.
|
|
305
|
+
*/ const focusedIndexRef = useRef(null);
|
|
306
|
+
useLayoutEffect(()=>{
|
|
307
|
+
const body = bodyRef.current;
|
|
308
|
+
const active = document.activeElement;
|
|
309
|
+
if (active && body?.contains(active)) {
|
|
310
|
+
const rowEl = active.closest("[data-index]");
|
|
311
|
+
focusedIndexRef.current = rowEl ? Number(rowEl.getAttribute("data-index")) : null;
|
|
312
|
+
return;
|
|
203
313
|
}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
handleIntersection,
|
|
207
|
-
rootMargin
|
|
208
|
-
]);
|
|
209
|
-
// Clean up observer on unmount.
|
|
210
|
-
useEffect(()=>{
|
|
211
|
-
return ()=>{
|
|
212
|
-
observerRef.current?.disconnect();
|
|
213
|
-
};
|
|
214
|
-
}, []);
|
|
215
|
-
/**
|
|
216
|
-
* Calculate marker position. The marker should be placed `threshold` items
|
|
217
|
-
* from the end of visible items. This allows seamless scrolling - new items
|
|
218
|
-
* load while user scrolls through the remaining `threshold` items.
|
|
219
|
-
*/ const markerPosition = useMemo(()=>{
|
|
220
|
-
if (!hasMore) {
|
|
221
|
-
return -1; // No marker needed when all items are loaded.
|
|
314
|
+
if (focusedIndexRef.current !== null && (active === document.body || active === null) && body && !body.querySelector(`[data-index="${focusedIndexRef.current}"]`)) {
|
|
315
|
+
ctx.focusGrid?.();
|
|
222
316
|
}
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
}, [
|
|
226
|
-
hasMore,
|
|
227
|
-
visibleCount,
|
|
228
|
-
threshold
|
|
229
|
-
]);
|
|
317
|
+
focusedIndexRef.current = null;
|
|
318
|
+
});
|
|
230
319
|
/**
|
|
231
|
-
* Context value for body rows (shared base
|
|
320
|
+
* Context value for body rows (shared base; per-row fields added below).
|
|
232
321
|
*/ const bodyContextBase = useMemo(()=>({
|
|
233
322
|
...ctx,
|
|
234
323
|
cellWrapper: CellWrapper.BODY
|
|
235
324
|
}), [
|
|
236
325
|
ctx
|
|
237
326
|
]);
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
* border removal (CSS :last-child doesn't work with wrappers).
|
|
243
|
-
*/ const renderedContent = useMemo(()=>{
|
|
244
|
-
const result = [];
|
|
245
|
-
// Determine the actual last visible index (for border styling).
|
|
246
|
-
const lastVisibleIndex = Math.min(visibleCount, totalItems) - 1;
|
|
247
|
-
for(let i = 0; i < visibleCount && i < totalItems; i++){
|
|
248
|
-
// Insert marker at the calculated position.
|
|
249
|
-
if (i === markerPosition) {
|
|
250
|
-
result.push(/*#__PURE__*/ jsx("div", {
|
|
251
|
-
ref: markerRefCallback,
|
|
252
|
-
"aria-hidden": "true",
|
|
253
|
-
style: {
|
|
254
|
-
height: "1px",
|
|
255
|
-
background: "transparent"
|
|
256
|
-
}
|
|
257
|
-
}, "__infinite-scroll-marker-inline__"));
|
|
258
|
-
}
|
|
259
|
-
/**
|
|
260
|
-
* Determine if this is the last row (only when all data is loaded). If
|
|
261
|
-
* hasMore is true, no row is "last" since more will be loaded.
|
|
262
|
-
*/ const isLastRow = !hasMore && i === lastVisibleIndex;
|
|
263
|
-
/**
|
|
264
|
-
* Wrap row with context provider that includes rowIndex for proper odd/even
|
|
265
|
-
* styling. Using display:contents so the wrapper doesn't affect grid layout.
|
|
266
|
-
*/ result.push(/*#__PURE__*/ jsx(DataGridContext.Provider, {
|
|
267
|
-
value: {
|
|
268
|
-
...bodyContextBase,
|
|
269
|
-
isLastRow
|
|
270
|
-
},
|
|
271
|
-
children: /*#__PURE__*/ jsx("div", {
|
|
272
|
-
[ROW_INDEX_DATA_ATTR]: i,
|
|
273
|
-
style: {
|
|
274
|
-
display: "contents"
|
|
275
|
-
},
|
|
276
|
-
children: renderRow(data[i], i)
|
|
277
|
-
})
|
|
278
|
-
}, i));
|
|
279
|
-
}
|
|
280
|
-
// If marker position is at the end (edge case with small data).
|
|
281
|
-
if (markerPosition === visibleCount && hasMore) {
|
|
282
|
-
result.push(/*#__PURE__*/ jsx("div", {
|
|
283
|
-
ref: markerRefCallback,
|
|
284
|
-
"aria-hidden": "true",
|
|
285
|
-
style: {
|
|
286
|
-
height: "1px",
|
|
287
|
-
background: "transparent"
|
|
288
|
-
}
|
|
289
|
-
}, "__infinite-scroll-marker-end__"));
|
|
290
|
-
}
|
|
291
|
-
return result;
|
|
292
|
-
}, [
|
|
293
|
-
data,
|
|
294
|
-
visibleCount,
|
|
295
|
-
totalItems,
|
|
296
|
-
markerPosition,
|
|
297
|
-
hasMore,
|
|
298
|
-
renderRow,
|
|
299
|
-
markerRefCallback,
|
|
300
|
-
bodyContextBase
|
|
301
|
-
]);
|
|
302
|
-
// Measure column widths for sticky header/footer sync.
|
|
303
|
-
useColumnMeasurement(bodyRef, renderedContent, noData);
|
|
327
|
+
// Measure column widths for sticky header/footer sync. Re-key on the first
|
|
328
|
+
// mounted row so the sticky header re-syncs as the window (and the auto-column
|
|
329
|
+
// width) slides.
|
|
330
|
+
useColumnMeasurement(bodyRef, virtualItems[0]?.index, noData);
|
|
304
331
|
const bodyClass = getBodyClass(className);
|
|
332
|
+
const paddingTop = virtualItems.length > 0 ? Math.max(0, virtualItems[0].start - scrollMargin) : 0;
|
|
333
|
+
const paddingBottom = virtualItems.length > 0 ? Math.max(0, totalSize - virtualItems[virtualItems.length - 1].end) : 0;
|
|
305
334
|
return /*#__PURE__*/ jsx(DataGridContext.Provider, {
|
|
306
335
|
value: {
|
|
307
336
|
...ctx,
|
|
@@ -324,7 +353,42 @@ const ROW_INDEX_DATA_ATTR = "data-row-index";
|
|
|
324
353
|
},
|
|
325
354
|
children: noDataText ?? "No Data"
|
|
326
355
|
})
|
|
327
|
-
}) :
|
|
356
|
+
}) : /*#__PURE__*/ jsxs(Fragment, {
|
|
357
|
+
children: [
|
|
358
|
+
/*#__PURE__*/ jsx("div", {
|
|
359
|
+
ref: sentinelRef,
|
|
360
|
+
"aria-hidden": "true",
|
|
361
|
+
style: {
|
|
362
|
+
gridColumn: "1 / -1",
|
|
363
|
+
height: 0
|
|
364
|
+
}
|
|
365
|
+
}),
|
|
366
|
+
paddingTop > 0 && /*#__PURE__*/ jsx("div", {
|
|
367
|
+
"aria-hidden": "true",
|
|
368
|
+
style: {
|
|
369
|
+
gridColumn: "1 / -1",
|
|
370
|
+
height: paddingTop
|
|
371
|
+
}
|
|
372
|
+
}),
|
|
373
|
+
virtualItems.map((vItem)=>/*#__PURE__*/ jsx(DataGridContext.Provider, {
|
|
374
|
+
value: {
|
|
375
|
+
...bodyContextBase,
|
|
376
|
+
isLastRow: vItem.index === totalItems - 1,
|
|
377
|
+
ariaRowIndex: vItem.index + headerRows + 1,
|
|
378
|
+
dataIndex: vItem.index,
|
|
379
|
+
measureRowElement: virtualizer.measureElement
|
|
380
|
+
},
|
|
381
|
+
children: renderRow(data[vItem.index], vItem.index)
|
|
382
|
+
}, vItem.key)),
|
|
383
|
+
paddingBottom > 0 && /*#__PURE__*/ jsx("div", {
|
|
384
|
+
"aria-hidden": "true",
|
|
385
|
+
style: {
|
|
386
|
+
gridColumn: "1 / -1",
|
|
387
|
+
height: paddingBottom
|
|
388
|
+
}
|
|
389
|
+
})
|
|
390
|
+
]
|
|
391
|
+
})
|
|
328
392
|
})
|
|
329
393
|
});
|
|
330
394
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/*!
|
|
2
|
-
@versini/ui-datagrid
|
|
2
|
+
@versini/ui-datagrid v4.0.1
|
|
3
3
|
© 2026 gizmette.com
|
|
4
4
|
*/
|
|
5
5
|
|
|
@@ -22,7 +22,7 @@ import { getRowClasses } from "../91.js";
|
|
|
22
22
|
// Count the number of direct children to determine column count.
|
|
23
23
|
const columnCount = react.Children.count(children);
|
|
24
24
|
return /*#__PURE__*/ jsx(DataGridContext.Consumer, {
|
|
25
|
-
children: ({ mode, cellWrapper, stickyHeader, stickyFooter, columns, measuredColumnWidths, isLastRow })=>{
|
|
25
|
+
children: ({ mode, cellWrapper, stickyHeader, stickyFooter, columns, measuredColumnWidths, isLastRow, ariaRowIndex, dataIndex, measureRowElement })=>{
|
|
26
26
|
/**
|
|
27
27
|
* Determine if this row is inside a sticky header/footer. Sticky elements
|
|
28
28
|
* are absolutely positioned and outside the main grid flow, so they can't
|
|
@@ -75,6 +75,7 @@ import { getRowClasses } from "../91.js";
|
|
|
75
75
|
};
|
|
76
76
|
}
|
|
77
77
|
return /*#__PURE__*/ jsx("div", {
|
|
78
|
+
ref: measureRowElement,
|
|
78
79
|
role: "row",
|
|
79
80
|
className: getRowClasses({
|
|
80
81
|
mode,
|
|
@@ -89,6 +90,8 @@ import { getRowClasses } from "../91.js";
|
|
|
89
90
|
...userStyle
|
|
90
91
|
},
|
|
91
92
|
"data-active": active || undefined,
|
|
93
|
+
"data-index": dataIndex,
|
|
94
|
+
"aria-rowindex": ariaRowIndex,
|
|
92
95
|
...rest,
|
|
93
96
|
children: children
|
|
94
97
|
});
|