@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/tree/index.ts
CHANGED
|
@@ -40,7 +40,12 @@ import type { ReactElement, ReactNode, Ref } from 'react';
|
|
|
40
40
|
import { createStyles } from 'react-x11/style';
|
|
41
41
|
import type { StyleProp } from 'react-x11';
|
|
42
42
|
import { Icon, useDirection, useTheme } from 'react-x11';
|
|
43
|
-
import type {
|
|
43
|
+
import type {
|
|
44
|
+
DrawnNode,
|
|
45
|
+
KeyboardEvent,
|
|
46
|
+
ScrollableNode,
|
|
47
|
+
Theme,
|
|
48
|
+
} from 'react-x11';
|
|
44
49
|
import {
|
|
45
50
|
XK_DOWN,
|
|
46
51
|
XK_END,
|
|
@@ -58,7 +63,23 @@ import type { Host } from './hx.js';
|
|
|
58
63
|
// Shared with <Table> — internal, deliberately not a shared *module*; the
|
|
59
64
|
// header of src/internal/heights.ts says why.
|
|
60
65
|
import { RowHeights } from '../internal/heights.js';
|
|
61
|
-
import {
|
|
66
|
+
import {
|
|
67
|
+
afterLayout,
|
|
68
|
+
cancelAfterLayout,
|
|
69
|
+
cancelLater,
|
|
70
|
+
later,
|
|
71
|
+
} from '../internal/timers.js';
|
|
72
|
+
import type { DelayTick } from '../internal/timers.js';
|
|
73
|
+
import { useReveal } from '../internal/scroll.js';
|
|
74
|
+
import {
|
|
75
|
+
BURST_BUDGET,
|
|
76
|
+
DEFAULT_OVERSCAN,
|
|
77
|
+
DEFAULT_PREFETCH,
|
|
78
|
+
SCROLL_HINT_DELAY_MS,
|
|
79
|
+
SKELETON_THRESHOLD,
|
|
80
|
+
SETTLE_BUDGET,
|
|
81
|
+
useVirtualWindow,
|
|
82
|
+
} from '../internal/window.js';
|
|
62
83
|
import { typeAheadChar, useTypeAhead } from './internal.js';
|
|
63
84
|
import {
|
|
64
85
|
branchEdges,
|
|
@@ -106,16 +127,6 @@ const TWISTY = 12;
|
|
|
106
127
|
* is half of that, so `size` for one reads as its width. */
|
|
107
128
|
const TWISTY_GLYPH = 10;
|
|
108
129
|
const ROW_HEIGHT = 22;
|
|
109
|
-
/** Rows kept either side of the viewport, so a fast scroll does not show a
|
|
110
|
-
* gap before the next frame catches up. */
|
|
111
|
-
const OVERSCAN = 6;
|
|
112
|
-
/**
|
|
113
|
-
* What to build before the viewport has been measured. `onViewport` cannot
|
|
114
|
-
* arrive until layout has run, which is a frame after the first commit, so
|
|
115
|
-
* there is always one render that has to guess — and guessing "all of them"
|
|
116
|
-
* puts a hundred thousand rows in the tree for a frame.
|
|
117
|
-
*/
|
|
118
|
-
const ASSUMED_ROWS = 40;
|
|
119
130
|
/**
|
|
120
131
|
* Where `virtual="auto"` starts virtualizing.
|
|
121
132
|
*
|
|
@@ -167,6 +178,39 @@ const s = createStyles({
|
|
|
167
178
|
},
|
|
168
179
|
subtree: { flexShrink: 0 },
|
|
169
180
|
spacer: { flexShrink: 0 },
|
|
181
|
+
/** The bar inside a skeleton row — a line of "text" with no text, so a
|
|
182
|
+
* band of placeholders reads as rows arriving rather than a void. */
|
|
183
|
+
skeletonBar: {
|
|
184
|
+
height: 8,
|
|
185
|
+
borderRadius: 4,
|
|
186
|
+
alignSelf: 'center',
|
|
187
|
+
flexShrink: 0,
|
|
188
|
+
},
|
|
189
|
+
/** The box the scroll pane and the fast-scroll pill share — it exists so
|
|
190
|
+
* the pill can float *outside* the pane, where a scroll cannot move it. */
|
|
191
|
+
outer: { flexGrow: 1, minHeight: 0 },
|
|
192
|
+
/** The lane the fast-scroll pill floats in: absolute against the outer
|
|
193
|
+
* box so the pane scrolls under it, full-width so the pill centres
|
|
194
|
+
* itself, and transparent to the pointer so the rows beneath stay
|
|
195
|
+
* clickable. */
|
|
196
|
+
scrollHintLane: {
|
|
197
|
+
position: 'absolute',
|
|
198
|
+
left: 0,
|
|
199
|
+
right: 0,
|
|
200
|
+
bottom: 12,
|
|
201
|
+
flexDirection: 'row',
|
|
202
|
+
justifyContent: 'center',
|
|
203
|
+
pointerEvents: 'none',
|
|
204
|
+
},
|
|
205
|
+
scrollHint: {
|
|
206
|
+
paddingStart: 10,
|
|
207
|
+
paddingEnd: 10,
|
|
208
|
+
paddingTop: 5,
|
|
209
|
+
paddingBottom: 5,
|
|
210
|
+
borderRadius: 12,
|
|
211
|
+
flexDirection: 'row',
|
|
212
|
+
alignItems: 'center',
|
|
213
|
+
},
|
|
170
214
|
});
|
|
171
215
|
|
|
172
216
|
// --- what the seams are told -----------------------------------------------
|
|
@@ -221,6 +265,28 @@ export interface TreeGuideState<T> {
|
|
|
221
265
|
height: number;
|
|
222
266
|
}
|
|
223
267
|
|
|
268
|
+
/**
|
|
269
|
+
* What `renderScrollHint` is told: where the viewport is, while a fast
|
|
270
|
+
* scroll is still being caught up with. The top row itself is included so a
|
|
271
|
+
* hint can show what is *at* this position rather than a number.
|
|
272
|
+
*/
|
|
273
|
+
export interface TreeScrollHintState<T> {
|
|
274
|
+
/** The first row in view, in draw order. */
|
|
275
|
+
row: TreeRow<T>;
|
|
276
|
+
/** Its position, 1-based — "row `from` of `count`". */
|
|
277
|
+
from: number;
|
|
278
|
+
/** The last row in view, 1-based. */
|
|
279
|
+
to: number;
|
|
280
|
+
/** How many rows the tree is showing. */
|
|
281
|
+
count: number;
|
|
282
|
+
/** How many of the rows in view are still placeholders. */
|
|
283
|
+
pending: number;
|
|
284
|
+
/** When the viewport first stopped being whole, epoch ms — what the
|
|
285
|
+
* show-delay was measured against. `Date.now() - since` is how long the
|
|
286
|
+
* user has been looking at unresolved content. */
|
|
287
|
+
since: number;
|
|
288
|
+
}
|
|
289
|
+
|
|
224
290
|
/** What `renderSubtree` is told, in `layout="nested"`. */
|
|
225
291
|
export interface TreeSubtreeState<T> {
|
|
226
292
|
/** The row the subtree hangs off. Never null: the roots are not a
|
|
@@ -317,10 +383,12 @@ export interface TreeProps<T = TreeItem>
|
|
|
317
383
|
* virtualizing. Defaults to `rowHeight`.
|
|
318
384
|
*
|
|
319
385
|
* Only the rows on screen have ever been laid out, so the scrollbar is
|
|
320
|
-
* this guess for everything else
|
|
321
|
-
*
|
|
322
|
-
*
|
|
323
|
-
* the
|
|
386
|
+
* this guess for everything else; it converges as you scroll, and once
|
|
387
|
+
* enough rows have been measured the guess itself is re-learnt from their
|
|
388
|
+
* mean. Set it when rows are typically much taller than `rowHeight` — a
|
|
389
|
+
* tree of two-line rows with the default guess starts with a scrollbar
|
|
390
|
+
* that thinks the tree is half its real length, until the re-learning
|
|
391
|
+
* corrects it.
|
|
324
392
|
*/
|
|
325
393
|
estimatedRowHeight?: number;
|
|
326
394
|
|
|
@@ -334,6 +402,15 @@ export interface TreeProps<T = TreeItem>
|
|
|
334
402
|
virtual?: boolean | 'auto';
|
|
335
403
|
/** Rows built either side of the viewport. */
|
|
336
404
|
overscan?: number;
|
|
405
|
+
/**
|
|
406
|
+
* Rows built *beyond* the overscan while the tree sits idle, per side.
|
|
407
|
+
* Default 40. The pane blits a scroll before React can run, so the only
|
|
408
|
+
* scroll with no blank frame is one that lands on rows already built —
|
|
409
|
+
* this band is that, grown in small steps while nobody is scrolling, and
|
|
410
|
+
* kept behind the viewport so a reversal lands on rows still mounted.
|
|
411
|
+
* `0` turns the band off: the slice is exactly viewport-plus-overscan.
|
|
412
|
+
*/
|
|
413
|
+
prefetch?: number;
|
|
337
414
|
|
|
338
415
|
/**
|
|
339
416
|
* `'flat'` (the default) makes every row a sibling — which is what lets the
|
|
@@ -362,6 +439,30 @@ export interface TreeProps<T = TreeItem>
|
|
|
362
439
|
renderContent?: (state: TreeRowState<T>, content: ReactNode[]) => ReactNode;
|
|
363
440
|
/** The subtree container, in `layout="nested"`. */
|
|
364
441
|
renderSubtree?: (state: TreeSubtreeState<T>, rows: ReactNode) => ReactNode;
|
|
442
|
+
/**
|
|
443
|
+
* The fast-scroll overlay. Shown only while a scroll has outrun the rows
|
|
444
|
+
* far enough that placeholders cover a meaningful part of the viewport —
|
|
445
|
+
* a scroll the tree absorbs within a frame never shows it — and hidden
|
|
446
|
+
* the moment the view is whole again. The default is a pill reading
|
|
447
|
+
* "2,345 / 100,000"; return something else to replace it, or null for no
|
|
448
|
+
* overlay at all.
|
|
449
|
+
*/
|
|
450
|
+
renderScrollHint?: (state: TreeScrollHintState<T>) => ReactNode;
|
|
451
|
+
/**
|
|
452
|
+
* How long the viewport must have been showing unresolved content before
|
|
453
|
+
* the overlay appears, in milliseconds. Default 250: a catch-up the next
|
|
454
|
+
* few frames absorb is never announced. `0` shows it the moment a
|
|
455
|
+
* catch-up engages.
|
|
456
|
+
*/
|
|
457
|
+
scrollHintDelay?: number;
|
|
458
|
+
/**
|
|
459
|
+
* Tuning for the catch-up pacing — how a scroll that outruns the built
|
|
460
|
+
* rows is absorbed. All optional, all in rows: `threshold` (default 16)
|
|
461
|
+
* is how many rows entering in one render count as a flood, `burst`
|
|
462
|
+
* (default 24) and `settle` (default 48) are the full rows built per
|
|
463
|
+
* render mid-scroll and after it. See the same prop on `<Table>`.
|
|
464
|
+
*/
|
|
465
|
+
catchup?: { threshold?: number; burst?: number; settle?: number };
|
|
365
466
|
|
|
366
467
|
styles?: TreeStyles<T>;
|
|
367
468
|
style?: StyleProp;
|
|
@@ -380,6 +481,224 @@ function labelNode(label: ReactNode, style: StyleProp): ReactNode {
|
|
|
380
481
|
: label;
|
|
381
482
|
}
|
|
382
483
|
|
|
484
|
+
/**
|
|
485
|
+
* One row, as its own memoized component.
|
|
486
|
+
*
|
|
487
|
+
* The reason is the CPU profile of a fast scroll: every notch re-renders
|
|
488
|
+
* the window, and re-creating a hundred rows' elements per notch — then
|
|
489
|
+
* reconciling them and re-applying identical props to every node — was
|
|
490
|
+
* over half the burst. Every prop here is identity-stable across a scroll
|
|
491
|
+
* render (the row model is memoized, the accessors and handlers are stable
|
|
492
|
+
* callbacks), so React bails out on the rows that did not change and a
|
|
493
|
+
* notch pays only for the rows it brought in.
|
|
494
|
+
*/
|
|
495
|
+
interface TreeRowViewProps<T> {
|
|
496
|
+
row: TreeRow<T>;
|
|
497
|
+
isSelected: boolean;
|
|
498
|
+
indent: number;
|
|
499
|
+
rowHeight: number;
|
|
500
|
+
rtl: boolean;
|
|
501
|
+
theme: Theme;
|
|
502
|
+
renderToggle?: (state: TreeToggleState<T>) => ReactNode;
|
|
503
|
+
renderGuide?: (state: TreeGuideState<T>) => ReactNode;
|
|
504
|
+
renderLabel?: (state: TreeRowState<T>) => ReactNode;
|
|
505
|
+
renderContent?: (state: TreeRowState<T>, content: ReactNode[]) => ReactNode;
|
|
506
|
+
rowStyle: TreeStyles<T>['row'];
|
|
507
|
+
guideStyle: TreeStyles<T>['guide'];
|
|
508
|
+
toggleStyle: StyleProp | undefined;
|
|
509
|
+
labelStyle: StyleProp | undefined;
|
|
510
|
+
getLabel: (item: T) => ReactNode;
|
|
511
|
+
onToggle: (id: TreeItemId, item: T, open?: boolean) => void;
|
|
512
|
+
onGo: (row: TreeRow<T>) => void;
|
|
513
|
+
onOpen: (row: TreeRow<T>) => void;
|
|
514
|
+
register: (id: TreeItemId, at: number, node: DrawnNode | null) => void;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function TreeRowView<T>(props: TreeRowViewProps<T>): ReactElement {
|
|
518
|
+
const {
|
|
519
|
+
row,
|
|
520
|
+
isSelected,
|
|
521
|
+
indent,
|
|
522
|
+
rowHeight,
|
|
523
|
+
rtl,
|
|
524
|
+
theme,
|
|
525
|
+
renderToggle,
|
|
526
|
+
renderGuide,
|
|
527
|
+
renderLabel,
|
|
528
|
+
renderContent,
|
|
529
|
+
rowStyle,
|
|
530
|
+
guideStyle,
|
|
531
|
+
toggleStyle,
|
|
532
|
+
labelStyle,
|
|
533
|
+
getLabel,
|
|
534
|
+
onToggle,
|
|
535
|
+
onGo,
|
|
536
|
+
onOpen,
|
|
537
|
+
register,
|
|
538
|
+
} = props;
|
|
539
|
+
const color = row.disabled
|
|
540
|
+
? theme.textMuted
|
|
541
|
+
: isSelected
|
|
542
|
+
? theme.hoverText
|
|
543
|
+
: theme.text;
|
|
544
|
+
const state: TreeRowState<T> = {
|
|
545
|
+
...row,
|
|
546
|
+
selected: isSelected,
|
|
547
|
+
color,
|
|
548
|
+
toggle: (open?: boolean) => onToggle(row.id, row.item, open),
|
|
549
|
+
select: () => onGo(row),
|
|
550
|
+
};
|
|
551
|
+
|
|
552
|
+
const content: ReactNode[] = [];
|
|
553
|
+
|
|
554
|
+
// The indent. With no guide seam it is one padding value rather than
|
|
555
|
+
// `depth` empty boxes — a tree ten deep would otherwise build ten nodes
|
|
556
|
+
// per row to draw nothing.
|
|
557
|
+
if (renderGuide && row.depth > 0) {
|
|
558
|
+
const edges = branchEdges(row);
|
|
559
|
+
for (let level = 0; level < row.depth; level++) {
|
|
560
|
+
const guide: TreeGuideState<T> = {
|
|
561
|
+
row: state,
|
|
562
|
+
level,
|
|
563
|
+
continues: edges[level],
|
|
564
|
+
own: level === row.depth - 1,
|
|
565
|
+
width: indent,
|
|
566
|
+
height: rowHeight,
|
|
567
|
+
};
|
|
568
|
+
content.push(
|
|
569
|
+
hx(
|
|
570
|
+
'box',
|
|
571
|
+
{
|
|
572
|
+
key: `guide${level}`,
|
|
573
|
+
style: [
|
|
574
|
+
s.guide,
|
|
575
|
+
{ width: indent },
|
|
576
|
+
typeof guideStyle === 'function' ? guideStyle(guide) : guideStyle,
|
|
577
|
+
],
|
|
578
|
+
},
|
|
579
|
+
renderGuide(guide),
|
|
580
|
+
),
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
const toggleState: TreeToggleState<T> = { ...state, size: TWISTY_GLYPH };
|
|
586
|
+
content.push(
|
|
587
|
+
hx(
|
|
588
|
+
'box',
|
|
589
|
+
{
|
|
590
|
+
key: 'toggle',
|
|
591
|
+
style: [s.twisty, toggleStyle],
|
|
592
|
+
// The twisty is its own hit target: clicking it opens the branch
|
|
593
|
+
// without moving the selection, the way a file browser lets you
|
|
594
|
+
// peek inside a folder you have not chosen.
|
|
595
|
+
onClick: row.branch
|
|
596
|
+
? (ev) => {
|
|
597
|
+
ev.stopPropagation();
|
|
598
|
+
onToggle(row.id, row.item);
|
|
599
|
+
}
|
|
600
|
+
: undefined,
|
|
601
|
+
},
|
|
602
|
+
renderToggle
|
|
603
|
+
? renderToggle(toggleState)
|
|
604
|
+
: row.branch
|
|
605
|
+
? React.createElement(Icon, {
|
|
606
|
+
name: row.open
|
|
607
|
+
? 'chevronDown'
|
|
608
|
+
: rtl
|
|
609
|
+
? 'chevronLeft'
|
|
610
|
+
: 'chevronRight',
|
|
611
|
+
size: TWISTY_GLYPH,
|
|
612
|
+
// dimmer than the label on a resting row, and the row's own
|
|
613
|
+
// ink once it is selected
|
|
614
|
+
style: isSelected ? undefined : { color: theme.textMuted },
|
|
615
|
+
})
|
|
616
|
+
: null,
|
|
617
|
+
),
|
|
618
|
+
);
|
|
619
|
+
|
|
620
|
+
content.push(
|
|
621
|
+
renderLabel
|
|
622
|
+
? // Keyed here rather than by the app, for the reason `renderSubtree`
|
|
623
|
+
// is: the label sits in an array beside the guides and the twisty,
|
|
624
|
+
// and "add a key to the box you return" is not something a render
|
|
625
|
+
// prop should have to know.
|
|
626
|
+
React.createElement(
|
|
627
|
+
React.Fragment,
|
|
628
|
+
{ key: 'label' },
|
|
629
|
+
renderLabel(state),
|
|
630
|
+
)
|
|
631
|
+
: labelNode(getLabel(row.item), [s.label, labelStyle]),
|
|
632
|
+
);
|
|
633
|
+
|
|
634
|
+
return hx(
|
|
635
|
+
'box',
|
|
636
|
+
{
|
|
637
|
+
role: 'treeitem',
|
|
638
|
+
'aria-level': row.depth + 1,
|
|
639
|
+
'aria-selected': isSelected,
|
|
640
|
+
'aria-expanded': row.branch ? row.open : undefined,
|
|
641
|
+
'aria-posinset': row.posInSet,
|
|
642
|
+
'aria-setsize': row.setSize,
|
|
643
|
+
// `disabled` rather than `aria-disabled`: on a react-x11 node it is
|
|
644
|
+
// the real thing — it clears the AT-SPI ENABLED/SENSITIVE states and
|
|
645
|
+
// selects the `:disabled` style block — and there is no aria spelling
|
|
646
|
+
// of it to write instead.
|
|
647
|
+
disabled: row.disabled || undefined,
|
|
648
|
+
// The index the row was drawn at travels with the node, so measuring
|
|
649
|
+
// does not have to search a hundred thousand rows for where it is.
|
|
650
|
+
// It can go stale — the rows may move before the tick that measures —
|
|
651
|
+
// and both this and the height index check it rather than trust it.
|
|
652
|
+
ref: (node: DrawnNode | null) => {
|
|
653
|
+
register(row.id, row.index, node);
|
|
654
|
+
},
|
|
655
|
+
onClick: (ev) => {
|
|
656
|
+
if (row.disabled) return;
|
|
657
|
+
onGo(row);
|
|
658
|
+
// Select on the first click, open on the second — the gesture every
|
|
659
|
+
// file list has. `detail` is the click count the renderer already
|
|
660
|
+
// counts for text selection.
|
|
661
|
+
if (ev.detail === 2) onOpen(row);
|
|
662
|
+
},
|
|
663
|
+
style: [
|
|
664
|
+
s.row,
|
|
665
|
+
// A floor, not a height. The row grows to whatever its content
|
|
666
|
+
// needs — a wrapped label, two lines, a thumbnail — and the height
|
|
667
|
+
// index reads back what it actually became.
|
|
668
|
+
{ minHeight: rowHeight },
|
|
669
|
+
// The indent is what says "inside", so it is measured from the edge
|
|
670
|
+
// the row's label begins at.
|
|
671
|
+
{ paddingStart: renderGuide ? 4 : 4 + row.depth * indent },
|
|
672
|
+
{
|
|
673
|
+
backgroundColor: isSelected ? theme.hoverBackground : 'transparent',
|
|
674
|
+
// The row's ink, said once: `color` inherits, so the label takes
|
|
675
|
+
// it without being handed it.
|
|
676
|
+
color,
|
|
677
|
+
},
|
|
678
|
+
!row.disabled && {
|
|
679
|
+
':hover': {
|
|
680
|
+
backgroundColor: isSelected
|
|
681
|
+
? theme.hoverBackground
|
|
682
|
+
: theme.surfaceHover,
|
|
683
|
+
},
|
|
684
|
+
// The selection only moves on the release, and `:active` marks
|
|
685
|
+
// the whole press chain, so a press on the label or the twisty
|
|
686
|
+
// still darkens the row it is in.
|
|
687
|
+
':active': {
|
|
688
|
+
backgroundColor: isSelected
|
|
689
|
+
? theme.accentActive
|
|
690
|
+
: theme.surfaceActive,
|
|
691
|
+
},
|
|
692
|
+
},
|
|
693
|
+
typeof rowStyle === 'function' ? rowStyle(state) : rowStyle,
|
|
694
|
+
],
|
|
695
|
+
},
|
|
696
|
+
renderContent ? renderContent(state, content) : content,
|
|
697
|
+
);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
const MemoTreeRow = React.memo(TreeRowView) as typeof TreeRowView;
|
|
701
|
+
|
|
383
702
|
/**
|
|
384
703
|
* `<Tree items />` — a disclosure tree.
|
|
385
704
|
*
|
|
@@ -426,13 +745,17 @@ export function Tree<T = TreeItem>({
|
|
|
426
745
|
rowHeight = ROW_HEIGHT,
|
|
427
746
|
estimatedRowHeight,
|
|
428
747
|
virtual = 'auto',
|
|
429
|
-
overscan =
|
|
748
|
+
overscan = DEFAULT_OVERSCAN,
|
|
749
|
+
prefetch = DEFAULT_PREFETCH,
|
|
430
750
|
layout = 'flat',
|
|
431
751
|
renderToggle,
|
|
432
752
|
renderGuide,
|
|
433
753
|
renderLabel,
|
|
434
754
|
renderContent,
|
|
435
755
|
renderSubtree,
|
|
756
|
+
renderScrollHint,
|
|
757
|
+
scrollHintDelay = SCROLL_HINT_DELAY_MS,
|
|
758
|
+
catchup,
|
|
436
759
|
styles,
|
|
437
760
|
style,
|
|
438
761
|
ref,
|
|
@@ -449,6 +772,7 @@ export function Tree<T = TreeItem>({
|
|
|
449
772
|
onViewport,
|
|
450
773
|
...boxProps
|
|
451
774
|
}: TreeProps<T>): ReactElement {
|
|
775
|
+
(globalThis as any).__renders = ((globalThis as any).__renders ?? 0) + 1;
|
|
452
776
|
const theme = useTheme();
|
|
453
777
|
const rtl = useDirection() === 'rtl';
|
|
454
778
|
const [ownExpanded, setOwnExpanded] = useState<ReadonlySet<TreeItemId>>(
|
|
@@ -457,7 +781,6 @@ export function Tree<T = TreeItem>({
|
|
|
457
781
|
const [ownSelected, setOwnSelected] = useState<TreeItemId | null>(
|
|
458
782
|
defaultSelected ?? null,
|
|
459
783
|
);
|
|
460
|
-
const [view, setView] = useState({ top: 0, height: 0 });
|
|
461
784
|
// Bumped by a measurement pass that found a row taller or shorter than the
|
|
462
785
|
// index believed. It is the only reason the component re-renders for a
|
|
463
786
|
// measurement, and a pass that finds nothing new does not bump it, which is
|
|
@@ -511,8 +834,6 @@ export function Tree<T = TreeItem>({
|
|
|
511
834
|
rowsRef.current = rows;
|
|
512
835
|
const itemsRef = useRef(items);
|
|
513
836
|
itemsRef.current = items;
|
|
514
|
-
const viewRef = useRef(view);
|
|
515
|
-
viewRef.current = view;
|
|
516
837
|
|
|
517
838
|
const virtualizing =
|
|
518
839
|
layout === 'flat' &&
|
|
@@ -525,30 +846,27 @@ export function Tree<T = TreeItem>({
|
|
|
525
846
|
const index = heights;
|
|
526
847
|
index.sync(rows, estimate);
|
|
527
848
|
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
* spacers that keep the scrollbar measuring the whole tree. */
|
|
550
|
-
const above = virtualizing ? index.offsetAt(first) : 0;
|
|
551
|
-
const below = virtualizing ? index.total() - index.offsetAt(last) : 0;
|
|
849
|
+
/** The viewport, and the slice worth building from it — the machinery
|
|
850
|
+
* shared with `<Table>` (`../internal/window.ts`). */
|
|
851
|
+
const win = useVirtualWindow({
|
|
852
|
+
box: scroller,
|
|
853
|
+
heights,
|
|
854
|
+
rows,
|
|
855
|
+
// tree rows are always measured, so the idle band above the viewport
|
|
856
|
+
// only re-builds territory already visited — see `exact` on the inputs
|
|
857
|
+
exact: false,
|
|
858
|
+
virtualizing,
|
|
859
|
+
overscan,
|
|
860
|
+
prefetch,
|
|
861
|
+
threshold: catchup?.threshold ?? SKELETON_THRESHOLD,
|
|
862
|
+
burstBudget: catchup?.burst ?? BURST_BUDGET,
|
|
863
|
+
settleBudget: catchup?.settle ?? SETTLE_BUDGET,
|
|
864
|
+
});
|
|
865
|
+
const { view, viewRef } = win;
|
|
866
|
+
/** Whether the fast-scroll pill is up — kept across renders so it does not
|
|
867
|
+
* flicker through a catch-up, only appearing and disappearing once. */
|
|
868
|
+
const hintShown = useRef(false);
|
|
869
|
+
const { first, last, above, below } = win.slice;
|
|
552
870
|
|
|
553
871
|
const setExpandedSet = useCallback(
|
|
554
872
|
(next: ReadonlySet<TreeItemId>, change: TreeExpandChange<T>): void => {
|
|
@@ -571,32 +889,34 @@ export function Tree<T = TreeItem>({
|
|
|
571
889
|
);
|
|
572
890
|
|
|
573
891
|
/**
|
|
574
|
-
*
|
|
575
|
-
*
|
|
576
|
-
*
|
|
577
|
-
*
|
|
578
|
-
*
|
|
579
|
-
*
|
|
580
|
-
* normal case, so the height index answers instead: where the row starts,
|
|
581
|
-
* and how tall it is or is estimated to be.
|
|
892
|
+
* The scroll the tree owes a row, and the pane's real offset read back
|
|
893
|
+
* after every layout — the two halves of `../internal/scroll.ts`, which
|
|
894
|
+
* says why a reveal cannot be a one-shot and why `onScroll` is not the
|
|
895
|
+
* whole story. A tree grows and shrinks under its own hands: opening a
|
|
896
|
+
* branch is a content that got taller between the ask and the layout, in
|
|
897
|
+
* exactly the way an arriving row is.
|
|
582
898
|
*/
|
|
583
|
-
const reveal =
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
899
|
+
const reveal = useReveal({
|
|
900
|
+
box: scroller,
|
|
901
|
+
rows: rowsRef,
|
|
902
|
+
nodes: rowNodes,
|
|
903
|
+
heights,
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
/** Put a row in view, by the index its call site already has. */
|
|
907
|
+
const revealAt = useCallback(
|
|
908
|
+
(at: number): void => {
|
|
909
|
+
const row = rowsRef.current[at];
|
|
910
|
+
if (row) reveal.to(row.id);
|
|
911
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- `reveal` is a
|
|
912
|
+
// stable handle
|
|
913
|
+
},
|
|
914
|
+
[reveal],
|
|
915
|
+
);
|
|
916
|
+
|
|
917
|
+
/** Re-read the offset the pane is *actually* at — the window's `sync`; see
|
|
918
|
+
* `../internal/window.ts` for why the pane moves silently. */
|
|
919
|
+
const syncScroll = win.sync;
|
|
600
920
|
|
|
601
921
|
/**
|
|
602
922
|
* Read back what the rows on screen actually laid out at.
|
|
@@ -611,8 +931,8 @@ export function Tree<T = TreeItem>({
|
|
|
611
931
|
* reports no change, so the second pass over the same rows costs a map walk
|
|
612
932
|
* and re-renders nothing, and measure → render → measure terminates.
|
|
613
933
|
*/
|
|
614
|
-
const measureRows = useCallback(():
|
|
615
|
-
if (!virtualizing) return;
|
|
934
|
+
const measureRows = useCallback((): boolean => {
|
|
935
|
+
if (!virtualizing) return false;
|
|
616
936
|
const box = scroller.current;
|
|
617
937
|
const rows = rowsRef.current;
|
|
618
938
|
const idx = heights;
|
|
@@ -631,17 +951,81 @@ export function Tree<T = TreeItem>({
|
|
|
631
951
|
changed = true;
|
|
632
952
|
if (at < anchor) shift += height - was;
|
|
633
953
|
}
|
|
634
|
-
if (!changed) return;
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
954
|
+
if (!changed) return false;
|
|
955
|
+
// A debt, not a one-shot: the pane clamps against the last layout's
|
|
956
|
+
// content height, so a shift from rows measured above the viewport can
|
|
957
|
+
// land short until the layout that admits the growth has run.
|
|
958
|
+
reveal.nudge(shift);
|
|
638
959
|
setMeasured((n) => n + 1);
|
|
960
|
+
return true;
|
|
639
961
|
}, [virtualizing]);
|
|
640
962
|
|
|
963
|
+
/**
|
|
964
|
+
* Let the estimate learn from the rows that have been measured — the
|
|
965
|
+
* scrollbar of a measured tree starts as a guess times the row count, and
|
|
966
|
+
* the measured mean is a far better guess for the rows not yet seen. Idle
|
|
967
|
+
* only: every unmeasured offset moves when it applies, and the anchor
|
|
968
|
+
* arithmetic keeping the screen still is `measureRows`'s.
|
|
969
|
+
*/
|
|
970
|
+
const adaptEstimate = useCallback((): boolean => {
|
|
971
|
+
if (!virtualizing) return false;
|
|
972
|
+
const box = scroller.current;
|
|
973
|
+
if (!box) return false;
|
|
974
|
+
const anchor = heights.indexAt(box.scrollY);
|
|
975
|
+
const before = heights.offsetAt(anchor);
|
|
976
|
+
if (!heights.adapt()) return false;
|
|
977
|
+
reveal.nudge(heights.offsetAt(anchor) - before);
|
|
978
|
+
setMeasured((n) => n + 1);
|
|
979
|
+
return true;
|
|
980
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- `heights` and
|
|
981
|
+
// `reveal` are stable instances
|
|
982
|
+
}, [virtualizing]);
|
|
983
|
+
|
|
984
|
+
/**
|
|
985
|
+
* The one tick after layout, and everything that can only be known there:
|
|
986
|
+
* what the rows measured, whether an owed scroll can go further now that
|
|
987
|
+
* the rows it was waiting for are laid out, and where the pane actually
|
|
988
|
+
* ended up. In that order — each step can move the offset the next one
|
|
989
|
+
* reads.
|
|
990
|
+
*/
|
|
991
|
+
/** Whether some drawn row has no size yet — a commit can land between
|
|
992
|
+
* frame flushes, and a measure pass over it reads zeros. */
|
|
993
|
+
const rowsPendingLayout = useCallback((): boolean => {
|
|
994
|
+
const rows = rowsRef.current;
|
|
995
|
+
for (const [id, { node, at }] of rowNodes.current) {
|
|
996
|
+
if (rows[at]?.id === id && !(node.abs.height > 0)) return true;
|
|
997
|
+
}
|
|
998
|
+
return false;
|
|
999
|
+
}, []);
|
|
1000
|
+
|
|
641
1001
|
useEffect(() => {
|
|
642
1002
|
if (!virtualizing) return undefined;
|
|
643
|
-
|
|
644
|
-
|
|
1003
|
+
let look: DelayTick = null;
|
|
1004
|
+
let tries = 0;
|
|
1005
|
+
const pass = (): void => {
|
|
1006
|
+
(globalThis as any).__ticks = ((globalThis as any).__ticks ?? 0) + 1;
|
|
1007
|
+
// `measureRows` first, and its answer handed on: a pass that moved the
|
|
1008
|
+
// heights has not settled anything, and an owed scroll judged against
|
|
1009
|
+
// the layout it is about to invalidate is not owed any less. During a
|
|
1010
|
+
// flick nothing is measured at all — every correction at that speed is
|
|
1011
|
+
// invalidated by the next event — and the settle tick that follows any
|
|
1012
|
+
// burst is where the deferred passes catch up.
|
|
1013
|
+
const moved = win.fast() ? false : measureRows();
|
|
1014
|
+
const adapted = !win.scrolling() && adaptEstimate();
|
|
1015
|
+
reveal.retry(moved || adapted);
|
|
1016
|
+
syncScroll();
|
|
1017
|
+
// A commit can land between frame flushes: its rows report zero size
|
|
1018
|
+
// until the flush, this tick has already run, and nothing else would
|
|
1019
|
+
// come back for them — a window that just finished growing renders
|
|
1020
|
+
// nothing further, and the missed measurements would stand for good.
|
|
1021
|
+
// Look again, briefly, while any drawn row is still unsized.
|
|
1022
|
+
if (rowsPendingLayout() && tries++ < 8) look = later(pass, 16);
|
|
1023
|
+
};
|
|
1024
|
+
const id = afterLayout(pass);
|
|
1025
|
+
return () => {
|
|
1026
|
+
cancelAfterLayout(id);
|
|
1027
|
+
cancelLater(look);
|
|
1028
|
+
};
|
|
645
1029
|
});
|
|
646
1030
|
|
|
647
1031
|
const goTo = useCallback(
|
|
@@ -650,9 +1034,9 @@ export function Tree<T = TreeItem>({
|
|
|
650
1034
|
currentRef.current = row.id;
|
|
651
1035
|
if (selected === undefined) setOwnSelected(row.id);
|
|
652
1036
|
onSelect?.(row.id, row.item);
|
|
653
|
-
|
|
1037
|
+
revealAt(row.index);
|
|
654
1038
|
},
|
|
655
|
-
[selected, onSelect,
|
|
1039
|
+
[selected, onSelect, revealAt],
|
|
656
1040
|
);
|
|
657
1041
|
|
|
658
1042
|
const activate = useCallback(
|
|
@@ -799,13 +1183,13 @@ export function Tree<T = TreeItem>({
|
|
|
799
1183
|
scrollToItem: (id) => {
|
|
800
1184
|
const row = rowsRef.current.find((r) => r.id === id);
|
|
801
1185
|
if (!row) return false;
|
|
802
|
-
|
|
1186
|
+
revealAt(row.index);
|
|
803
1187
|
return true;
|
|
804
1188
|
},
|
|
805
1189
|
handleKey,
|
|
806
1190
|
rows: () => rowsRef.current,
|
|
807
1191
|
}),
|
|
808
|
-
[goTo, toggleId,
|
|
1192
|
+
[goTo, toggleId, revealAt, handleKey, selected, accessors],
|
|
809
1193
|
);
|
|
810
1194
|
|
|
811
1195
|
// --- rendering -----------------------------------------------------------
|
|
@@ -813,7 +1197,103 @@ export function Tree<T = TreeItem>({
|
|
|
813
1197
|
const rowStyleProp = styles?.row;
|
|
814
1198
|
const guideStyleProp = styles?.guide;
|
|
815
1199
|
|
|
1200
|
+
// The row component's stable half — `MemoTreeRow` bails out of a
|
|
1201
|
+
// re-render only if every prop kept its identity, and this one would
|
|
1202
|
+
// otherwise be rebuilt per row per render.
|
|
1203
|
+
const registerRow = useCallback(
|
|
1204
|
+
(id: TreeItemId, at: number, node: DrawnNode | null): void => {
|
|
1205
|
+
if (node) rowNodes.current.set(id, { node, at });
|
|
1206
|
+
else rowNodes.current.delete(id);
|
|
1207
|
+
},
|
|
1208
|
+
[],
|
|
1209
|
+
);
|
|
1210
|
+
|
|
1211
|
+
/**
|
|
1212
|
+
* The row *elements*, reused by identity while nothing they depend on has
|
|
1213
|
+
* changed. The memo already skips re-rendering an unchanged row, but the
|
|
1214
|
+
* skip still costs a `createElement` and a props compare per row per
|
|
1215
|
+
* notch — the burst profile put bare `createElement` at a tenth of a
|
|
1216
|
+
* flick's CPU. Handing React the identical element object instead takes
|
|
1217
|
+
* the cheapest path it has: the fiber is reused with no compare at all.
|
|
1218
|
+
*/
|
|
1219
|
+
const rowElems = useRef(
|
|
1220
|
+
new Map<
|
|
1221
|
+
TreeItemId,
|
|
1222
|
+
{ row: TreeRow<T>; selected: boolean; el: ReactElement }
|
|
1223
|
+
>(),
|
|
1224
|
+
);
|
|
1225
|
+
const rowElemDeps = useRef<readonly unknown[]>([]);
|
|
1226
|
+
{
|
|
1227
|
+
const deps = [
|
|
1228
|
+
indent,
|
|
1229
|
+
rowHeight,
|
|
1230
|
+
rtl,
|
|
1231
|
+
theme,
|
|
1232
|
+
renderToggle,
|
|
1233
|
+
renderGuide,
|
|
1234
|
+
renderLabel,
|
|
1235
|
+
renderContent,
|
|
1236
|
+
rowStyleProp,
|
|
1237
|
+
guideStyleProp,
|
|
1238
|
+
styles?.toggle,
|
|
1239
|
+
styles?.label,
|
|
1240
|
+
accessors,
|
|
1241
|
+
toggleId,
|
|
1242
|
+
goTo,
|
|
1243
|
+
activate,
|
|
1244
|
+
registerRow,
|
|
1245
|
+
];
|
|
1246
|
+
const prev = rowElemDeps.current;
|
|
1247
|
+
if (prev.length !== deps.length || deps.some((d, at) => d !== prev[at])) {
|
|
1248
|
+
rowElems.current.clear();
|
|
1249
|
+
rowElemDeps.current = deps;
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
|
|
816
1253
|
const renderOneRow = (row: TreeRow<T>): ReactElement => {
|
|
1254
|
+
const isSelected = row.id === current;
|
|
1255
|
+
const cached = rowElems.current.get(row.id);
|
|
1256
|
+
if (cached && cached.row === row && cached.selected === isSelected) {
|
|
1257
|
+
return cached.el;
|
|
1258
|
+
}
|
|
1259
|
+
const el = React.createElement(
|
|
1260
|
+
MemoTreeRow as (p: TreeRowViewProps<T>) => ReactElement,
|
|
1261
|
+
{
|
|
1262
|
+
key: String(row.id),
|
|
1263
|
+
row,
|
|
1264
|
+
isSelected,
|
|
1265
|
+
indent,
|
|
1266
|
+
rowHeight,
|
|
1267
|
+
rtl,
|
|
1268
|
+
theme,
|
|
1269
|
+
renderToggle,
|
|
1270
|
+
renderGuide,
|
|
1271
|
+
renderLabel,
|
|
1272
|
+
renderContent,
|
|
1273
|
+
rowStyle: rowStyleProp,
|
|
1274
|
+
guideStyle: guideStyleProp,
|
|
1275
|
+
toggleStyle: styles?.toggle,
|
|
1276
|
+
labelStyle: styles?.label,
|
|
1277
|
+
getLabel: accessors.getLabel,
|
|
1278
|
+
onToggle: toggleId,
|
|
1279
|
+
onGo: goTo,
|
|
1280
|
+
onOpen: activate,
|
|
1281
|
+
register: registerRow,
|
|
1282
|
+
},
|
|
1283
|
+
);
|
|
1284
|
+
rowElems.current.set(row.id, { row, selected: isSelected, el });
|
|
1285
|
+
return el;
|
|
1286
|
+
};
|
|
1287
|
+
|
|
1288
|
+
/**
|
|
1289
|
+
* A row the window said not to build in full yet: the box at its indexed
|
|
1290
|
+
* height and none of its content — no guides, no twisty, no label — so
|
|
1291
|
+
* the commit answering a flood lands frames before the full rows could.
|
|
1292
|
+
* `styles.row` still applies, so row backgrounds hold. Not registered in
|
|
1293
|
+
* `rowNodes`: a skeleton must not be measured into the height index, and
|
|
1294
|
+
* cannot satisfy a reveal.
|
|
1295
|
+
*/
|
|
1296
|
+
const renderSkeletonRow = (row: TreeRow<T>): ReactElement => {
|
|
817
1297
|
const isSelected = row.id === current;
|
|
818
1298
|
const color = row.disabled
|
|
819
1299
|
? theme.textMuted
|
|
@@ -827,158 +1307,37 @@ export function Tree<T = TreeItem>({
|
|
|
827
1307
|
toggle: (open?: boolean) => toggleId(row.id, row.item, open),
|
|
828
1308
|
select: () => goTo(row),
|
|
829
1309
|
};
|
|
830
|
-
|
|
831
|
-
const content: ReactNode[] = [];
|
|
832
|
-
|
|
833
|
-
// The indent. With no guide seam it is one padding value rather than
|
|
834
|
-
// `depth` empty boxes — a tree ten deep would otherwise build ten nodes
|
|
835
|
-
// per row to draw nothing.
|
|
836
|
-
if (renderGuide && row.depth > 0) {
|
|
837
|
-
const edges = branchEdges(row);
|
|
838
|
-
for (let level = 0; level < row.depth; level++) {
|
|
839
|
-
const guide: TreeGuideState<T> = {
|
|
840
|
-
row: state,
|
|
841
|
-
level,
|
|
842
|
-
continues: edges[level],
|
|
843
|
-
own: level === row.depth - 1,
|
|
844
|
-
width: indent,
|
|
845
|
-
height: rowHeight,
|
|
846
|
-
};
|
|
847
|
-
content.push(
|
|
848
|
-
hx(
|
|
849
|
-
'box',
|
|
850
|
-
{
|
|
851
|
-
key: `guide${level}`,
|
|
852
|
-
style: [
|
|
853
|
-
s.guide,
|
|
854
|
-
{ width: indent },
|
|
855
|
-
typeof guideStyleProp === 'function'
|
|
856
|
-
? guideStyleProp(guide)
|
|
857
|
-
: guideStyleProp,
|
|
858
|
-
],
|
|
859
|
-
},
|
|
860
|
-
renderGuide(guide),
|
|
861
|
-
),
|
|
862
|
-
);
|
|
863
|
-
}
|
|
864
|
-
}
|
|
865
|
-
|
|
866
|
-
const toggleState: TreeToggleState<T> = { ...state, size: TWISTY_GLYPH };
|
|
867
|
-
content.push(
|
|
868
|
-
hx(
|
|
869
|
-
'box',
|
|
870
|
-
{
|
|
871
|
-
key: 'toggle',
|
|
872
|
-
style: [s.twisty, styles?.toggle],
|
|
873
|
-
// The twisty is its own hit target: clicking it opens the branch
|
|
874
|
-
// without moving the selection, the way a file browser lets you
|
|
875
|
-
// peek inside a folder you have not chosen.
|
|
876
|
-
onClick: row.branch
|
|
877
|
-
? (ev) => {
|
|
878
|
-
ev.stopPropagation();
|
|
879
|
-
toggleId(row.id, row.item);
|
|
880
|
-
}
|
|
881
|
-
: undefined,
|
|
882
|
-
},
|
|
883
|
-
renderToggle
|
|
884
|
-
? renderToggle(toggleState)
|
|
885
|
-
: row.branch
|
|
886
|
-
? React.createElement(Icon, {
|
|
887
|
-
name: row.open
|
|
888
|
-
? 'chevronDown'
|
|
889
|
-
: rtl
|
|
890
|
-
? 'chevronLeft'
|
|
891
|
-
: 'chevronRight',
|
|
892
|
-
size: TWISTY_GLYPH,
|
|
893
|
-
// dimmer than the label on a resting row, and the row's own
|
|
894
|
-
// ink once it is selected
|
|
895
|
-
style: isSelected ? undefined : { color: theme.textMuted },
|
|
896
|
-
})
|
|
897
|
-
: null,
|
|
898
|
-
),
|
|
899
|
-
);
|
|
900
|
-
|
|
901
|
-
content.push(
|
|
902
|
-
renderLabel
|
|
903
|
-
? // Keyed here rather than by the app, for the reason `renderSubtree`
|
|
904
|
-
// is: the label sits in an array beside the guides and the twisty,
|
|
905
|
-
// and "add a key to the box you return" is not something a render
|
|
906
|
-
// prop should have to know.
|
|
907
|
-
React.createElement(
|
|
908
|
-
React.Fragment,
|
|
909
|
-
{ key: 'label' },
|
|
910
|
-
renderLabel(state),
|
|
911
|
-
)
|
|
912
|
-
: labelNode(accessors.getLabel(row.item), [s.label, styles?.label]),
|
|
913
|
-
);
|
|
914
|
-
|
|
915
1310
|
return hx(
|
|
916
1311
|
'box',
|
|
917
1312
|
{
|
|
918
1313
|
key: String(row.id),
|
|
919
|
-
|
|
920
|
-
'aria-level': row.depth + 1,
|
|
921
|
-
'aria-selected': isSelected,
|
|
922
|
-
'aria-expanded': row.branch ? row.open : undefined,
|
|
923
|
-
'aria-posinset': row.posInSet,
|
|
924
|
-
'aria-setsize': row.setSize,
|
|
925
|
-
// `disabled` rather than `aria-disabled`: on a react-x11 node it is
|
|
926
|
-
// the real thing — it clears the AT-SPI ENABLED/SENSITIVE states and
|
|
927
|
-
// selects the `:disabled` style block — and there is no aria spelling
|
|
928
|
-
// of it to write instead.
|
|
929
|
-
disabled: row.disabled || undefined,
|
|
930
|
-
// The index the row was drawn at travels with the node, so measuring
|
|
931
|
-
// does not have to search a hundred thousand rows for where it is.
|
|
932
|
-
// It can go stale — the rows may move before the tick that measures —
|
|
933
|
-
// and both this and the height index check it rather than trust it.
|
|
934
|
-
ref: (node: DrawnNode | null) => {
|
|
935
|
-
if (node) rowNodes.current.set(row.id, { node, at: row.index });
|
|
936
|
-
else rowNodes.current.delete(row.id);
|
|
937
|
-
},
|
|
938
|
-
onClick: (ev) => {
|
|
939
|
-
if (row.disabled) return;
|
|
940
|
-
goTo(row);
|
|
941
|
-
// Select on the first click, open on the second — the gesture every
|
|
942
|
-
// file list has. `detail` is the click count the renderer already
|
|
943
|
-
// counts for text selection.
|
|
944
|
-
if (ev.detail === 2) activate(row);
|
|
945
|
-
},
|
|
1314
|
+
'aria-hidden': true,
|
|
946
1315
|
style: [
|
|
947
1316
|
s.row,
|
|
948
|
-
//
|
|
949
|
-
//
|
|
950
|
-
|
|
951
|
-
{ minHeight: rowHeight },
|
|
952
|
-
// The indent is what says "inside", so it is measured from the edge
|
|
953
|
-
// the row's label begins at.
|
|
954
|
-
{ paddingStart: renderGuide ? 4 : 4 + row.depth * indent },
|
|
1317
|
+
// Exactly what the index believes, so the spacers and the
|
|
1318
|
+
// scrollbar agree with the rows on where everything is.
|
|
1319
|
+
{ height: index.heightAt(row.index) },
|
|
955
1320
|
{
|
|
956
1321
|
backgroundColor: isSelected ? theme.hoverBackground : 'transparent',
|
|
957
|
-
// The row's ink, said once: `color` inherits, so the label takes
|
|
958
|
-
// it without being handed it.
|
|
959
|
-
color,
|
|
960
|
-
},
|
|
961
|
-
!row.disabled && {
|
|
962
|
-
':hover': {
|
|
963
|
-
backgroundColor: isSelected
|
|
964
|
-
? theme.hoverBackground
|
|
965
|
-
: theme.surfaceHover,
|
|
966
|
-
},
|
|
967
|
-
// The selection only moves on the release, and `:active` marks
|
|
968
|
-
// the whole press chain, so a press on the label or the twisty
|
|
969
|
-
// still darkens the row it is in.
|
|
970
|
-
':active': {
|
|
971
|
-
backgroundColor: isSelected
|
|
972
|
-
? theme.accentActive
|
|
973
|
-
: theme.surfaceActive,
|
|
974
|
-
},
|
|
975
1322
|
},
|
|
976
1323
|
typeof rowStyleProp === 'function'
|
|
977
1324
|
? rowStyleProp(state)
|
|
978
1325
|
: rowStyleProp,
|
|
979
1326
|
],
|
|
980
1327
|
},
|
|
981
|
-
|
|
1328
|
+
// A line of "text" with no text, at the row's own indent, so a band
|
|
1329
|
+
// of placeholders reads as the tree arriving rather than a void.
|
|
1330
|
+
hx('box', {
|
|
1331
|
+
key: 'bar',
|
|
1332
|
+
style: [
|
|
1333
|
+
s.skeletonBar,
|
|
1334
|
+
{
|
|
1335
|
+
width: 72 + ((row.index * 37) % 89),
|
|
1336
|
+
marginStart: 4 + row.depth * indent + TWISTY + 4,
|
|
1337
|
+
backgroundColor: theme.track,
|
|
1338
|
+
},
|
|
1339
|
+
],
|
|
1340
|
+
}),
|
|
982
1341
|
);
|
|
983
1342
|
};
|
|
984
1343
|
|
|
@@ -1026,7 +1385,13 @@ export function Tree<T = TreeItem>({
|
|
|
1026
1385
|
}),
|
|
1027
1386
|
);
|
|
1028
1387
|
}
|
|
1029
|
-
for (let i = first; i < last; i++)
|
|
1388
|
+
for (let i = first; i < last; i++) {
|
|
1389
|
+
body.push(
|
|
1390
|
+
win.skeletons.has(rows[i].id)
|
|
1391
|
+
? renderSkeletonRow(rows[i])
|
|
1392
|
+
: renderOneRow(rows[i]),
|
|
1393
|
+
);
|
|
1394
|
+
}
|
|
1030
1395
|
if (virtualizing && last < rows.length) {
|
|
1031
1396
|
body.push(
|
|
1032
1397
|
hx('box', {
|
|
@@ -1035,53 +1400,155 @@ export function Tree<T = TreeItem>({
|
|
|
1035
1400
|
}),
|
|
1036
1401
|
);
|
|
1037
1402
|
}
|
|
1403
|
+
// Rows that left the window leave the cache too, once it has grown
|
|
1404
|
+
// well past the window — a scrub across a long list would otherwise
|
|
1405
|
+
// hold an element for every row it passed.
|
|
1406
|
+
if (rowElems.current.size > (last - first) * 3 + 64) {
|
|
1407
|
+
rowElems.current.clear();
|
|
1408
|
+
}
|
|
1038
1409
|
}
|
|
1039
1410
|
|
|
1411
|
+
/**
|
|
1412
|
+
* The fast-scroll overlay — shown only while placeholders cover enough of
|
|
1413
|
+
* the viewport that the user would otherwise be looking at blank rows.
|
|
1414
|
+
* The half-viewport threshold keeps a near-miss quiet: a scroll the next
|
|
1415
|
+
* frame will absorb is not worth announcing. Once up it stays until the
|
|
1416
|
+
* view is whole again, so it does not flicker through the catch-up.
|
|
1417
|
+
*
|
|
1418
|
+
* A sibling of the scroll pane, never a child: everything inside the pane
|
|
1419
|
+
* — absolute children included — is shifted by the scroll, so a pill in
|
|
1420
|
+
* there rides away with the very flick it is meant to narrate. Outside,
|
|
1421
|
+
* it is painted after the pane on every repaint frame, which is what a
|
|
1422
|
+
* scrub produces (a jump past the viewport cannot take the blit fast
|
|
1423
|
+
* path), so it stays put while the content flies.
|
|
1424
|
+
*/
|
|
1425
|
+
let scrollHint: ReactNode = null;
|
|
1426
|
+
if (virtualizing && rows.length > 0 && view.height > 0) {
|
|
1427
|
+
const vFirst = index.indexAt(view.top);
|
|
1428
|
+
const vLast = Math.min(
|
|
1429
|
+
rows.length - 1,
|
|
1430
|
+
index.indexAt(view.top + view.height),
|
|
1431
|
+
);
|
|
1432
|
+
// Two ways in: placeholders covering enough of the viewport that it
|
|
1433
|
+
// would otherwise read as blank, or a scrub — the window teleporting
|
|
1434
|
+
// while the burst is still in flight, where every commit chases a
|
|
1435
|
+
// viewport that has already left and nothing useful can be on screen.
|
|
1436
|
+
// Either way only once the catch-up has already *lasted*: a jump the
|
|
1437
|
+
// next few frames absorb is not worth announcing, so the pill waits
|
|
1438
|
+
// out the show-delay against the catch-up clock. Latched once
|
|
1439
|
+
// triggered: `pending` bounces to zero between catch-up commits, and a
|
|
1440
|
+
// pill that blinked with it would read as a glitch. It goes when the
|
|
1441
|
+
// burst does.
|
|
1442
|
+
const engaged =
|
|
1443
|
+
(win.pending > 0 && win.pending * 2 >= vLast - vFirst + 1) ||
|
|
1444
|
+
(win.jumped && win.scrolling());
|
|
1445
|
+
const lasted =
|
|
1446
|
+
win.catchupSince !== null &&
|
|
1447
|
+
Date.now() - win.catchupSince >= scrollHintDelay;
|
|
1448
|
+
const show =
|
|
1449
|
+
(engaged && lasted) ||
|
|
1450
|
+
(hintShown.current && (win.pending > 0 || win.scrolling()));
|
|
1451
|
+
hintShown.current = show;
|
|
1452
|
+
if (show) {
|
|
1453
|
+
const hintState: TreeScrollHintState<T> = {
|
|
1454
|
+
row: rows[vFirst],
|
|
1455
|
+
from: vFirst + 1,
|
|
1456
|
+
to: vLast + 1,
|
|
1457
|
+
count: rows.length,
|
|
1458
|
+
pending: win.pending,
|
|
1459
|
+
since: win.catchupSince ?? Date.now(),
|
|
1460
|
+
};
|
|
1461
|
+
const content = renderScrollHint
|
|
1462
|
+
? renderScrollHint(hintState)
|
|
1463
|
+
: hx(
|
|
1464
|
+
'text',
|
|
1465
|
+
{ style: { fontSize: 11, color: theme.hoverText } },
|
|
1466
|
+
`${hintState.from.toLocaleString()} / ${hintState.count.toLocaleString()}`,
|
|
1467
|
+
);
|
|
1468
|
+
if (content !== null && content !== undefined && content !== false) {
|
|
1469
|
+
scrollHint = hx(
|
|
1470
|
+
'box',
|
|
1471
|
+
{
|
|
1472
|
+
key: 'scroll-hint',
|
|
1473
|
+
// The pill duplicates what the scrollbar already tells an
|
|
1474
|
+
// assistive technology, and it comes and goes with the
|
|
1475
|
+
// catch-up — chatter, not content.
|
|
1476
|
+
'aria-hidden': true,
|
|
1477
|
+
style: s.scrollHintLane,
|
|
1478
|
+
},
|
|
1479
|
+
hx(
|
|
1480
|
+
'box',
|
|
1481
|
+
{
|
|
1482
|
+
style: [s.scrollHint, { backgroundColor: theme.hoverBackground }],
|
|
1483
|
+
},
|
|
1484
|
+
content,
|
|
1485
|
+
),
|
|
1486
|
+
);
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
} else {
|
|
1490
|
+
hintShown.current = false;
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
// The wrapper exists for the overlay: the scroll pane keeps the role, the
|
|
1494
|
+
// focus, the refs and the events — everything a `<Tree>` has always put
|
|
1495
|
+
// on its root — and the caller's `style` lands out here, where the
|
|
1496
|
+
// tree's place in the layout is decided.
|
|
1040
1497
|
return hx(
|
|
1041
1498
|
'box',
|
|
1042
|
-
{
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
)
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
);
|
|
1081
|
-
|
|
1082
|
-
|
|
1499
|
+
{ style: [s.outer, style] },
|
|
1500
|
+
hx(
|
|
1501
|
+
'box',
|
|
1502
|
+
{
|
|
1503
|
+
theme,
|
|
1504
|
+
role: 'tree',
|
|
1505
|
+
// The tree takes the focus, not the row — see the doc comment.
|
|
1506
|
+
focusable: true,
|
|
1507
|
+
...boxProps,
|
|
1508
|
+
ref: scroller,
|
|
1509
|
+
style: s.root,
|
|
1510
|
+
/**
|
|
1511
|
+
* `preventDefault` is the load-bearing half.
|
|
1512
|
+
*
|
|
1513
|
+
* The tree's root is a scroll container **and** the focused node, and a
|
|
1514
|
+
* focused scroller has default key actions of its own: Down and Up
|
|
1515
|
+
* scroll by a wheel notch, the Page keys by a viewport, Home and End to
|
|
1516
|
+
* the ends, Space by a page. Without this, every arrow did both — moved
|
|
1517
|
+
* the selection *and* scrolled the list under it — which reads as the
|
|
1518
|
+
* tree scrolling whenever you use the keyboard rather than only when
|
|
1519
|
+
* the selection would otherwise leave the viewport.
|
|
1520
|
+
*
|
|
1521
|
+
* `handleKey` reports whether the tree took the key, so a key it did
|
|
1522
|
+
* not take (a letter that matched nothing) still gets the default.
|
|
1523
|
+
*/
|
|
1524
|
+
onKeyDown: (ev) => {
|
|
1525
|
+
if (handleKey(ev)) ev.preventDefault();
|
|
1526
|
+
},
|
|
1527
|
+
// Layout, not scrolling, is what first tells a list how much of it is
|
|
1528
|
+
// worth building — and it is also where a page key gets its distance,
|
|
1529
|
+
// so this is measured whether or not the tree virtualizes.
|
|
1530
|
+
onViewport: (ev) => {
|
|
1531
|
+
win.sized(ev.width, ev.height);
|
|
1532
|
+
// The content just changed size, which is both the moment an owed
|
|
1533
|
+
// scroll can reach further than the clamp let it and the moment the
|
|
1534
|
+
// pane may have re-clamped its offset without saying so. It is not a
|
|
1535
|
+
// moment anything can be *settled* in: this runs from layout, a tick
|
|
1536
|
+
// before the pass that reads the rows it just drew back.
|
|
1537
|
+
reveal.retry(virtualizing);
|
|
1538
|
+
syncScroll();
|
|
1539
|
+
onViewport?.(ev);
|
|
1540
|
+
},
|
|
1541
|
+
onScroll: (ev) => {
|
|
1542
|
+
// A scroll this component did not ask for is the user taking over,
|
|
1543
|
+
// and an owed reveal must not yank the tree back out from under them
|
|
1544
|
+
// on the next layout.
|
|
1545
|
+
reveal.heard(ev.scrollY);
|
|
1546
|
+
win.scrolled(ev.scrollY);
|
|
1547
|
+
onScroll?.(ev);
|
|
1548
|
+
},
|
|
1083
1549
|
},
|
|
1084
|
-
|
|
1085
|
-
|
|
1550
|
+
body,
|
|
1551
|
+
),
|
|
1552
|
+
scrollHint,
|
|
1086
1553
|
);
|
|
1087
1554
|
}
|