@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.
@@ -36,7 +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
+ 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';
40
42
  import { MIN_COLUMN, columnValue, orderRows, resolveGetId, resolveWidths, } from './rows.js';
41
43
  export { MIN_COLUMN, UNSIZED_MIN, columnValue, defaultCompare, orderRows, resolveGetId, resolveWidths, } from './rows.js';
42
44
  /** The header strip's height. Core's number — independent of the body's
@@ -54,16 +56,6 @@ const HALF = 3;
54
56
  const GRIP = HALF + RULE + HALF;
55
57
  /** What one Left/Right on a focused grip is worth. */
56
58
  const STEP = 16;
57
- /** Rows kept either side of the viewport, so a fast scroll does not show a
58
- * gap before the next frame catches up. */
59
- const OVERSCAN = 6;
60
- /**
61
- * What to build before the viewport has been measured. `onViewport` cannot
62
- * arrive until layout has run, which is a frame after the first commit, so
63
- * there is always one render that has to guess — and guessing "all of them"
64
- * puts a hundred thousand rows in the tree for a frame.
65
- */
66
- const ASSUMED_ROWS = 40;
67
59
  /**
68
60
  * Where `virtual="auto"` starts virtualizing.
69
61
  *
@@ -124,6 +116,37 @@ const s = createStyles({
124
116
  },
125
117
  cellText: { fontSize: 12, textWrap: 'nowrap', textBoxTrim: 'cap-alphabetic' },
126
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
+ },
127
150
  sortMark: { marginStart: 4 },
128
151
  empty: {
129
152
  flexGrow: 1,
@@ -133,6 +156,97 @@ const s = createStyles({
133
156
  },
134
157
  });
135
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);
136
250
  /**
137
251
  * `<Table columns rows />` — a data table with a header that stays put.
138
252
  *
@@ -165,7 +279,7 @@ const EMPTY_SET = new Set();
165
279
  * the same rows a sighted user can see.
166
280
  */
167
281
  export function Table(props) {
168
- 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,
169
283
  // ours to chain rather than to hand over: virtualization and the sticky
170
284
  // header are measured through both of these
171
285
  onScroll, onViewport, ...boxProps } = props;
@@ -198,7 +312,6 @@ export function Table(props) {
198
312
  const [cursorId, setCursorId] = useState(null);
199
313
  const [userWidths, setUserWidths] = useState({});
200
314
  const [scrollX, setScrollX] = useState(0);
201
- const [view, setView] = useState({ top: 0, height: 0, width: 0 });
202
315
  // Bumped by a measurement pass that found a row taller or shorter than the
203
316
  // index believed. It is the only reason the component re-renders for a
204
317
  // measurement, and a pass that finds nothing new does not bump it — which
@@ -234,8 +347,6 @@ export function Table(props) {
234
347
  selectedRef.current = selectedIds;
235
348
  const cursorRef = useRef(cursor);
236
349
  cursorRef.current = cursor;
237
- const viewRef = useRef(view);
238
- viewRef.current = view;
239
350
  /** Where a Shift range grows from — the last plain click or plain step. */
240
351
  const anchorRef = useRef(null);
241
352
  const root = useRef(null);
@@ -244,6 +355,9 @@ export function Table(props) {
244
355
  * measurement pass does not have to search the row list for it. */
245
356
  const rowNodes = useRef(new Map());
246
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);
247
361
  const userWidthsRef = useRef(userWidths);
248
362
  userWidthsRef.current = userWidths;
249
363
  /** Declared uniform: divide, never measure — core's model. */
@@ -259,26 +373,54 @@ export function Table(props) {
259
373
  heights.sync(ordered, estimate);
260
374
  const virtualizing = virtual === true ||
261
375
  (virtual === 'auto' && ordered.length > VIRTUAL_THRESHOLD);
262
- // The slice worth building: what is on screen, plus a little either side.
263
- const first = virtualizing
264
- ? Math.max(0, heights.indexAt(view.top) - overscan)
265
- : 0;
266
- let last = ordered.length;
267
- if (virtualizing) {
268
- if (view.height > 0) {
269
- last = Math.min(ordered.length, heights.indexAt(view.top + view.height) + 1 + overscan);
270
- }
271
- else {
272
- last = Math.min(ordered.length, first + ASSUMED_ROWS);
273
- }
274
- }
275
- /** Where the slice starts, and how much of the list is below it — the two
276
- * spacers that keep the scrollbar measuring the whole table. */
277
- const above = virtualizing ? heights.offsetAt(first) : 0;
278
- 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;
279
394
  /** Columns resolve to pixels once, at the table level, per (columns,
280
395
  * viewport, resizes) — every row agrees on the grid by construction. */
281
396
  const { widths, total } = useMemo(() => resolveWidths(columns, userWidths, view.width), [columns, userWidths, view.width]);
397
+ /**
398
+ * The scroll the table owes a row, and the pane's real offset read back
399
+ * after every layout — the two halves of `../internal/scroll.ts`, which
400
+ * says why a reveal cannot be a one-shot and why `onScroll` is not the
401
+ * whole story.
402
+ */
403
+ const reveal = useReveal({
404
+ box: body,
405
+ rows: orderedRef,
406
+ nodes: rowNodes,
407
+ heights,
408
+ });
409
+ /**
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.
414
+ */
415
+ const winSync = win.sync;
416
+ const syncScroll = useCallback(() => {
417
+ const box = body.current;
418
+ if (!box)
419
+ return;
420
+ const x = box.scrollX;
421
+ setScrollX((prev) => (prev === x ? prev : x));
422
+ winSync();
423
+ }, [winSync]);
282
424
  /**
283
425
  * Read back what the rows on screen actually laid out at.
284
426
  *
@@ -291,12 +433,12 @@ export function Table(props) {
291
433
  */
292
434
  const measureRows = useCallback(() => {
293
435
  if (uniform || !virtualizing)
294
- return;
436
+ return false;
295
437
  // Before the first `onViewport` the flex columns sit on their floors and
296
438
  // every row is laid out against a width that is about to change — there
297
439
  // is nothing honest to measure yet.
298
440
  if (viewRef.current.width <= 0)
299
- return;
441
+ return false;
300
442
  // A row laid out at a width the columns no longer resolve to is a
301
443
  // measurement of the wrong table, and it must not be recorded — a row
302
444
  // that scrolls out before the corrected pass would keep a wrong-width
@@ -325,46 +467,100 @@ export function Table(props) {
325
467
  shift += height - was;
326
468
  }
327
469
  if (!changed)
328
- return;
329
- if (shift !== 0 && box) {
330
- box.scrollTo({ y: Math.max(0, box.scrollY + shift) });
331
- }
470
+ return false;
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);
332
475
  setMeasured((n) => n + 1);
476
+ return true;
333
477
  // eslint-disable-next-line react-hooks/exhaustive-deps -- `heights` is a
334
478
  // stable instance
335
479
  }, [uniform, virtualizing]);
336
- useEffect(() => {
337
- if (uniform || !virtualizing)
338
- return undefined;
339
- const id = afterLayout(measureRows);
340
- return () => cancelAfterLayout(id);
341
- });
342
480
  /**
343
- * Put a row in view. A mounted row can say where it is, and
344
- * `scrollIntoView` then works whatever height it turned out to be; a row
345
- * that is not mounted has no geometry to ask, so the height index answers
346
- * instead.
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.
347
486
  */
348
- const reveal = useCallback((at) => {
487
+ const adaptEstimate = useCallback(() => {
488
+ if (uniform || !virtualizing)
489
+ return false;
349
490
  const box = body.current;
350
- const row = orderedRef.current[at];
351
- if (!box || !row)
352
- return;
353
- const drawn = rowNodes.current.get(row.id);
354
- if (drawn) {
355
- box.scrollIntoView(drawn.node);
356
- return;
357
- }
358
- const top = heights.offsetAt(at);
359
- const rowH = heights.heightAt(at);
360
- const height = viewRef.current.height;
361
- if (top < box.scrollY)
362
- box.scrollTo({ y: top });
363
- else if (height > 0 && top + rowH > box.scrollY + height) {
364
- box.scrollTo({ y: top + rowH - height });
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]);
503
+ /**
504
+ * The one tick after layout, and everything that can only be known there:
505
+ * what the rows measured, whether an owed scroll can go further now that
506
+ * the new rows are laid out, and where the body actually ended up. In that
507
+ * order — each step can move the offset the next one reads.
508
+ *
509
+ * Scheduled for every render a virtualized table makes, because every one
510
+ * of them can move the offset its next slice is built from. A whole table
511
+ * needs none of it: `onViewport` is when its content can have been
512
+ * re-clamped, and it rebuilds no slice anyway.
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;
365
523
  }
366
- // eslint-disable-next-line react-hooks/exhaustive-deps
367
- }, []);
524
+ return false;
525
+ }, [uniform]);
526
+ useEffect(() => {
527
+ if (!virtualizing)
528
+ return undefined;
529
+ let look = null;
530
+ let tries = 0;
531
+ const pass = () => {
532
+ // `measureRows` first, and its answer handed on: a pass that moved the
533
+ // heights has not settled anything, and an owed scroll judged against
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);
541
+ syncScroll();
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
+ };
555
+ });
556
+ /** Put a row in view, by the index its call site already has. */
557
+ const revealAt = useCallback((at) => {
558
+ const row = orderedRef.current[at];
559
+ if (row)
560
+ reveal.to(row.id);
561
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- `reveal` is a
562
+ // stable handle
563
+ }, [reveal]);
368
564
  const commitSingle = useCallback((row) => {
369
565
  cursorRef.current = row.id;
370
566
  if (selected === undefined)
@@ -389,7 +585,7 @@ export function Table(props) {
389
585
  return;
390
586
  if (selectionMode === 'single') {
391
587
  commitSingle(row);
392
- reveal(row.index);
588
+ revealAt(row.index);
393
589
  return;
394
590
  }
395
591
  cursorRef.current = row.id;
@@ -417,8 +613,8 @@ export function Table(props) {
417
613
  anchorRef.current = row.id;
418
614
  commitMulti([row.id], { type: 'replace', id: row.id, row: row.row });
419
615
  }
420
- reveal(row.index);
421
- }, [selectionMode, commitSingle, commitMulti, reveal]);
616
+ revealAt(row.index);
617
+ }, [selectionMode, commitSingle, commitMulti, revealAt]);
422
618
  const activate = useCallback((row) => {
423
619
  onActivate?.(row.id, row.row);
424
620
  }, [onActivate]);
@@ -573,120 +769,79 @@ export function Table(props) {
573
769
  const row = orderedRef.current.find((r) => r.id === id);
574
770
  if (!row)
575
771
  return false;
576
- reveal(row.index);
772
+ revealAt(row.index);
577
773
  return true;
578
774
  },
579
775
  handleKey,
580
776
  rows: () => orderedRef.current,
581
- }), [tap, handleKey, reveal, selectionMode, selected]);
777
+ }), [tap, handleKey, revealAt, selectionMode, selected]);
582
778
  // --- rendering -----------------------------------------------------------
583
779
  const rowStyleProp = styles?.row;
584
780
  const cellStyleProp = styles?.cell;
585
781
  const headerCellStyleProp = styles?.headerCell;
586
782
  const selectable = selectionMode !== 'none';
587
- 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) => {
588
811
  const isSelected = selectedIds.has(entry.id);
589
- const color = isSelected ? theme.hoverText : theme.text;
590
- const state = { ...entry, selected: isSelected, color };
591
- const content = columns.map((column, at) => {
592
- const cellState = { ...state, column };
593
- return hx('box', {
594
- key: column.id,
595
- role: 'cell',
596
- style: [
597
- s.cell,
598
- { width: widths[at] },
599
- uniform && { height: rowHeight },
600
- column.align === 'end' && {
601
- alignItems: 'flex-end',
602
- paddingEnd: 8,
603
- },
604
- column.align === 'center' && { alignItems: 'center' },
605
- typeof cellStyleProp === 'function'
606
- ? cellStyleProp(cellState)
607
- : cellStyleProp,
608
- ],
609
- }, column.render
610
- ? // A cell that draws itself still has to know it is on the
611
- // selected row — see the doc comment. Keyed by the component,
612
- // the way every seam's return is.
613
- React.createElement(React.Fragment, { key: 'content' }, column.render(entry.row, cellState))
614
- : hx('text', { style: [s.cellText, { color }] }, String(columnValue(entry.row, column) ?? '')));
615
- });
812
+ const state = {
813
+ ...entry,
814
+ selected: isSelected,
815
+ color: isSelected ? theme.hoverText : theme.text,
816
+ };
616
817
  return hx('box', {
617
818
  key: String(entry.id),
618
- role: 'row',
619
- 'aria-selected': selectable ? isSelected : undefined,
620
- 'aria-posinset': entry.index + 1,
621
- 'aria-setsize': ordered.length,
622
- // The index the row was drawn at travels with the node, so measuring
623
- // does not have to search the row list for it. It can go stale — the
624
- // rows may move before the tick that measures — and both this and
625
- // the height index check it rather than trust it.
626
- ref: (node) => {
627
- if (node)
628
- rowNodes.current.set(entry.id, { node, at: entry.index });
629
- else
630
- rowNodes.current.delete(entry.id);
631
- },
632
- onClick: (ev) => {
633
- // A right-click also arrives here as a click; the selection it
634
- // implies is `onContextMenu`'s to make (select-unless-selected),
635
- // not the left button's replace.
636
- if (ev.button !== 1)
637
- return;
638
- tap(entry, { ctrl: ev.ctrlKey, shift: ev.shiftKey });
639
- // Select on the first click, open on the second — the gesture
640
- // every file list has. `detail` is the click count the renderer
641
- // already counts for text selection.
642
- if (ev.detail === 2)
643
- activate(entry);
644
- },
645
- onContextMenu: onRowContextMenu
646
- ? (ev) => {
647
- // The menu applies to what is under the pointer, so the row is
648
- // selected first — unless it is already part of the selection,
649
- // which a menu over "the selected files" must not collapse.
650
- if (selectable && !selectedRef.current.has(entry.id)) {
651
- tap(entry, { ctrl: false, shift: false });
652
- }
653
- onRowContextMenu(entry.id, entry.row, ev);
654
- }
655
- : undefined,
819
+ 'aria-hidden': true,
656
820
  style: [
657
821
  s.row,
658
- // Declared uniform: exactly this tall, content clipped core's
659
- // row. Measured: a floor, and the row grows to whatever its
660
- // content needs; the height index reads back what it became.
661
- uniform
662
- ? { height: rowHeight, alignItems: 'center' }
663
- : { minHeight: estimate },
664
- 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) },
665
825
  {
666
826
  backgroundColor: isSelected ? theme.hoverBackground : 'transparent',
667
- // The row's ink, said once: `color` inherits, so default cells
668
- // take it without being handed it.
669
- color,
670
- },
671
- selectable && {
672
- // pressed even on the selected row: a re-press on the row that
673
- // is already current is the one click in the table that would
674
- // otherwise look ignored
675
- ':active': {
676
- backgroundColor: isSelected
677
- ? theme.accentActive
678
- : theme.surfaceActive,
679
- },
680
- },
681
- selectable &&
682
- !isSelected && {
683
- ':hover': { backgroundColor: theme.surfaceHover },
684
827
  },
685
828
  typeof rowStyleProp === 'function'
686
829
  ? rowStyleProp(state)
687
830
  : rowStyleProp,
688
831
  ],
689
- }, 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
+ }));
690
845
  };
691
846
  const headerCells = columns.map((column, at) => {
692
847
  const canSort = column.sortable !== false;
@@ -759,6 +914,73 @@ export function Table(props) {
759
914
  ],
760
915
  }, hx('box', { style: [s.rule, { backgroundColor: theme.border }] })));
761
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
+ };
762
984
  const bodyChildren = [];
763
985
  if (ordered.length === 0) {
764
986
  if (renderEmpty) {
@@ -772,14 +994,81 @@ export function Table(props) {
772
994
  style: [s.spacer, { height: above }],
773
995
  }));
774
996
  }
775
- for (let i = first; i < last; i++)
776
- 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
+ }
777
1003
  if (virtualizing && last < ordered.length) {
778
1004
  bodyChildren.push(hx('box', {
779
1005
  key: 'spacer:after',
780
1006
  style: [s.spacer, { height: below }],
781
1007
  }));
782
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;
783
1072
  }
784
1073
  return hx('box', {
785
1074
  theme,
@@ -816,10 +1105,12 @@ export function Table(props) {
816
1105
  ref: body,
817
1106
  style: s.body,
818
1107
  onScroll: (ev) => {
1108
+ // A scroll this component did not ask for is the user taking over,
1109
+ // and an owed `scrollToRow` must not yank the list back out from
1110
+ // under them on the next layout.
1111
+ reveal.heard(ev.scrollY);
819
1112
  setScrollX((prev) => (prev === ev.scrollX ? prev : ev.scrollX));
820
- if (virtualizing) {
821
- setView((prev) => prev.top === ev.scrollY ? prev : { ...prev, top: ev.scrollY });
822
- }
1113
+ win.scrolled(ev.scrollY);
823
1114
  onScroll?.(ev);
824
1115
  },
825
1116
  // Layout, not scrolling, is what first tells a table how much of it
@@ -827,18 +1118,17 @@ export function Table(props) {
827
1118
  // columns resolve against, so this is measured whether or not the
828
1119
  // table virtualizes.
829
1120
  onViewport: (ev) => {
830
- // The ref first, at event time: the measure tick runs before the
831
- // re-render this setState causes, and it must see this viewport.
832
- viewRef.current = {
833
- ...viewRef.current,
834
- height: ev.height,
835
- width: ev.width,
836
- };
837
- setView((prev) => prev.height === ev.height && prev.width === ev.width
838
- ? prev
839
- : { ...prev, height: ev.height, width: ev.width });
1121
+ win.sized(ev.width, ev.height);
1122
+ // The content just changed size, which is both the moment an owed
1123
+ // scroll can reach further than the clamp let it and the moment the
1124
+ // container may have re-clamped the offset without saying so. It is
1125
+ // not a moment anything can be *settled* in while rows are still
1126
+ // being measured: this runs from layout, a tick before the pass that
1127
+ // reads those rows back.
1128
+ reveal.retry(virtualizing && !uniform);
1129
+ syncScroll();
840
1130
  onViewport?.(ev);
841
1131
  },
842
- }, hx('box', { style: [s.rowsBox, { width: total }] }, bodyChildren)));
1132
+ }, hx('box', { style: [s.rowsBox, { width: total }] }, bodyChildren)), scrollHint);
843
1133
  }
844
1134
  //# sourceMappingURL=index.js.map