@react-x11/components 0.2.1 → 0.3.0

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.
@@ -36,8 +36,9 @@ import { hx } from './hx.js';
36
36
  // Shared with <Tree> — internal, deliberately not a shared *module*; the
37
37
  // header of src/internal/heights.ts says why.
38
38
  import { RowHeights } from '../internal/heights.js';
39
- import { afterLayout, cancelAfterLayout } from '../internal/timers.js';
39
+ import { afterLayout, cancelAfterLayout, cancelLater, later, } from '../internal/timers.js';
40
40
  import { useReveal } from '../internal/scroll.js';
41
+ import { BURST_BUDGET, DEFAULT_OVERSCAN, DEFAULT_PREFETCH, SCROLL_HINT_DELAY_MS, SKELETON_THRESHOLD, SETTLE_BUDGET, useVirtualWindow, } from '../internal/window.js';
41
42
  import { MIN_COLUMN, columnValue, orderRows, resolveGetId, resolveWidths, } from './rows.js';
42
43
  export { MIN_COLUMN, UNSIZED_MIN, columnValue, defaultCompare, orderRows, resolveGetId, resolveWidths, } from './rows.js';
43
44
  /** The header strip's height. Core's number — independent of the body's
@@ -55,16 +56,6 @@ const HALF = 3;
55
56
  const GRIP = HALF + RULE + HALF;
56
57
  /** What one Left/Right on a focused grip is worth. */
57
58
  const STEP = 16;
58
- /** Rows kept either side of the viewport, so a fast scroll does not show a
59
- * gap before the next frame catches up. */
60
- const OVERSCAN = 6;
61
- /**
62
- * What to build before the viewport has been measured. `onViewport` cannot
63
- * arrive until layout has run, which is a frame after the first commit, so
64
- * there is always one render that has to guess — and guessing "all of them"
65
- * puts a hundred thousand rows in the tree for a frame.
66
- */
67
- const ASSUMED_ROWS = 40;
68
59
  /**
69
60
  * Where `virtual="auto"` starts virtualizing.
70
61
  *
@@ -125,6 +116,37 @@ const s = createStyles({
125
116
  },
126
117
  cellText: { fontSize: 12, textWrap: 'nowrap', textBoxTrim: 'cap-alphabetic' },
127
118
  spacer: { flexShrink: 0 },
119
+ /** The bar inside a skeleton row — a line of "text" with no text, so a
120
+ * band of placeholders reads as rows arriving rather than a void. */
121
+ skeletonBar: {
122
+ height: 8,
123
+ borderRadius: 4,
124
+ marginStart: 8,
125
+ alignSelf: 'center',
126
+ flexShrink: 0,
127
+ },
128
+ /** The lane the fast-scroll pill floats in: absolute against the table's
129
+ * root so the body pane scrolls under it, full-width so the pill centres
130
+ * itself, and transparent to the pointer so the rows beneath stay
131
+ * clickable. */
132
+ scrollHintLane: {
133
+ position: 'absolute',
134
+ left: 0,
135
+ right: 0,
136
+ bottom: 12,
137
+ flexDirection: 'row',
138
+ justifyContent: 'center',
139
+ pointerEvents: 'none',
140
+ },
141
+ scrollHint: {
142
+ paddingStart: 10,
143
+ paddingEnd: 10,
144
+ paddingTop: 5,
145
+ paddingBottom: 5,
146
+ borderRadius: 12,
147
+ flexDirection: 'row',
148
+ alignItems: 'center',
149
+ },
128
150
  sortMark: { marginStart: 4 },
129
151
  empty: {
130
152
  flexGrow: 1,
@@ -134,6 +156,97 @@ const s = createStyles({
134
156
  },
135
157
  });
136
158
  const EMPTY_SET = new Set();
159
+ function TableRowView(props) {
160
+ const { entry, columns, widths, setSize, isSelected, selectable, uniform, rowHeight, estimate, theme, rowStyle, cellStyle, renderRow, hasMenu, onTap, onOpen, onMenu, register, } = props;
161
+ const color = isSelected ? theme.hoverText : theme.text;
162
+ const state = { ...entry, selected: isSelected, color };
163
+ const content = columns.map((column, at) => {
164
+ const cellState = { ...state, column };
165
+ return hx('box', {
166
+ key: column.id,
167
+ role: 'cell',
168
+ style: [
169
+ s.cell,
170
+ { width: widths[at] },
171
+ uniform && { height: rowHeight },
172
+ column.align === 'end' && {
173
+ alignItems: 'flex-end',
174
+ paddingEnd: 8,
175
+ },
176
+ column.align === 'center' && { alignItems: 'center' },
177
+ typeof cellStyle === 'function' ? cellStyle(cellState) : cellStyle,
178
+ ],
179
+ }, column.render
180
+ ? // A cell that draws itself still has to know it is on the
181
+ // selected row — see the doc comment. Keyed by the component,
182
+ // the way every seam's return is.
183
+ React.createElement(React.Fragment, { key: 'content' }, column.render(entry.row, cellState))
184
+ : hx('text', { style: [s.cellText, { color }] }, String(columnValue(entry.row, column) ?? '')));
185
+ });
186
+ return hx('box', {
187
+ role: 'row',
188
+ 'aria-selected': selectable ? isSelected : undefined,
189
+ 'aria-posinset': entry.index + 1,
190
+ 'aria-setsize': setSize,
191
+ // The index the row was drawn at travels with the node, so measuring
192
+ // does not have to search the row list for it. It can go stale — the
193
+ // rows may move before the tick that measures — and both this and
194
+ // the height index check it rather than trust it.
195
+ ref: (node) => {
196
+ register(entry.id, entry.index, node);
197
+ },
198
+ onClick: (ev) => {
199
+ // A right-click also arrives here as a click; the selection it
200
+ // implies is `onContextMenu`'s to make (select-unless-selected),
201
+ // not the left button's replace.
202
+ if (ev.button !== 1)
203
+ return;
204
+ onTap(entry, { ctrl: ev.ctrlKey, shift: ev.shiftKey });
205
+ // Select on the first click, open on the second — the gesture
206
+ // every file list has. `detail` is the click count the renderer
207
+ // already counts for text selection.
208
+ if (ev.detail === 2)
209
+ onOpen(entry);
210
+ },
211
+ onContextMenu: hasMenu
212
+ ? (ev) => {
213
+ onMenu(entry, ev);
214
+ }
215
+ : undefined,
216
+ style: [
217
+ s.row,
218
+ // Declared uniform: exactly this tall, content clipped — core's
219
+ // row. Measured: a floor, and the row grows to whatever its
220
+ // content needs; the height index reads back what it became.
221
+ uniform
222
+ ? { height: rowHeight, alignItems: 'center' }
223
+ : { minHeight: estimate },
224
+ selectable && { cursor: 'pointer' },
225
+ {
226
+ backgroundColor: isSelected ? theme.hoverBackground : 'transparent',
227
+ // The row's ink, said once: `color` inherits, so default cells
228
+ // take it without being handed it.
229
+ color,
230
+ },
231
+ selectable && {
232
+ // pressed even on the selected row: a re-press on the row that
233
+ // is already current is the one click in the table that would
234
+ // otherwise look ignored
235
+ ':active': {
236
+ backgroundColor: isSelected
237
+ ? theme.accentActive
238
+ : theme.surfaceActive,
239
+ },
240
+ },
241
+ selectable &&
242
+ !isSelected && {
243
+ ':hover': { backgroundColor: theme.surfaceHover },
244
+ },
245
+ typeof rowStyle === 'function' ? rowStyle(state) : rowStyle,
246
+ ],
247
+ }, renderRow ? renderRow(state, content) : content);
248
+ }
249
+ const MemoTableRow = React.memo(TableRowView);
137
250
  /**
138
251
  * `<Table columns rows />` — a data table with a header that stays put.
139
252
  *
@@ -166,7 +279,7 @@ const EMPTY_SET = new Set();
166
279
  * the same rows a sighted user can see.
167
280
  */
168
281
  export function Table(props) {
169
- const { columns = [], rows = [], getId, sort, defaultSort, onSortChange, presorted = false, onActivate, onRowContextMenu, onColumnResize, rowHeight, estimatedRowHeight, virtual = 'auto', overscan = OVERSCAN, renderRow, renderEmpty, styles, style, focusable = true, ref, selectionMode = 'single', selected, defaultSelected, onSelect, onSelectedChange,
282
+ const { columns = [], rows = [], getId, sort, defaultSort, onSortChange, presorted = false, onActivate, onRowContextMenu, onColumnResize, rowHeight, estimatedRowHeight, virtual = 'auto', overscan = DEFAULT_OVERSCAN, prefetch = DEFAULT_PREFETCH, renderRow, renderEmpty, renderScrollHint, scrollHintDelay = SCROLL_HINT_DELAY_MS, catchup, styles, style, focusable = true, ref, selectionMode = 'single', selected, defaultSelected, onSelect, onSelectedChange,
170
283
  // ours to chain rather than to hand over: virtualization and the sticky
171
284
  // header are measured through both of these
172
285
  onScroll, onViewport, ...boxProps } = props;
@@ -199,7 +312,6 @@ export function Table(props) {
199
312
  const [cursorId, setCursorId] = useState(null);
200
313
  const [userWidths, setUserWidths] = useState({});
201
314
  const [scrollX, setScrollX] = useState(0);
202
- const [view, setView] = useState({ top: 0, height: 0, width: 0 });
203
315
  // Bumped by a measurement pass that found a row taller or shorter than the
204
316
  // index believed. It is the only reason the component re-renders for a
205
317
  // measurement, and a pass that finds nothing new does not bump it — which
@@ -235,8 +347,6 @@ export function Table(props) {
235
347
  selectedRef.current = selectedIds;
236
348
  const cursorRef = useRef(cursor);
237
349
  cursorRef.current = cursor;
238
- const viewRef = useRef(view);
239
- viewRef.current = view;
240
350
  /** Where a Shift range grows from — the last plain click or plain step. */
241
351
  const anchorRef = useRef(null);
242
352
  const root = useRef(null);
@@ -245,6 +355,9 @@ export function Table(props) {
245
355
  * measurement pass does not have to search the row list for it. */
246
356
  const rowNodes = useRef(new Map());
247
357
  const drag = useRef(null);
358
+ /** Whether the fast-scroll pill is up — kept across renders so it does not
359
+ * flicker through a catch-up, only appearing and disappearing once. */
360
+ const hintShown = useRef(false);
248
361
  const userWidthsRef = useRef(userWidths);
249
362
  userWidthsRef.current = userWidths;
250
363
  /** Declared uniform: divide, never measure — core's model. */
@@ -260,23 +373,24 @@ export function Table(props) {
260
373
  heights.sync(ordered, estimate);
261
374
  const virtualizing = virtual === true ||
262
375
  (virtual === 'auto' && ordered.length > VIRTUAL_THRESHOLD);
263
- // The slice worth building: what is on screen, plus a little either side.
264
- const first = virtualizing
265
- ? Math.max(0, heights.indexAt(view.top) - overscan)
266
- : 0;
267
- let last = ordered.length;
268
- if (virtualizing) {
269
- if (view.height > 0) {
270
- last = Math.min(ordered.length, heights.indexAt(view.top + view.height) + 1 + overscan);
271
- }
272
- else {
273
- last = Math.min(ordered.length, first + ASSUMED_ROWS);
274
- }
275
- }
276
- /** Where the slice starts, and how much of the list is below it — the two
277
- * spacers that keep the scrollbar measuring the whole table. */
278
- const above = virtualizing ? heights.offsetAt(first) : 0;
279
- const below = virtualizing ? heights.total() - heights.offsetAt(last) : 0;
376
+ /** The viewport, and the slice worth building from it the machinery
377
+ * shared with `<Tree>` (`../internal/window.ts`). */
378
+ const win = useVirtualWindow({
379
+ box: body,
380
+ heights,
381
+ rows: ordered,
382
+ // declared uniform: every height is exact, so the idle band may grow
383
+ // upward freely see `exact` on the inputs
384
+ exact: uniform,
385
+ virtualizing,
386
+ overscan,
387
+ prefetch,
388
+ threshold: catchup?.threshold ?? SKELETON_THRESHOLD,
389
+ burstBudget: catchup?.burst ?? BURST_BUDGET,
390
+ settleBudget: catchup?.settle ?? SETTLE_BUDGET,
391
+ });
392
+ const { view, viewRef } = win;
393
+ const { first, last, above, below } = win.slice;
280
394
  /** Columns resolve to pixels once, at the table level, per (columns,
281
395
  * viewport, resizes) — every row agrees on the grid by construction. */
282
396
  const { widths, total } = useMemo(() => resolveWidths(columns, userWidths, view.width), [columns, userWidths, view.width]);
@@ -293,26 +407,20 @@ export function Table(props) {
293
407
  heights,
294
408
  });
295
409
  /**
296
- * Re-read the offset the body is *actually* at.
297
- *
298
- * The pane moves silently it resolves a queued reveal during layout, and
299
- * re-clamps an offset the content outgrew or outshrank and a slice built
300
- * from the offset before those is drawn where the viewport is not: a blank
301
- * band where the rows should be, and no way back until a scroll of your own
302
- * re-syncs it by accident.
410
+ * Re-read the offset the body is *actually* at — the window's `sync` (see
411
+ * `../internal/window.ts` for why the pane moves silently), plus the
412
+ * horizontal half only this component has: the header is shifted by
413
+ * `scrollX`, so the sideways offset is re-read on the same tick.
303
414
  */
415
+ const winSync = win.sync;
304
416
  const syncScroll = useCallback(() => {
305
417
  const box = body.current;
306
418
  if (!box)
307
419
  return;
308
- const { scrollX: x, scrollY: y } = box;
420
+ const x = box.scrollX;
309
421
  setScrollX((prev) => (prev === x ? prev : x));
310
- // Only a virtualizing table reads the vertical offset — a whole one has
311
- // no slice to rebuild, and re-rendering it on a scroll it already drew
312
- // would be work for nothing.
313
- if (virtualizing)
314
- setView((prev) => (prev.top === y ? prev : { ...prev, top: y }));
315
- }, [virtualizing]);
422
+ winSync();
423
+ }, [winSync]);
316
424
  /**
317
425
  * Read back what the rows on screen actually laid out at.
318
426
  *
@@ -360,14 +468,38 @@ export function Table(props) {
360
468
  }
361
469
  if (!changed)
362
470
  return false;
363
- if (shift !== 0 && box) {
364
- reveal.scrollTo(box.scrollY + shift);
365
- }
471
+ // A debt, not a one-shot: the pane clamps against the last layout's
472
+ // content height, so a shift from rows measured above the viewport can
473
+ // land short until the layout that admits the growth has run.
474
+ reveal.nudge(shift);
366
475
  setMeasured((n) => n + 1);
367
476
  return true;
368
477
  // eslint-disable-next-line react-hooks/exhaustive-deps -- `heights` is a
369
478
  // stable instance
370
479
  }, [uniform, virtualizing]);
480
+ /**
481
+ * Let the estimate learn from the rows that have been measured — the
482
+ * scrollbar of a measured table starts as a guess times the row count,
483
+ * and the measured mean is a far better guess for the rows not yet seen.
484
+ * Idle only: every unmeasured offset moves when it applies, and the
485
+ * anchor arithmetic keeping the screen still is `measureRows`'s.
486
+ */
487
+ const adaptEstimate = useCallback(() => {
488
+ if (uniform || !virtualizing)
489
+ return false;
490
+ const box = body.current;
491
+ if (!box)
492
+ return false;
493
+ const anchor = heights.indexAt(box.scrollY);
494
+ const before = heights.offsetAt(anchor);
495
+ if (!heights.adapt())
496
+ return false;
497
+ reveal.nudge(heights.offsetAt(anchor) - before);
498
+ setMeasured((n) => n + 1);
499
+ return true;
500
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- `heights` and
501
+ // `reveal` are stable instances
502
+ }, [uniform, virtualizing]);
371
503
  /**
372
504
  * The one tick after layout, and everything that can only be known there:
373
505
  * what the rows measured, whether an owed scroll can go further now that
@@ -379,17 +511,47 @@ export function Table(props) {
379
511
  * needs none of it: `onViewport` is when its content can have been
380
512
  * re-clamped, and it rebuilds no slice anyway.
381
513
  */
514
+ /** Whether some drawn row has no size yet — a commit can land between
515
+ * frame flushes, and a measure pass over it reads zeros. */
516
+ const rowsPendingLayout = useCallback(() => {
517
+ if (uniform)
518
+ return false;
519
+ const rows = orderedRef.current;
520
+ for (const [id, { node, at }] of rowNodes.current) {
521
+ if (rows[at]?.id === id && !(node.abs.height > 0))
522
+ return true;
523
+ }
524
+ return false;
525
+ }, [uniform]);
382
526
  useEffect(() => {
383
527
  if (!virtualizing)
384
528
  return undefined;
385
- const id = afterLayout(() => {
529
+ let look = null;
530
+ let tries = 0;
531
+ const pass = () => {
386
532
  // `measureRows` first, and its answer handed on: a pass that moved the
387
533
  // heights has not settled anything, and an owed scroll judged against
388
- // the layout it is about to invalidate is not owed any less.
389
- reveal.retry(measureRows());
534
+ // the layout it is about to invalidate is not owed any less. During a
535
+ // flick nothing is measured at all — every correction at that speed is
536
+ // invalidated by the next event — and the settle tick that follows any
537
+ // burst is where the deferred passes catch up.
538
+ const moved = win.fast() ? false : measureRows();
539
+ const adapted = !win.scrolling() && adaptEstimate();
540
+ reveal.retry(moved || adapted);
390
541
  syncScroll();
391
- });
392
- return () => cancelAfterLayout(id);
542
+ // A commit can land between frame flushes: its rows report zero size
543
+ // until the flush, this tick has already run, and nothing else would
544
+ // come back for them — a window that just finished growing renders
545
+ // nothing further, and the missed measurements would stand for good.
546
+ // Look again, briefly, while any drawn row is still unsized.
547
+ if (rowsPendingLayout() && tries++ < 8)
548
+ look = later(pass, 16);
549
+ };
550
+ const id = afterLayout(pass);
551
+ return () => {
552
+ cancelAfterLayout(id);
553
+ cancelLater(look);
554
+ };
393
555
  });
394
556
  /** Put a row in view, by the index its call site already has. */
395
557
  const revealAt = useCallback((at) => {
@@ -618,109 +780,68 @@ export function Table(props) {
618
780
  const cellStyleProp = styles?.cell;
619
781
  const headerCellStyleProp = styles?.headerCell;
620
782
  const selectable = selectionMode !== 'none';
621
- const renderOneRow = (entry) => {
783
+ // The row component's stable halves — `MemoTableRow` bails out of a
784
+ // re-render only if every prop kept its identity, and these are the two
785
+ // that would otherwise be rebuilt per row per render.
786
+ const registerRow = useCallback((id, at, node) => {
787
+ if (node)
788
+ rowNodes.current.set(id, { node, at });
789
+ else
790
+ rowNodes.current.delete(id);
791
+ }, []);
792
+ const rowMenu = useCallback((entry, ev) => {
793
+ // The menu applies to what is under the pointer, so the row is
794
+ // selected first — unless it is already part of the selection,
795
+ // which a menu over "the selected files" must not collapse.
796
+ if (selectable && !selectedRef.current.has(entry.id)) {
797
+ tap(entry, { ctrl: false, shift: false });
798
+ }
799
+ onRowContextMenu?.(entry.id, entry.row, ev);
800
+ }, [selectable, tap, onRowContextMenu]);
801
+ /**
802
+ * A row the window said not to build in full yet: the box at its indexed
803
+ * height and none of its content. Cheap on purpose — no cells, no text, no
804
+ * seams — so the commit answering a flood lands frames before the full
805
+ * rows could, and what blits in reads as rows arriving rather than a
806
+ * void. `styles.row` still applies, so zebra striping and row backgrounds
807
+ * hold. Not registered in `rowNodes`: a skeleton must not be measured
808
+ * into the height index, and cannot satisfy a reveal.
809
+ */
810
+ const renderSkeletonRow = (entry) => {
622
811
  const isSelected = selectedIds.has(entry.id);
623
- const color = isSelected ? theme.hoverText : theme.text;
624
- const state = { ...entry, selected: isSelected, color };
625
- const content = columns.map((column, at) => {
626
- const cellState = { ...state, column };
627
- return hx('box', {
628
- key: column.id,
629
- role: 'cell',
630
- style: [
631
- s.cell,
632
- { width: widths[at] },
633
- uniform && { height: rowHeight },
634
- column.align === 'end' && {
635
- alignItems: 'flex-end',
636
- paddingEnd: 8,
637
- },
638
- column.align === 'center' && { alignItems: 'center' },
639
- typeof cellStyleProp === 'function'
640
- ? cellStyleProp(cellState)
641
- : cellStyleProp,
642
- ],
643
- }, column.render
644
- ? // A cell that draws itself still has to know it is on the
645
- // selected row — see the doc comment. Keyed by the component,
646
- // the way every seam's return is.
647
- React.createElement(React.Fragment, { key: 'content' }, column.render(entry.row, cellState))
648
- : hx('text', { style: [s.cellText, { color }] }, String(columnValue(entry.row, column) ?? '')));
649
- });
812
+ const state = {
813
+ ...entry,
814
+ selected: isSelected,
815
+ color: isSelected ? theme.hoverText : theme.text,
816
+ };
650
817
  return hx('box', {
651
818
  key: String(entry.id),
652
- role: 'row',
653
- 'aria-selected': selectable ? isSelected : undefined,
654
- 'aria-posinset': entry.index + 1,
655
- 'aria-setsize': ordered.length,
656
- // The index the row was drawn at travels with the node, so measuring
657
- // does not have to search the row list for it. It can go stale — the
658
- // rows may move before the tick that measures — and both this and
659
- // the height index check it rather than trust it.
660
- ref: (node) => {
661
- if (node)
662
- rowNodes.current.set(entry.id, { node, at: entry.index });
663
- else
664
- rowNodes.current.delete(entry.id);
665
- },
666
- onClick: (ev) => {
667
- // A right-click also arrives here as a click; the selection it
668
- // implies is `onContextMenu`'s to make (select-unless-selected),
669
- // not the left button's replace.
670
- if (ev.button !== 1)
671
- return;
672
- tap(entry, { ctrl: ev.ctrlKey, shift: ev.shiftKey });
673
- // Select on the first click, open on the second — the gesture
674
- // every file list has. `detail` is the click count the renderer
675
- // already counts for text selection.
676
- if (ev.detail === 2)
677
- activate(entry);
678
- },
679
- onContextMenu: onRowContextMenu
680
- ? (ev) => {
681
- // The menu applies to what is under the pointer, so the row is
682
- // selected first — unless it is already part of the selection,
683
- // which a menu over "the selected files" must not collapse.
684
- if (selectable && !selectedRef.current.has(entry.id)) {
685
- tap(entry, { ctrl: false, shift: false });
686
- }
687
- onRowContextMenu(entry.id, entry.row, ev);
688
- }
689
- : undefined,
819
+ 'aria-hidden': true,
690
820
  style: [
691
821
  s.row,
692
- // Declared uniform: exactly this tall, content clipped core's
693
- // row. Measured: a floor, and the row grows to whatever its
694
- // content needs; the height index reads back what it became.
695
- uniform
696
- ? { height: rowHeight, alignItems: 'center' }
697
- : { minHeight: estimate },
698
- selectable && { cursor: 'pointer' },
822
+ // Exactly what the index believes, so the spacers and the
823
+ // scrollbar agree with the rows on where everything is.
824
+ { height: heights.heightAt(entry.index) },
699
825
  {
700
826
  backgroundColor: isSelected ? theme.hoverBackground : 'transparent',
701
- // The row's ink, said once: `color` inherits, so default cells
702
- // take it without being handed it.
703
- color,
704
- },
705
- selectable && {
706
- // pressed even on the selected row: a re-press on the row that
707
- // is already current is the one click in the table that would
708
- // otherwise look ignored
709
- ':active': {
710
- backgroundColor: isSelected
711
- ? theme.accentActive
712
- : theme.surfaceActive,
713
- },
714
- },
715
- selectable &&
716
- !isSelected && {
717
- ':hover': { backgroundColor: theme.surfaceHover },
718
827
  },
719
828
  typeof rowStyleProp === 'function'
720
829
  ? rowStyleProp(state)
721
830
  : rowStyleProp,
722
831
  ],
723
- }, renderRow ? renderRow(state, content) : content);
832
+ },
833
+ // A line of "text" with no text. Width varied by index, so a band of
834
+ // placeholders reads as rows arriving rather than a repeated tile.
835
+ hx('box', {
836
+ key: 'bar',
837
+ style: [
838
+ s.skeletonBar,
839
+ {
840
+ width: 96 + ((entry.index * 37) % 89),
841
+ backgroundColor: theme.track,
842
+ },
843
+ ],
844
+ }));
724
845
  };
725
846
  const headerCells = columns.map((column, at) => {
726
847
  const canSort = column.sortable !== false;
@@ -793,6 +914,73 @@ export function Table(props) {
793
914
  ],
794
915
  }, hx('box', { style: [s.rule, { backgroundColor: theme.border }] })));
795
916
  });
917
+ /**
918
+ * The row *elements*, reused by identity while nothing they depend on has
919
+ * changed. The memo already skips re-rendering an unchanged row, but the
920
+ * skip still costs a `createElement` and a props compare per row per
921
+ * notch — the burst profile put bare `createElement` at a tenth of a
922
+ * flick's CPU. Handing React the identical element object instead takes
923
+ * the cheapest path it has: the fiber is reused with no compare at all.
924
+ * The cache empties whenever any shared input changes identity, and
925
+ * per-row entries revalidate on the row object and its selection.
926
+ */
927
+ const rowElems = useRef(new Map());
928
+ const rowElemDeps = useRef([]);
929
+ {
930
+ const deps = [
931
+ columns,
932
+ widths,
933
+ theme,
934
+ uniform,
935
+ rowHeight,
936
+ estimate,
937
+ selectable,
938
+ rowStyleProp,
939
+ cellStyleProp,
940
+ renderRow,
941
+ Boolean(onRowContextMenu),
942
+ tap,
943
+ activate,
944
+ rowMenu,
945
+ registerRow,
946
+ ordered.length,
947
+ ];
948
+ const prev = rowElemDeps.current;
949
+ if (prev.length !== deps.length || deps.some((d, at) => d !== prev[at])) {
950
+ rowElems.current.clear();
951
+ rowElemDeps.current = deps;
952
+ }
953
+ }
954
+ const rowElement = (entry) => {
955
+ const isSelected = selectedIds.has(entry.id);
956
+ const cached = rowElems.current.get(entry.id);
957
+ if (cached && cached.entry === entry && cached.selected === isSelected) {
958
+ return cached.el;
959
+ }
960
+ const el = React.createElement(MemoTableRow, {
961
+ key: String(entry.id),
962
+ entry,
963
+ columns,
964
+ widths,
965
+ setSize: ordered.length,
966
+ isSelected,
967
+ selectable,
968
+ uniform,
969
+ rowHeight,
970
+ estimate,
971
+ theme,
972
+ rowStyle: rowStyleProp,
973
+ cellStyle: cellStyleProp,
974
+ renderRow,
975
+ hasMenu: Boolean(onRowContextMenu),
976
+ onTap: tap,
977
+ onOpen: activate,
978
+ onMenu: rowMenu,
979
+ register: registerRow,
980
+ });
981
+ rowElems.current.set(entry.id, { entry, selected: isSelected, el });
982
+ return el;
983
+ };
796
984
  const bodyChildren = [];
797
985
  if (ordered.length === 0) {
798
986
  if (renderEmpty) {
@@ -806,14 +994,81 @@ export function Table(props) {
806
994
  style: [s.spacer, { height: above }],
807
995
  }));
808
996
  }
809
- for (let i = first; i < last; i++)
810
- bodyChildren.push(renderOneRow(ordered[i]));
997
+ for (let i = first; i < last; i++) {
998
+ const entry = ordered[i];
999
+ bodyChildren.push(win.skeletons.has(entry.id)
1000
+ ? renderSkeletonRow(entry)
1001
+ : rowElement(entry));
1002
+ }
811
1003
  if (virtualizing && last < ordered.length) {
812
1004
  bodyChildren.push(hx('box', {
813
1005
  key: 'spacer:after',
814
1006
  style: [s.spacer, { height: below }],
815
1007
  }));
816
1008
  }
1009
+ // Rows that left the window leave the cache too, once it has grown
1010
+ // well past the window — a scrub across a long list would otherwise
1011
+ // hold an element for every row it passed.
1012
+ if (rowElems.current.size > (last - first) * 3 + 64) {
1013
+ rowElems.current.clear();
1014
+ }
1015
+ }
1016
+ /**
1017
+ * The fast-scroll overlay — shown only while placeholders cover enough of
1018
+ * the viewport that the user would otherwise be looking at blank rows.
1019
+ * The half-viewport threshold keeps a near-miss quiet: a scroll the next
1020
+ * frame will absorb is not worth announcing. Once up it stays until the
1021
+ * view is whole again, so it does not flicker through the catch-up.
1022
+ */
1023
+ let scrollHint = null;
1024
+ if (virtualizing && ordered.length > 0 && view.height > 0) {
1025
+ const vFirst = heights.indexAt(view.top);
1026
+ const vLast = Math.min(ordered.length - 1, heights.indexAt(view.top + view.height));
1027
+ // Two ways in: placeholders covering enough of the viewport that it
1028
+ // would otherwise read as blank, or a scrub — the window teleporting
1029
+ // while the burst is still in flight, where every commit chases a
1030
+ // viewport that has already left and nothing useful can be on screen.
1031
+ // Either way only once the catch-up has already *lasted*: a jump the
1032
+ // next few frames absorb is not worth announcing, so the pill waits
1033
+ // out the show-delay against the catch-up clock. Latched once
1034
+ // triggered: `pending` bounces to zero between catch-up commits, and a
1035
+ // pill that blinked with it would read as a glitch. It goes when the
1036
+ // burst does.
1037
+ const engaged = (win.pending > 0 && win.pending * 2 >= vLast - vFirst + 1) ||
1038
+ (win.jumped && win.scrolling());
1039
+ const lasted = win.catchupSince !== null &&
1040
+ Date.now() - win.catchupSince >= scrollHintDelay;
1041
+ const show = (engaged && lasted) ||
1042
+ (hintShown.current && (win.pending > 0 || win.scrolling()));
1043
+ hintShown.current = show;
1044
+ if (show) {
1045
+ const hintState = {
1046
+ row: ordered[vFirst],
1047
+ from: vFirst + 1,
1048
+ to: vLast + 1,
1049
+ count: ordered.length,
1050
+ pending: win.pending,
1051
+ since: win.catchupSince ?? Date.now(),
1052
+ };
1053
+ const content = renderScrollHint
1054
+ ? renderScrollHint(hintState)
1055
+ : hx('text', { style: { fontSize: 11, color: theme.hoverText } }, `${hintState.from.toLocaleString()} / ${hintState.count.toLocaleString()}`);
1056
+ if (content !== null && content !== undefined && content !== false) {
1057
+ scrollHint = hx('box', {
1058
+ key: 'scroll-hint',
1059
+ // The pill duplicates what the scrollbar already tells an
1060
+ // assistive technology, and it comes and goes with the catch-up
1061
+ // — chatter, not content.
1062
+ 'aria-hidden': true,
1063
+ style: s.scrollHintLane,
1064
+ }, hx('box', {
1065
+ style: [s.scrollHint, { backgroundColor: theme.hoverBackground }],
1066
+ }, content));
1067
+ }
1068
+ }
1069
+ }
1070
+ else {
1071
+ hintShown.current = false;
817
1072
  }
818
1073
  return hx('box', {
819
1074
  theme,
@@ -855,9 +1110,7 @@ export function Table(props) {
855
1110
  // under them on the next layout.
856
1111
  reveal.heard(ev.scrollY);
857
1112
  setScrollX((prev) => (prev === ev.scrollX ? prev : ev.scrollX));
858
- if (virtualizing) {
859
- setView((prev) => prev.top === ev.scrollY ? prev : { ...prev, top: ev.scrollY });
860
- }
1113
+ win.scrolled(ev.scrollY);
861
1114
  onScroll?.(ev);
862
1115
  },
863
1116
  // Layout, not scrolling, is what first tells a table how much of it
@@ -865,16 +1118,7 @@ export function Table(props) {
865
1118
  // columns resolve against, so this is measured whether or not the
866
1119
  // table virtualizes.
867
1120
  onViewport: (ev) => {
868
- // The ref first, at event time: the measure tick runs before the
869
- // re-render this setState causes, and it must see this viewport.
870
- viewRef.current = {
871
- ...viewRef.current,
872
- height: ev.height,
873
- width: ev.width,
874
- };
875
- setView((prev) => prev.height === ev.height && prev.width === ev.width
876
- ? prev
877
- : { ...prev, height: ev.height, width: ev.width });
1121
+ win.sized(ev.width, ev.height);
878
1122
  // The content just changed size, which is both the moment an owed
879
1123
  // scroll can reach further than the clamp let it and the moment the
880
1124
  // container may have re-clamped the offset without saying so. It is
@@ -885,6 +1129,6 @@ export function Table(props) {
885
1129
  syncScroll();
886
1130
  onViewport?.(ev);
887
1131
  },
888
- }, hx('box', { style: [s.rowsBox, { width: total }] }, bodyChildren)));
1132
+ }, hx('box', { style: [s.rowsBox, { width: total }] }, bodyChildren)), scrollHint);
889
1133
  }
890
1134
  //# sourceMappingURL=index.js.map