@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.
- package/dist/internal/heights.d.ts +33 -1
- package/dist/internal/heights.d.ts.map +1 -1
- package/dist/internal/heights.js +72 -11
- package/dist/internal/heights.js.map +1 -1
- package/dist/internal/scroll.d.ts +73 -0
- package/dist/internal/scroll.d.ts.map +1 -0
- package/dist/internal/scroll.js +264 -0
- package/dist/internal/scroll.js.map +1 -0
- package/dist/internal/timers.d.ts +8 -0
- package/dist/internal/timers.d.ts.map +1 -1
- package/dist/internal/timers.js +10 -0
- package/dist/internal/timers.js.map +1 -1
- package/dist/internal/window.d.ts +161 -0
- package/dist/internal/window.d.ts.map +1 -0
- package/dist/internal/window.js +417 -0
- package/dist/internal/window.js.map +1 -0
- package/dist/table/index.d.ts +73 -2
- package/dist/table/index.d.ts.map +1 -1
- package/dist/table/index.js +468 -178
- package/dist/table/index.js.map +1 -1
- package/dist/tree/index.d.ts +65 -5
- package/dist/tree/index.d.ts.map +1 -1
- package/dist/tree/index.js +494 -202
- package/dist/tree/index.js.map +1 -1
- package/package.json +1 -1
- package/src/internal/heights.ts +73 -11
- package/src/internal/scroll.ts +344 -0
- package/src/internal/timers.ts +13 -0
- package/src/internal/window.ts +570 -0
- package/src/table/index.ts +666 -194
- package/src/tree/index.ts +730 -263
package/src/table/index.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
286
|
-
*
|
|
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 =
|
|
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
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
:
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
const above
|
|
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(():
|
|
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
|
-
|
|
625
|
-
|
|
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
|
-
*
|
|
640
|
-
*
|
|
641
|
-
*
|
|
642
|
-
*
|
|
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
|
|
944
|
+
const adaptEstimate = useCallback((): boolean => {
|
|
945
|
+
if (uniform || !virtualizing) return false;
|
|
645
946
|
const box = body.current;
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
const
|
|
649
|
-
if (
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1076
|
+
revealAt(row.index);
|
|
720
1077
|
},
|
|
721
|
-
[selectionMode, commitSingle, commitMulti,
|
|
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
|
-
|
|
1242
|
+
revealAt(row.index);
|
|
886
1243
|
return true;
|
|
887
1244
|
},
|
|
888
1245
|
handleKey,
|
|
889
1246
|
rows: () => orderedRef.current,
|
|
890
1247
|
}),
|
|
891
|
-
[tap, handleKey,
|
|
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
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
985
|
-
//
|
|
986
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1202
|
-
//
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
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
|
}
|