@react-x11/components 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -36,7 +36,9 @@ import { hx } from './hx.js';
36
36
  // Shared with <Table> — internal, deliberately not a shared *module*; the
37
37
  // header of src/internal/heights.ts says why.
38
38
  import { RowHeights } from '../internal/heights.js';
39
- import { afterLayout, cancelAfterLayout } from '../internal/timers.js';
39
+ import { afterLayout, cancelAfterLayout, cancelLater, later, } from '../internal/timers.js';
40
+ import { useReveal } from '../internal/scroll.js';
41
+ import { BURST_BUDGET, DEFAULT_OVERSCAN, DEFAULT_PREFETCH, SCROLL_HINT_DELAY_MS, SKELETON_THRESHOLD, SETTLE_BUDGET, useVirtualWindow, } from '../internal/window.js';
40
42
  import { typeAheadChar, useTypeAhead } from './internal.js';
41
43
  import { branchEdges, findItem, groupRows, isGroup, resolveAccessors, visibleRows, } from './rows.js';
42
44
  export { branchEdges, findItem, resolveAccessors, visibleRows, } from './rows.js';
@@ -49,16 +51,6 @@ const TWISTY = 12;
49
51
  * is half of that, so `size` for one reads as its width. */
50
52
  const TWISTY_GLYPH = 10;
51
53
  const ROW_HEIGHT = 22;
52
- /** Rows kept either side of the viewport, so a fast scroll does not show a
53
- * gap before the next frame catches up. */
54
- const OVERSCAN = 6;
55
- /**
56
- * What to build before the viewport has been measured. `onViewport` cannot
57
- * arrive until layout has run, which is a frame after the first commit, so
58
- * there is always one render that has to guess — and guessing "all of them"
59
- * puts a hundred thousand rows in the tree for a frame.
60
- */
61
- const ASSUMED_ROWS = 40;
62
54
  /**
63
55
  * Where `virtual="auto"` starts virtualizing.
64
56
  *
@@ -109,6 +101,39 @@ const s = createStyles({
109
101
  },
110
102
  subtree: { flexShrink: 0 },
111
103
  spacer: { flexShrink: 0 },
104
+ /** The bar inside a skeleton row — a line of "text" with no text, so a
105
+ * band of placeholders reads as rows arriving rather than a void. */
106
+ skeletonBar: {
107
+ height: 8,
108
+ borderRadius: 4,
109
+ alignSelf: 'center',
110
+ flexShrink: 0,
111
+ },
112
+ /** The box the scroll pane and the fast-scroll pill share — it exists so
113
+ * the pill can float *outside* the pane, where a scroll cannot move it. */
114
+ outer: { flexGrow: 1, minHeight: 0 },
115
+ /** The lane the fast-scroll pill floats in: absolute against the outer
116
+ * box so the pane scrolls under it, full-width so the pill centres
117
+ * itself, and transparent to the pointer so the rows beneath stay
118
+ * clickable. */
119
+ scrollHintLane: {
120
+ position: 'absolute',
121
+ left: 0,
122
+ right: 0,
123
+ bottom: 12,
124
+ flexDirection: 'row',
125
+ justifyContent: 'center',
126
+ pointerEvents: 'none',
127
+ },
128
+ scrollHint: {
129
+ paddingStart: 10,
130
+ paddingEnd: 10,
131
+ paddingTop: 5,
132
+ paddingBottom: 5,
133
+ borderRadius: 12,
134
+ flexDirection: 'row',
135
+ alignItems: 'center',
136
+ },
112
137
  });
113
138
  /** A string or a number becomes a `<text>`; anything else is already a
114
139
  * node. */
@@ -117,6 +142,144 @@ function labelNode(label, style) {
117
142
  ? hx('text', { key: 'label', style }, String(label))
118
143
  : label;
119
144
  }
145
+ function TreeRowView(props) {
146
+ const { row, isSelected, indent, rowHeight, rtl, theme, renderToggle, renderGuide, renderLabel, renderContent, rowStyle, guideStyle, toggleStyle, labelStyle, getLabel, onToggle, onGo, onOpen, register, } = props;
147
+ const color = row.disabled
148
+ ? theme.textMuted
149
+ : isSelected
150
+ ? theme.hoverText
151
+ : theme.text;
152
+ const state = {
153
+ ...row,
154
+ selected: isSelected,
155
+ color,
156
+ toggle: (open) => onToggle(row.id, row.item, open),
157
+ select: () => onGo(row),
158
+ };
159
+ const content = [];
160
+ // The indent. With no guide seam it is one padding value rather than
161
+ // `depth` empty boxes — a tree ten deep would otherwise build ten nodes
162
+ // per row to draw nothing.
163
+ if (renderGuide && row.depth > 0) {
164
+ const edges = branchEdges(row);
165
+ for (let level = 0; level < row.depth; level++) {
166
+ const guide = {
167
+ row: state,
168
+ level,
169
+ continues: edges[level],
170
+ own: level === row.depth - 1,
171
+ width: indent,
172
+ height: rowHeight,
173
+ };
174
+ content.push(hx('box', {
175
+ key: `guide${level}`,
176
+ style: [
177
+ s.guide,
178
+ { width: indent },
179
+ typeof guideStyle === 'function' ? guideStyle(guide) : guideStyle,
180
+ ],
181
+ }, renderGuide(guide)));
182
+ }
183
+ }
184
+ const toggleState = { ...state, size: TWISTY_GLYPH };
185
+ content.push(hx('box', {
186
+ key: 'toggle',
187
+ style: [s.twisty, toggleStyle],
188
+ // The twisty is its own hit target: clicking it opens the branch
189
+ // without moving the selection, the way a file browser lets you
190
+ // peek inside a folder you have not chosen.
191
+ onClick: row.branch
192
+ ? (ev) => {
193
+ ev.stopPropagation();
194
+ onToggle(row.id, row.item);
195
+ }
196
+ : undefined,
197
+ }, renderToggle
198
+ ? renderToggle(toggleState)
199
+ : row.branch
200
+ ? React.createElement(Icon, {
201
+ name: row.open
202
+ ? 'chevronDown'
203
+ : rtl
204
+ ? 'chevronLeft'
205
+ : 'chevronRight',
206
+ size: TWISTY_GLYPH,
207
+ // dimmer than the label on a resting row, and the row's own
208
+ // ink once it is selected
209
+ style: isSelected ? undefined : { color: theme.textMuted },
210
+ })
211
+ : null));
212
+ content.push(renderLabel
213
+ ? // Keyed here rather than by the app, for the reason `renderSubtree`
214
+ // is: the label sits in an array beside the guides and the twisty,
215
+ // and "add a key to the box you return" is not something a render
216
+ // prop should have to know.
217
+ React.createElement(React.Fragment, { key: 'label' }, renderLabel(state))
218
+ : labelNode(getLabel(row.item), [s.label, labelStyle]));
219
+ return hx('box', {
220
+ role: 'treeitem',
221
+ 'aria-level': row.depth + 1,
222
+ 'aria-selected': isSelected,
223
+ 'aria-expanded': row.branch ? row.open : undefined,
224
+ 'aria-posinset': row.posInSet,
225
+ 'aria-setsize': row.setSize,
226
+ // `disabled` rather than `aria-disabled`: on a react-x11 node it is
227
+ // the real thing — it clears the AT-SPI ENABLED/SENSITIVE states and
228
+ // selects the `:disabled` style block — and there is no aria spelling
229
+ // of it to write instead.
230
+ disabled: row.disabled || undefined,
231
+ // The index the row was drawn at travels with the node, so measuring
232
+ // does not have to search a hundred thousand rows for where it is.
233
+ // It can go stale — the rows may move before the tick that measures —
234
+ // and both this and the height index check it rather than trust it.
235
+ ref: (node) => {
236
+ register(row.id, row.index, node);
237
+ },
238
+ onClick: (ev) => {
239
+ if (row.disabled)
240
+ return;
241
+ onGo(row);
242
+ // Select on the first click, open on the second — the gesture every
243
+ // file list has. `detail` is the click count the renderer already
244
+ // counts for text selection.
245
+ if (ev.detail === 2)
246
+ onOpen(row);
247
+ },
248
+ style: [
249
+ s.row,
250
+ // A floor, not a height. The row grows to whatever its content
251
+ // needs — a wrapped label, two lines, a thumbnail — and the height
252
+ // index reads back what it actually became.
253
+ { minHeight: rowHeight },
254
+ // The indent is what says "inside", so it is measured from the edge
255
+ // the row's label begins at.
256
+ { paddingStart: renderGuide ? 4 : 4 + row.depth * indent },
257
+ {
258
+ backgroundColor: isSelected ? theme.hoverBackground : 'transparent',
259
+ // The row's ink, said once: `color` inherits, so the label takes
260
+ // it without being handed it.
261
+ color,
262
+ },
263
+ !row.disabled && {
264
+ ':hover': {
265
+ backgroundColor: isSelected
266
+ ? theme.hoverBackground
267
+ : theme.surfaceHover,
268
+ },
269
+ // The selection only moves on the release, and `:active` marks
270
+ // the whole press chain, so a press on the label or the twisty
271
+ // still darkens the row it is in.
272
+ ':active': {
273
+ backgroundColor: isSelected
274
+ ? theme.accentActive
275
+ : theme.surfaceActive,
276
+ },
277
+ },
278
+ typeof rowStyle === 'function' ? rowStyle(state) : rowStyle,
279
+ ],
280
+ }, renderContent ? renderContent(state, content) : content);
281
+ }
282
+ const MemoTreeRow = React.memo(TreeRowView);
120
283
  /**
121
284
  * `<Tree items />` — a disclosure tree.
122
285
  *
@@ -150,17 +313,17 @@ function labelNode(label, style) {
150
313
  * such policy is expressible on top of what is here: hold the set yourself,
151
314
  * pass `selected` for the cursor, and paint the rest from `styles.row`.
152
315
  */
153
- export function Tree({ items = [], expanded, defaultExpanded, onExpandedChange, selected, defaultSelected, onSelect, onActivate, indent = INDENT, rowHeight = ROW_HEIGHT, estimatedRowHeight, virtual = 'auto', overscan = OVERSCAN, layout = 'flat', renderToggle, renderGuide, renderLabel, renderContent, renderSubtree, styles, style, ref,
316
+ export function Tree({ items = [], expanded, defaultExpanded, onExpandedChange, selected, defaultSelected, onSelect, onActivate, indent = INDENT, rowHeight = ROW_HEIGHT, estimatedRowHeight, virtual = 'auto', overscan = DEFAULT_OVERSCAN, prefetch = DEFAULT_PREFETCH, layout = 'flat', renderToggle, renderGuide, renderLabel, renderContent, renderSubtree, renderScrollHint, scrollHintDelay = SCROLL_HINT_DELAY_MS, catchup, styles, style, ref,
154
317
  // the accessors, pulled out so the rest can be spread onto the box
155
318
  getId, getLabel, getText, getChildren, isBranch, isDisabled,
156
319
  // ours to chain rather than to hand over: virtualization is measured
157
320
  // through both of these
158
321
  onScroll, onViewport, ...boxProps }) {
322
+ globalThis.__renders = (globalThis.__renders ?? 0) + 1;
159
323
  const theme = useTheme();
160
324
  const rtl = useDirection() === 'rtl';
161
325
  const [ownExpanded, setOwnExpanded] = useState(() => new Set(defaultExpanded));
162
326
  const [ownSelected, setOwnSelected] = useState(defaultSelected ?? null);
163
- const [view, setView] = useState({ top: 0, height: 0 });
164
327
  // Bumped by a measurement pass that found a row taller or shorter than the
165
328
  // index believed. It is the only reason the component re-renders for a
166
329
  // measurement, and a pass that finds nothing new does not bump it, which is
@@ -199,8 +362,6 @@ onScroll, onViewport, ...boxProps }) {
199
362
  rowsRef.current = rows;
200
363
  const itemsRef = useRef(items);
201
364
  itemsRef.current = items;
202
- const viewRef = useRef(view);
203
- viewRef.current = view;
204
365
  const virtualizing = layout === 'flat' &&
205
366
  (virtual === true ||
206
367
  (virtual === 'auto' && rows.length > VIRTUAL_THRESHOLD));
@@ -209,28 +370,27 @@ onScroll, onViewport, ...boxProps }) {
209
370
  // on every render that did not change the tree.
210
371
  const index = heights;
211
372
  index.sync(rows, estimate);
212
- // The slice worth building: what is on screen, plus a little either side.
213
- // Which rows those are is a question for the height index now — with rows
214
- // of different heights there is no division that answers it.
215
- const first = virtualizing
216
- ? Math.max(0, index.indexAt(view.top) - overscan)
217
- : 0;
218
- let last = rows.length;
219
- if (virtualizing) {
220
- if (view.height > 0) {
221
- last = Math.min(rows.length, index.indexAt(view.top + view.height) + 1 + overscan);
222
- }
223
- else {
224
- // Before the first layout there is no viewport to measure against, and
225
- // guessing "all of them" would put a hundred thousand rows in the tree
226
- // for a frame.
227
- last = Math.min(rows.length, first + ASSUMED_ROWS);
228
- }
229
- }
230
- /** Where the slice starts, and how much of the list is below it — the two
231
- * spacers that keep the scrollbar measuring the whole tree. */
232
- const above = virtualizing ? index.offsetAt(first) : 0;
233
- const below = virtualizing ? index.total() - index.offsetAt(last) : 0;
373
+ /** The viewport, and the slice worth building from it the machinery
374
+ * shared with `<Table>` (`../internal/window.ts`). */
375
+ const win = useVirtualWindow({
376
+ box: scroller,
377
+ heights,
378
+ rows,
379
+ // tree rows are always measured, so the idle band above the viewport
380
+ // only re-builds territory already visited — see `exact` on the inputs
381
+ exact: false,
382
+ virtualizing,
383
+ overscan,
384
+ prefetch,
385
+ threshold: catchup?.threshold ?? SKELETON_THRESHOLD,
386
+ burstBudget: catchup?.burst ?? BURST_BUDGET,
387
+ settleBudget: catchup?.settle ?? SETTLE_BUDGET,
388
+ });
389
+ const { view, viewRef } = win;
390
+ /** Whether the fast-scroll pill is up — kept across renders so it does not
391
+ * flicker through a catch-up, only appearing and disappearing once. */
392
+ const hintShown = useRef(false);
393
+ const { first, last, above, below } = win.slice;
234
394
  const setExpandedSet = useCallback((next, change) => {
235
395
  openRef.current = next;
236
396
  if (expanded === undefined)
@@ -247,34 +407,30 @@ onScroll, onViewport, ...boxProps }) {
247
407
  setExpandedSet(next, { id, item, open: shouldOpen });
248
408
  }, [setExpandedSet]);
249
409
  /**
250
- * Put a row in view.
251
- *
252
- * A mounted row can say where it is, and `scrollIntoView` then works
253
- * whatever height it turned out to be which is what a tree that is *not*
254
- * virtualizing wants, since nothing has measured its rows. A row that is not
255
- * mounted has no geometry to ask about, and while virtualizing that is the
256
- * normal case, so the height index answers instead: where the row starts,
257
- * and how tall it is or is estimated to be.
410
+ * The scroll the tree owes a row, and the pane's real offset read back
411
+ * after every layout — the two halves of `../internal/scroll.ts`, which
412
+ * says why a reveal cannot be a one-shot and why `onScroll` is not the
413
+ * whole story. A tree grows and shrinks under its own hands: opening a
414
+ * branch is a content that got taller between the ask and the layout, in
415
+ * exactly the way an arriving row is.
258
416
  */
259
- const reveal = useCallback((at) => {
260
- const box = scroller.current;
417
+ const reveal = useReveal({
418
+ box: scroller,
419
+ rows: rowsRef,
420
+ nodes: rowNodes,
421
+ heights,
422
+ });
423
+ /** Put a row in view, by the index its call site already has. */
424
+ const revealAt = useCallback((at) => {
261
425
  const row = rowsRef.current[at];
262
- if (!box || !row)
263
- return;
264
- const drawn = rowNodes.current.get(row.id);
265
- if (drawn) {
266
- box.scrollIntoView(drawn.node);
267
- return;
268
- }
269
- const top = heights.offsetAt(at);
270
- const rowH = heights.heightAt(at);
271
- const height = viewRef.current.height;
272
- if (top < box.scrollY)
273
- box.scrollTo({ y: top });
274
- else if (height > 0 && top + rowH > box.scrollY + height) {
275
- box.scrollTo({ y: top + rowH - height });
276
- }
277
- }, []);
426
+ if (row)
427
+ reveal.to(row.id);
428
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- `reveal` is a
429
+ // stable handle
430
+ }, [reveal]);
431
+ /** Re-read the offset the pane is *actually* at — the window's `sync`; see
432
+ * `../internal/window.ts` for why the pane moves silently. */
433
+ const syncScroll = win.sync;
278
434
  /**
279
435
  * Read back what the rows on screen actually laid out at.
280
436
  *
@@ -290,7 +446,7 @@ onScroll, onViewport, ...boxProps }) {
290
446
  */
291
447
  const measureRows = useCallback(() => {
292
448
  if (!virtualizing)
293
- return;
449
+ return false;
294
450
  const box = scroller.current;
295
451
  const rows = rowsRef.current;
296
452
  const idx = heights;
@@ -313,17 +469,84 @@ onScroll, onViewport, ...boxProps }) {
313
469
  shift += height - was;
314
470
  }
315
471
  if (!changed)
316
- return;
317
- if (shift !== 0 && box) {
318
- box.scrollTo({ y: Math.max(0, box.scrollY + shift) });
319
- }
472
+ return false;
473
+ // A debt, not a one-shot: the pane clamps against the last layout's
474
+ // content height, so a shift from rows measured above the viewport can
475
+ // land short until the layout that admits the growth has run.
476
+ reveal.nudge(shift);
477
+ setMeasured((n) => n + 1);
478
+ return true;
479
+ }, [virtualizing]);
480
+ /**
481
+ * Let the estimate learn from the rows that have been measured — the
482
+ * scrollbar of a measured tree starts as a guess times the row count, and
483
+ * the measured mean is a far better guess for the rows not yet seen. Idle
484
+ * only: every unmeasured offset moves when it applies, and the anchor
485
+ * arithmetic keeping the screen still is `measureRows`'s.
486
+ */
487
+ const adaptEstimate = useCallback(() => {
488
+ if (!virtualizing)
489
+ return false;
490
+ const box = scroller.current;
491
+ if (!box)
492
+ return false;
493
+ const anchor = heights.indexAt(box.scrollY);
494
+ const before = heights.offsetAt(anchor);
495
+ if (!heights.adapt())
496
+ return false;
497
+ reveal.nudge(heights.offsetAt(anchor) - before);
320
498
  setMeasured((n) => n + 1);
499
+ return true;
500
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- `heights` and
501
+ // `reveal` are stable instances
321
502
  }, [virtualizing]);
503
+ /**
504
+ * The one tick after layout, and everything that can only be known there:
505
+ * what the rows measured, whether an owed scroll can go further now that
506
+ * the rows it was waiting for are laid out, and where the pane actually
507
+ * ended up. In that order — each step can move the offset the next one
508
+ * reads.
509
+ */
510
+ /** Whether some drawn row has no size yet — a commit can land between
511
+ * frame flushes, and a measure pass over it reads zeros. */
512
+ const rowsPendingLayout = useCallback(() => {
513
+ const rows = rowsRef.current;
514
+ for (const [id, { node, at }] of rowNodes.current) {
515
+ if (rows[at]?.id === id && !(node.abs.height > 0))
516
+ return true;
517
+ }
518
+ return false;
519
+ }, []);
322
520
  useEffect(() => {
323
521
  if (!virtualizing)
324
522
  return undefined;
325
- const id = afterLayout(measureRows);
326
- return () => cancelAfterLayout(id);
523
+ let look = null;
524
+ let tries = 0;
525
+ const pass = () => {
526
+ globalThis.__ticks = (globalThis.__ticks ?? 0) + 1;
527
+ // `measureRows` first, and its answer handed on: a pass that moved the
528
+ // heights has not settled anything, and an owed scroll judged against
529
+ // the layout it is about to invalidate is not owed any less. During a
530
+ // flick nothing is measured at all — every correction at that speed is
531
+ // invalidated by the next event — and the settle tick that follows any
532
+ // burst is where the deferred passes catch up.
533
+ const moved = win.fast() ? false : measureRows();
534
+ const adapted = !win.scrolling() && adaptEstimate();
535
+ reveal.retry(moved || adapted);
536
+ syncScroll();
537
+ // A commit can land between frame flushes: its rows report zero size
538
+ // until the flush, this tick has already run, and nothing else would
539
+ // come back for them — a window that just finished growing renders
540
+ // nothing further, and the missed measurements would stand for good.
541
+ // Look again, briefly, while any drawn row is still unsized.
542
+ if (rowsPendingLayout() && tries++ < 8)
543
+ look = later(pass, 16);
544
+ };
545
+ const id = afterLayout(pass);
546
+ return () => {
547
+ cancelAfterLayout(id);
548
+ cancelLater(look);
549
+ };
327
550
  });
328
551
  const goTo = useCallback((row) => {
329
552
  if (!row)
@@ -332,8 +555,8 @@ onScroll, onViewport, ...boxProps }) {
332
555
  if (selected === undefined)
333
556
  setOwnSelected(row.id);
334
557
  onSelect?.(row.id, row.item);
335
- reveal(row.index);
336
- }, [selected, onSelect, reveal]);
558
+ revealAt(row.index);
559
+ }, [selected, onSelect, revealAt]);
337
560
  const activate = useCallback((row) => {
338
561
  if (row.branch)
339
562
  toggleId(row.id, row.item);
@@ -476,16 +699,100 @@ onScroll, onViewport, ...boxProps }) {
476
699
  const row = rowsRef.current.find((r) => r.id === id);
477
700
  if (!row)
478
701
  return false;
479
- reveal(row.index);
702
+ revealAt(row.index);
480
703
  return true;
481
704
  },
482
705
  handleKey,
483
706
  rows: () => rowsRef.current,
484
- }), [goTo, toggleId, reveal, handleKey, selected, accessors]);
707
+ }), [goTo, toggleId, revealAt, handleKey, selected, accessors]);
485
708
  // --- rendering -----------------------------------------------------------
486
709
  const rowStyleProp = styles?.row;
487
710
  const guideStyleProp = styles?.guide;
711
+ // The row component's stable half — `MemoTreeRow` bails out of a
712
+ // re-render only if every prop kept its identity, and this one would
713
+ // otherwise be rebuilt per row per render.
714
+ const registerRow = useCallback((id, at, node) => {
715
+ if (node)
716
+ rowNodes.current.set(id, { node, at });
717
+ else
718
+ rowNodes.current.delete(id);
719
+ }, []);
720
+ /**
721
+ * The row *elements*, reused by identity while nothing they depend on has
722
+ * changed. The memo already skips re-rendering an unchanged row, but the
723
+ * skip still costs a `createElement` and a props compare per row per
724
+ * notch — the burst profile put bare `createElement` at a tenth of a
725
+ * flick's CPU. Handing React the identical element object instead takes
726
+ * the cheapest path it has: the fiber is reused with no compare at all.
727
+ */
728
+ const rowElems = useRef(new Map());
729
+ const rowElemDeps = useRef([]);
730
+ {
731
+ const deps = [
732
+ indent,
733
+ rowHeight,
734
+ rtl,
735
+ theme,
736
+ renderToggle,
737
+ renderGuide,
738
+ renderLabel,
739
+ renderContent,
740
+ rowStyleProp,
741
+ guideStyleProp,
742
+ styles?.toggle,
743
+ styles?.label,
744
+ accessors,
745
+ toggleId,
746
+ goTo,
747
+ activate,
748
+ registerRow,
749
+ ];
750
+ const prev = rowElemDeps.current;
751
+ if (prev.length !== deps.length || deps.some((d, at) => d !== prev[at])) {
752
+ rowElems.current.clear();
753
+ rowElemDeps.current = deps;
754
+ }
755
+ }
488
756
  const renderOneRow = (row) => {
757
+ const isSelected = row.id === current;
758
+ const cached = rowElems.current.get(row.id);
759
+ if (cached && cached.row === row && cached.selected === isSelected) {
760
+ return cached.el;
761
+ }
762
+ const el = React.createElement(MemoTreeRow, {
763
+ key: String(row.id),
764
+ row,
765
+ isSelected,
766
+ indent,
767
+ rowHeight,
768
+ rtl,
769
+ theme,
770
+ renderToggle,
771
+ renderGuide,
772
+ renderLabel,
773
+ renderContent,
774
+ rowStyle: rowStyleProp,
775
+ guideStyle: guideStyleProp,
776
+ toggleStyle: styles?.toggle,
777
+ labelStyle: styles?.label,
778
+ getLabel: accessors.getLabel,
779
+ onToggle: toggleId,
780
+ onGo: goTo,
781
+ onOpen: activate,
782
+ register: registerRow,
783
+ });
784
+ rowElems.current.set(row.id, { row, selected: isSelected, el });
785
+ return el;
786
+ };
787
+ /**
788
+ * A row the window said not to build in full yet: the box at its indexed
789
+ * height and none of its content — no guides, no twisty, no label — so
790
+ * the commit answering a flood lands frames before the full rows could.
791
+ * `styles.row` still applies, so row backgrounds hold. Not registered in
792
+ * `rowNodes`: a skeleton must not be measured into the height index, and
793
+ * cannot satisfy a reveal.
794
+ */
795
+ const renderSkeletonRow = (row) => {
489
796
  const isSelected = row.id === current;
490
797
  const color = row.disabled
491
798
  ? theme.textMuted
@@ -499,136 +806,35 @@ onScroll, onViewport, ...boxProps }) {
499
806
  toggle: (open) => toggleId(row.id, row.item, open),
500
807
  select: () => goTo(row),
501
808
  };
502
- const content = [];
503
- // The indent. With no guide seam it is one padding value rather than
504
- // `depth` empty boxes — a tree ten deep would otherwise build ten nodes
505
- // per row to draw nothing.
506
- if (renderGuide && row.depth > 0) {
507
- const edges = branchEdges(row);
508
- for (let level = 0; level < row.depth; level++) {
509
- const guide = {
510
- row: state,
511
- level,
512
- continues: edges[level],
513
- own: level === row.depth - 1,
514
- width: indent,
515
- height: rowHeight,
516
- };
517
- content.push(hx('box', {
518
- key: `guide${level}`,
519
- style: [
520
- s.guide,
521
- { width: indent },
522
- typeof guideStyleProp === 'function'
523
- ? guideStyleProp(guide)
524
- : guideStyleProp,
525
- ],
526
- }, renderGuide(guide)));
527
- }
528
- }
529
- const toggleState = { ...state, size: TWISTY_GLYPH };
530
- content.push(hx('box', {
531
- key: 'toggle',
532
- style: [s.twisty, styles?.toggle],
533
- // The twisty is its own hit target: clicking it opens the branch
534
- // without moving the selection, the way a file browser lets you
535
- // peek inside a folder you have not chosen.
536
- onClick: row.branch
537
- ? (ev) => {
538
- ev.stopPropagation();
539
- toggleId(row.id, row.item);
540
- }
541
- : undefined,
542
- }, renderToggle
543
- ? renderToggle(toggleState)
544
- : row.branch
545
- ? React.createElement(Icon, {
546
- name: row.open
547
- ? 'chevronDown'
548
- : rtl
549
- ? 'chevronLeft'
550
- : 'chevronRight',
551
- size: TWISTY_GLYPH,
552
- // dimmer than the label on a resting row, and the row's own
553
- // ink once it is selected
554
- style: isSelected ? undefined : { color: theme.textMuted },
555
- })
556
- : null));
557
- content.push(renderLabel
558
- ? // Keyed here rather than by the app, for the reason `renderSubtree`
559
- // is: the label sits in an array beside the guides and the twisty,
560
- // and "add a key to the box you return" is not something a render
561
- // prop should have to know.
562
- React.createElement(React.Fragment, { key: 'label' }, renderLabel(state))
563
- : labelNode(accessors.getLabel(row.item), [s.label, styles?.label]));
564
809
  return hx('box', {
565
810
  key: String(row.id),
566
- role: 'treeitem',
567
- 'aria-level': row.depth + 1,
568
- 'aria-selected': isSelected,
569
- 'aria-expanded': row.branch ? row.open : undefined,
570
- 'aria-posinset': row.posInSet,
571
- 'aria-setsize': row.setSize,
572
- // `disabled` rather than `aria-disabled`: on a react-x11 node it is
573
- // the real thing — it clears the AT-SPI ENABLED/SENSITIVE states and
574
- // selects the `:disabled` style block — and there is no aria spelling
575
- // of it to write instead.
576
- disabled: row.disabled || undefined,
577
- // The index the row was drawn at travels with the node, so measuring
578
- // does not have to search a hundred thousand rows for where it is.
579
- // It can go stale — the rows may move before the tick that measures —
580
- // and both this and the height index check it rather than trust it.
581
- ref: (node) => {
582
- if (node)
583
- rowNodes.current.set(row.id, { node, at: row.index });
584
- else
585
- rowNodes.current.delete(row.id);
586
- },
587
- onClick: (ev) => {
588
- if (row.disabled)
589
- return;
590
- goTo(row);
591
- // Select on the first click, open on the second — the gesture every
592
- // file list has. `detail` is the click count the renderer already
593
- // counts for text selection.
594
- if (ev.detail === 2)
595
- activate(row);
596
- },
811
+ 'aria-hidden': true,
597
812
  style: [
598
813
  s.row,
599
- // A floor, not a height. The row grows to whatever its content
600
- // needs a wrapped label, two lines, a thumbnail — and the height
601
- // index reads back what it actually became.
602
- { minHeight: rowHeight },
603
- // The indent is what says "inside", so it is measured from the edge
604
- // the row's label begins at.
605
- { paddingStart: renderGuide ? 4 : 4 + row.depth * indent },
814
+ // Exactly what the index believes, so the spacers and the
815
+ // scrollbar agree with the rows on where everything is.
816
+ { height: index.heightAt(row.index) },
606
817
  {
607
818
  backgroundColor: isSelected ? theme.hoverBackground : 'transparent',
608
- // The row's ink, said once: `color` inherits, so the label takes
609
- // it without being handed it.
610
- color,
611
- },
612
- !row.disabled && {
613
- ':hover': {
614
- backgroundColor: isSelected
615
- ? theme.hoverBackground
616
- : theme.surfaceHover,
617
- },
618
- // The selection only moves on the release, and `:active` marks
619
- // the whole press chain, so a press on the label or the twisty
620
- // still darkens the row it is in.
621
- ':active': {
622
- backgroundColor: isSelected
623
- ? theme.accentActive
624
- : theme.surfaceActive,
625
- },
626
819
  },
627
820
  typeof rowStyleProp === 'function'
628
821
  ? rowStyleProp(state)
629
822
  : rowStyleProp,
630
823
  ],
631
- }, renderContent ? renderContent(state, content) : content);
824
+ },
825
+ // A line of "text" with no text, at the row's own indent, so a band
826
+ // of placeholders reads as the tree arriving rather than a void.
827
+ hx('box', {
828
+ key: 'bar',
829
+ style: [
830
+ s.skeletonBar,
831
+ {
832
+ width: 72 + ((row.index * 37) % 89),
833
+ marginStart: 4 + row.depth * indent + TWISTY + 4,
834
+ backgroundColor: theme.track,
835
+ },
836
+ ],
837
+ }));
632
838
  };
633
839
  /** `layout="nested"`: the same rows, wrapped group by group. */
634
840
  const renderGroup = (group, key) => {
@@ -663,23 +869,100 @@ onScroll, onViewport, ...boxProps }) {
663
869
  style: [s.spacer, { height: above }],
664
870
  }));
665
871
  }
666
- for (let i = first; i < last; i++)
667
- body.push(renderOneRow(rows[i]));
872
+ for (let i = first; i < last; i++) {
873
+ body.push(win.skeletons.has(rows[i].id)
874
+ ? renderSkeletonRow(rows[i])
875
+ : renderOneRow(rows[i]));
876
+ }
668
877
  if (virtualizing && last < rows.length) {
669
878
  body.push(hx('box', {
670
879
  key: 'spacer:after',
671
880
  style: [s.spacer, { height: below }],
672
881
  }));
673
882
  }
883
+ // Rows that left the window leave the cache too, once it has grown
884
+ // well past the window — a scrub across a long list would otherwise
885
+ // hold an element for every row it passed.
886
+ if (rowElems.current.size > (last - first) * 3 + 64) {
887
+ rowElems.current.clear();
888
+ }
674
889
  }
675
- return hx('box', {
890
+ /**
891
+ * The fast-scroll overlay — shown only while placeholders cover enough of
892
+ * the viewport that the user would otherwise be looking at blank rows.
893
+ * The half-viewport threshold keeps a near-miss quiet: a scroll the next
894
+ * frame will absorb is not worth announcing. Once up it stays until the
895
+ * view is whole again, so it does not flicker through the catch-up.
896
+ *
897
+ * A sibling of the scroll pane, never a child: everything inside the pane
898
+ * — absolute children included — is shifted by the scroll, so a pill in
899
+ * there rides away with the very flick it is meant to narrate. Outside,
900
+ * it is painted after the pane on every repaint frame, which is what a
901
+ * scrub produces (a jump past the viewport cannot take the blit fast
902
+ * path), so it stays put while the content flies.
903
+ */
904
+ let scrollHint = null;
905
+ if (virtualizing && rows.length > 0 && view.height > 0) {
906
+ const vFirst = index.indexAt(view.top);
907
+ const vLast = Math.min(rows.length - 1, index.indexAt(view.top + view.height));
908
+ // Two ways in: placeholders covering enough of the viewport that it
909
+ // would otherwise read as blank, or a scrub — the window teleporting
910
+ // while the burst is still in flight, where every commit chases a
911
+ // viewport that has already left and nothing useful can be on screen.
912
+ // Either way only once the catch-up has already *lasted*: a jump the
913
+ // next few frames absorb is not worth announcing, so the pill waits
914
+ // out the show-delay against the catch-up clock. Latched once
915
+ // triggered: `pending` bounces to zero between catch-up commits, and a
916
+ // pill that blinked with it would read as a glitch. It goes when the
917
+ // burst does.
918
+ const engaged = (win.pending > 0 && win.pending * 2 >= vLast - vFirst + 1) ||
919
+ (win.jumped && win.scrolling());
920
+ const lasted = win.catchupSince !== null &&
921
+ Date.now() - win.catchupSince >= scrollHintDelay;
922
+ const show = (engaged && lasted) ||
923
+ (hintShown.current && (win.pending > 0 || win.scrolling()));
924
+ hintShown.current = show;
925
+ if (show) {
926
+ const hintState = {
927
+ row: rows[vFirst],
928
+ from: vFirst + 1,
929
+ to: vLast + 1,
930
+ count: rows.length,
931
+ pending: win.pending,
932
+ since: win.catchupSince ?? Date.now(),
933
+ };
934
+ const content = renderScrollHint
935
+ ? renderScrollHint(hintState)
936
+ : hx('text', { style: { fontSize: 11, color: theme.hoverText } }, `${hintState.from.toLocaleString()} / ${hintState.count.toLocaleString()}`);
937
+ if (content !== null && content !== undefined && content !== false) {
938
+ scrollHint = hx('box', {
939
+ key: 'scroll-hint',
940
+ // The pill duplicates what the scrollbar already tells an
941
+ // assistive technology, and it comes and goes with the
942
+ // catch-up — chatter, not content.
943
+ 'aria-hidden': true,
944
+ style: s.scrollHintLane,
945
+ }, hx('box', {
946
+ style: [s.scrollHint, { backgroundColor: theme.hoverBackground }],
947
+ }, content));
948
+ }
949
+ }
950
+ }
951
+ else {
952
+ hintShown.current = false;
953
+ }
954
+ // The wrapper exists for the overlay: the scroll pane keeps the role, the
955
+ // focus, the refs and the events — everything a `<Tree>` has always put
956
+ // on its root — and the caller's `style` lands out here, where the
957
+ // tree's place in the layout is decided.
958
+ return hx('box', { style: [s.outer, style] }, hx('box', {
676
959
  theme,
677
960
  role: 'tree',
678
961
  // The tree takes the focus, not the row — see the doc comment.
679
962
  focusable: true,
680
963
  ...boxProps,
681
964
  ref: scroller,
682
- style: [s.root, style],
965
+ style: s.root,
683
966
  /**
684
967
  * `preventDefault` is the load-bearing half.
685
968
  *
@@ -702,15 +985,24 @@ onScroll, onViewport, ...boxProps }) {
702
985
  // worth building — and it is also where a page key gets its distance,
703
986
  // so this is measured whether or not the tree virtualizes.
704
987
  onViewport: (ev) => {
705
- setView((prev) => prev.height === ev.height ? prev : { ...prev, height: ev.height });
988
+ win.sized(ev.width, ev.height);
989
+ // The content just changed size, which is both the moment an owed
990
+ // scroll can reach further than the clamp let it and the moment the
991
+ // pane may have re-clamped its offset without saying so. It is not a
992
+ // moment anything can be *settled* in: this runs from layout, a tick
993
+ // before the pass that reads the rows it just drew back.
994
+ reveal.retry(virtualizing);
995
+ syncScroll();
706
996
  onViewport?.(ev);
707
997
  },
708
998
  onScroll: (ev) => {
709
- if (virtualizing) {
710
- setView((prev) => prev.top === ev.scrollY ? prev : { ...prev, top: ev.scrollY });
711
- }
999
+ // A scroll this component did not ask for is the user taking over,
1000
+ // and an owed reveal must not yank the tree back out from under them
1001
+ // on the next layout.
1002
+ reveal.heard(ev.scrollY);
1003
+ win.scrolled(ev.scrollY);
712
1004
  onScroll?.(ev);
713
1005
  },
714
- }, body);
1006
+ }, body), scrollHint);
715
1007
  }
716
1008
  //# sourceMappingURL=index.js.map