@react-x11/components 0.2.1 → 0.4.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.
Files changed (137) hide show
  1. package/README.md +6 -0
  2. package/dist/index.d.ts +4 -0
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +5 -0
  5. package/dist/index.js.map +1 -1
  6. package/dist/internal/heights.d.ts +26 -1
  7. package/dist/internal/heights.d.ts.map +1 -1
  8. package/dist/internal/heights.js +57 -6
  9. package/dist/internal/heights.js.map +1 -1
  10. package/dist/internal/scroll.d.ts +11 -0
  11. package/dist/internal/scroll.d.ts.map +1 -1
  12. package/dist/internal/scroll.js +43 -3
  13. package/dist/internal/scroll.js.map +1 -1
  14. package/dist/internal/timers.d.ts +8 -0
  15. package/dist/internal/timers.d.ts.map +1 -1
  16. package/dist/internal/timers.js +10 -0
  17. package/dist/internal/timers.js.map +1 -1
  18. package/dist/internal/window.d.ts +161 -0
  19. package/dist/internal/window.d.ts.map +1 -0
  20. package/dist/internal/window.js +417 -0
  21. package/dist/internal/window.js.map +1 -0
  22. package/dist/qml/containers.d.ts +3 -0
  23. package/dist/qml/containers.d.ts.map +1 -0
  24. package/dist/qml/containers.js +331 -0
  25. package/dist/qml/containers.js.map +1 -0
  26. package/dist/qml/controls.d.ts +12 -0
  27. package/dist/qml/controls.d.ts.map +1 -0
  28. package/dist/qml/controls.js +21 -0
  29. package/dist/qml/controls.js.map +1 -0
  30. package/dist/qml/globals.d.ts +6 -0
  31. package/dist/qml/globals.d.ts.map +1 -0
  32. package/dist/qml/globals.js +25 -0
  33. package/dist/qml/globals.js.map +1 -0
  34. package/dist/qml/index.d.ts +15 -0
  35. package/dist/qml/index.d.ts.map +1 -0
  36. package/dist/qml/index.js +25 -0
  37. package/dist/qml/index.js.map +1 -0
  38. package/dist/qml/interaction.d.ts +12 -0
  39. package/dist/qml/interaction.d.ts.map +1 -0
  40. package/dist/qml/interaction.js +246 -0
  41. package/dist/qml/interaction.js.map +1 -0
  42. package/dist/qml/ir.d.ts +106 -0
  43. package/dist/qml/ir.d.ts.map +1 -0
  44. package/dist/qml/ir.js +7 -0
  45. package/dist/qml/ir.js.map +1 -0
  46. package/dist/qml/layouts.d.ts +3 -0
  47. package/dist/qml/layouts.d.ts.map +1 -0
  48. package/dist/qml/layouts.js +122 -0
  49. package/dist/qml/layouts.js.map +1 -0
  50. package/dist/qml/models.d.ts +14 -0
  51. package/dist/qml/models.d.ts.map +1 -0
  52. package/dist/qml/models.js +80 -0
  53. package/dist/qml/models.js.map +1 -0
  54. package/dist/qml/objects.d.ts +228 -0
  55. package/dist/qml/objects.d.ts.map +1 -0
  56. package/dist/qml/objects.js +1103 -0
  57. package/dist/qml/objects.js.map +1 -0
  58. package/dist/qml/parse.d.ts +10 -0
  59. package/dist/qml/parse.d.ts.map +1 -0
  60. package/dist/qml/parse.js +607 -0
  61. package/dist/qml/parse.js.map +1 -0
  62. package/dist/qml/qtquick.d.ts +7 -0
  63. package/dist/qml/qtquick.d.ts.map +1 -0
  64. package/dist/qml/qtquick.js +347 -0
  65. package/dist/qml/qtquick.js.map +1 -0
  66. package/dist/qml/react.d.ts +83 -0
  67. package/dist/qml/react.d.ts.map +1 -0
  68. package/dist/qml/react.js +323 -0
  69. package/dist/qml/react.js.map +1 -0
  70. package/dist/qml/resolver.d.ts +38 -0
  71. package/dist/qml/resolver.d.ts.map +1 -0
  72. package/dist/qml/resolver.js +64 -0
  73. package/dist/qml/resolver.js.map +1 -0
  74. package/dist/qml/slots.d.ts +65 -0
  75. package/dist/qml/slots.d.ts.map +1 -0
  76. package/dist/qml/slots.js +230 -0
  77. package/dist/qml/slots.js.map +1 -0
  78. package/dist/qml/states.d.ts +5 -0
  79. package/dist/qml/states.d.ts.map +1 -0
  80. package/dist/qml/states.js +276 -0
  81. package/dist/qml/states.js.map +1 -0
  82. package/dist/qml/view-utils.d.ts +42 -0
  83. package/dist/qml/view-utils.d.ts.map +1 -0
  84. package/dist/qml/view-utils.js +121 -0
  85. package/dist/qml/view-utils.js.map +1 -0
  86. package/dist/table/index.d.ts +73 -2
  87. package/dist/table/index.d.ts.map +1 -1
  88. package/dist/table/index.js +405 -161
  89. package/dist/table/index.js.map +1 -1
  90. package/dist/tabs/hx.d.ts +18 -0
  91. package/dist/tabs/hx.d.ts.map +1 -0
  92. package/dist/tabs/hx.js +10 -0
  93. package/dist/tabs/hx.js.map +1 -0
  94. package/dist/tabs/index.d.ts +143 -0
  95. package/dist/tabs/index.d.ts.map +1 -0
  96. package/dist/tabs/index.js +602 -0
  97. package/dist/tabs/index.js.map +1 -0
  98. package/dist/terminal/vt/index.d.ts +1 -1
  99. package/dist/terminal/vt/index.d.ts.map +1 -1
  100. package/dist/terminal/vt/index.js +3 -3
  101. package/dist/terminal/vt/index.js.map +1 -1
  102. package/dist/terminal/vt/pty.d.ts +16 -0
  103. package/dist/terminal/vt/pty.d.ts.map +1 -1
  104. package/dist/terminal/vt/pty.js +186 -0
  105. package/dist/terminal/vt/pty.js.map +1 -1
  106. package/dist/tree/index.d.ts +65 -5
  107. package/dist/tree/index.d.ts.map +1 -1
  108. package/dist/tree/index.js +446 -189
  109. package/dist/tree/index.js.map +1 -1
  110. package/package.json +11 -1
  111. package/src/index.ts +44 -0
  112. package/src/internal/heights.ts +57 -6
  113. package/src/internal/scroll.ts +59 -3
  114. package/src/internal/timers.ts +13 -0
  115. package/src/internal/window.ts +570 -0
  116. package/src/qml/containers.tsx +380 -0
  117. package/src/qml/controls.tsx +47 -0
  118. package/src/qml/globals.ts +33 -0
  119. package/src/qml/index.ts +67 -0
  120. package/src/qml/interaction.tsx +317 -0
  121. package/src/qml/ir.ts +80 -0
  122. package/src/qml/layouts.tsx +141 -0
  123. package/src/qml/models.ts +101 -0
  124. package/src/qml/objects.ts +1431 -0
  125. package/src/qml/parse.ts +624 -0
  126. package/src/qml/qtquick.tsx +413 -0
  127. package/src/qml/react.tsx +454 -0
  128. package/src/qml/resolver.ts +99 -0
  129. package/src/qml/slots.ts +249 -0
  130. package/src/qml/states.ts +318 -0
  131. package/src/qml/view-utils.ts +140 -0
  132. package/src/table/index.ts +599 -178
  133. package/src/tabs/hx.ts +31 -0
  134. package/src/tabs/index.ts +850 -0
  135. package/src/terminal/vt/index.ts +4 -2
  136. package/src/terminal/vt/pty.ts +243 -0
  137. package/src/tree/index.ts +687 -260
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 { DrawnNode, KeyboardEvent, ScrollableNode } from 'react-x11';
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,8 +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 { afterLayout, cancelAfterLayout } from '../internal/timers.js';
66
+ import {
67
+ afterLayout,
68
+ cancelAfterLayout,
69
+ cancelLater,
70
+ later,
71
+ } from '../internal/timers.js';
72
+ import type { DelayTick } from '../internal/timers.js';
62
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';
63
83
  import { typeAheadChar, useTypeAhead } from './internal.js';
64
84
  import {
65
85
  branchEdges,
@@ -107,16 +127,6 @@ const TWISTY = 12;
107
127
  * is half of that, so `size` for one reads as its width. */
108
128
  const TWISTY_GLYPH = 10;
109
129
  const ROW_HEIGHT = 22;
110
- /** Rows kept either side of the viewport, so a fast scroll does not show a
111
- * gap before the next frame catches up. */
112
- const OVERSCAN = 6;
113
- /**
114
- * What to build before the viewport has been measured. `onViewport` cannot
115
- * arrive until layout has run, which is a frame after the first commit, so
116
- * there is always one render that has to guess — and guessing "all of them"
117
- * puts a hundred thousand rows in the tree for a frame.
118
- */
119
- const ASSUMED_ROWS = 40;
120
130
  /**
121
131
  * Where `virtual="auto"` starts virtualizing.
122
132
  *
@@ -168,6 +178,39 @@ const s = createStyles({
168
178
  },
169
179
  subtree: { flexShrink: 0 },
170
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
+ },
171
214
  });
172
215
 
173
216
  // --- what the seams are told -----------------------------------------------
@@ -222,6 +265,28 @@ export interface TreeGuideState<T> {
222
265
  height: number;
223
266
  }
224
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
+
225
290
  /** What `renderSubtree` is told, in `layout="nested"`. */
226
291
  export interface TreeSubtreeState<T> {
227
292
  /** The row the subtree hangs off. Never null: the roots are not a
@@ -318,10 +383,12 @@ export interface TreeProps<T = TreeItem>
318
383
  * virtualizing. Defaults to `rowHeight`.
319
384
  *
320
385
  * Only the rows on screen have ever been laid out, so the scrollbar is
321
- * this guess for everything else, and it converges as you scroll. Set it
322
- * when rows are typically much taller than `rowHeight` a tree of
323
- * two-line rows with the default guess starts with a scrollbar that thinks
324
- * the tree is half its real length.
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.
325
392
  */
326
393
  estimatedRowHeight?: number;
327
394
 
@@ -335,6 +402,15 @@ export interface TreeProps<T = TreeItem>
335
402
  virtual?: boolean | 'auto';
336
403
  /** Rows built either side of the viewport. */
337
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;
338
414
 
339
415
  /**
340
416
  * `'flat'` (the default) makes every row a sibling — which is what lets the
@@ -363,6 +439,30 @@ export interface TreeProps<T = TreeItem>
363
439
  renderContent?: (state: TreeRowState<T>, content: ReactNode[]) => ReactNode;
364
440
  /** The subtree container, in `layout="nested"`. */
365
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 };
366
466
 
367
467
  styles?: TreeStyles<T>;
368
468
  style?: StyleProp;
@@ -381,6 +481,224 @@ function labelNode(label: ReactNode, style: StyleProp): ReactNode {
381
481
  : label;
382
482
  }
383
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
+
384
702
  /**
385
703
  * `<Tree items />` — a disclosure tree.
386
704
  *
@@ -427,13 +745,17 @@ export function Tree<T = TreeItem>({
427
745
  rowHeight = ROW_HEIGHT,
428
746
  estimatedRowHeight,
429
747
  virtual = 'auto',
430
- overscan = OVERSCAN,
748
+ overscan = DEFAULT_OVERSCAN,
749
+ prefetch = DEFAULT_PREFETCH,
431
750
  layout = 'flat',
432
751
  renderToggle,
433
752
  renderGuide,
434
753
  renderLabel,
435
754
  renderContent,
436
755
  renderSubtree,
756
+ renderScrollHint,
757
+ scrollHintDelay = SCROLL_HINT_DELAY_MS,
758
+ catchup,
437
759
  styles,
438
760
  style,
439
761
  ref,
@@ -450,6 +772,7 @@ export function Tree<T = TreeItem>({
450
772
  onViewport,
451
773
  ...boxProps
452
774
  }: TreeProps<T>): ReactElement {
775
+ (globalThis as any).__renders = ((globalThis as any).__renders ?? 0) + 1;
453
776
  const theme = useTheme();
454
777
  const rtl = useDirection() === 'rtl';
455
778
  const [ownExpanded, setOwnExpanded] = useState<ReadonlySet<TreeItemId>>(
@@ -458,7 +781,6 @@ export function Tree<T = TreeItem>({
458
781
  const [ownSelected, setOwnSelected] = useState<TreeItemId | null>(
459
782
  defaultSelected ?? null,
460
783
  );
461
- const [view, setView] = useState({ top: 0, height: 0 });
462
784
  // Bumped by a measurement pass that found a row taller or shorter than the
463
785
  // index believed. It is the only reason the component re-renders for a
464
786
  // measurement, and a pass that finds nothing new does not bump it, which is
@@ -512,8 +834,6 @@ export function Tree<T = TreeItem>({
512
834
  rowsRef.current = rows;
513
835
  const itemsRef = useRef(items);
514
836
  itemsRef.current = items;
515
- const viewRef = useRef(view);
516
- viewRef.current = view;
517
837
 
518
838
  const virtualizing =
519
839
  layout === 'flat' &&
@@ -526,30 +846,27 @@ export function Tree<T = TreeItem>({
526
846
  const index = heights;
527
847
  index.sync(rows, estimate);
528
848
 
529
- // The slice worth building: what is on screen, plus a little either side.
530
- // Which rows those are is a question for the height index now — with rows
531
- // of different heights there is no division that answers it.
532
- const first = virtualizing
533
- ? Math.max(0, index.indexAt(view.top) - overscan)
534
- : 0;
535
- let last = rows.length;
536
- if (virtualizing) {
537
- if (view.height > 0) {
538
- last = Math.min(
539
- rows.length,
540
- index.indexAt(view.top + view.height) + 1 + overscan,
541
- );
542
- } else {
543
- // Before the first layout there is no viewport to measure against, and
544
- // guessing "all of them" would put a hundred thousand rows in the tree
545
- // for a frame.
546
- last = Math.min(rows.length, first + ASSUMED_ROWS);
547
- }
548
- }
549
- /** Where the slice starts, and how much of the list is below it the two
550
- * spacers that keep the scrollbar measuring the whole tree. */
551
- const above = virtualizing ? index.offsetAt(first) : 0;
552
- 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;
553
870
 
554
871
  const setExpandedSet = useCallback(
555
872
  (next: ReadonlySet<TreeItemId>, change: TreeExpandChange<T>): void => {
@@ -597,21 +914,9 @@ export function Tree<T = TreeItem>({
597
914
  [reveal],
598
915
  );
599
916
 
600
- /**
601
- * Re-read the offset the pane is *actually* at.
602
- *
603
- * It moves silently — a queued reveal resolves during layout, and an offset
604
- * the content outgrew or outshrank is re-clamped there — and a slice built
605
- * from the offset before those is drawn where the viewport is not: a blank
606
- * band where the rows should be, until a scroll of your own re-syncs it by
607
- * accident.
608
- */
609
- const syncScroll = useCallback((): void => {
610
- const box = scroller.current;
611
- if (!box || !virtualizing) return;
612
- const y = box.scrollY;
613
- setView((prev) => (prev.top === y ? prev : { ...prev, top: y }));
614
- }, [virtualizing]);
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;
615
920
 
616
921
  /**
617
922
  * Read back what the rows on screen actually laid out at.
@@ -647,11 +952,33 @@ export function Tree<T = TreeItem>({
647
952
  if (at < anchor) shift += height - was;
648
953
  }
649
954
  if (!changed) return false;
650
- if (shift !== 0 && box) {
651
- reveal.scrollTo(box.scrollY + shift);
652
- }
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);
959
+ setMeasured((n) => n + 1);
960
+ return true;
961
+ }, [virtualizing]);
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);
653
978
  setMeasured((n) => n + 1);
654
979
  return true;
980
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- `heights` and
981
+ // `reveal` are stable instances
655
982
  }, [virtualizing]);
656
983
 
657
984
  /**
@@ -661,16 +988,44 @@ export function Tree<T = TreeItem>({
661
988
  * ended up. In that order — each step can move the offset the next one
662
989
  * reads.
663
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
+
664
1001
  useEffect(() => {
665
1002
  if (!virtualizing) return undefined;
666
- const id = afterLayout(() => {
1003
+ let look: DelayTick = null;
1004
+ let tries = 0;
1005
+ const pass = (): void => {
1006
+ (globalThis as any).__ticks = ((globalThis as any).__ticks ?? 0) + 1;
667
1007
  // `measureRows` first, and its answer handed on: a pass that moved the
668
1008
  // heights has not settled anything, and an owed scroll judged against
669
- // the layout it is about to invalidate is not owed any less.
670
- reveal.retry(measureRows());
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);
671
1016
  syncScroll();
672
- });
673
- return () => cancelAfterLayout(id);
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
+ };
674
1029
  });
675
1030
 
676
1031
  const goTo = useCallback(
@@ -842,7 +1197,103 @@ export function Tree<T = TreeItem>({
842
1197
  const rowStyleProp = styles?.row;
843
1198
  const guideStyleProp = styles?.guide;
844
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
+
845
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 => {
846
1297
  const isSelected = row.id === current;
847
1298
  const color = row.disabled
848
1299
  ? theme.textMuted
@@ -856,158 +1307,37 @@ export function Tree<T = TreeItem>({
856
1307
  toggle: (open?: boolean) => toggleId(row.id, row.item, open),
857
1308
  select: () => goTo(row),
858
1309
  };
859
-
860
- const content: ReactNode[] = [];
861
-
862
- // The indent. With no guide seam it is one padding value rather than
863
- // `depth` empty boxes — a tree ten deep would otherwise build ten nodes
864
- // per row to draw nothing.
865
- if (renderGuide && row.depth > 0) {
866
- const edges = branchEdges(row);
867
- for (let level = 0; level < row.depth; level++) {
868
- const guide: TreeGuideState<T> = {
869
- row: state,
870
- level,
871
- continues: edges[level],
872
- own: level === row.depth - 1,
873
- width: indent,
874
- height: rowHeight,
875
- };
876
- content.push(
877
- hx(
878
- 'box',
879
- {
880
- key: `guide${level}`,
881
- style: [
882
- s.guide,
883
- { width: indent },
884
- typeof guideStyleProp === 'function'
885
- ? guideStyleProp(guide)
886
- : guideStyleProp,
887
- ],
888
- },
889
- renderGuide(guide),
890
- ),
891
- );
892
- }
893
- }
894
-
895
- const toggleState: TreeToggleState<T> = { ...state, size: TWISTY_GLYPH };
896
- content.push(
897
- hx(
898
- 'box',
899
- {
900
- key: 'toggle',
901
- style: [s.twisty, styles?.toggle],
902
- // The twisty is its own hit target: clicking it opens the branch
903
- // without moving the selection, the way a file browser lets you
904
- // peek inside a folder you have not chosen.
905
- onClick: row.branch
906
- ? (ev) => {
907
- ev.stopPropagation();
908
- toggleId(row.id, row.item);
909
- }
910
- : undefined,
911
- },
912
- renderToggle
913
- ? renderToggle(toggleState)
914
- : row.branch
915
- ? React.createElement(Icon, {
916
- name: row.open
917
- ? 'chevronDown'
918
- : rtl
919
- ? 'chevronLeft'
920
- : 'chevronRight',
921
- size: TWISTY_GLYPH,
922
- // dimmer than the label on a resting row, and the row's own
923
- // ink once it is selected
924
- style: isSelected ? undefined : { color: theme.textMuted },
925
- })
926
- : null,
927
- ),
928
- );
929
-
930
- content.push(
931
- renderLabel
932
- ? // Keyed here rather than by the app, for the reason `renderSubtree`
933
- // is: the label sits in an array beside the guides and the twisty,
934
- // and "add a key to the box you return" is not something a render
935
- // prop should have to know.
936
- React.createElement(
937
- React.Fragment,
938
- { key: 'label' },
939
- renderLabel(state),
940
- )
941
- : labelNode(accessors.getLabel(row.item), [s.label, styles?.label]),
942
- );
943
-
944
1310
  return hx(
945
1311
  'box',
946
1312
  {
947
1313
  key: String(row.id),
948
- role: 'treeitem',
949
- 'aria-level': row.depth + 1,
950
- 'aria-selected': isSelected,
951
- 'aria-expanded': row.branch ? row.open : undefined,
952
- 'aria-posinset': row.posInSet,
953
- 'aria-setsize': row.setSize,
954
- // `disabled` rather than `aria-disabled`: on a react-x11 node it is
955
- // the real thing — it clears the AT-SPI ENABLED/SENSITIVE states and
956
- // selects the `:disabled` style block — and there is no aria spelling
957
- // of it to write instead.
958
- disabled: row.disabled || undefined,
959
- // The index the row was drawn at travels with the node, so measuring
960
- // does not have to search a hundred thousand rows for where it is.
961
- // It can go stale — the rows may move before the tick that measures —
962
- // and both this and the height index check it rather than trust it.
963
- ref: (node: DrawnNode | null) => {
964
- if (node) rowNodes.current.set(row.id, { node, at: row.index });
965
- else rowNodes.current.delete(row.id);
966
- },
967
- onClick: (ev) => {
968
- if (row.disabled) return;
969
- goTo(row);
970
- // Select on the first click, open on the second — the gesture every
971
- // file list has. `detail` is the click count the renderer already
972
- // counts for text selection.
973
- if (ev.detail === 2) activate(row);
974
- },
1314
+ 'aria-hidden': true,
975
1315
  style: [
976
1316
  s.row,
977
- // A floor, not a height. The row grows to whatever its content
978
- // needs a wrapped label, two lines, a thumbnail — and the height
979
- // index reads back what it actually became.
980
- { minHeight: rowHeight },
981
- // The indent is what says "inside", so it is measured from the edge
982
- // the row's label begins at.
983
- { 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) },
984
1320
  {
985
1321
  backgroundColor: isSelected ? theme.hoverBackground : 'transparent',
986
- // The row's ink, said once: `color` inherits, so the label takes
987
- // it without being handed it.
988
- color,
989
- },
990
- !row.disabled && {
991
- ':hover': {
992
- backgroundColor: isSelected
993
- ? theme.hoverBackground
994
- : theme.surfaceHover,
995
- },
996
- // The selection only moves on the release, and `:active` marks
997
- // the whole press chain, so a press on the label or the twisty
998
- // still darkens the row it is in.
999
- ':active': {
1000
- backgroundColor: isSelected
1001
- ? theme.accentActive
1002
- : theme.surfaceActive,
1003
- },
1004
1322
  },
1005
1323
  typeof rowStyleProp === 'function'
1006
1324
  ? rowStyleProp(state)
1007
1325
  : rowStyleProp,
1008
1326
  ],
1009
1327
  },
1010
- renderContent ? renderContent(state, content) : content,
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
+ }),
1011
1341
  );
1012
1342
  };
1013
1343
 
@@ -1055,7 +1385,13 @@ export function Tree<T = TreeItem>({
1055
1385
  }),
1056
1386
  );
1057
1387
  }
1058
- for (let i = first; i < last; i++) body.push(renderOneRow(rows[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
+ }
1059
1395
  if (virtualizing && last < rows.length) {
1060
1396
  body.push(
1061
1397
  hx('box', {
@@ -1064,64 +1400,155 @@ export function Tree<T = TreeItem>({
1064
1400
  }),
1065
1401
  );
1066
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
+ }
1409
+ }
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;
1067
1491
  }
1068
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.
1069
1497
  return hx(
1070
1498
  'box',
1071
- {
1072
- theme,
1073
- role: 'tree',
1074
- // The tree takes the focus, not the row — see the doc comment.
1075
- focusable: true,
1076
- ...boxProps,
1077
- ref: scroller,
1078
- style: [s.root, style],
1079
- /**
1080
- * `preventDefault` is the load-bearing half.
1081
- *
1082
- * The tree's root is a scroll container **and** the focused node, and a
1083
- * focused scroller has default key actions of its own: Down and Up
1084
- * scroll by a wheel notch, the Page keys by a viewport, Home and End to
1085
- * the ends, Space by a page. Without this, every arrow did both — moved
1086
- * the selection *and* scrolled the list under it which reads as the
1087
- * tree scrolling whenever you use the keyboard rather than only when
1088
- * the selection would otherwise leave the viewport.
1089
- *
1090
- * `handleKey` reports whether the tree took the key, so a key it did
1091
- * not take (a letter that matched nothing) still gets the default.
1092
- */
1093
- onKeyDown: (ev) => {
1094
- if (handleKey(ev)) ev.preventDefault();
1095
- },
1096
- // Layout, not scrolling, is what first tells a list how much of it is
1097
- // worth building — and it is also where a page key gets its distance,
1098
- // so this is measured whether or not the tree virtualizes.
1099
- onViewport: (ev) => {
1100
- setView((prev) =>
1101
- prev.height === ev.height ? prev : { ...prev, height: ev.height },
1102
- );
1103
- // The content just changed size, which is both the moment an owed
1104
- // scroll can reach further than the clamp let it and the moment the
1105
- // pane may have re-clamped its offset without saying so. It is not a
1106
- // moment anything can be *settled* in: this runs from layout, a tick
1107
- // before the pass that reads the rows it just drew back.
1108
- reveal.retry(virtualizing);
1109
- syncScroll();
1110
- onViewport?.(ev);
1111
- },
1112
- onScroll: (ev) => {
1113
- // A scroll this component did not ask for is the user taking over,
1114
- // and an owed reveal must not yank the tree back out from under them
1115
- // on the next layout.
1116
- reveal.heard(ev.scrollY);
1117
- if (virtualizing) {
1118
- setView((prev) =>
1119
- prev.top === ev.scrollY ? prev : { ...prev, top: ev.scrollY },
1120
- );
1121
- }
1122
- onScroll?.(ev);
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
+ },
1123
1549
  },
1124
- },
1125
- body,
1550
+ body,
1551
+ ),
1552
+ scrollHint,
1126
1553
  );
1127
1554
  }