@react-x11/components 0.2.1 → 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 +26 -1
- package/dist/internal/heights.d.ts.map +1 -1
- package/dist/internal/heights.js +57 -6
- package/dist/internal/heights.js.map +1 -1
- package/dist/internal/scroll.d.ts +11 -0
- package/dist/internal/scroll.d.ts.map +1 -1
- package/dist/internal/scroll.js +43 -3
- package/dist/internal/scroll.js.map +1 -1
- 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 +405 -161
- 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 +446 -189
- package/dist/tree/index.js.map +1 -1
- package/package.json +1 -1
- package/src/internal/heights.ts +57 -6
- package/src/internal/scroll.ts +59 -3
- package/src/internal/timers.ts +13 -0
- package/src/internal/window.ts +570 -0
- package/src/table/index.ts +599 -178
- package/src/tree/index.ts +687 -260
package/dist/tree/index.js
CHANGED
|
@@ -36,8 +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
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';
|
|
41
42
|
import { typeAheadChar, useTypeAhead } from './internal.js';
|
|
42
43
|
import { branchEdges, findItem, groupRows, isGroup, resolveAccessors, visibleRows, } from './rows.js';
|
|
43
44
|
export { branchEdges, findItem, resolveAccessors, visibleRows, } from './rows.js';
|
|
@@ -50,16 +51,6 @@ const TWISTY = 12;
|
|
|
50
51
|
* is half of that, so `size` for one reads as its width. */
|
|
51
52
|
const TWISTY_GLYPH = 10;
|
|
52
53
|
const ROW_HEIGHT = 22;
|
|
53
|
-
/** Rows kept either side of the viewport, so a fast scroll does not show a
|
|
54
|
-
* gap before the next frame catches up. */
|
|
55
|
-
const OVERSCAN = 6;
|
|
56
|
-
/**
|
|
57
|
-
* What to build before the viewport has been measured. `onViewport` cannot
|
|
58
|
-
* arrive until layout has run, which is a frame after the first commit, so
|
|
59
|
-
* there is always one render that has to guess — and guessing "all of them"
|
|
60
|
-
* puts a hundred thousand rows in the tree for a frame.
|
|
61
|
-
*/
|
|
62
|
-
const ASSUMED_ROWS = 40;
|
|
63
54
|
/**
|
|
64
55
|
* Where `virtual="auto"` starts virtualizing.
|
|
65
56
|
*
|
|
@@ -110,6 +101,39 @@ const s = createStyles({
|
|
|
110
101
|
},
|
|
111
102
|
subtree: { flexShrink: 0 },
|
|
112
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
|
+
},
|
|
113
137
|
});
|
|
114
138
|
/** A string or a number becomes a `<text>`; anything else is already a
|
|
115
139
|
* node. */
|
|
@@ -118,6 +142,144 @@ function labelNode(label, style) {
|
|
|
118
142
|
? hx('text', { key: 'label', style }, String(label))
|
|
119
143
|
: label;
|
|
120
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);
|
|
121
283
|
/**
|
|
122
284
|
* `<Tree items />` — a disclosure tree.
|
|
123
285
|
*
|
|
@@ -151,17 +313,17 @@ function labelNode(label, style) {
|
|
|
151
313
|
* such policy is expressible on top of what is here: hold the set yourself,
|
|
152
314
|
* pass `selected` for the cursor, and paint the rest from `styles.row`.
|
|
153
315
|
*/
|
|
154
|
-
export function Tree({ items = [], expanded, defaultExpanded, onExpandedChange, selected, defaultSelected, onSelect, onActivate, indent = INDENT, rowHeight = ROW_HEIGHT, estimatedRowHeight, virtual = 'auto', overscan =
|
|
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,
|
|
155
317
|
// the accessors, pulled out so the rest can be spread onto the box
|
|
156
318
|
getId, getLabel, getText, getChildren, isBranch, isDisabled,
|
|
157
319
|
// ours to chain rather than to hand over: virtualization is measured
|
|
158
320
|
// through both of these
|
|
159
321
|
onScroll, onViewport, ...boxProps }) {
|
|
322
|
+
globalThis.__renders = (globalThis.__renders ?? 0) + 1;
|
|
160
323
|
const theme = useTheme();
|
|
161
324
|
const rtl = useDirection() === 'rtl';
|
|
162
325
|
const [ownExpanded, setOwnExpanded] = useState(() => new Set(defaultExpanded));
|
|
163
326
|
const [ownSelected, setOwnSelected] = useState(defaultSelected ?? null);
|
|
164
|
-
const [view, setView] = useState({ top: 0, height: 0 });
|
|
165
327
|
// Bumped by a measurement pass that found a row taller or shorter than the
|
|
166
328
|
// index believed. It is the only reason the component re-renders for a
|
|
167
329
|
// measurement, and a pass that finds nothing new does not bump it, which is
|
|
@@ -200,8 +362,6 @@ onScroll, onViewport, ...boxProps }) {
|
|
|
200
362
|
rowsRef.current = rows;
|
|
201
363
|
const itemsRef = useRef(items);
|
|
202
364
|
itemsRef.current = items;
|
|
203
|
-
const viewRef = useRef(view);
|
|
204
|
-
viewRef.current = view;
|
|
205
365
|
const virtualizing = layout === 'flat' &&
|
|
206
366
|
(virtual === true ||
|
|
207
367
|
(virtual === 'auto' && rows.length > VIRTUAL_THRESHOLD));
|
|
@@ -210,28 +370,27 @@ onScroll, onViewport, ...boxProps }) {
|
|
|
210
370
|
// on every render that did not change the tree.
|
|
211
371
|
const index = heights;
|
|
212
372
|
index.sync(rows, estimate);
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
const above
|
|
234
|
-
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;
|
|
235
394
|
const setExpandedSet = useCallback((next, change) => {
|
|
236
395
|
openRef.current = next;
|
|
237
396
|
if (expanded === undefined)
|
|
@@ -269,22 +428,9 @@ onScroll, onViewport, ...boxProps }) {
|
|
|
269
428
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- `reveal` is a
|
|
270
429
|
// stable handle
|
|
271
430
|
}, [reveal]);
|
|
272
|
-
/**
|
|
273
|
-
*
|
|
274
|
-
|
|
275
|
-
* It moves silently — a queued reveal resolves during layout, and an offset
|
|
276
|
-
* the content outgrew or outshrank is re-clamped there — and a slice built
|
|
277
|
-
* from the offset before those is drawn where the viewport is not: a blank
|
|
278
|
-
* band where the rows should be, until a scroll of your own re-syncs it by
|
|
279
|
-
* accident.
|
|
280
|
-
*/
|
|
281
|
-
const syncScroll = useCallback(() => {
|
|
282
|
-
const box = scroller.current;
|
|
283
|
-
if (!box || !virtualizing)
|
|
284
|
-
return;
|
|
285
|
-
const y = box.scrollY;
|
|
286
|
-
setView((prev) => (prev.top === y ? prev : { ...prev, top: y }));
|
|
287
|
-
}, [virtualizing]);
|
|
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;
|
|
288
434
|
/**
|
|
289
435
|
* Read back what the rows on screen actually laid out at.
|
|
290
436
|
*
|
|
@@ -324,12 +470,36 @@ onScroll, onViewport, ...boxProps }) {
|
|
|
324
470
|
}
|
|
325
471
|
if (!changed)
|
|
326
472
|
return false;
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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);
|
|
330
477
|
setMeasured((n) => n + 1);
|
|
331
478
|
return true;
|
|
332
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);
|
|
498
|
+
setMeasured((n) => n + 1);
|
|
499
|
+
return true;
|
|
500
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- `heights` and
|
|
501
|
+
// `reveal` are stable instances
|
|
502
|
+
}, [virtualizing]);
|
|
333
503
|
/**
|
|
334
504
|
* The one tick after layout, and everything that can only be known there:
|
|
335
505
|
* what the rows measured, whether an owed scroll can go further now that
|
|
@@ -337,17 +507,46 @@ onScroll, onViewport, ...boxProps }) {
|
|
|
337
507
|
* ended up. In that order — each step can move the offset the next one
|
|
338
508
|
* reads.
|
|
339
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
|
+
}, []);
|
|
340
520
|
useEffect(() => {
|
|
341
521
|
if (!virtualizing)
|
|
342
522
|
return undefined;
|
|
343
|
-
|
|
523
|
+
let look = null;
|
|
524
|
+
let tries = 0;
|
|
525
|
+
const pass = () => {
|
|
526
|
+
globalThis.__ticks = (globalThis.__ticks ?? 0) + 1;
|
|
344
527
|
// `measureRows` first, and its answer handed on: a pass that moved the
|
|
345
528
|
// heights has not settled anything, and an owed scroll judged against
|
|
346
|
-
// the layout it is about to invalidate is not owed any less.
|
|
347
|
-
|
|
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);
|
|
348
536
|
syncScroll();
|
|
349
|
-
|
|
350
|
-
|
|
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
|
+
};
|
|
351
550
|
});
|
|
352
551
|
const goTo = useCallback((row) => {
|
|
353
552
|
if (!row)
|
|
@@ -509,7 +708,91 @@ onScroll, onViewport, ...boxProps }) {
|
|
|
509
708
|
// --- rendering -----------------------------------------------------------
|
|
510
709
|
const rowStyleProp = styles?.row;
|
|
511
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
|
+
}
|
|
512
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) => {
|
|
513
796
|
const isSelected = row.id === current;
|
|
514
797
|
const color = row.disabled
|
|
515
798
|
? theme.textMuted
|
|
@@ -523,136 +806,35 @@ onScroll, onViewport, ...boxProps }) {
|
|
|
523
806
|
toggle: (open) => toggleId(row.id, row.item, open),
|
|
524
807
|
select: () => goTo(row),
|
|
525
808
|
};
|
|
526
|
-
const content = [];
|
|
527
|
-
// The indent. With no guide seam it is one padding value rather than
|
|
528
|
-
// `depth` empty boxes — a tree ten deep would otherwise build ten nodes
|
|
529
|
-
// per row to draw nothing.
|
|
530
|
-
if (renderGuide && row.depth > 0) {
|
|
531
|
-
const edges = branchEdges(row);
|
|
532
|
-
for (let level = 0; level < row.depth; level++) {
|
|
533
|
-
const guide = {
|
|
534
|
-
row: state,
|
|
535
|
-
level,
|
|
536
|
-
continues: edges[level],
|
|
537
|
-
own: level === row.depth - 1,
|
|
538
|
-
width: indent,
|
|
539
|
-
height: rowHeight,
|
|
540
|
-
};
|
|
541
|
-
content.push(hx('box', {
|
|
542
|
-
key: `guide${level}`,
|
|
543
|
-
style: [
|
|
544
|
-
s.guide,
|
|
545
|
-
{ width: indent },
|
|
546
|
-
typeof guideStyleProp === 'function'
|
|
547
|
-
? guideStyleProp(guide)
|
|
548
|
-
: guideStyleProp,
|
|
549
|
-
],
|
|
550
|
-
}, renderGuide(guide)));
|
|
551
|
-
}
|
|
552
|
-
}
|
|
553
|
-
const toggleState = { ...state, size: TWISTY_GLYPH };
|
|
554
|
-
content.push(hx('box', {
|
|
555
|
-
key: 'toggle',
|
|
556
|
-
style: [s.twisty, styles?.toggle],
|
|
557
|
-
// The twisty is its own hit target: clicking it opens the branch
|
|
558
|
-
// without moving the selection, the way a file browser lets you
|
|
559
|
-
// peek inside a folder you have not chosen.
|
|
560
|
-
onClick: row.branch
|
|
561
|
-
? (ev) => {
|
|
562
|
-
ev.stopPropagation();
|
|
563
|
-
toggleId(row.id, row.item);
|
|
564
|
-
}
|
|
565
|
-
: undefined,
|
|
566
|
-
}, renderToggle
|
|
567
|
-
? renderToggle(toggleState)
|
|
568
|
-
: row.branch
|
|
569
|
-
? React.createElement(Icon, {
|
|
570
|
-
name: row.open
|
|
571
|
-
? 'chevronDown'
|
|
572
|
-
: rtl
|
|
573
|
-
? 'chevronLeft'
|
|
574
|
-
: 'chevronRight',
|
|
575
|
-
size: TWISTY_GLYPH,
|
|
576
|
-
// dimmer than the label on a resting row, and the row's own
|
|
577
|
-
// ink once it is selected
|
|
578
|
-
style: isSelected ? undefined : { color: theme.textMuted },
|
|
579
|
-
})
|
|
580
|
-
: null));
|
|
581
|
-
content.push(renderLabel
|
|
582
|
-
? // Keyed here rather than by the app, for the reason `renderSubtree`
|
|
583
|
-
// is: the label sits in an array beside the guides and the twisty,
|
|
584
|
-
// and "add a key to the box you return" is not something a render
|
|
585
|
-
// prop should have to know.
|
|
586
|
-
React.createElement(React.Fragment, { key: 'label' }, renderLabel(state))
|
|
587
|
-
: labelNode(accessors.getLabel(row.item), [s.label, styles?.label]));
|
|
588
809
|
return hx('box', {
|
|
589
810
|
key: String(row.id),
|
|
590
|
-
|
|
591
|
-
'aria-level': row.depth + 1,
|
|
592
|
-
'aria-selected': isSelected,
|
|
593
|
-
'aria-expanded': row.branch ? row.open : undefined,
|
|
594
|
-
'aria-posinset': row.posInSet,
|
|
595
|
-
'aria-setsize': row.setSize,
|
|
596
|
-
// `disabled` rather than `aria-disabled`: on a react-x11 node it is
|
|
597
|
-
// the real thing — it clears the AT-SPI ENABLED/SENSITIVE states and
|
|
598
|
-
// selects the `:disabled` style block — and there is no aria spelling
|
|
599
|
-
// of it to write instead.
|
|
600
|
-
disabled: row.disabled || undefined,
|
|
601
|
-
// The index the row was drawn at travels with the node, so measuring
|
|
602
|
-
// does not have to search a hundred thousand rows for where it is.
|
|
603
|
-
// It can go stale — the rows may move before the tick that measures —
|
|
604
|
-
// and both this and the height index check it rather than trust it.
|
|
605
|
-
ref: (node) => {
|
|
606
|
-
if (node)
|
|
607
|
-
rowNodes.current.set(row.id, { node, at: row.index });
|
|
608
|
-
else
|
|
609
|
-
rowNodes.current.delete(row.id);
|
|
610
|
-
},
|
|
611
|
-
onClick: (ev) => {
|
|
612
|
-
if (row.disabled)
|
|
613
|
-
return;
|
|
614
|
-
goTo(row);
|
|
615
|
-
// Select on the first click, open on the second — the gesture every
|
|
616
|
-
// file list has. `detail` is the click count the renderer already
|
|
617
|
-
// counts for text selection.
|
|
618
|
-
if (ev.detail === 2)
|
|
619
|
-
activate(row);
|
|
620
|
-
},
|
|
811
|
+
'aria-hidden': true,
|
|
621
812
|
style: [
|
|
622
813
|
s.row,
|
|
623
|
-
//
|
|
624
|
-
//
|
|
625
|
-
|
|
626
|
-
{ minHeight: rowHeight },
|
|
627
|
-
// The indent is what says "inside", so it is measured from the edge
|
|
628
|
-
// the row's label begins at.
|
|
629
|
-
{ 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) },
|
|
630
817
|
{
|
|
631
818
|
backgroundColor: isSelected ? theme.hoverBackground : 'transparent',
|
|
632
|
-
// The row's ink, said once: `color` inherits, so the label takes
|
|
633
|
-
// it without being handed it.
|
|
634
|
-
color,
|
|
635
|
-
},
|
|
636
|
-
!row.disabled && {
|
|
637
|
-
':hover': {
|
|
638
|
-
backgroundColor: isSelected
|
|
639
|
-
? theme.hoverBackground
|
|
640
|
-
: theme.surfaceHover,
|
|
641
|
-
},
|
|
642
|
-
// The selection only moves on the release, and `:active` marks
|
|
643
|
-
// the whole press chain, so a press on the label or the twisty
|
|
644
|
-
// still darkens the row it is in.
|
|
645
|
-
':active': {
|
|
646
|
-
backgroundColor: isSelected
|
|
647
|
-
? theme.accentActive
|
|
648
|
-
: theme.surfaceActive,
|
|
649
|
-
},
|
|
650
819
|
},
|
|
651
820
|
typeof rowStyleProp === 'function'
|
|
652
821
|
? rowStyleProp(state)
|
|
653
822
|
: rowStyleProp,
|
|
654
823
|
],
|
|
655
|
-
},
|
|
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
|
+
}));
|
|
656
838
|
};
|
|
657
839
|
/** `layout="nested"`: the same rows, wrapped group by group. */
|
|
658
840
|
const renderGroup = (group, key) => {
|
|
@@ -687,23 +869,100 @@ onScroll, onViewport, ...boxProps }) {
|
|
|
687
869
|
style: [s.spacer, { height: above }],
|
|
688
870
|
}));
|
|
689
871
|
}
|
|
690
|
-
for (let i = first; i < last; i++)
|
|
691
|
-
body.push(
|
|
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
|
+
}
|
|
692
877
|
if (virtualizing && last < rows.length) {
|
|
693
878
|
body.push(hx('box', {
|
|
694
879
|
key: 'spacer:after',
|
|
695
880
|
style: [s.spacer, { height: below }],
|
|
696
881
|
}));
|
|
697
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
|
+
}
|
|
698
889
|
}
|
|
699
|
-
|
|
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', {
|
|
700
959
|
theme,
|
|
701
960
|
role: 'tree',
|
|
702
961
|
// The tree takes the focus, not the row — see the doc comment.
|
|
703
962
|
focusable: true,
|
|
704
963
|
...boxProps,
|
|
705
964
|
ref: scroller,
|
|
706
|
-
style:
|
|
965
|
+
style: s.root,
|
|
707
966
|
/**
|
|
708
967
|
* `preventDefault` is the load-bearing half.
|
|
709
968
|
*
|
|
@@ -726,7 +985,7 @@ onScroll, onViewport, ...boxProps }) {
|
|
|
726
985
|
// worth building — and it is also where a page key gets its distance,
|
|
727
986
|
// so this is measured whether or not the tree virtualizes.
|
|
728
987
|
onViewport: (ev) => {
|
|
729
|
-
|
|
988
|
+
win.sized(ev.width, ev.height);
|
|
730
989
|
// The content just changed size, which is both the moment an owed
|
|
731
990
|
// scroll can reach further than the clamp let it and the moment the
|
|
732
991
|
// pane may have re-clamped its offset without saying so. It is not a
|
|
@@ -741,11 +1000,9 @@ onScroll, onViewport, ...boxProps }) {
|
|
|
741
1000
|
// and an owed reveal must not yank the tree back out from under them
|
|
742
1001
|
// on the next layout.
|
|
743
1002
|
reveal.heard(ev.scrollY);
|
|
744
|
-
|
|
745
|
-
setView((prev) => prev.top === ev.scrollY ? prev : { ...prev, top: ev.scrollY });
|
|
746
|
-
}
|
|
1003
|
+
win.scrolled(ev.scrollY);
|
|
747
1004
|
onScroll?.(ev);
|
|
748
1005
|
},
|
|
749
|
-
}, body);
|
|
1006
|
+
}, body), scrollHint);
|
|
750
1007
|
}
|
|
751
1008
|
//# sourceMappingURL=index.js.map
|