@eifi1/ui-kit 0.48.0 → 0.48.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 (47) hide show
  1. package/README.md +4 -3
  2. package/dist/components/data-table-fit.d.ts +8 -1
  3. package/dist/components/data-table-fit.js +7 -1
  4. package/dist/components/data-table-fit.js.map +1 -1
  5. package/dist/components/data-table-groups.d.ts +3 -1
  6. package/dist/components/data-table-groups.js +11 -5
  7. package/dist/components/data-table-groups.js.map +1 -1
  8. package/dist/components/data-table-head.d.ts +8 -1
  9. package/dist/components/data-table-head.js +11 -3
  10. package/dist/components/data-table-head.js.map +1 -1
  11. package/dist/components/data-table-leading-row.d.ts +3 -1
  12. package/dist/components/data-table-leading-row.js +2 -1
  13. package/dist/components/data-table-leading-row.js.map +1 -1
  14. package/dist/components/data-table-row-actions.js +2 -2
  15. package/dist/components/data-table-row-actions.js.map +1 -1
  16. package/dist/components/data-table-row.d.ts +84 -0
  17. package/dist/components/data-table-row.js +261 -0
  18. package/dist/components/data-table-row.js.map +1 -0
  19. package/dist/components/data-table-selection.d.ts +3 -0
  20. package/dist/components/data-table-selection.js +12 -1
  21. package/dist/components/data-table-selection.js.map +1 -1
  22. package/dist/components/data-table-virtual.d.ts +98 -0
  23. package/dist/components/data-table-virtual.js +369 -0
  24. package/dist/components/data-table-virtual.js.map +1 -0
  25. package/dist/components/data-table.d.ts +58 -2
  26. package/dist/components/data-table.js +227 -230
  27. package/dist/components/data-table.js.map +1 -1
  28. package/dist/data-table.d.ts +1 -0
  29. package/dist/data-table.js.map +1 -1
  30. package/dist/eslint/index.js +1 -1
  31. package/dist/hooks/use-anchored-panel.js +2 -1
  32. package/dist/hooks/use-anchored-panel.js.map +1 -1
  33. package/dist/shell/app-shell.js +7 -1
  34. package/dist/shell/app-shell.js.map +1 -1
  35. package/package.json +1 -1
  36. package/src/components/data-table-fit.ts +17 -2
  37. package/src/components/data-table-groups.tsx +15 -4
  38. package/src/components/data-table-head.tsx +25 -2
  39. package/src/components/data-table-leading-row.tsx +4 -1
  40. package/src/components/data-table-row-actions.tsx +3 -2
  41. package/src/components/data-table-row.tsx +483 -0
  42. package/src/components/data-table-selection.ts +17 -1
  43. package/src/components/data-table-virtual.ts +547 -0
  44. package/src/components/data-table.tsx +326 -327
  45. package/src/data-table.ts +2 -0
  46. package/src/hooks/use-anchored-panel.ts +9 -1
  47. package/src/shell/app-shell.tsx +7 -1
@@ -0,0 +1,547 @@
1
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
2
+ import type { RefObject } from "react";
3
+
4
+ // DataTable's row windowing (0.48.2, keksdose dev #632 — Marcel: "The table is extremely
5
+ // slow. Is it virtualised or is everytime the whole table rendered?"). Opt-in through
6
+ // `virtualize`: the desktop table renders the rows near the visible part of its scroller
7
+ // (plus `overscan`) and stands spacer rows in for the rest, inside each group's own
8
+ // `<tbody>`, so the sticky group bands keep working as they do on a full table — a band
9
+ // cell's sticky containing block is the TABLE, and the latest band of each level that has
10
+ // scrolled past is the one drawn (Chromium, checked with a probe at the group's middle,
11
+ // its end and the next group's start).
12
+ //
13
+ // What it keeps rendered whatever the scroll: the header, the leading row, the band rows
14
+ // of every group that has a rendered row (and the first day of the period the window
15
+ // starts in, whose band is the period's), every OPEN row (`isExpanded`, so an inline
16
+ // editor keeps its state), the roving tab stop, a row holding the focus, and the row the
17
+ // keyboard is moving to.
18
+ //
19
+ // Heights: each rendered row is measured after render (the distance to the next row's
20
+ // top, which is exact under collapsed borders where a row's own box is half a border
21
+ // short) and remembered by its key; a row never rendered counts as the average of the
22
+ // measured ones, or `estimateRowHeight` before any. The spacers are the sums, so the
23
+ // scroll height settles as rows are seen. Scroll anchoring (the browser's) keeps the rows
24
+ // in view still while a spacer above them is corrected.
25
+ //
26
+ // Column widths: in an auto table layout a column is as wide as its widest RENDERED
27
+ // cell, so rows coming and going would move the columns. The header cells' widths are
28
+ // read after the first render and locked into a `<colgroup>`, only ever growing while the
29
+ // rows scroll (a wider cell arriving widens its column once; one leaving narrows
30
+ // nothing). Re-measured from scratch when the data, the columns, a user width, the text
31
+ // size or the scroller's width change — and when a grown column would push the table
32
+ // past its scroller, which it fit before. A `grow` column and a user-resized one are not
33
+ // locked (the first takes the rest, the second is pinned already).
34
+
35
+ /** {@link DataTableProps.virtualize}'s options. */
36
+ export interface DataTableVirtualize {
37
+ /** Rows rendered beyond each edge of the visible part. Default 12. */
38
+ overscan?: number;
39
+ /** A row's height in px before it has been measured. Default: 37 (`comfortable`) /
40
+ * 29 (`compact`) — the kit's one-line rows. */
41
+ estimateRowHeight?: number;
42
+ }
43
+
44
+ /** One innermost group's run of rows, `[start, end)` in the rows on screen, with the
45
+ * band levels its `<tbody>` opens with. `first` is the index of the innermost group that
46
+ * opens its level-1 group (itself with one level). Ungrouped: one run with no bands. */
47
+ export interface VirtualGroup {
48
+ start: number;
49
+ end: number;
50
+ bands: readonly (1 | 2)[];
51
+ first: number;
52
+ }
53
+
54
+ /** A row, or a spacer standing in for the rows between two rendered ones. */
55
+ export type VirtualItem = { kind: "row"; index: number } | { kind: "gap"; key: string; height: number };
56
+
57
+ /** A group's `<tbody>` with its rendered rows, or one spacer `<tbody>` for a run of
58
+ * groups of which nothing is rendered. */
59
+ export type VirtualBody =
60
+ | { kind: "spacer"; key: string; height: number }
61
+ | { kind: "group"; group: number; items: VirtualItem[] };
62
+
63
+ export interface RowWindow {
64
+ bodies: VirtualBody[];
65
+ /** `aria-rowcount`: every row of the table, header, bands, details and footer included. */
66
+ rowCount: number;
67
+ /** `aria-rowindex` of data row `i`; its detail row (open) is the next one. */
68
+ ariaRow: (i: number) => number;
69
+ /** `aria-rowindex` of group `g`'s band of `level`. */
70
+ ariaBand: (g: number, level: 1 | 2) => number;
71
+ /** The locked widths for the `<colgroup>`, by column key; null while measuring. */
72
+ colWidths: Record<string, number> | null;
73
+ /** Move the focus to row `i`, rendering it first when it is not. */
74
+ focusRow: (i: number) => void;
75
+ }
76
+
77
+ /** The heights measured so far, by row key; the bands' by level. */
78
+ interface Measured {
79
+ heights: Map<string | number, number>;
80
+ details: Map<string | number, number>;
81
+ sum: number;
82
+ count: number;
83
+ band: [number, number];
84
+ }
85
+
86
+ const SELECT_KEY = "\u0000select";
87
+ /** The `<colgroup>` key of the selection column. */
88
+ export const VIRTUAL_SELECT_COL = SELECT_KEY;
89
+
90
+ interface Model {
91
+ n: number;
92
+ keys: (string | number)[];
93
+ /** Top of row i, from the top of the first group's `<tbody>`. */
94
+ tops: Float64Array;
95
+ /** Full height of row i (its detail row included while open). */
96
+ h: Float64Array;
97
+ /** Top of group g's `<tbody>`; [groups.length] is the total. */
98
+ gTop: Float64Array;
99
+ total: number;
100
+ ariaRows: Int32Array;
101
+ ariaBands: Int32Array;
102
+ rowCount: number;
103
+ }
104
+
105
+ /** First index whose bottom is below `y` (n when none). */
106
+ function firstBelow(m: Model, y: number): number {
107
+ let lo = 0;
108
+ let hi = m.n;
109
+ while (lo < hi) {
110
+ const mid = (lo + hi) >> 1;
111
+ if (m.tops[mid] + m.h[mid] > y) hi = mid;
112
+ else lo = mid + 1;
113
+ }
114
+ return lo;
115
+ }
116
+
117
+ /** One past the last index whose top is above `y`. */
118
+ function lastAbove(m: Model, y: number): number {
119
+ let lo = 0;
120
+ let hi = m.n;
121
+ while (lo < hi) {
122
+ const mid = (lo + hi) >> 1;
123
+ if (m.tops[mid] < y) lo = mid + 1;
124
+ else hi = mid;
125
+ }
126
+ return lo;
127
+ }
128
+
129
+ /** The `<tbody>`s to render: the groups holding a rendered row (or the period's first
130
+ * day, for its band), and one spacer for each run of groups in between. Pure. */
131
+ export function planBodies(
132
+ m: Pick<Model, "n" | "tops" | "h" | "gTop">,
133
+ groups: readonly VirtualGroup[],
134
+ start: number,
135
+ end: number,
136
+ pinned: readonly number[],
137
+ ): VirtualBody[] {
138
+ const rendered = new Set<number>();
139
+ for (let i = Math.max(0, start); i < Math.min(m.n, end); i++) rendered.add(i);
140
+ for (const p of pinned) if (p >= 0 && p < m.n) rendered.add(p);
141
+ const sorted = [...rendered].sort((a, b) => a - b);
142
+ // The group the window starts in, and the first day of its period.
143
+ let anchor = -1;
144
+ if (sorted.length > 0 && start < m.n) {
145
+ const s = Math.max(0, start);
146
+ const g = groups.findIndex((gr) => s >= gr.start && s < gr.end);
147
+ if (g >= 0) anchor = groups[g].first;
148
+ }
149
+ const bottom = (i: number) => m.tops[i] + m.h[i];
150
+ const out: VirtualBody[] = [];
151
+ let k = 0;
152
+ let skippedFrom = -1;
153
+ const flushSkipped = (upTo: number) => {
154
+ if (skippedFrom < 0) return;
155
+ out.push({ kind: "spacer", key: `vs:${skippedFrom}`, height: m.gTop[upTo] - m.gTop[skippedFrom] });
156
+ skippedFrom = -1;
157
+ };
158
+ groups.forEach((g, gi) => {
159
+ while (k < sorted.length && sorted[k] < g.start) k++;
160
+ const items: VirtualItem[] = [];
161
+ let prevBottom = m.tops[g.start] ?? m.gTop[gi + 1];
162
+ let any = false;
163
+ while (k < sorted.length && sorted[k] < g.end) {
164
+ const i = sorted[k++];
165
+ const gap = m.tops[i] - prevBottom;
166
+ if (gap > 0.01) items.push({ kind: "gap", key: `gap:${i}`, height: gap });
167
+ items.push({ kind: "row", index: i });
168
+ prevBottom = bottom(i);
169
+ any = true;
170
+ }
171
+ if (!any && gi !== anchor) {
172
+ if (skippedFrom < 0) skippedFrom = gi;
173
+ return;
174
+ }
175
+ flushSkipped(gi);
176
+ const rest = m.gTop[gi + 1] - prevBottom;
177
+ if (rest > 0.01) items.push({ kind: "gap", key: `gap:end:${gi}`, height: rest });
178
+ out.push({ kind: "group", group: gi, items });
179
+ });
180
+ flushSkipped(groups.length);
181
+ return out;
182
+ }
183
+
184
+ export function useRowWindow<T>({
185
+ enabled,
186
+ rows,
187
+ rowKey,
188
+ groups,
189
+ expanded,
190
+ pinnedKeys,
191
+ scrollerRef,
192
+ tableRef,
193
+ options,
194
+ compact,
195
+ rowsBefore,
196
+ rowsAfter,
197
+ colKeys,
198
+ lockable,
199
+ resetKey,
200
+ }: {
201
+ enabled: boolean;
202
+ /** The rows on screen, in order. */
203
+ rows: T[];
204
+ rowKey: (row: T) => string | number;
205
+ groups: readonly VirtualGroup[];
206
+ /** Indices of the open rows: always rendered, their detail rows counted. */
207
+ expanded: readonly number[];
208
+ /** Keys always rendered (the roving tab stop). */
209
+ pinnedKeys: readonly (string | number | null)[];
210
+ scrollerRef: RefObject<HTMLElement | null>;
211
+ tableRef: RefObject<HTMLTableElement | null>;
212
+ options: DataTableVirtualize;
213
+ compact: boolean;
214
+ /** Rows above the first group: the header, the leading row. */
215
+ rowsBefore: number;
216
+ /** Rows after the last: the totals row. */
217
+ rowsAfter: number;
218
+ /** The header cells' keys, in order (the selection column as {@link VIRTUAL_SELECT_COL}). */
219
+ colKeys: readonly string[];
220
+ /** The keys whose width is locked (not a `grow` column, not a user-resized one). */
221
+ lockable: ReadonlySet<string>;
222
+ /** Anything that re-measures the column widths from scratch when it changes. */
223
+ resetKey: string;
224
+ }): RowWindow | null {
225
+ const overscan = Math.max(1, options.overscan ?? 12);
226
+ const estimate = options.estimateRowHeight ?? (compact ? 29 : 37);
227
+ const n = rows.length;
228
+
229
+ const [measured, setMeasured] = useState<Measured>(() => ({
230
+ heights: new Map(),
231
+ details: new Map(),
232
+ sum: 0,
233
+ count: 0,
234
+ band: [estimate, estimate * 0.8],
235
+ }));
236
+ const initialEnd = () =>
237
+ Math.min(n, Math.ceil((typeof window === "undefined" ? 800 : Math.min(window.innerHeight || 800, 1600)) / estimate) + overscan);
238
+ const [range, setRange] = useState(() => ({ start: 0, end: initialEnd() }));
239
+ const [focusTarget, setFocusTarget] = useState<number | null>(null);
240
+ const [focusKey, setFocusKey] = useState<string | number | null>(null);
241
+ const [scrollerWidth, setScrollerWidth] = useState(0);
242
+ const [lock, setLock] = useState<{ sig: string; widths: Record<string, number> } | null>(null);
243
+ const naturalOverflow = useRef(false);
244
+
245
+ const expandedSig = expanded.join(",");
246
+ const model = useMemo<Model | null>(() => {
247
+ if (!enabled) return null;
248
+ const keys = rows.map(rowKey);
249
+ const avg = measured.count > 0 ? measured.sum / measured.count : estimate;
250
+ const open = new Set(expanded);
251
+ const h = new Float64Array(n);
252
+ for (let i = 0; i < n; i++) {
253
+ h[i] = (measured.heights.get(keys[i]) ?? avg) + (open.has(i) ? (measured.details.get(keys[i]) ?? 0) : 0);
254
+ }
255
+ const tops = new Float64Array(n + 1);
256
+ const gTop = new Float64Array(groups.length + 1);
257
+ const ariaRows = new Int32Array(n);
258
+ const ariaBands = new Int32Array(groups.length * 2);
259
+ let y = 0;
260
+ let aria = rowsBefore + 1;
261
+ groups.forEach((g, gi) => {
262
+ gTop[gi] = y;
263
+ for (const level of g.bands) {
264
+ y += measured.band[level - 1];
265
+ ariaBands[gi * 2 + level - 1] = aria++;
266
+ }
267
+ for (let i = g.start; i < g.end; i++) {
268
+ tops[i] = y;
269
+ y += h[i];
270
+ ariaRows[i] = aria++;
271
+ if (open.has(i)) aria++;
272
+ }
273
+ });
274
+ gTop[groups.length] = y;
275
+ tops[n] = y;
276
+ return { n, keys, tops, h, gTop, total: y, ariaRows, ariaBands, rowCount: aria - 1 + rowsAfter };
277
+ // `expanded` by its signature.
278
+ // eslint-disable-next-line react-hooks/exhaustive-deps
279
+ }, [enabled, rows, rowKey, groups, expandedSig, measured, estimate, n, rowsBefore, rowsAfter]);
280
+
281
+ // The rows outside the range that stay: open rows, the tab stop, the focused row and
282
+ // the keyboard's target.
283
+ const pinned = useMemo(() => {
284
+ if (!model) return [];
285
+ const out = [...expanded];
286
+ for (const key of [...pinnedKeys, focusKey]) {
287
+ if (key === null) continue;
288
+ const i = model.keys.indexOf(key);
289
+ if (i >= 0) out.push(i);
290
+ }
291
+ if (focusTarget !== null) out.push(focusTarget);
292
+ return out;
293
+ }, [model, expanded, pinnedKeys, focusKey, focusTarget]);
294
+ const pinnedSig = pinned.join(",");
295
+
296
+ const start = Math.min(range.start, n);
297
+ const end = Math.min(range.end, n);
298
+ const bodies = useMemo(
299
+ () => (model ? planBodies(model, groups, start, end, pinned) : []),
300
+ // eslint-disable-next-line react-hooks/exhaustive-deps
301
+ [model, groups, start, end, pinnedSig],
302
+ );
303
+
304
+ const lockSig = `${resetKey}|${scrollerWidth}|${colKeys.join(",")}`;
305
+ const colWidths = lock && lock.sig === lockSig ? lock.widths : null;
306
+
307
+ // What the scroll handler and the frame's measure read: this render's model and lock.
308
+ const latest = useRef({ model, colWidths, lockSig, colKeys, lockable, overscan });
309
+ useLayoutEffect(() => {
310
+ latest.current = { model, colWidths, lockSig, colKeys, lockable, overscan };
311
+ });
312
+
313
+ // Which rows the visible part of the scroller (and of the window, for a table in a page
314
+ // that scrolls) shows, and a new range around them when the rendered one no longer
315
+ // covers them. Read from the client rects, which a scroll leaves laid out.
316
+ const update = useCallback(() => {
317
+ const { model: m, overscan: over } = latest.current;
318
+ const sc = scrollerRef.current;
319
+ const table = tableRef.current;
320
+ if (!m || !sc || !table) return;
321
+ const origin = table.querySelector<HTMLElement>("tbody[data-vt-body]");
322
+ if (!origin) return;
323
+ const sr = sc.getBoundingClientRect();
324
+ const top = Math.max(sr.top, 0);
325
+ const bottom = Math.min(sr.bottom, window.innerHeight || sr.bottom);
326
+ if (!(bottom > top)) return;
327
+ const o = origin.getBoundingClientRect().top;
328
+ const a = Math.min(firstBelow(m, top - o), m.n);
329
+ const b = Math.max(a, Math.min(lastAbove(m, bottom - o), m.n));
330
+ setRange((prev) =>
331
+ prev.start <= a && prev.end >= b && prev.end <= m.n && prev.end - prev.start <= b - a + 4 * over
332
+ ? prev
333
+ : { start: Math.max(0, a - over), end: Math.min(m.n, b + over) },
334
+ );
335
+ }, [scrollerRef, tableRef]);
336
+
337
+ // After a render that changed what is drawn: measure the rendered rows and bands, and
338
+ // the header cells for the lock. Not after a render that only ticked a box. In the next
339
+ // animation frame rather than in the commit: there the browser lays the table out once
340
+ // for the frame anyway, where a read in the commit forced a layout of its own, and the
341
+ // re-render the heights cause forced another (Chromium, 3000 rows: 35–130 ms of forced
342
+ // layout per scrolled step, against one layout per frame now).
343
+ const measure = useCallback(() => {
344
+ const { model: m, colWidths: locked, lockSig: sig, colKeys: cols, lockable: lockKeys } = latest.current;
345
+ const table = tableRef.current;
346
+ const sc = scrollerRef.current;
347
+ if (!m || !table || !sc) return;
348
+ const trs = table.rows;
349
+ const rects = Array.from(trs, (tr) => tr.getBoundingClientRect());
350
+ const rowSamples: [string | number, number][] = [];
351
+ const detailSamples: [string | number, number][] = [];
352
+ const bandSamples: [0 | 1, number][] = [];
353
+ for (let k = 0; k < trs.length; k++) {
354
+ const own = rects[k];
355
+ const next = rects[k + 1];
356
+ // The distance to the next row's top: exact under collapsed borders.
357
+ const h = next && next.top > own.top ? next.top - own.top : own.height;
358
+ if (!(h > 0)) continue;
359
+ const ds = trs[k].dataset;
360
+ if (ds.rowIndex !== undefined) {
361
+ const key = m.keys[Number(ds.rowIndex)];
362
+ if (key !== undefined) rowSamples.push([key, h]);
363
+ } else if (ds.rowDetail !== undefined) {
364
+ const key = m.keys[Number(ds.rowDetail)];
365
+ if (key !== undefined) detailSamples.push([key, h]);
366
+ } else if (ds.groupRow === "1" || ds.groupRow === "2") {
367
+ bandSamples.push([ds.groupRow === "1" ? 0 : 1, h]);
368
+ }
369
+ }
370
+ if (rowSamples.length + detailSamples.length + bandSamples.length > 0) {
371
+ setMeasured((prev) => {
372
+ let { heights, details, sum, count } = prev;
373
+ let band = prev.band;
374
+ for (const [key, h] of rowSamples) {
375
+ const old = heights.get(key);
376
+ if (old !== undefined && Math.abs(old - h) <= 0.5) continue;
377
+ if (heights === prev.heights) heights = new Map(heights);
378
+ heights.set(key, h);
379
+ sum += h - (old ?? 0);
380
+ if (old === undefined) count++;
381
+ }
382
+ for (const [key, h] of detailSamples) {
383
+ const old = details.get(key);
384
+ if (old !== undefined && Math.abs(old - h) <= 0.5) continue;
385
+ if (details === prev.details) details = new Map(details);
386
+ details.set(key, h);
387
+ }
388
+ for (const [level, h] of bandSamples) {
389
+ if (Math.abs(band[level] - h) <= 0.5) continue;
390
+ band = level === 0 ? [h, band[1]] : [band[0], h];
391
+ }
392
+ return heights === prev.heights && details === prev.details && band === prev.band
393
+ ? prev
394
+ : { heights, details, sum, count, band };
395
+ });
396
+ }
397
+ // The column lock.
398
+ const cells = table.tHead?.rows[0]?.cells;
399
+ if (!cells || cells.length !== cols.length) return;
400
+ const widths: Record<string, number> = {};
401
+ let anyWidth = false;
402
+ for (let c = 0; c < cells.length; c++) {
403
+ if (!lockKeys.has(cols[c])) continue;
404
+ const w = cells[c].getBoundingClientRect().width;
405
+ if (w > 0) {
406
+ widths[cols[c]] = w;
407
+ anyWidth = true;
408
+ }
409
+ }
410
+ if (!anyWidth) return;
411
+ const over = table.getBoundingClientRect().width > sc.clientWidth + 1;
412
+ if (!locked) {
413
+ naturalOverflow.current = over;
414
+ setLock({ sig, widths });
415
+ } else if (over && !naturalOverflow.current) {
416
+ // A grown column pushed the table past a scroller it fit: measure afresh.
417
+ setLock(null);
418
+ } else {
419
+ let grew = false;
420
+ const merged = { ...locked };
421
+ for (const [key, w] of Object.entries(widths)) {
422
+ if (w > (merged[key] ?? 0) + 0.5) {
423
+ merged[key] = w;
424
+ grew = true;
425
+ }
426
+ }
427
+ if (grew) setLock({ sig, widths: merged });
428
+ }
429
+ }, [scrollerRef, tableRef]);
430
+
431
+ const frame = useRef(0);
432
+ const schedule = useCallback(() => {
433
+ if (frame.current) return;
434
+ const run = () => {
435
+ frame.current = 0;
436
+ measure();
437
+ update();
438
+ };
439
+ frame.current =
440
+ typeof requestAnimationFrame === "undefined" ? -(setTimeout(run, 16) as unknown as number) : requestAnimationFrame(run);
441
+ }, [measure, update]);
442
+ useEffect(
443
+ () => () => {
444
+ if (frame.current > 0) cancelAnimationFrame(frame.current);
445
+ else if (frame.current < 0) clearTimeout(-frame.current);
446
+ frame.current = 0;
447
+ },
448
+ [],
449
+ );
450
+
451
+ useEffect(() => {
452
+ if (!enabled) return;
453
+ const onScroll = () => update();
454
+ window.addEventListener("scroll", onScroll, { capture: true, passive: true });
455
+ window.addEventListener("resize", onScroll, { passive: true });
456
+ const sc = scrollerRef.current;
457
+ let ro: ResizeObserver | undefined;
458
+ if (sc && typeof ResizeObserver !== "undefined") {
459
+ ro = new ResizeObserver(() => {
460
+ setScrollerWidth(Math.round(sc.clientWidth));
461
+ update();
462
+ });
463
+ ro.observe(sc);
464
+ }
465
+ return () => {
466
+ window.removeEventListener("scroll", onScroll, { capture: true });
467
+ window.removeEventListener("resize", onScroll);
468
+ ro?.disconnect();
469
+ };
470
+ }, [enabled, update, scrollerRef]);
471
+
472
+ // The row holding the focus (a row, or a control in it or in its open detail) stays.
473
+ useEffect(() => {
474
+ const table = tableRef.current;
475
+ if (!enabled || !table) return;
476
+ const onIn = (e: FocusEvent) => {
477
+ const tr = (e.target as Element | null)?.closest?.<HTMLElement>("tr[data-row-index], tr[data-row-detail]");
478
+ const index = tr ? Number(tr.dataset.rowIndex ?? tr.dataset.rowDetail) : NaN;
479
+ const key = Number.isNaN(index) ? undefined : latest.current.model?.keys[index];
480
+ setFocusKey(key ?? null);
481
+ setFocusTarget(null);
482
+ };
483
+ const onOut = (e: FocusEvent) => {
484
+ const to = e.relatedTarget as Node | null;
485
+ if (!to || !table.contains(to)) setFocusKey(null);
486
+ };
487
+ table.addEventListener("focusin", onIn);
488
+ table.addEventListener("focusout", onOut);
489
+ return () => {
490
+ table.removeEventListener("focusin", onIn);
491
+ table.removeEventListener("focusout", onOut);
492
+ };
493
+ }, [enabled, tableRef]);
494
+
495
+ const dataSig = n > 0 && model ? `${n}:${String(model.keys[0])}:${String(model.keys[n - 1])}` : "0";
496
+ const renderSig = `${start}:${end}:${dataSig}:${expandedSig}:${pinnedSig}:${lockSig}:${colWidths ? 1 : 0}`;
497
+ const lastRender = useRef("");
498
+ useLayoutEffect(() => {
499
+ if (!enabled || lastRender.current === renderSig) return;
500
+ lastRender.current = renderSig;
501
+ schedule();
502
+ });
503
+
504
+ // A new layout (heights measured, rows added): the range may no longer cover the view.
505
+ const total = model?.total ?? 0;
506
+ useLayoutEffect(() => {
507
+ if (enabled) schedule();
508
+ }, [enabled, schedule, total, n]);
509
+
510
+ // The keyboard's target: rendered (pinned), then focused — its focus scrolls it into
511
+ // view, and the scroll renders the rows around it.
512
+ useLayoutEffect(() => {
513
+ if (focusTarget === null) return;
514
+ // Released by the focus it receives (the `focusin` above), which pins it as the
515
+ // focused row from then on.
516
+ tableRef.current?.querySelector<HTMLElement>(`tr[data-row-index="${focusTarget}"]`)?.focus();
517
+ }, [focusTarget, bodies, tableRef]);
518
+
519
+ const focusRow = useCallback(
520
+ (i: number) => {
521
+ const el = tableRef.current?.querySelector<HTMLElement>(`tr[data-row-index="${i}"]`);
522
+ if (el) el.focus();
523
+ else setFocusTarget(i);
524
+ },
525
+ [tableRef],
526
+ );
527
+
528
+ if (!enabled || !model) return null;
529
+ return {
530
+ bodies,
531
+ rowCount: model.rowCount,
532
+ ariaRow: (i) => model.ariaRows[i],
533
+ ariaBand: (g, level) => model.ariaBands[g * 2 + level - 1],
534
+ colWidths,
535
+ focusRow,
536
+ };
537
+ }
538
+
539
+ /** The roving rows' Arrow/Home/End target over ALL the rows, rendered or not. */
540
+ export function rowNavTarget(from: number, key: string, n: number): number | null {
541
+ if (n === 0) return null;
542
+ if (key === "ArrowDown") return Math.min(n - 1, from + 1);
543
+ if (key === "ArrowUp") return Math.max(0, from - 1);
544
+ if (key === "Home") return 0;
545
+ if (key === "End") return n - 1;
546
+ return null;
547
+ }