@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.
@@ -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,8 +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';
67
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';
68
84
  import {
69
85
  MIN_COLUMN,
70
86
  columnValue,
@@ -120,16 +136,6 @@ const HALF = 3;
120
136
  const GRIP = HALF + RULE + HALF;
121
137
  /** What one Left/Right on a focused grip is worth. */
122
138
  const STEP = 16;
123
- /** Rows kept either side of the viewport, so a fast scroll does not show a
124
- * gap before the next frame catches up. */
125
- const OVERSCAN = 6;
126
- /**
127
- * What to build before the viewport has been measured. `onViewport` cannot
128
- * arrive until layout has run, which is a frame after the first commit, so
129
- * there is always one render that has to guess — and guessing "all of them"
130
- * puts a hundred thousand rows in the tree for a frame.
131
- */
132
- const ASSUMED_ROWS = 40;
133
139
  /**
134
140
  * Where `virtual="auto"` starts virtualizing.
135
141
  *
@@ -191,6 +197,37 @@ const s = createStyles({
191
197
  },
192
198
  cellText: { fontSize: 12, textWrap: 'nowrap', textBoxTrim: 'cap-alphabetic' },
193
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
+ },
194
231
  sortMark: { marginStart: 4 },
195
232
  empty: {
196
233
  flexGrow: 1,
@@ -200,6 +237,29 @@ const s = createStyles({
200
237
  },
201
238
  });
202
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
+
203
263
  /** The selection that just changed, alongside the whole set. `id`/`row` name
204
264
  * the row the gesture landed on; a select-all has no single row to name. */
205
265
  export interface TableSelectChange<Row> {
@@ -283,14 +343,25 @@ interface TableBaseProps<Row> extends Omit<
283
343
  */
284
344
  rowHeight?: number;
285
345
  /** What an unmeasured row is assumed — and floored — at, while measuring.
286
- * Default 24. The scrollbar is this guess for every row not yet seen, and
287
- * 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. */
288
350
  estimatedRowHeight?: number;
289
351
  /** Build only the rows on screen. `'auto'` (the default) turns it on past
290
352
  * 200 rows. */
291
353
  virtual?: boolean | 'auto';
292
354
  /** Rows built either side of the viewport. */
293
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;
294
365
 
295
366
  /**
296
367
  * Everything inside the row box, given what would have been there.
@@ -303,6 +374,40 @@ interface TableBaseProps<Row> extends Omit<
303
374
  /** The body's content when the rows resolve empty. Nothing by default —
304
375
  * the header still shows. */
305
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 };
306
411
 
307
412
  styles?: TableStyles<Row>;
308
413
  style?: StyleProp;
@@ -367,6 +472,172 @@ interface TableAllProps<Row> extends TableBaseProps<Row> {
367
472
 
368
473
  const EMPTY_SET: ReadonlySet<TableRowId> = new Set();
369
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
+
370
641
  /**
371
642
  * `<Table columns rows />` — a data table with a header that stays put.
372
643
  *
@@ -413,9 +684,13 @@ export function Table<Row = any>(props: TableProps<Row>): ReactElement {
413
684
  rowHeight,
414
685
  estimatedRowHeight,
415
686
  virtual = 'auto',
416
- overscan = OVERSCAN,
687
+ overscan = DEFAULT_OVERSCAN,
688
+ prefetch = DEFAULT_PREFETCH,
417
689
  renderRow,
418
690
  renderEmpty,
691
+ renderScrollHint,
692
+ scrollHintDelay = SCROLL_HINT_DELAY_MS,
693
+ catchup,
419
694
  styles,
420
695
  style,
421
696
  focusable = true,
@@ -473,7 +748,6 @@ export function Table<Row = any>(props: TableProps<Row>): ReactElement {
473
748
  Readonly<Record<string, number>>
474
749
  >({});
475
750
  const [scrollX, setScrollX] = useState(0);
476
- const [view, setView] = useState({ top: 0, height: 0, width: 0 });
477
751
  // Bumped by a measurement pass that found a row taller or shorter than the
478
752
  // index believed. It is the only reason the component re-renders for a
479
753
  // measurement, and a pass that finds nothing new does not bump it — which
@@ -519,8 +793,6 @@ export function Table<Row = any>(props: TableProps<Row>): ReactElement {
519
793
  selectedRef.current = selectedIds;
520
794
  const cursorRef = useRef(cursor);
521
795
  cursorRef.current = cursor;
522
- const viewRef = useRef(view);
523
- viewRef.current = view;
524
796
  /** Where a Shift range grows from — the last plain click or plain step. */
525
797
  const anchorRef = useRef<TableRowId | null>(null);
526
798
 
@@ -532,6 +804,9 @@ export function Table<Row = any>(props: TableProps<Row>): ReactElement {
532
804
  new Map<TableRowId, { node: DrawnNode; at: number }>(),
533
805
  );
534
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);
535
810
  const userWidthsRef = useRef(userWidths);
536
811
  userWidthsRef.current = userWidths;
537
812
 
@@ -551,25 +826,24 @@ export function Table<Row = any>(props: TableProps<Row>): ReactElement {
551
826
  virtual === true ||
552
827
  (virtual === 'auto' && ordered.length > VIRTUAL_THRESHOLD);
553
828
 
554
- // The slice worth building: what is on screen, plus a little either side.
555
- const first = virtualizing
556
- ? Math.max(0, heights.indexAt(view.top) - overscan)
557
- : 0;
558
- let last = ordered.length;
559
- if (virtualizing) {
560
- if (view.height > 0) {
561
- last = Math.min(
562
- ordered.length,
563
- heights.indexAt(view.top + view.height) + 1 + overscan,
564
- );
565
- } else {
566
- last = Math.min(ordered.length, first + ASSUMED_ROWS);
567
- }
568
- }
569
- /** Where the slice starts, and how much of the list is below it — the two
570
- * spacers that keep the scrollbar measuring the whole table. */
571
- const above = virtualizing ? heights.offsetAt(first) : 0;
572
- 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;
573
847
 
574
848
  /** Columns resolve to pixels once, at the table level, per (columns,
575
849
  * viewport, resizes) — every row agrees on the grid by construction. */
@@ -592,25 +866,19 @@ export function Table<Row = any>(props: TableProps<Row>): ReactElement {
592
866
  });
593
867
 
594
868
  /**
595
- * Re-read the offset the body is *actually* at.
596
- *
597
- * The pane moves silently it resolves a queued reveal during layout, and
598
- * re-clamps an offset the content outgrew or outshrank and a slice built
599
- * from the offset before those is drawn where the viewport is not: a blank
600
- * band where the rows should be, and no way back until a scroll of your own
601
- * re-syncs it by accident.
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.
602
873
  */
874
+ const winSync = win.sync;
603
875
  const syncScroll = useCallback((): void => {
604
876
  const box = body.current;
605
877
  if (!box) return;
606
- const { scrollX: x, scrollY: y } = box;
878
+ const x = box.scrollX;
607
879
  setScrollX((prev) => (prev === x ? prev : x));
608
- // Only a virtualizing table reads the vertical offset — a whole one has
609
- // no slice to rebuild, and re-rendering it on a scroll it already drew
610
- // would be work for nothing.
611
- if (virtualizing)
612
- setView((prev) => (prev.top === y ? prev : { ...prev, top: y }));
613
- }, [virtualizing]);
880
+ winSync();
881
+ }, [winSync]);
614
882
 
615
883
  /**
616
884
  * Read back what the rows on screen actually laid out at.
@@ -656,15 +924,37 @@ export function Table<Row = any>(props: TableProps<Row>): ReactElement {
656
924
  if (at < anchor) shift += height - was;
657
925
  }
658
926
  if (!changed) return false;
659
- if (shift !== 0 && box) {
660
- reveal.scrollTo(box.scrollY + shift);
661
- }
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);
662
931
  setMeasured((n) => n + 1);
663
932
  return true;
664
933
  // eslint-disable-next-line react-hooks/exhaustive-deps -- `heights` is a
665
934
  // stable instance
666
935
  }, [uniform, virtualizing]);
667
936
 
937
+ /**
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.
943
+ */
944
+ const adaptEstimate = useCallback((): boolean => {
945
+ if (uniform || !virtualizing) return false;
946
+ const box = body.current;
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
+
668
958
  /**
669
959
  * The one tick after layout, and everything that can only be known there:
670
960
  * what the rows measured, whether an owed scroll can go further now that
@@ -676,16 +966,44 @@ export function Table<Row = any>(props: TableProps<Row>): ReactElement {
676
966
  * needs none of it: `onViewport` is when its content can have been
677
967
  * re-clamped, and it rebuilds no slice anyway.
678
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;
976
+ }
977
+ return false;
978
+ }, [uniform]);
979
+
679
980
  useEffect(() => {
680
981
  if (!virtualizing) return undefined;
681
- const id = afterLayout(() => {
982
+ let look: DelayTick = null;
983
+ let tries = 0;
984
+ const pass = (): void => {
682
985
  // `measureRows` first, and its answer handed on: a pass that moved the
683
986
  // heights has not settled anything, and an owed scroll judged against
684
- // the layout it is about to invalidate is not owed any less.
685
- reveal.retry(measureRows());
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);
686
994
  syncScroll();
687
- });
688
- return () => cancelAfterLayout(id);
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
+ };
689
1007
  });
690
1008
 
691
1009
  /** Put a row in view, by the index its call site already has. */
@@ -937,122 +1255,75 @@ export function Table<Row = any>(props: TableProps<Row>): ReactElement {
937
1255
  const headerCellStyleProp = styles?.headerCell;
938
1256
  const selectable = selectionMode !== 'none';
939
1257
 
940
- const renderOneRow = (entry: TableRow<Row>): ReactElement => {
941
- const isSelected = selectedIds.has(entry.id);
942
- const color = isSelected ? theme.hoverText : theme.text;
943
- const state: TableRowState<Row> = { ...entry, selected: isSelected, color };
944
-
945
- const content: ReactNode[] = columns.map((column, at) => {
946
- const cellState: TableCellState<Row> = { ...state, column };
947
- return hx(
948
- 'box',
949
- {
950
- key: column.id,
951
- role: 'cell',
952
- style: [
953
- s.cell,
954
- { width: widths[at] },
955
- uniform && { height: rowHeight },
956
- column.align === 'end' && {
957
- alignItems: 'flex-end',
958
- paddingEnd: 8,
959
- },
960
- column.align === 'center' && { alignItems: 'center' },
961
- typeof cellStyleProp === 'function'
962
- ? cellStyleProp(cellState)
963
- : cellStyleProp,
964
- ],
965
- },
966
- column.render
967
- ? // A cell that draws itself still has to know it is on the
968
- // selected row — see the doc comment. Keyed by the component,
969
- // the way every seam's return is.
970
- React.createElement(
971
- React.Fragment,
972
- { key: 'content' },
973
- column.render(entry.row, cellState),
974
- )
975
- : hx(
976
- 'text',
977
- { style: [s.cellText, { color }] },
978
- String(columnValue(entry.row, column) ?? ''),
979
- ),
980
- );
981
- });
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
+ );
982
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
+ };
983
1297
  return hx(
984
1298
  'box',
985
1299
  {
986
1300
  key: String(entry.id),
987
- role: 'row',
988
- 'aria-selected': selectable ? isSelected : undefined,
989
- 'aria-posinset': entry.index + 1,
990
- 'aria-setsize': ordered.length,
991
- // The index the row was drawn at travels with the node, so measuring
992
- // does not have to search the row list for it. It can go stale — the
993
- // rows may move before the tick that measures — and both this and
994
- // the height index check it rather than trust it.
995
- ref: (node: DrawnNode | null) => {
996
- if (node) rowNodes.current.set(entry.id, { node, at: entry.index });
997
- else rowNodes.current.delete(entry.id);
998
- },
999
- onClick: (ev: MouseEvent) => {
1000
- // A right-click also arrives here as a click; the selection it
1001
- // implies is `onContextMenu`'s to make (select-unless-selected),
1002
- // not the left button's replace.
1003
- if (ev.button !== 1) return;
1004
- tap(entry, { ctrl: ev.ctrlKey, shift: ev.shiftKey });
1005
- // Select on the first click, open on the second — the gesture
1006
- // every file list has. `detail` is the click count the renderer
1007
- // already counts for text selection.
1008
- if (ev.detail === 2) activate(entry);
1009
- },
1010
- onContextMenu: onRowContextMenu
1011
- ? (ev: MouseEvent) => {
1012
- // The menu applies to what is under the pointer, so the row is
1013
- // selected first — unless it is already part of the selection,
1014
- // which a menu over "the selected files" must not collapse.
1015
- if (selectable && !selectedRef.current.has(entry.id)) {
1016
- tap(entry, { ctrl: false, shift: false });
1017
- }
1018
- onRowContextMenu(entry.id, entry.row, ev);
1019
- }
1020
- : undefined,
1301
+ 'aria-hidden': true,
1021
1302
  style: [
1022
1303
  s.row,
1023
- // Declared uniform: exactly this tall, content clipped core's
1024
- // row. Measured: a floor, and the row grows to whatever its
1025
- // content needs; the height index reads back what it became.
1026
- uniform
1027
- ? { height: rowHeight, alignItems: 'center' }
1028
- : { minHeight: estimate },
1029
- 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) },
1030
1307
  {
1031
1308
  backgroundColor: isSelected ? theme.hoverBackground : 'transparent',
1032
- // The row's ink, said once: `color` inherits, so default cells
1033
- // take it without being handed it.
1034
- color,
1035
- },
1036
- selectable && {
1037
- // pressed even on the selected row: a re-press on the row that
1038
- // is already current is the one click in the table that would
1039
- // otherwise look ignored
1040
- ':active': {
1041
- backgroundColor: isSelected
1042
- ? theme.accentActive
1043
- : theme.surfaceActive,
1044
- },
1045
1309
  },
1046
- selectable &&
1047
- !isSelected && {
1048
- ':hover': { backgroundColor: theme.surfaceHover },
1049
- },
1050
1310
  typeof rowStyleProp === 'function'
1051
1311
  ? rowStyleProp(state)
1052
1312
  : rowStyleProp,
1053
1313
  ],
1054
1314
  },
1055
- 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
+ }),
1056
1327
  );
1057
1328
  };
1058
1329
 
@@ -1149,6 +1420,83 @@ export function Table<Row = any>(props: TableProps<Row>): ReactElement {
1149
1420
  );
1150
1421
  });
1151
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
+
1152
1500
  const bodyChildren: ReactNode[] = [];
1153
1501
  if (ordered.length === 0) {
1154
1502
  if (renderEmpty) {
@@ -1165,8 +1513,14 @@ export function Table<Row = any>(props: TableProps<Row>): ReactElement {
1165
1513
  }),
1166
1514
  );
1167
1515
  }
1168
- for (let i = first; i < last; i++)
1169
- 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
+ }
1170
1524
  if (virtualizing && last < ordered.length) {
1171
1525
  bodyChildren.push(
1172
1526
  hx('box', {
@@ -1175,6 +1529,87 @@ export function Table<Row = any>(props: TableProps<Row>): ReactElement {
1175
1529
  }),
1176
1530
  );
1177
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;
1178
1613
  }
1179
1614
 
1180
1615
  return hx(
@@ -1229,11 +1664,7 @@ export function Table<Row = any>(props: TableProps<Row>): ReactElement {
1229
1664
  // under them on the next layout.
1230
1665
  reveal.heard(ev.scrollY);
1231
1666
  setScrollX((prev) => (prev === ev.scrollX ? prev : ev.scrollX));
1232
- if (virtualizing) {
1233
- setView((prev) =>
1234
- prev.top === ev.scrollY ? prev : { ...prev, top: ev.scrollY },
1235
- );
1236
- }
1667
+ win.scrolled(ev.scrollY);
1237
1668
  onScroll?.(ev);
1238
1669
  },
1239
1670
  // Layout, not scrolling, is what first tells a table how much of it
@@ -1241,18 +1672,7 @@ export function Table<Row = any>(props: TableProps<Row>): ReactElement {
1241
1672
  // columns resolve against, so this is measured whether or not the
1242
1673
  // table virtualizes.
1243
1674
  onViewport: (ev) => {
1244
- // The ref first, at event time: the measure tick runs before the
1245
- // re-render this setState causes, and it must see this viewport.
1246
- viewRef.current = {
1247
- ...viewRef.current,
1248
- height: ev.height,
1249
- width: ev.width,
1250
- };
1251
- setView((prev) =>
1252
- prev.height === ev.height && prev.width === ev.width
1253
- ? prev
1254
- : { ...prev, height: ev.height, width: ev.width },
1255
- );
1675
+ win.sized(ev.width, ev.height);
1256
1676
  // The content just changed size, which is both the moment an owed
1257
1677
  // scroll can reach further than the clamp let it and the moment the
1258
1678
  // container may have re-clamped the offset without saying so. It is
@@ -1266,5 +1686,6 @@ export function Table<Row = any>(props: TableProps<Row>): ReactElement {
1266
1686
  },
1267
1687
  hx('box', { style: [s.rowsBox, { width: total }] }, bodyChildren),
1268
1688
  ),
1689
+ scrollHint,
1269
1690
  );
1270
1691
  }