@react-x11/components 0.2.0 → 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.
@@ -45,6 +45,7 @@ import type {
45
45
  KeyboardEvent,
46
46
  MouseEvent,
47
47
  ScrollableNode,
48
+ Theme,
48
49
  } from 'react-x11';
49
50
  import {
50
51
  XK_DOWN,
@@ -63,7 +64,23 @@ import type { Host } from './hx.js';
63
64
  // Shared with <Tree> — internal, deliberately not a shared *module*; the
64
65
  // header of src/internal/heights.ts says why.
65
66
  import { RowHeights } from '../internal/heights.js';
66
- import { afterLayout, cancelAfterLayout } from '../internal/timers.js';
67
+ import {
68
+ afterLayout,
69
+ cancelAfterLayout,
70
+ cancelLater,
71
+ later,
72
+ } from '../internal/timers.js';
73
+ import type { DelayTick } from '../internal/timers.js';
74
+ import { useReveal } from '../internal/scroll.js';
75
+ import {
76
+ BURST_BUDGET,
77
+ DEFAULT_OVERSCAN,
78
+ DEFAULT_PREFETCH,
79
+ SCROLL_HINT_DELAY_MS,
80
+ SKELETON_THRESHOLD,
81
+ SETTLE_BUDGET,
82
+ useVirtualWindow,
83
+ } from '../internal/window.js';
67
84
  import {
68
85
  MIN_COLUMN,
69
86
  columnValue,
@@ -119,16 +136,6 @@ const HALF = 3;
119
136
  const GRIP = HALF + RULE + HALF;
120
137
  /** What one Left/Right on a focused grip is worth. */
121
138
  const STEP = 16;
122
- /** Rows kept either side of the viewport, so a fast scroll does not show a
123
- * gap before the next frame catches up. */
124
- const OVERSCAN = 6;
125
- /**
126
- * What to build before the viewport has been measured. `onViewport` cannot
127
- * arrive until layout has run, which is a frame after the first commit, so
128
- * there is always one render that has to guess — and guessing "all of them"
129
- * puts a hundred thousand rows in the tree for a frame.
130
- */
131
- const ASSUMED_ROWS = 40;
132
139
  /**
133
140
  * Where `virtual="auto"` starts virtualizing.
134
141
  *
@@ -190,6 +197,37 @@ const s = createStyles({
190
197
  },
191
198
  cellText: { fontSize: 12, textWrap: 'nowrap', textBoxTrim: 'cap-alphabetic' },
192
199
  spacer: { flexShrink: 0 },
200
+ /** The bar inside a skeleton row — a line of "text" with no text, so a
201
+ * band of placeholders reads as rows arriving rather than a void. */
202
+ skeletonBar: {
203
+ height: 8,
204
+ borderRadius: 4,
205
+ marginStart: 8,
206
+ alignSelf: 'center',
207
+ flexShrink: 0,
208
+ },
209
+ /** The lane the fast-scroll pill floats in: absolute against the table's
210
+ * root so the body pane scrolls under it, full-width so the pill centres
211
+ * itself, and transparent to the pointer so the rows beneath stay
212
+ * clickable. */
213
+ scrollHintLane: {
214
+ position: 'absolute',
215
+ left: 0,
216
+ right: 0,
217
+ bottom: 12,
218
+ flexDirection: 'row',
219
+ justifyContent: 'center',
220
+ pointerEvents: 'none',
221
+ },
222
+ scrollHint: {
223
+ paddingStart: 10,
224
+ paddingEnd: 10,
225
+ paddingTop: 5,
226
+ paddingBottom: 5,
227
+ borderRadius: 12,
228
+ flexDirection: 'row',
229
+ alignItems: 'center',
230
+ },
193
231
  sortMark: { marginStart: 4 },
194
232
  empty: {
195
233
  flexGrow: 1,
@@ -199,6 +237,29 @@ const s = createStyles({
199
237
  },
200
238
  });
201
239
 
240
+ /**
241
+ * What `renderScrollHint` is told: where the viewport is, while a fast
242
+ * scroll is still being caught up with. The top row itself is included so a
243
+ * hint can show what is *at* this position — a date, a group, a name — the
244
+ * way a photo library's scrubber shows the month.
245
+ */
246
+ export interface TableScrollHintState<Row = any> {
247
+ /** The first row in view, in display order. */
248
+ row: TableRow<Row>;
249
+ /** Its position, 1-based — "row `from` of `count`". */
250
+ from: number;
251
+ /** The last row in view, 1-based. */
252
+ to: number;
253
+ /** How many rows the table has. */
254
+ count: number;
255
+ /** How many of the rows in view are still placeholders. */
256
+ pending: number;
257
+ /** When the viewport first stopped being whole, epoch ms — what the
258
+ * show-delay was measured against. `Date.now() - since` is how long the
259
+ * user has been looking at unresolved content. */
260
+ since: number;
261
+ }
262
+
202
263
  /** The selection that just changed, alongside the whole set. `id`/`row` name
203
264
  * the row the gesture landed on; a select-all has no single row to name. */
204
265
  export interface TableSelectChange<Row> {
@@ -282,14 +343,25 @@ interface TableBaseProps<Row> extends Omit<
282
343
  */
283
344
  rowHeight?: number;
284
345
  /** What an unmeasured row is assumed — and floored — at, while measuring.
285
- * Default 24. The scrollbar is this guess for every row not yet seen, and
286
- * it converges as you scroll. */
346
+ * Default 24. The scrollbar is this guess for every row not yet seen; it
347
+ * converges as you scroll, and once enough rows have been measured the
348
+ * guess itself is re-learnt from their mean, so the scrollbar lands near
349
+ * the truth without visiting the whole list. */
287
350
  estimatedRowHeight?: number;
288
351
  /** Build only the rows on screen. `'auto'` (the default) turns it on past
289
352
  * 200 rows. */
290
353
  virtual?: boolean | 'auto';
291
354
  /** Rows built either side of the viewport. */
292
355
  overscan?: number;
356
+ /**
357
+ * Rows built *beyond* the overscan while the table sits idle, per side.
358
+ * Default 40. The pane blits a scroll before React can run, so the only
359
+ * scroll with no blank frame is one that lands on rows already built —
360
+ * this band is that, grown in small steps while nobody is scrolling, and
361
+ * kept behind the viewport so a reversal lands on rows still mounted.
362
+ * `0` turns the band off: the slice is exactly viewport-plus-overscan.
363
+ */
364
+ prefetch?: number;
293
365
 
294
366
  /**
295
367
  * Everything inside the row box, given what would have been there.
@@ -302,6 +374,40 @@ interface TableBaseProps<Row> extends Omit<
302
374
  /** The body's content when the rows resolve empty. Nothing by default —
303
375
  * the header still shows. */
304
376
  renderEmpty?: () => ReactNode;
377
+ /**
378
+ * The fast-scroll overlay. Shown only while a scroll has outrun the rows
379
+ * far enough that placeholders cover a meaningful part of the viewport —
380
+ * a scroll the table absorbs within a frame never shows it — and hidden
381
+ * the moment the view is whole again. The default is a pill reading
382
+ * "2,345 / 100,000"; return something else to replace it (the state
383
+ * carries the top row, so a hint can show a date or a name instead of a
384
+ * number), or null for no overlay at all.
385
+ */
386
+ renderScrollHint?: (state: TableScrollHintState<Row>) => ReactNode;
387
+ /**
388
+ * How long the viewport must have been showing unresolved content before
389
+ * the overlay appears, in milliseconds. Default 250: a catch-up the next
390
+ * few frames absorb is never announced. `0` shows it the moment a
391
+ * catch-up engages.
392
+ */
393
+ scrollHintDelay?: number;
394
+ /**
395
+ * Tuning for the catch-up pacing — how a scroll that outruns the built
396
+ * rows is absorbed. All optional, all in rows:
397
+ *
398
+ * - `threshold` (default 16): more rows than this entering the window in
399
+ * one render is a flood, and floods build skeletons first. Raise it
400
+ * past the window size to never show skeletons; `0` skeletons every
401
+ * scroll.
402
+ * - `burst` (default 24): full rows built per render while the scroll is
403
+ * still moving.
404
+ * - `settle` (default 48): the same once it has stopped.
405
+ *
406
+ * The defaults follow the measured cost of a default-shape row (about
407
+ * twice a skeleton, warm); tables with heavy `render` seams may want
408
+ * smaller budgets, and cheap tables may raise the threshold instead.
409
+ */
410
+ catchup?: { threshold?: number; burst?: number; settle?: number };
305
411
 
306
412
  styles?: TableStyles<Row>;
307
413
  style?: StyleProp;
@@ -366,6 +472,172 @@ interface TableAllProps<Row> extends TableBaseProps<Row> {
366
472
 
367
473
  const EMPTY_SET: ReadonlySet<TableRowId> = new Set();
368
474
 
475
+ /**
476
+ * One row, as its own memoized component.
477
+ *
478
+ * The reason is the CPU profile of a fast scroll: every notch re-renders
479
+ * the window, and re-creating a hundred rows' elements per notch — then
480
+ * reconciling them and re-applying identical props to every node — was
481
+ * over half the burst. Every prop here is identity-stable across a scroll
482
+ * render (the row model is memoized, the widths resolve once, the handlers
483
+ * are stable callbacks), so React bails out on the rows that did not
484
+ * change and a notch pays only for the rows it brought in.
485
+ */
486
+ interface TableRowViewProps<Row> {
487
+ entry: TableRow<Row>;
488
+ columns: readonly TableColumn<Row>[];
489
+ widths: readonly number[];
490
+ /** How many rows the table has — `aria-setsize`. */
491
+ setSize: number;
492
+ isSelected: boolean;
493
+ selectable: boolean;
494
+ uniform: boolean;
495
+ rowHeight: number | undefined;
496
+ estimate: number;
497
+ theme: Theme;
498
+ rowStyle: TableStyles<Row>['row'];
499
+ cellStyle: TableStyles<Row>['cell'];
500
+ renderRow?: (state: TableRowState<Row>, content: ReactNode[]) => ReactNode;
501
+ /** Whether the app passed `onRowContextMenu` — the prop itself stays out
502
+ * of the row so a re-created handler does not re-render every row. */
503
+ hasMenu: boolean;
504
+ onTap: (
505
+ entry: TableRow<Row>,
506
+ mods: { ctrl: boolean; shift: boolean },
507
+ ) => void;
508
+ onOpen: (entry: TableRow<Row>) => void;
509
+ onMenu: (entry: TableRow<Row>, ev: MouseEvent) => void;
510
+ register: (id: TableRowId, at: number, node: DrawnNode | null) => void;
511
+ }
512
+
513
+ function TableRowView<Row>(props: TableRowViewProps<Row>): ReactElement {
514
+ const {
515
+ entry,
516
+ columns,
517
+ widths,
518
+ setSize,
519
+ isSelected,
520
+ selectable,
521
+ uniform,
522
+ rowHeight,
523
+ estimate,
524
+ theme,
525
+ rowStyle,
526
+ cellStyle,
527
+ renderRow,
528
+ hasMenu,
529
+ onTap,
530
+ onOpen,
531
+ onMenu,
532
+ register,
533
+ } = props;
534
+ const color = isSelected ? theme.hoverText : theme.text;
535
+ const state: TableRowState<Row> = { ...entry, selected: isSelected, color };
536
+
537
+ const content: ReactNode[] = columns.map((column, at) => {
538
+ const cellState: TableCellState<Row> = { ...state, column };
539
+ return hx(
540
+ 'box',
541
+ {
542
+ key: column.id,
543
+ role: 'cell',
544
+ style: [
545
+ s.cell,
546
+ { width: widths[at] },
547
+ uniform && { height: rowHeight },
548
+ column.align === 'end' && {
549
+ alignItems: 'flex-end',
550
+ paddingEnd: 8,
551
+ },
552
+ column.align === 'center' && { alignItems: 'center' },
553
+ typeof cellStyle === 'function' ? cellStyle(cellState) : cellStyle,
554
+ ],
555
+ },
556
+ column.render
557
+ ? // A cell that draws itself still has to know it is on the
558
+ // selected row — see the doc comment. Keyed by the component,
559
+ // the way every seam's return is.
560
+ React.createElement(
561
+ React.Fragment,
562
+ { key: 'content' },
563
+ column.render(entry.row, cellState),
564
+ )
565
+ : hx(
566
+ 'text',
567
+ { style: [s.cellText, { color }] },
568
+ String(columnValue(entry.row, column) ?? ''),
569
+ ),
570
+ );
571
+ });
572
+
573
+ return hx(
574
+ 'box',
575
+ {
576
+ role: 'row',
577
+ 'aria-selected': selectable ? isSelected : undefined,
578
+ 'aria-posinset': entry.index + 1,
579
+ 'aria-setsize': setSize,
580
+ // The index the row was drawn at travels with the node, so measuring
581
+ // does not have to search the row list for it. It can go stale — the
582
+ // rows may move before the tick that measures — and both this and
583
+ // the height index check it rather than trust it.
584
+ ref: (node: DrawnNode | null) => {
585
+ register(entry.id, entry.index, node);
586
+ },
587
+ onClick: (ev: MouseEvent) => {
588
+ // A right-click also arrives here as a click; the selection it
589
+ // implies is `onContextMenu`'s to make (select-unless-selected),
590
+ // not the left button's replace.
591
+ if (ev.button !== 1) return;
592
+ onTap(entry, { ctrl: ev.ctrlKey, shift: ev.shiftKey });
593
+ // Select on the first click, open on the second — the gesture
594
+ // every file list has. `detail` is the click count the renderer
595
+ // already counts for text selection.
596
+ if (ev.detail === 2) onOpen(entry);
597
+ },
598
+ onContextMenu: hasMenu
599
+ ? (ev: MouseEvent) => {
600
+ onMenu(entry, ev);
601
+ }
602
+ : undefined,
603
+ style: [
604
+ s.row,
605
+ // Declared uniform: exactly this tall, content clipped — core's
606
+ // row. Measured: a floor, and the row grows to whatever its
607
+ // content needs; the height index reads back what it became.
608
+ uniform
609
+ ? { height: rowHeight, alignItems: 'center' }
610
+ : { minHeight: estimate },
611
+ selectable && { cursor: 'pointer' },
612
+ {
613
+ backgroundColor: isSelected ? theme.hoverBackground : 'transparent',
614
+ // The row's ink, said once: `color` inherits, so default cells
615
+ // take it without being handed it.
616
+ color,
617
+ },
618
+ selectable && {
619
+ // pressed even on the selected row: a re-press on the row that
620
+ // is already current is the one click in the table that would
621
+ // otherwise look ignored
622
+ ':active': {
623
+ backgroundColor: isSelected
624
+ ? theme.accentActive
625
+ : theme.surfaceActive,
626
+ },
627
+ },
628
+ selectable &&
629
+ !isSelected && {
630
+ ':hover': { backgroundColor: theme.surfaceHover },
631
+ },
632
+ typeof rowStyle === 'function' ? rowStyle(state) : rowStyle,
633
+ ],
634
+ },
635
+ renderRow ? renderRow(state, content) : content,
636
+ );
637
+ }
638
+
639
+ const MemoTableRow = React.memo(TableRowView) as typeof TableRowView;
640
+
369
641
  /**
370
642
  * `<Table columns rows />` — a data table with a header that stays put.
371
643
  *
@@ -412,9 +684,13 @@ export function Table<Row = any>(props: TableProps<Row>): ReactElement {
412
684
  rowHeight,
413
685
  estimatedRowHeight,
414
686
  virtual = 'auto',
415
- overscan = OVERSCAN,
687
+ overscan = DEFAULT_OVERSCAN,
688
+ prefetch = DEFAULT_PREFETCH,
416
689
  renderRow,
417
690
  renderEmpty,
691
+ renderScrollHint,
692
+ scrollHintDelay = SCROLL_HINT_DELAY_MS,
693
+ catchup,
418
694
  styles,
419
695
  style,
420
696
  focusable = true,
@@ -472,7 +748,6 @@ export function Table<Row = any>(props: TableProps<Row>): ReactElement {
472
748
  Readonly<Record<string, number>>
473
749
  >({});
474
750
  const [scrollX, setScrollX] = useState(0);
475
- const [view, setView] = useState({ top: 0, height: 0, width: 0 });
476
751
  // Bumped by a measurement pass that found a row taller or shorter than the
477
752
  // index believed. It is the only reason the component re-renders for a
478
753
  // measurement, and a pass that finds nothing new does not bump it — which
@@ -518,8 +793,6 @@ export function Table<Row = any>(props: TableProps<Row>): ReactElement {
518
793
  selectedRef.current = selectedIds;
519
794
  const cursorRef = useRef(cursor);
520
795
  cursorRef.current = cursor;
521
- const viewRef = useRef(view);
522
- viewRef.current = view;
523
796
  /** Where a Shift range grows from — the last plain click or plain step. */
524
797
  const anchorRef = useRef<TableRowId | null>(null);
525
798
 
@@ -531,6 +804,9 @@ export function Table<Row = any>(props: TableProps<Row>): ReactElement {
531
804
  new Map<TableRowId, { node: DrawnNode; at: number }>(),
532
805
  );
533
806
  const drag = useRef<{ id: string; from: number; width: number } | null>(null);
807
+ /** Whether the fast-scroll pill is up — kept across renders so it does not
808
+ * flicker through a catch-up, only appearing and disappearing once. */
809
+ const hintShown = useRef(false);
534
810
  const userWidthsRef = useRef(userWidths);
535
811
  userWidthsRef.current = userWidths;
536
812
 
@@ -550,25 +826,24 @@ export function Table<Row = any>(props: TableProps<Row>): ReactElement {
550
826
  virtual === true ||
551
827
  (virtual === 'auto' && ordered.length > VIRTUAL_THRESHOLD);
552
828
 
553
- // The slice worth building: what is on screen, plus a little either side.
554
- const first = virtualizing
555
- ? Math.max(0, heights.indexAt(view.top) - overscan)
556
- : 0;
557
- let last = ordered.length;
558
- if (virtualizing) {
559
- if (view.height > 0) {
560
- last = Math.min(
561
- ordered.length,
562
- heights.indexAt(view.top + view.height) + 1 + overscan,
563
- );
564
- } else {
565
- last = Math.min(ordered.length, first + ASSUMED_ROWS);
566
- }
567
- }
568
- /** Where the slice starts, and how much of the list is below it — the two
569
- * spacers that keep the scrollbar measuring the whole table. */
570
- const above = virtualizing ? heights.offsetAt(first) : 0;
571
- const below = virtualizing ? heights.total() - heights.offsetAt(last) : 0;
829
+ /** The viewport, and the slice worth building from it the machinery
830
+ * shared with `<Tree>` (`../internal/window.ts`). */
831
+ const win = useVirtualWindow({
832
+ box: body,
833
+ heights,
834
+ rows: ordered,
835
+ // declared uniform: every height is exact, so the idle band may grow
836
+ // upward freely — see `exact` on the inputs
837
+ exact: uniform,
838
+ virtualizing,
839
+ overscan,
840
+ prefetch,
841
+ threshold: catchup?.threshold ?? SKELETON_THRESHOLD,
842
+ burstBudget: catchup?.burst ?? BURST_BUDGET,
843
+ settleBudget: catchup?.settle ?? SETTLE_BUDGET,
844
+ });
845
+ const { view, viewRef } = win;
846
+ const { first, last, above, below } = win.slice;
572
847
 
573
848
  /** Columns resolve to pixels once, at the table level, per (columns,
574
849
  * viewport, resizes) — every row agrees on the grid by construction. */
@@ -577,6 +852,34 @@ export function Table<Row = any>(props: TableProps<Row>): ReactElement {
577
852
  [columns, userWidths, view.width],
578
853
  );
579
854
 
855
+ /**
856
+ * The scroll the table owes a row, and the pane's real offset read back
857
+ * after every layout — the two halves of `../internal/scroll.ts`, which
858
+ * says why a reveal cannot be a one-shot and why `onScroll` is not the
859
+ * whole story.
860
+ */
861
+ const reveal = useReveal({
862
+ box: body,
863
+ rows: orderedRef,
864
+ nodes: rowNodes,
865
+ heights,
866
+ });
867
+
868
+ /**
869
+ * Re-read the offset the body is *actually* at — the window's `sync` (see
870
+ * `../internal/window.ts` for why the pane moves silently), plus the
871
+ * horizontal half only this component has: the header is shifted by
872
+ * `scrollX`, so the sideways offset is re-read on the same tick.
873
+ */
874
+ const winSync = win.sync;
875
+ const syncScroll = useCallback((): void => {
876
+ const box = body.current;
877
+ if (!box) return;
878
+ const x = box.scrollX;
879
+ setScrollX((prev) => (prev === x ? prev : x));
880
+ winSync();
881
+ }, [winSync]);
882
+
580
883
  /**
581
884
  * Read back what the rows on screen actually laid out at.
582
885
  *
@@ -587,12 +890,12 @@ export function Table<Row = any>(props: TableProps<Row>): ReactElement {
587
890
  * the viewport anchor shift the scroll offset by their delta, or
588
891
  * measuring a row already scrolled past yanks the list under the pointer.
589
892
  */
590
- const measureRows = useCallback((): void => {
591
- if (uniform || !virtualizing) return;
893
+ const measureRows = useCallback((): boolean => {
894
+ if (uniform || !virtualizing) return false;
592
895
  // Before the first `onViewport` the flex columns sit on their floors and
593
896
  // every row is laid out against a width that is about to change — there
594
897
  // is nothing honest to measure yet.
595
- if (viewRef.current.width <= 0) return;
898
+ if (viewRef.current.width <= 0) return false;
596
899
  // A row laid out at a width the columns no longer resolve to is a
597
900
  // measurement of the wrong table, and it must not be recorded — a row
598
901
  // that scrolls out before the corrected pass would keep a wrong-width
@@ -620,45 +923,99 @@ export function Table<Row = any>(props: TableProps<Row>): ReactElement {
620
923
  changed = true;
621
924
  if (at < anchor) shift += height - was;
622
925
  }
623
- if (!changed) return;
624
- if (shift !== 0 && box) {
625
- box.scrollTo({ y: Math.max(0, box.scrollY + shift) });
626
- }
926
+ if (!changed) return false;
927
+ // A debt, not a one-shot: the pane clamps against the last layout's
928
+ // content height, so a shift from rows measured above the viewport can
929
+ // land short until the layout that admits the growth has run.
930
+ reveal.nudge(shift);
627
931
  setMeasured((n) => n + 1);
932
+ return true;
628
933
  // eslint-disable-next-line react-hooks/exhaustive-deps -- `heights` is a
629
934
  // stable instance
630
935
  }, [uniform, virtualizing]);
631
936
 
632
- useEffect(() => {
633
- if (uniform || !virtualizing) return undefined;
634
- const id = afterLayout(measureRows);
635
- return () => cancelAfterLayout(id);
636
- });
637
-
638
937
  /**
639
- * Put a row in view. A mounted row can say where it is, and
640
- * `scrollIntoView` then works whatever height it turned out to be; a row
641
- * that is not mounted has no geometry to ask, so the height index answers
642
- * instead.
938
+ * Let the estimate learn from the rows that have been measured the
939
+ * scrollbar of a measured table starts as a guess times the row count,
940
+ * and the measured mean is a far better guess for the rows not yet seen.
941
+ * Idle only: every unmeasured offset moves when it applies, and the
942
+ * anchor arithmetic keeping the screen still is `measureRows`'s.
643
943
  */
644
- const reveal = useCallback((at: number): void => {
944
+ const adaptEstimate = useCallback((): boolean => {
945
+ if (uniform || !virtualizing) return false;
645
946
  const box = body.current;
646
- const row = orderedRef.current[at];
647
- if (!box || !row) return;
648
- const drawn = rowNodes.current.get(row.id);
649
- if (drawn) {
650
- box.scrollIntoView(drawn.node);
651
- return;
652
- }
653
- const top = heights.offsetAt(at);
654
- const rowH = heights.heightAt(at);
655
- const height = viewRef.current.height;
656
- if (top < box.scrollY) box.scrollTo({ y: top });
657
- else if (height > 0 && top + rowH > box.scrollY + height) {
658
- box.scrollTo({ y: top + rowH - height });
947
+ if (!box) return false;
948
+ const anchor = heights.indexAt(box.scrollY);
949
+ const before = heights.offsetAt(anchor);
950
+ if (!heights.adapt()) return false;
951
+ reveal.nudge(heights.offsetAt(anchor) - before);
952
+ setMeasured((n) => n + 1);
953
+ return true;
954
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- `heights` and
955
+ // `reveal` are stable instances
956
+ }, [uniform, virtualizing]);
957
+
958
+ /**
959
+ * The one tick after layout, and everything that can only be known there:
960
+ * what the rows measured, whether an owed scroll can go further now that
961
+ * the new rows are laid out, and where the body actually ended up. In that
962
+ * order — each step can move the offset the next one reads.
963
+ *
964
+ * Scheduled for every render a virtualized table makes, because every one
965
+ * of them can move the offset its next slice is built from. A whole table
966
+ * needs none of it: `onViewport` is when its content can have been
967
+ * re-clamped, and it rebuilds no slice anyway.
968
+ */
969
+ /** Whether some drawn row has no size yet — a commit can land between
970
+ * frame flushes, and a measure pass over it reads zeros. */
971
+ const rowsPendingLayout = useCallback((): boolean => {
972
+ if (uniform) return false;
973
+ const rows = orderedRef.current;
974
+ for (const [id, { node, at }] of rowNodes.current) {
975
+ if (rows[at]?.id === id && !(node.abs.height > 0)) return true;
659
976
  }
660
- // eslint-disable-next-line react-hooks/exhaustive-deps
661
- }, []);
977
+ return false;
978
+ }, [uniform]);
979
+
980
+ useEffect(() => {
981
+ if (!virtualizing) return undefined;
982
+ let look: DelayTick = null;
983
+ let tries = 0;
984
+ const pass = (): void => {
985
+ // `measureRows` first, and its answer handed on: a pass that moved the
986
+ // heights has not settled anything, and an owed scroll judged against
987
+ // the layout it is about to invalidate is not owed any less. During a
988
+ // flick nothing is measured at all — every correction at that speed is
989
+ // invalidated by the next event — and the settle tick that follows any
990
+ // burst is where the deferred passes catch up.
991
+ const moved = win.fast() ? false : measureRows();
992
+ const adapted = !win.scrolling() && adaptEstimate();
993
+ reveal.retry(moved || adapted);
994
+ syncScroll();
995
+ // A commit can land between frame flushes: its rows report zero size
996
+ // until the flush, this tick has already run, and nothing else would
997
+ // come back for them — a window that just finished growing renders
998
+ // nothing further, and the missed measurements would stand for good.
999
+ // Look again, briefly, while any drawn row is still unsized.
1000
+ if (rowsPendingLayout() && tries++ < 8) look = later(pass, 16);
1001
+ };
1002
+ const id = afterLayout(pass);
1003
+ return () => {
1004
+ cancelAfterLayout(id);
1005
+ cancelLater(look);
1006
+ };
1007
+ });
1008
+
1009
+ /** Put a row in view, by the index its call site already has. */
1010
+ const revealAt = useCallback(
1011
+ (at: number): void => {
1012
+ const row = orderedRef.current[at];
1013
+ if (row) reveal.to(row.id);
1014
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- `reveal` is a
1015
+ // stable handle
1016
+ },
1017
+ [reveal],
1018
+ );
662
1019
 
663
1020
  const commitSingle = useCallback(
664
1021
  (row: TableRow<Row>): void => {
@@ -690,7 +1047,7 @@ export function Table<Row = any>(props: TableProps<Row>): ReactElement {
690
1047
  if (selectionMode === 'none') return;
691
1048
  if (selectionMode === 'single') {
692
1049
  commitSingle(row);
693
- reveal(row.index);
1050
+ revealAt(row.index);
694
1051
  return;
695
1052
  }
696
1053
  cursorRef.current = row.id;
@@ -716,9 +1073,9 @@ export function Table<Row = any>(props: TableProps<Row>): ReactElement {
716
1073
  anchorRef.current = row.id;
717
1074
  commitMulti([row.id], { type: 'replace', id: row.id, row: row.row });
718
1075
  }
719
- reveal(row.index);
1076
+ revealAt(row.index);
720
1077
  },
721
- [selectionMode, commitSingle, commitMulti, reveal],
1078
+ [selectionMode, commitSingle, commitMulti, revealAt],
722
1079
  );
723
1080
 
724
1081
  const activate = useCallback(
@@ -882,13 +1239,13 @@ export function Table<Row = any>(props: TableProps<Row>): ReactElement {
882
1239
  scrollToRow: (id) => {
883
1240
  const row = orderedRef.current.find((r) => r.id === id);
884
1241
  if (!row) return false;
885
- reveal(row.index);
1242
+ revealAt(row.index);
886
1243
  return true;
887
1244
  },
888
1245
  handleKey,
889
1246
  rows: () => orderedRef.current,
890
1247
  }),
891
- [tap, handleKey, reveal, selectionMode, selected],
1248
+ [tap, handleKey, revealAt, selectionMode, selected],
892
1249
  );
893
1250
 
894
1251
  // --- rendering -----------------------------------------------------------
@@ -898,122 +1255,75 @@ export function Table<Row = any>(props: TableProps<Row>): ReactElement {
898
1255
  const headerCellStyleProp = styles?.headerCell;
899
1256
  const selectable = selectionMode !== 'none';
900
1257
 
901
- const renderOneRow = (entry: TableRow<Row>): ReactElement => {
902
- const isSelected = selectedIds.has(entry.id);
903
- const color = isSelected ? theme.hoverText : theme.text;
904
- const state: TableRowState<Row> = { ...entry, selected: isSelected, color };
905
-
906
- const content: ReactNode[] = columns.map((column, at) => {
907
- const cellState: TableCellState<Row> = { ...state, column };
908
- return hx(
909
- 'box',
910
- {
911
- key: column.id,
912
- role: 'cell',
913
- style: [
914
- s.cell,
915
- { width: widths[at] },
916
- uniform && { height: rowHeight },
917
- column.align === 'end' && {
918
- alignItems: 'flex-end',
919
- paddingEnd: 8,
920
- },
921
- column.align === 'center' && { alignItems: 'center' },
922
- typeof cellStyleProp === 'function'
923
- ? cellStyleProp(cellState)
924
- : cellStyleProp,
925
- ],
926
- },
927
- column.render
928
- ? // A cell that draws itself still has to know it is on the
929
- // selected row — see the doc comment. Keyed by the component,
930
- // the way every seam's return is.
931
- React.createElement(
932
- React.Fragment,
933
- { key: 'content' },
934
- column.render(entry.row, cellState),
935
- )
936
- : hx(
937
- 'text',
938
- { style: [s.cellText, { color }] },
939
- String(columnValue(entry.row, column) ?? ''),
940
- ),
941
- );
942
- });
1258
+ // The row component's stable halves `MemoTableRow` bails out of a
1259
+ // re-render only if every prop kept its identity, and these are the two
1260
+ // that would otherwise be rebuilt per row per render.
1261
+ const registerRow = useCallback(
1262
+ (id: TableRowId, at: number, node: DrawnNode | null): void => {
1263
+ if (node) rowNodes.current.set(id, { node, at });
1264
+ else rowNodes.current.delete(id);
1265
+ },
1266
+ [],
1267
+ );
1268
+ const rowMenu = useCallback(
1269
+ (entry: TableRow<Row>, ev: MouseEvent): void => {
1270
+ // The menu applies to what is under the pointer, so the row is
1271
+ // selected first — unless it is already part of the selection,
1272
+ // which a menu over "the selected files" must not collapse.
1273
+ if (selectable && !selectedRef.current.has(entry.id)) {
1274
+ tap(entry, { ctrl: false, shift: false });
1275
+ }
1276
+ onRowContextMenu?.(entry.id, entry.row, ev);
1277
+ },
1278
+ [selectable, tap, onRowContextMenu],
1279
+ );
943
1280
 
1281
+ /**
1282
+ * A row the window said not to build in full yet: the box at its indexed
1283
+ * height and none of its content. Cheap on purpose — no cells, no text, no
1284
+ * seams — so the commit answering a flood lands frames before the full
1285
+ * rows could, and what blits in reads as rows arriving rather than a
1286
+ * void. `styles.row` still applies, so zebra striping and row backgrounds
1287
+ * hold. Not registered in `rowNodes`: a skeleton must not be measured
1288
+ * into the height index, and cannot satisfy a reveal.
1289
+ */
1290
+ const renderSkeletonRow = (entry: TableRow<Row>): ReactElement => {
1291
+ const isSelected = selectedIds.has(entry.id);
1292
+ const state: TableRowState<Row> = {
1293
+ ...entry,
1294
+ selected: isSelected,
1295
+ color: isSelected ? theme.hoverText : theme.text,
1296
+ };
944
1297
  return hx(
945
1298
  'box',
946
1299
  {
947
1300
  key: String(entry.id),
948
- role: 'row',
949
- 'aria-selected': selectable ? isSelected : undefined,
950
- 'aria-posinset': entry.index + 1,
951
- 'aria-setsize': ordered.length,
952
- // The index the row was drawn at travels with the node, so measuring
953
- // does not have to search the row list for it. It can go stale — the
954
- // rows may move before the tick that measures — and both this and
955
- // the height index check it rather than trust it.
956
- ref: (node: DrawnNode | null) => {
957
- if (node) rowNodes.current.set(entry.id, { node, at: entry.index });
958
- else rowNodes.current.delete(entry.id);
959
- },
960
- onClick: (ev: MouseEvent) => {
961
- // A right-click also arrives here as a click; the selection it
962
- // implies is `onContextMenu`'s to make (select-unless-selected),
963
- // not the left button's replace.
964
- if (ev.button !== 1) return;
965
- tap(entry, { ctrl: ev.ctrlKey, shift: ev.shiftKey });
966
- // Select on the first click, open on the second — the gesture
967
- // every file list has. `detail` is the click count the renderer
968
- // already counts for text selection.
969
- if (ev.detail === 2) activate(entry);
970
- },
971
- onContextMenu: onRowContextMenu
972
- ? (ev: MouseEvent) => {
973
- // The menu applies to what is under the pointer, so the row is
974
- // selected first — unless it is already part of the selection,
975
- // which a menu over "the selected files" must not collapse.
976
- if (selectable && !selectedRef.current.has(entry.id)) {
977
- tap(entry, { ctrl: false, shift: false });
978
- }
979
- onRowContextMenu(entry.id, entry.row, ev);
980
- }
981
- : undefined,
1301
+ 'aria-hidden': true,
982
1302
  style: [
983
1303
  s.row,
984
- // Declared uniform: exactly this tall, content clipped core's
985
- // row. Measured: a floor, and the row grows to whatever its
986
- // content needs; the height index reads back what it became.
987
- uniform
988
- ? { height: rowHeight, alignItems: 'center' }
989
- : { minHeight: estimate },
990
- selectable && { cursor: 'pointer' },
1304
+ // Exactly what the index believes, so the spacers and the
1305
+ // scrollbar agree with the rows on where everything is.
1306
+ { height: heights.heightAt(entry.index) },
991
1307
  {
992
1308
  backgroundColor: isSelected ? theme.hoverBackground : 'transparent',
993
- // The row's ink, said once: `color` inherits, so default cells
994
- // take it without being handed it.
995
- color,
996
1309
  },
997
- selectable && {
998
- // pressed even on the selected row: a re-press on the row that
999
- // is already current is the one click in the table that would
1000
- // otherwise look ignored
1001
- ':active': {
1002
- backgroundColor: isSelected
1003
- ? theme.accentActive
1004
- : theme.surfaceActive,
1005
- },
1006
- },
1007
- selectable &&
1008
- !isSelected && {
1009
- ':hover': { backgroundColor: theme.surfaceHover },
1010
- },
1011
1310
  typeof rowStyleProp === 'function'
1012
1311
  ? rowStyleProp(state)
1013
1312
  : rowStyleProp,
1014
1313
  ],
1015
1314
  },
1016
- renderRow ? renderRow(state, content) : content,
1315
+ // A line of "text" with no text. Width varied by index, so a band of
1316
+ // placeholders reads as rows arriving rather than a repeated tile.
1317
+ hx('box', {
1318
+ key: 'bar',
1319
+ style: [
1320
+ s.skeletonBar,
1321
+ {
1322
+ width: 96 + ((entry.index * 37) % 89),
1323
+ backgroundColor: theme.track,
1324
+ },
1325
+ ],
1326
+ }),
1017
1327
  );
1018
1328
  };
1019
1329
 
@@ -1110,6 +1420,83 @@ export function Table<Row = any>(props: TableProps<Row>): ReactElement {
1110
1420
  );
1111
1421
  });
1112
1422
 
1423
+ /**
1424
+ * The row *elements*, reused by identity while nothing they depend on has
1425
+ * changed. The memo already skips re-rendering an unchanged row, but the
1426
+ * skip still costs a `createElement` and a props compare per row per
1427
+ * notch — the burst profile put bare `createElement` at a tenth of a
1428
+ * flick's CPU. Handing React the identical element object instead takes
1429
+ * the cheapest path it has: the fiber is reused with no compare at all.
1430
+ * The cache empties whenever any shared input changes identity, and
1431
+ * per-row entries revalidate on the row object and its selection.
1432
+ */
1433
+ const rowElems = useRef(
1434
+ new Map<
1435
+ TableRowId,
1436
+ { entry: TableRow<Row>; selected: boolean; el: ReactElement }
1437
+ >(),
1438
+ );
1439
+ const rowElemDeps = useRef<readonly unknown[]>([]);
1440
+ {
1441
+ const deps = [
1442
+ columns,
1443
+ widths,
1444
+ theme,
1445
+ uniform,
1446
+ rowHeight,
1447
+ estimate,
1448
+ selectable,
1449
+ rowStyleProp,
1450
+ cellStyleProp,
1451
+ renderRow,
1452
+ Boolean(onRowContextMenu),
1453
+ tap,
1454
+ activate,
1455
+ rowMenu,
1456
+ registerRow,
1457
+ ordered.length,
1458
+ ];
1459
+ const prev = rowElemDeps.current;
1460
+ if (prev.length !== deps.length || deps.some((d, at) => d !== prev[at])) {
1461
+ rowElems.current.clear();
1462
+ rowElemDeps.current = deps;
1463
+ }
1464
+ }
1465
+
1466
+ const rowElement = (entry: TableRow<Row>): ReactElement => {
1467
+ const isSelected = selectedIds.has(entry.id);
1468
+ const cached = rowElems.current.get(entry.id);
1469
+ if (cached && cached.entry === entry && cached.selected === isSelected) {
1470
+ return cached.el;
1471
+ }
1472
+ const el = React.createElement(
1473
+ MemoTableRow as (p: TableRowViewProps<Row>) => ReactElement,
1474
+ {
1475
+ key: String(entry.id),
1476
+ entry,
1477
+ columns,
1478
+ widths,
1479
+ setSize: ordered.length,
1480
+ isSelected,
1481
+ selectable,
1482
+ uniform,
1483
+ rowHeight,
1484
+ estimate,
1485
+ theme,
1486
+ rowStyle: rowStyleProp,
1487
+ cellStyle: cellStyleProp,
1488
+ renderRow,
1489
+ hasMenu: Boolean(onRowContextMenu),
1490
+ onTap: tap,
1491
+ onOpen: activate,
1492
+ onMenu: rowMenu,
1493
+ register: registerRow,
1494
+ },
1495
+ );
1496
+ rowElems.current.set(entry.id, { entry, selected: isSelected, el });
1497
+ return el;
1498
+ };
1499
+
1113
1500
  const bodyChildren: ReactNode[] = [];
1114
1501
  if (ordered.length === 0) {
1115
1502
  if (renderEmpty) {
@@ -1126,8 +1513,14 @@ export function Table<Row = any>(props: TableProps<Row>): ReactElement {
1126
1513
  }),
1127
1514
  );
1128
1515
  }
1129
- for (let i = first; i < last; i++)
1130
- bodyChildren.push(renderOneRow(ordered[i]));
1516
+ for (let i = first; i < last; i++) {
1517
+ const entry = ordered[i];
1518
+ bodyChildren.push(
1519
+ win.skeletons.has(entry.id)
1520
+ ? renderSkeletonRow(entry)
1521
+ : rowElement(entry),
1522
+ );
1523
+ }
1131
1524
  if (virtualizing && last < ordered.length) {
1132
1525
  bodyChildren.push(
1133
1526
  hx('box', {
@@ -1136,6 +1529,87 @@ export function Table<Row = any>(props: TableProps<Row>): ReactElement {
1136
1529
  }),
1137
1530
  );
1138
1531
  }
1532
+ // Rows that left the window leave the cache too, once it has grown
1533
+ // well past the window — a scrub across a long list would otherwise
1534
+ // hold an element for every row it passed.
1535
+ if (rowElems.current.size > (last - first) * 3 + 64) {
1536
+ rowElems.current.clear();
1537
+ }
1538
+ }
1539
+
1540
+ /**
1541
+ * The fast-scroll overlay — shown only while placeholders cover enough of
1542
+ * the viewport that the user would otherwise be looking at blank rows.
1543
+ * The half-viewport threshold keeps a near-miss quiet: a scroll the next
1544
+ * frame will absorb is not worth announcing. Once up it stays until the
1545
+ * view is whole again, so it does not flicker through the catch-up.
1546
+ */
1547
+ let scrollHint: ReactNode = null;
1548
+ if (virtualizing && ordered.length > 0 && view.height > 0) {
1549
+ const vFirst = heights.indexAt(view.top);
1550
+ const vLast = Math.min(
1551
+ ordered.length - 1,
1552
+ heights.indexAt(view.top + view.height),
1553
+ );
1554
+ // Two ways in: placeholders covering enough of the viewport that it
1555
+ // would otherwise read as blank, or a scrub — the window teleporting
1556
+ // while the burst is still in flight, where every commit chases a
1557
+ // viewport that has already left and nothing useful can be on screen.
1558
+ // Either way only once the catch-up has already *lasted*: a jump the
1559
+ // next few frames absorb is not worth announcing, so the pill waits
1560
+ // out the show-delay against the catch-up clock. Latched once
1561
+ // triggered: `pending` bounces to zero between catch-up commits, and a
1562
+ // pill that blinked with it would read as a glitch. It goes when the
1563
+ // burst does.
1564
+ const engaged =
1565
+ (win.pending > 0 && win.pending * 2 >= vLast - vFirst + 1) ||
1566
+ (win.jumped && win.scrolling());
1567
+ const lasted =
1568
+ win.catchupSince !== null &&
1569
+ Date.now() - win.catchupSince >= scrollHintDelay;
1570
+ const show =
1571
+ (engaged && lasted) ||
1572
+ (hintShown.current && (win.pending > 0 || win.scrolling()));
1573
+ hintShown.current = show;
1574
+ if (show) {
1575
+ const hintState: TableScrollHintState<Row> = {
1576
+ row: ordered[vFirst],
1577
+ from: vFirst + 1,
1578
+ to: vLast + 1,
1579
+ count: ordered.length,
1580
+ pending: win.pending,
1581
+ since: win.catchupSince ?? Date.now(),
1582
+ };
1583
+ const content = renderScrollHint
1584
+ ? renderScrollHint(hintState)
1585
+ : hx(
1586
+ 'text',
1587
+ { style: { fontSize: 11, color: theme.hoverText } },
1588
+ `${hintState.from.toLocaleString()} / ${hintState.count.toLocaleString()}`,
1589
+ );
1590
+ if (content !== null && content !== undefined && content !== false) {
1591
+ scrollHint = hx(
1592
+ 'box',
1593
+ {
1594
+ key: 'scroll-hint',
1595
+ // The pill duplicates what the scrollbar already tells an
1596
+ // assistive technology, and it comes and goes with the catch-up
1597
+ // — chatter, not content.
1598
+ 'aria-hidden': true,
1599
+ style: s.scrollHintLane,
1600
+ },
1601
+ hx(
1602
+ 'box',
1603
+ {
1604
+ style: [s.scrollHint, { backgroundColor: theme.hoverBackground }],
1605
+ },
1606
+ content,
1607
+ ),
1608
+ );
1609
+ }
1610
+ }
1611
+ } else {
1612
+ hintShown.current = false;
1139
1613
  }
1140
1614
 
1141
1615
  return hx(
@@ -1185,12 +1659,12 @@ export function Table<Row = any>(props: TableProps<Row>): ReactElement {
1185
1659
  ref: body,
1186
1660
  style: s.body,
1187
1661
  onScroll: (ev) => {
1662
+ // A scroll this component did not ask for is the user taking over,
1663
+ // and an owed `scrollToRow` must not yank the list back out from
1664
+ // under them on the next layout.
1665
+ reveal.heard(ev.scrollY);
1188
1666
  setScrollX((prev) => (prev === ev.scrollX ? prev : ev.scrollX));
1189
- if (virtualizing) {
1190
- setView((prev) =>
1191
- prev.top === ev.scrollY ? prev : { ...prev, top: ev.scrollY },
1192
- );
1193
- }
1667
+ win.scrolled(ev.scrollY);
1194
1668
  onScroll?.(ev);
1195
1669
  },
1196
1670
  // Layout, not scrolling, is what first tells a table how much of it
@@ -1198,22 +1672,20 @@ export function Table<Row = any>(props: TableProps<Row>): ReactElement {
1198
1672
  // columns resolve against, so this is measured whether or not the
1199
1673
  // table virtualizes.
1200
1674
  onViewport: (ev) => {
1201
- // The ref first, at event time: the measure tick runs before the
1202
- // re-render this setState causes, and it must see this viewport.
1203
- viewRef.current = {
1204
- ...viewRef.current,
1205
- height: ev.height,
1206
- width: ev.width,
1207
- };
1208
- setView((prev) =>
1209
- prev.height === ev.height && prev.width === ev.width
1210
- ? prev
1211
- : { ...prev, height: ev.height, width: ev.width },
1212
- );
1675
+ win.sized(ev.width, ev.height);
1676
+ // The content just changed size, which is both the moment an owed
1677
+ // scroll can reach further than the clamp let it and the moment the
1678
+ // container may have re-clamped the offset without saying so. It is
1679
+ // not a moment anything can be *settled* in while rows are still
1680
+ // being measured: this runs from layout, a tick before the pass that
1681
+ // reads those rows back.
1682
+ reveal.retry(virtualizing && !uniform);
1683
+ syncScroll();
1213
1684
  onViewport?.(ev);
1214
1685
  },
1215
1686
  },
1216
1687
  hx('box', { style: [s.rowsBox, { width: total }] }, bodyChildren),
1217
1688
  ),
1689
+ scrollHint,
1218
1690
  );
1219
1691
  }