@react-x11/components 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/internal/heights.d.ts +33 -1
- package/dist/internal/heights.d.ts.map +1 -1
- package/dist/internal/heights.js +72 -11
- package/dist/internal/heights.js.map +1 -1
- package/dist/internal/scroll.d.ts +73 -0
- package/dist/internal/scroll.d.ts.map +1 -0
- package/dist/internal/scroll.js +264 -0
- package/dist/internal/scroll.js.map +1 -0
- package/dist/internal/timers.d.ts +8 -0
- package/dist/internal/timers.d.ts.map +1 -1
- package/dist/internal/timers.js +10 -0
- package/dist/internal/timers.js.map +1 -1
- package/dist/internal/window.d.ts +161 -0
- package/dist/internal/window.d.ts.map +1 -0
- package/dist/internal/window.js +417 -0
- package/dist/internal/window.js.map +1 -0
- package/dist/table/index.d.ts +73 -2
- package/dist/table/index.d.ts.map +1 -1
- package/dist/table/index.js +468 -178
- package/dist/table/index.js.map +1 -1
- package/dist/tree/index.d.ts +65 -5
- package/dist/tree/index.d.ts.map +1 -1
- package/dist/tree/index.js +494 -202
- package/dist/tree/index.js.map +1 -1
- package/package.json +1 -1
- package/src/internal/heights.ts +73 -11
- package/src/internal/scroll.ts +344 -0
- package/src/internal/timers.ts +13 -0
- package/src/internal/window.ts +570 -0
- package/src/table/index.ts +666 -194
- package/src/tree/index.ts +730 -263
package/dist/table/index.js
CHANGED
|
@@ -36,7 +36,9 @@ import { hx } from './hx.js';
|
|
|
36
36
|
// Shared with <Tree> — 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 { MIN_COLUMN, columnValue, orderRows, resolveGetId, resolveWidths, } from './rows.js';
|
|
41
43
|
export { MIN_COLUMN, UNSIZED_MIN, columnValue, defaultCompare, orderRows, resolveGetId, resolveWidths, } from './rows.js';
|
|
42
44
|
/** The header strip's height. Core's number — independent of the body's
|
|
@@ -54,16 +56,6 @@ const HALF = 3;
|
|
|
54
56
|
const GRIP = HALF + RULE + HALF;
|
|
55
57
|
/** What one Left/Right on a focused grip is worth. */
|
|
56
58
|
const STEP = 16;
|
|
57
|
-
/** Rows kept either side of the viewport, so a fast scroll does not show a
|
|
58
|
-
* gap before the next frame catches up. */
|
|
59
|
-
const OVERSCAN = 6;
|
|
60
|
-
/**
|
|
61
|
-
* What to build before the viewport has been measured. `onViewport` cannot
|
|
62
|
-
* arrive until layout has run, which is a frame after the first commit, so
|
|
63
|
-
* there is always one render that has to guess — and guessing "all of them"
|
|
64
|
-
* puts a hundred thousand rows in the tree for a frame.
|
|
65
|
-
*/
|
|
66
|
-
const ASSUMED_ROWS = 40;
|
|
67
59
|
/**
|
|
68
60
|
* Where `virtual="auto"` starts virtualizing.
|
|
69
61
|
*
|
|
@@ -124,6 +116,37 @@ const s = createStyles({
|
|
|
124
116
|
},
|
|
125
117
|
cellText: { fontSize: 12, textWrap: 'nowrap', textBoxTrim: 'cap-alphabetic' },
|
|
126
118
|
spacer: { flexShrink: 0 },
|
|
119
|
+
/** The bar inside a skeleton row — a line of "text" with no text, so a
|
|
120
|
+
* band of placeholders reads as rows arriving rather than a void. */
|
|
121
|
+
skeletonBar: {
|
|
122
|
+
height: 8,
|
|
123
|
+
borderRadius: 4,
|
|
124
|
+
marginStart: 8,
|
|
125
|
+
alignSelf: 'center',
|
|
126
|
+
flexShrink: 0,
|
|
127
|
+
},
|
|
128
|
+
/** The lane the fast-scroll pill floats in: absolute against the table's
|
|
129
|
+
* root so the body pane scrolls under it, full-width so the pill centres
|
|
130
|
+
* itself, and transparent to the pointer so the rows beneath stay
|
|
131
|
+
* clickable. */
|
|
132
|
+
scrollHintLane: {
|
|
133
|
+
position: 'absolute',
|
|
134
|
+
left: 0,
|
|
135
|
+
right: 0,
|
|
136
|
+
bottom: 12,
|
|
137
|
+
flexDirection: 'row',
|
|
138
|
+
justifyContent: 'center',
|
|
139
|
+
pointerEvents: 'none',
|
|
140
|
+
},
|
|
141
|
+
scrollHint: {
|
|
142
|
+
paddingStart: 10,
|
|
143
|
+
paddingEnd: 10,
|
|
144
|
+
paddingTop: 5,
|
|
145
|
+
paddingBottom: 5,
|
|
146
|
+
borderRadius: 12,
|
|
147
|
+
flexDirection: 'row',
|
|
148
|
+
alignItems: 'center',
|
|
149
|
+
},
|
|
127
150
|
sortMark: { marginStart: 4 },
|
|
128
151
|
empty: {
|
|
129
152
|
flexGrow: 1,
|
|
@@ -133,6 +156,97 @@ const s = createStyles({
|
|
|
133
156
|
},
|
|
134
157
|
});
|
|
135
158
|
const EMPTY_SET = new Set();
|
|
159
|
+
function TableRowView(props) {
|
|
160
|
+
const { entry, columns, widths, setSize, isSelected, selectable, uniform, rowHeight, estimate, theme, rowStyle, cellStyle, renderRow, hasMenu, onTap, onOpen, onMenu, register, } = props;
|
|
161
|
+
const color = isSelected ? theme.hoverText : theme.text;
|
|
162
|
+
const state = { ...entry, selected: isSelected, color };
|
|
163
|
+
const content = columns.map((column, at) => {
|
|
164
|
+
const cellState = { ...state, column };
|
|
165
|
+
return hx('box', {
|
|
166
|
+
key: column.id,
|
|
167
|
+
role: 'cell',
|
|
168
|
+
style: [
|
|
169
|
+
s.cell,
|
|
170
|
+
{ width: widths[at] },
|
|
171
|
+
uniform && { height: rowHeight },
|
|
172
|
+
column.align === 'end' && {
|
|
173
|
+
alignItems: 'flex-end',
|
|
174
|
+
paddingEnd: 8,
|
|
175
|
+
},
|
|
176
|
+
column.align === 'center' && { alignItems: 'center' },
|
|
177
|
+
typeof cellStyle === 'function' ? cellStyle(cellState) : cellStyle,
|
|
178
|
+
],
|
|
179
|
+
}, column.render
|
|
180
|
+
? // A cell that draws itself still has to know it is on the
|
|
181
|
+
// selected row — see the doc comment. Keyed by the component,
|
|
182
|
+
// the way every seam's return is.
|
|
183
|
+
React.createElement(React.Fragment, { key: 'content' }, column.render(entry.row, cellState))
|
|
184
|
+
: hx('text', { style: [s.cellText, { color }] }, String(columnValue(entry.row, column) ?? '')));
|
|
185
|
+
});
|
|
186
|
+
return hx('box', {
|
|
187
|
+
role: 'row',
|
|
188
|
+
'aria-selected': selectable ? isSelected : undefined,
|
|
189
|
+
'aria-posinset': entry.index + 1,
|
|
190
|
+
'aria-setsize': setSize,
|
|
191
|
+
// The index the row was drawn at travels with the node, so measuring
|
|
192
|
+
// does not have to search the row list for it. It can go stale — the
|
|
193
|
+
// rows may move before the tick that measures — and both this and
|
|
194
|
+
// the height index check it rather than trust it.
|
|
195
|
+
ref: (node) => {
|
|
196
|
+
register(entry.id, entry.index, node);
|
|
197
|
+
},
|
|
198
|
+
onClick: (ev) => {
|
|
199
|
+
// A right-click also arrives here as a click; the selection it
|
|
200
|
+
// implies is `onContextMenu`'s to make (select-unless-selected),
|
|
201
|
+
// not the left button's replace.
|
|
202
|
+
if (ev.button !== 1)
|
|
203
|
+
return;
|
|
204
|
+
onTap(entry, { ctrl: ev.ctrlKey, shift: ev.shiftKey });
|
|
205
|
+
// Select on the first click, open on the second — the gesture
|
|
206
|
+
// every file list has. `detail` is the click count the renderer
|
|
207
|
+
// already counts for text selection.
|
|
208
|
+
if (ev.detail === 2)
|
|
209
|
+
onOpen(entry);
|
|
210
|
+
},
|
|
211
|
+
onContextMenu: hasMenu
|
|
212
|
+
? (ev) => {
|
|
213
|
+
onMenu(entry, ev);
|
|
214
|
+
}
|
|
215
|
+
: undefined,
|
|
216
|
+
style: [
|
|
217
|
+
s.row,
|
|
218
|
+
// Declared uniform: exactly this tall, content clipped — core's
|
|
219
|
+
// row. Measured: a floor, and the row grows to whatever its
|
|
220
|
+
// content needs; the height index reads back what it became.
|
|
221
|
+
uniform
|
|
222
|
+
? { height: rowHeight, alignItems: 'center' }
|
|
223
|
+
: { minHeight: estimate },
|
|
224
|
+
selectable && { cursor: 'pointer' },
|
|
225
|
+
{
|
|
226
|
+
backgroundColor: isSelected ? theme.hoverBackground : 'transparent',
|
|
227
|
+
// The row's ink, said once: `color` inherits, so default cells
|
|
228
|
+
// take it without being handed it.
|
|
229
|
+
color,
|
|
230
|
+
},
|
|
231
|
+
selectable && {
|
|
232
|
+
// pressed even on the selected row: a re-press on the row that
|
|
233
|
+
// is already current is the one click in the table that would
|
|
234
|
+
// otherwise look ignored
|
|
235
|
+
':active': {
|
|
236
|
+
backgroundColor: isSelected
|
|
237
|
+
? theme.accentActive
|
|
238
|
+
: theme.surfaceActive,
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
selectable &&
|
|
242
|
+
!isSelected && {
|
|
243
|
+
':hover': { backgroundColor: theme.surfaceHover },
|
|
244
|
+
},
|
|
245
|
+
typeof rowStyle === 'function' ? rowStyle(state) : rowStyle,
|
|
246
|
+
],
|
|
247
|
+
}, renderRow ? renderRow(state, content) : content);
|
|
248
|
+
}
|
|
249
|
+
const MemoTableRow = React.memo(TableRowView);
|
|
136
250
|
/**
|
|
137
251
|
* `<Table columns rows />` — a data table with a header that stays put.
|
|
138
252
|
*
|
|
@@ -165,7 +279,7 @@ const EMPTY_SET = new Set();
|
|
|
165
279
|
* the same rows a sighted user can see.
|
|
166
280
|
*/
|
|
167
281
|
export function Table(props) {
|
|
168
|
-
const { columns = [], rows = [], getId, sort, defaultSort, onSortChange, presorted = false, onActivate, onRowContextMenu, onColumnResize, rowHeight, estimatedRowHeight, virtual = 'auto', overscan =
|
|
282
|
+
const { columns = [], rows = [], getId, sort, defaultSort, onSortChange, presorted = false, onActivate, onRowContextMenu, onColumnResize, rowHeight, estimatedRowHeight, virtual = 'auto', overscan = DEFAULT_OVERSCAN, prefetch = DEFAULT_PREFETCH, renderRow, renderEmpty, renderScrollHint, scrollHintDelay = SCROLL_HINT_DELAY_MS, catchup, styles, style, focusable = true, ref, selectionMode = 'single', selected, defaultSelected, onSelect, onSelectedChange,
|
|
169
283
|
// ours to chain rather than to hand over: virtualization and the sticky
|
|
170
284
|
// header are measured through both of these
|
|
171
285
|
onScroll, onViewport, ...boxProps } = props;
|
|
@@ -198,7 +312,6 @@ export function Table(props) {
|
|
|
198
312
|
const [cursorId, setCursorId] = useState(null);
|
|
199
313
|
const [userWidths, setUserWidths] = useState({});
|
|
200
314
|
const [scrollX, setScrollX] = useState(0);
|
|
201
|
-
const [view, setView] = useState({ top: 0, height: 0, width: 0 });
|
|
202
315
|
// Bumped by a measurement pass that found a row taller or shorter than the
|
|
203
316
|
// index believed. It is the only reason the component re-renders for a
|
|
204
317
|
// measurement, and a pass that finds nothing new does not bump it — which
|
|
@@ -234,8 +347,6 @@ export function Table(props) {
|
|
|
234
347
|
selectedRef.current = selectedIds;
|
|
235
348
|
const cursorRef = useRef(cursor);
|
|
236
349
|
cursorRef.current = cursor;
|
|
237
|
-
const viewRef = useRef(view);
|
|
238
|
-
viewRef.current = view;
|
|
239
350
|
/** Where a Shift range grows from — the last plain click or plain step. */
|
|
240
351
|
const anchorRef = useRef(null);
|
|
241
352
|
const root = useRef(null);
|
|
@@ -244,6 +355,9 @@ export function Table(props) {
|
|
|
244
355
|
* measurement pass does not have to search the row list for it. */
|
|
245
356
|
const rowNodes = useRef(new Map());
|
|
246
357
|
const drag = useRef(null);
|
|
358
|
+
/** Whether the fast-scroll pill is up — kept across renders so it does not
|
|
359
|
+
* flicker through a catch-up, only appearing and disappearing once. */
|
|
360
|
+
const hintShown = useRef(false);
|
|
247
361
|
const userWidthsRef = useRef(userWidths);
|
|
248
362
|
userWidthsRef.current = userWidths;
|
|
249
363
|
/** Declared uniform: divide, never measure — core's model. */
|
|
@@ -259,26 +373,54 @@ export function Table(props) {
|
|
|
259
373
|
heights.sync(ordered, estimate);
|
|
260
374
|
const virtualizing = virtual === true ||
|
|
261
375
|
(virtual === 'auto' && ordered.length > VIRTUAL_THRESHOLD);
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
:
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
const
|
|
376
|
+
/** The viewport, and the slice worth building from it — the machinery
|
|
377
|
+
* shared with `<Tree>` (`../internal/window.ts`). */
|
|
378
|
+
const win = useVirtualWindow({
|
|
379
|
+
box: body,
|
|
380
|
+
heights,
|
|
381
|
+
rows: ordered,
|
|
382
|
+
// declared uniform: every height is exact, so the idle band may grow
|
|
383
|
+
// upward freely — see `exact` on the inputs
|
|
384
|
+
exact: uniform,
|
|
385
|
+
virtualizing,
|
|
386
|
+
overscan,
|
|
387
|
+
prefetch,
|
|
388
|
+
threshold: catchup?.threshold ?? SKELETON_THRESHOLD,
|
|
389
|
+
burstBudget: catchup?.burst ?? BURST_BUDGET,
|
|
390
|
+
settleBudget: catchup?.settle ?? SETTLE_BUDGET,
|
|
391
|
+
});
|
|
392
|
+
const { view, viewRef } = win;
|
|
393
|
+
const { first, last, above, below } = win.slice;
|
|
279
394
|
/** Columns resolve to pixels once, at the table level, per (columns,
|
|
280
395
|
* viewport, resizes) — every row agrees on the grid by construction. */
|
|
281
396
|
const { widths, total } = useMemo(() => resolveWidths(columns, userWidths, view.width), [columns, userWidths, view.width]);
|
|
397
|
+
/**
|
|
398
|
+
* The scroll the table owes a row, and the pane's real offset read back
|
|
399
|
+
* after every layout — the two halves of `../internal/scroll.ts`, which
|
|
400
|
+
* says why a reveal cannot be a one-shot and why `onScroll` is not the
|
|
401
|
+
* whole story.
|
|
402
|
+
*/
|
|
403
|
+
const reveal = useReveal({
|
|
404
|
+
box: body,
|
|
405
|
+
rows: orderedRef,
|
|
406
|
+
nodes: rowNodes,
|
|
407
|
+
heights,
|
|
408
|
+
});
|
|
409
|
+
/**
|
|
410
|
+
* Re-read the offset the body is *actually* at — the window's `sync` (see
|
|
411
|
+
* `../internal/window.ts` for why the pane moves silently), plus the
|
|
412
|
+
* horizontal half only this component has: the header is shifted by
|
|
413
|
+
* `scrollX`, so the sideways offset is re-read on the same tick.
|
|
414
|
+
*/
|
|
415
|
+
const winSync = win.sync;
|
|
416
|
+
const syncScroll = useCallback(() => {
|
|
417
|
+
const box = body.current;
|
|
418
|
+
if (!box)
|
|
419
|
+
return;
|
|
420
|
+
const x = box.scrollX;
|
|
421
|
+
setScrollX((prev) => (prev === x ? prev : x));
|
|
422
|
+
winSync();
|
|
423
|
+
}, [winSync]);
|
|
282
424
|
/**
|
|
283
425
|
* Read back what the rows on screen actually laid out at.
|
|
284
426
|
*
|
|
@@ -291,12 +433,12 @@ export function Table(props) {
|
|
|
291
433
|
*/
|
|
292
434
|
const measureRows = useCallback(() => {
|
|
293
435
|
if (uniform || !virtualizing)
|
|
294
|
-
return;
|
|
436
|
+
return false;
|
|
295
437
|
// Before the first `onViewport` the flex columns sit on their floors and
|
|
296
438
|
// every row is laid out against a width that is about to change — there
|
|
297
439
|
// is nothing honest to measure yet.
|
|
298
440
|
if (viewRef.current.width <= 0)
|
|
299
|
-
return;
|
|
441
|
+
return false;
|
|
300
442
|
// A row laid out at a width the columns no longer resolve to is a
|
|
301
443
|
// measurement of the wrong table, and it must not be recorded — a row
|
|
302
444
|
// that scrolls out before the corrected pass would keep a wrong-width
|
|
@@ -325,46 +467,100 @@ export function Table(props) {
|
|
|
325
467
|
shift += height - was;
|
|
326
468
|
}
|
|
327
469
|
if (!changed)
|
|
328
|
-
return;
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
470
|
+
return false;
|
|
471
|
+
// A debt, not a one-shot: the pane clamps against the last layout's
|
|
472
|
+
// content height, so a shift from rows measured above the viewport can
|
|
473
|
+
// land short until the layout that admits the growth has run.
|
|
474
|
+
reveal.nudge(shift);
|
|
332
475
|
setMeasured((n) => n + 1);
|
|
476
|
+
return true;
|
|
333
477
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- `heights` is a
|
|
334
478
|
// stable instance
|
|
335
479
|
}, [uniform, virtualizing]);
|
|
336
|
-
useEffect(() => {
|
|
337
|
-
if (uniform || !virtualizing)
|
|
338
|
-
return undefined;
|
|
339
|
-
const id = afterLayout(measureRows);
|
|
340
|
-
return () => cancelAfterLayout(id);
|
|
341
|
-
});
|
|
342
480
|
/**
|
|
343
|
-
*
|
|
344
|
-
*
|
|
345
|
-
*
|
|
346
|
-
*
|
|
481
|
+
* Let the estimate learn from the rows that have been measured — the
|
|
482
|
+
* scrollbar of a measured table starts as a guess times the row count,
|
|
483
|
+
* and the measured mean is a far better guess for the rows not yet seen.
|
|
484
|
+
* Idle only: every unmeasured offset moves when it applies, and the
|
|
485
|
+
* anchor arithmetic keeping the screen still is `measureRows`'s.
|
|
347
486
|
*/
|
|
348
|
-
const
|
|
487
|
+
const adaptEstimate = useCallback(() => {
|
|
488
|
+
if (uniform || !virtualizing)
|
|
489
|
+
return false;
|
|
349
490
|
const box = body.current;
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
const
|
|
354
|
-
if (
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
+
}, [uniform, 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 new rows are laid out, and where the body actually ended up. In that
|
|
507
|
+
* order — each step can move the offset the next one reads.
|
|
508
|
+
*
|
|
509
|
+
* Scheduled for every render a virtualized table makes, because every one
|
|
510
|
+
* of them can move the offset its next slice is built from. A whole table
|
|
511
|
+
* needs none of it: `onViewport` is when its content can have been
|
|
512
|
+
* re-clamped, and it rebuilds no slice anyway.
|
|
513
|
+
*/
|
|
514
|
+
/** Whether some drawn row has no size yet — a commit can land between
|
|
515
|
+
* frame flushes, and a measure pass over it reads zeros. */
|
|
516
|
+
const rowsPendingLayout = useCallback(() => {
|
|
517
|
+
if (uniform)
|
|
518
|
+
return false;
|
|
519
|
+
const rows = orderedRef.current;
|
|
520
|
+
for (const [id, { node, at }] of rowNodes.current) {
|
|
521
|
+
if (rows[at]?.id === id && !(node.abs.height > 0))
|
|
522
|
+
return true;
|
|
365
523
|
}
|
|
366
|
-
|
|
367
|
-
}, []);
|
|
524
|
+
return false;
|
|
525
|
+
}, [uniform]);
|
|
526
|
+
useEffect(() => {
|
|
527
|
+
if (!virtualizing)
|
|
528
|
+
return undefined;
|
|
529
|
+
let look = null;
|
|
530
|
+
let tries = 0;
|
|
531
|
+
const pass = () => {
|
|
532
|
+
// `measureRows` first, and its answer handed on: a pass that moved the
|
|
533
|
+
// heights has not settled anything, and an owed scroll judged against
|
|
534
|
+
// the layout it is about to invalidate is not owed any less. During a
|
|
535
|
+
// flick nothing is measured at all — every correction at that speed is
|
|
536
|
+
// invalidated by the next event — and the settle tick that follows any
|
|
537
|
+
// burst is where the deferred passes catch up.
|
|
538
|
+
const moved = win.fast() ? false : measureRows();
|
|
539
|
+
const adapted = !win.scrolling() && adaptEstimate();
|
|
540
|
+
reveal.retry(moved || adapted);
|
|
541
|
+
syncScroll();
|
|
542
|
+
// A commit can land between frame flushes: its rows report zero size
|
|
543
|
+
// until the flush, this tick has already run, and nothing else would
|
|
544
|
+
// come back for them — a window that just finished growing renders
|
|
545
|
+
// nothing further, and the missed measurements would stand for good.
|
|
546
|
+
// Look again, briefly, while any drawn row is still unsized.
|
|
547
|
+
if (rowsPendingLayout() && tries++ < 8)
|
|
548
|
+
look = later(pass, 16);
|
|
549
|
+
};
|
|
550
|
+
const id = afterLayout(pass);
|
|
551
|
+
return () => {
|
|
552
|
+
cancelAfterLayout(id);
|
|
553
|
+
cancelLater(look);
|
|
554
|
+
};
|
|
555
|
+
});
|
|
556
|
+
/** Put a row in view, by the index its call site already has. */
|
|
557
|
+
const revealAt = useCallback((at) => {
|
|
558
|
+
const row = orderedRef.current[at];
|
|
559
|
+
if (row)
|
|
560
|
+
reveal.to(row.id);
|
|
561
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- `reveal` is a
|
|
562
|
+
// stable handle
|
|
563
|
+
}, [reveal]);
|
|
368
564
|
const commitSingle = useCallback((row) => {
|
|
369
565
|
cursorRef.current = row.id;
|
|
370
566
|
if (selected === undefined)
|
|
@@ -389,7 +585,7 @@ export function Table(props) {
|
|
|
389
585
|
return;
|
|
390
586
|
if (selectionMode === 'single') {
|
|
391
587
|
commitSingle(row);
|
|
392
|
-
|
|
588
|
+
revealAt(row.index);
|
|
393
589
|
return;
|
|
394
590
|
}
|
|
395
591
|
cursorRef.current = row.id;
|
|
@@ -417,8 +613,8 @@ export function Table(props) {
|
|
|
417
613
|
anchorRef.current = row.id;
|
|
418
614
|
commitMulti([row.id], { type: 'replace', id: row.id, row: row.row });
|
|
419
615
|
}
|
|
420
|
-
|
|
421
|
-
}, [selectionMode, commitSingle, commitMulti,
|
|
616
|
+
revealAt(row.index);
|
|
617
|
+
}, [selectionMode, commitSingle, commitMulti, revealAt]);
|
|
422
618
|
const activate = useCallback((row) => {
|
|
423
619
|
onActivate?.(row.id, row.row);
|
|
424
620
|
}, [onActivate]);
|
|
@@ -573,120 +769,79 @@ export function Table(props) {
|
|
|
573
769
|
const row = orderedRef.current.find((r) => r.id === id);
|
|
574
770
|
if (!row)
|
|
575
771
|
return false;
|
|
576
|
-
|
|
772
|
+
revealAt(row.index);
|
|
577
773
|
return true;
|
|
578
774
|
},
|
|
579
775
|
handleKey,
|
|
580
776
|
rows: () => orderedRef.current,
|
|
581
|
-
}), [tap, handleKey,
|
|
777
|
+
}), [tap, handleKey, revealAt, selectionMode, selected]);
|
|
582
778
|
// --- rendering -----------------------------------------------------------
|
|
583
779
|
const rowStyleProp = styles?.row;
|
|
584
780
|
const cellStyleProp = styles?.cell;
|
|
585
781
|
const headerCellStyleProp = styles?.headerCell;
|
|
586
782
|
const selectable = selectionMode !== 'none';
|
|
587
|
-
|
|
783
|
+
// The row component's stable halves — `MemoTableRow` bails out of a
|
|
784
|
+
// re-render only if every prop kept its identity, and these are the two
|
|
785
|
+
// that would otherwise be rebuilt per row per render.
|
|
786
|
+
const registerRow = useCallback((id, at, node) => {
|
|
787
|
+
if (node)
|
|
788
|
+
rowNodes.current.set(id, { node, at });
|
|
789
|
+
else
|
|
790
|
+
rowNodes.current.delete(id);
|
|
791
|
+
}, []);
|
|
792
|
+
const rowMenu = useCallback((entry, ev) => {
|
|
793
|
+
// The menu applies to what is under the pointer, so the row is
|
|
794
|
+
// selected first — unless it is already part of the selection,
|
|
795
|
+
// which a menu over "the selected files" must not collapse.
|
|
796
|
+
if (selectable && !selectedRef.current.has(entry.id)) {
|
|
797
|
+
tap(entry, { ctrl: false, shift: false });
|
|
798
|
+
}
|
|
799
|
+
onRowContextMenu?.(entry.id, entry.row, ev);
|
|
800
|
+
}, [selectable, tap, onRowContextMenu]);
|
|
801
|
+
/**
|
|
802
|
+
* A row the window said not to build in full yet: the box at its indexed
|
|
803
|
+
* height and none of its content. Cheap on purpose — no cells, no text, no
|
|
804
|
+
* seams — so the commit answering a flood lands frames before the full
|
|
805
|
+
* rows could, and what blits in reads as rows arriving rather than a
|
|
806
|
+
* void. `styles.row` still applies, so zebra striping and row backgrounds
|
|
807
|
+
* hold. Not registered in `rowNodes`: a skeleton must not be measured
|
|
808
|
+
* into the height index, and cannot satisfy a reveal.
|
|
809
|
+
*/
|
|
810
|
+
const renderSkeletonRow = (entry) => {
|
|
588
811
|
const isSelected = selectedIds.has(entry.id);
|
|
589
|
-
const
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
key: column.id,
|
|
595
|
-
role: 'cell',
|
|
596
|
-
style: [
|
|
597
|
-
s.cell,
|
|
598
|
-
{ width: widths[at] },
|
|
599
|
-
uniform && { height: rowHeight },
|
|
600
|
-
column.align === 'end' && {
|
|
601
|
-
alignItems: 'flex-end',
|
|
602
|
-
paddingEnd: 8,
|
|
603
|
-
},
|
|
604
|
-
column.align === 'center' && { alignItems: 'center' },
|
|
605
|
-
typeof cellStyleProp === 'function'
|
|
606
|
-
? cellStyleProp(cellState)
|
|
607
|
-
: cellStyleProp,
|
|
608
|
-
],
|
|
609
|
-
}, column.render
|
|
610
|
-
? // A cell that draws itself still has to know it is on the
|
|
611
|
-
// selected row — see the doc comment. Keyed by the component,
|
|
612
|
-
// the way every seam's return is.
|
|
613
|
-
React.createElement(React.Fragment, { key: 'content' }, column.render(entry.row, cellState))
|
|
614
|
-
: hx('text', { style: [s.cellText, { color }] }, String(columnValue(entry.row, column) ?? '')));
|
|
615
|
-
});
|
|
812
|
+
const state = {
|
|
813
|
+
...entry,
|
|
814
|
+
selected: isSelected,
|
|
815
|
+
color: isSelected ? theme.hoverText : theme.text,
|
|
816
|
+
};
|
|
616
817
|
return hx('box', {
|
|
617
818
|
key: String(entry.id),
|
|
618
|
-
|
|
619
|
-
'aria-selected': selectable ? isSelected : undefined,
|
|
620
|
-
'aria-posinset': entry.index + 1,
|
|
621
|
-
'aria-setsize': ordered.length,
|
|
622
|
-
// The index the row was drawn at travels with the node, so measuring
|
|
623
|
-
// does not have to search the row list for it. It can go stale — the
|
|
624
|
-
// rows may move before the tick that measures — and both this and
|
|
625
|
-
// the height index check it rather than trust it.
|
|
626
|
-
ref: (node) => {
|
|
627
|
-
if (node)
|
|
628
|
-
rowNodes.current.set(entry.id, { node, at: entry.index });
|
|
629
|
-
else
|
|
630
|
-
rowNodes.current.delete(entry.id);
|
|
631
|
-
},
|
|
632
|
-
onClick: (ev) => {
|
|
633
|
-
// A right-click also arrives here as a click; the selection it
|
|
634
|
-
// implies is `onContextMenu`'s to make (select-unless-selected),
|
|
635
|
-
// not the left button's replace.
|
|
636
|
-
if (ev.button !== 1)
|
|
637
|
-
return;
|
|
638
|
-
tap(entry, { ctrl: ev.ctrlKey, shift: ev.shiftKey });
|
|
639
|
-
// Select on the first click, open on the second — the gesture
|
|
640
|
-
// every file list has. `detail` is the click count the renderer
|
|
641
|
-
// already counts for text selection.
|
|
642
|
-
if (ev.detail === 2)
|
|
643
|
-
activate(entry);
|
|
644
|
-
},
|
|
645
|
-
onContextMenu: onRowContextMenu
|
|
646
|
-
? (ev) => {
|
|
647
|
-
// The menu applies to what is under the pointer, so the row is
|
|
648
|
-
// selected first — unless it is already part of the selection,
|
|
649
|
-
// which a menu over "the selected files" must not collapse.
|
|
650
|
-
if (selectable && !selectedRef.current.has(entry.id)) {
|
|
651
|
-
tap(entry, { ctrl: false, shift: false });
|
|
652
|
-
}
|
|
653
|
-
onRowContextMenu(entry.id, entry.row, ev);
|
|
654
|
-
}
|
|
655
|
-
: undefined,
|
|
819
|
+
'aria-hidden': true,
|
|
656
820
|
style: [
|
|
657
821
|
s.row,
|
|
658
|
-
//
|
|
659
|
-
//
|
|
660
|
-
|
|
661
|
-
uniform
|
|
662
|
-
? { height: rowHeight, alignItems: 'center' }
|
|
663
|
-
: { minHeight: estimate },
|
|
664
|
-
selectable && { cursor: 'pointer' },
|
|
822
|
+
// Exactly what the index believes, so the spacers and the
|
|
823
|
+
// scrollbar agree with the rows on where everything is.
|
|
824
|
+
{ height: heights.heightAt(entry.index) },
|
|
665
825
|
{
|
|
666
826
|
backgroundColor: isSelected ? theme.hoverBackground : 'transparent',
|
|
667
|
-
// The row's ink, said once: `color` inherits, so default cells
|
|
668
|
-
// take it without being handed it.
|
|
669
|
-
color,
|
|
670
|
-
},
|
|
671
|
-
selectable && {
|
|
672
|
-
// pressed even on the selected row: a re-press on the row that
|
|
673
|
-
// is already current is the one click in the table that would
|
|
674
|
-
// otherwise look ignored
|
|
675
|
-
':active': {
|
|
676
|
-
backgroundColor: isSelected
|
|
677
|
-
? theme.accentActive
|
|
678
|
-
: theme.surfaceActive,
|
|
679
|
-
},
|
|
680
|
-
},
|
|
681
|
-
selectable &&
|
|
682
|
-
!isSelected && {
|
|
683
|
-
':hover': { backgroundColor: theme.surfaceHover },
|
|
684
827
|
},
|
|
685
828
|
typeof rowStyleProp === 'function'
|
|
686
829
|
? rowStyleProp(state)
|
|
687
830
|
: rowStyleProp,
|
|
688
831
|
],
|
|
689
|
-
},
|
|
832
|
+
},
|
|
833
|
+
// A line of "text" with no text. Width varied by index, so a band of
|
|
834
|
+
// placeholders reads as rows arriving rather than a repeated tile.
|
|
835
|
+
hx('box', {
|
|
836
|
+
key: 'bar',
|
|
837
|
+
style: [
|
|
838
|
+
s.skeletonBar,
|
|
839
|
+
{
|
|
840
|
+
width: 96 + ((entry.index * 37) % 89),
|
|
841
|
+
backgroundColor: theme.track,
|
|
842
|
+
},
|
|
843
|
+
],
|
|
844
|
+
}));
|
|
690
845
|
};
|
|
691
846
|
const headerCells = columns.map((column, at) => {
|
|
692
847
|
const canSort = column.sortable !== false;
|
|
@@ -759,6 +914,73 @@ export function Table(props) {
|
|
|
759
914
|
],
|
|
760
915
|
}, hx('box', { style: [s.rule, { backgroundColor: theme.border }] })));
|
|
761
916
|
});
|
|
917
|
+
/**
|
|
918
|
+
* The row *elements*, reused by identity while nothing they depend on has
|
|
919
|
+
* changed. The memo already skips re-rendering an unchanged row, but the
|
|
920
|
+
* skip still costs a `createElement` and a props compare per row per
|
|
921
|
+
* notch — the burst profile put bare `createElement` at a tenth of a
|
|
922
|
+
* flick's CPU. Handing React the identical element object instead takes
|
|
923
|
+
* the cheapest path it has: the fiber is reused with no compare at all.
|
|
924
|
+
* The cache empties whenever any shared input changes identity, and
|
|
925
|
+
* per-row entries revalidate on the row object and its selection.
|
|
926
|
+
*/
|
|
927
|
+
const rowElems = useRef(new Map());
|
|
928
|
+
const rowElemDeps = useRef([]);
|
|
929
|
+
{
|
|
930
|
+
const deps = [
|
|
931
|
+
columns,
|
|
932
|
+
widths,
|
|
933
|
+
theme,
|
|
934
|
+
uniform,
|
|
935
|
+
rowHeight,
|
|
936
|
+
estimate,
|
|
937
|
+
selectable,
|
|
938
|
+
rowStyleProp,
|
|
939
|
+
cellStyleProp,
|
|
940
|
+
renderRow,
|
|
941
|
+
Boolean(onRowContextMenu),
|
|
942
|
+
tap,
|
|
943
|
+
activate,
|
|
944
|
+
rowMenu,
|
|
945
|
+
registerRow,
|
|
946
|
+
ordered.length,
|
|
947
|
+
];
|
|
948
|
+
const prev = rowElemDeps.current;
|
|
949
|
+
if (prev.length !== deps.length || deps.some((d, at) => d !== prev[at])) {
|
|
950
|
+
rowElems.current.clear();
|
|
951
|
+
rowElemDeps.current = deps;
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
const rowElement = (entry) => {
|
|
955
|
+
const isSelected = selectedIds.has(entry.id);
|
|
956
|
+
const cached = rowElems.current.get(entry.id);
|
|
957
|
+
if (cached && cached.entry === entry && cached.selected === isSelected) {
|
|
958
|
+
return cached.el;
|
|
959
|
+
}
|
|
960
|
+
const el = React.createElement(MemoTableRow, {
|
|
961
|
+
key: String(entry.id),
|
|
962
|
+
entry,
|
|
963
|
+
columns,
|
|
964
|
+
widths,
|
|
965
|
+
setSize: ordered.length,
|
|
966
|
+
isSelected,
|
|
967
|
+
selectable,
|
|
968
|
+
uniform,
|
|
969
|
+
rowHeight,
|
|
970
|
+
estimate,
|
|
971
|
+
theme,
|
|
972
|
+
rowStyle: rowStyleProp,
|
|
973
|
+
cellStyle: cellStyleProp,
|
|
974
|
+
renderRow,
|
|
975
|
+
hasMenu: Boolean(onRowContextMenu),
|
|
976
|
+
onTap: tap,
|
|
977
|
+
onOpen: activate,
|
|
978
|
+
onMenu: rowMenu,
|
|
979
|
+
register: registerRow,
|
|
980
|
+
});
|
|
981
|
+
rowElems.current.set(entry.id, { entry, selected: isSelected, el });
|
|
982
|
+
return el;
|
|
983
|
+
};
|
|
762
984
|
const bodyChildren = [];
|
|
763
985
|
if (ordered.length === 0) {
|
|
764
986
|
if (renderEmpty) {
|
|
@@ -772,14 +994,81 @@ export function Table(props) {
|
|
|
772
994
|
style: [s.spacer, { height: above }],
|
|
773
995
|
}));
|
|
774
996
|
}
|
|
775
|
-
for (let i = first; i < last; i++)
|
|
776
|
-
|
|
997
|
+
for (let i = first; i < last; i++) {
|
|
998
|
+
const entry = ordered[i];
|
|
999
|
+
bodyChildren.push(win.skeletons.has(entry.id)
|
|
1000
|
+
? renderSkeletonRow(entry)
|
|
1001
|
+
: rowElement(entry));
|
|
1002
|
+
}
|
|
777
1003
|
if (virtualizing && last < ordered.length) {
|
|
778
1004
|
bodyChildren.push(hx('box', {
|
|
779
1005
|
key: 'spacer:after',
|
|
780
1006
|
style: [s.spacer, { height: below }],
|
|
781
1007
|
}));
|
|
782
1008
|
}
|
|
1009
|
+
// Rows that left the window leave the cache too, once it has grown
|
|
1010
|
+
// well past the window — a scrub across a long list would otherwise
|
|
1011
|
+
// hold an element for every row it passed.
|
|
1012
|
+
if (rowElems.current.size > (last - first) * 3 + 64) {
|
|
1013
|
+
rowElems.current.clear();
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
/**
|
|
1017
|
+
* The fast-scroll overlay — shown only while placeholders cover enough of
|
|
1018
|
+
* the viewport that the user would otherwise be looking at blank rows.
|
|
1019
|
+
* The half-viewport threshold keeps a near-miss quiet: a scroll the next
|
|
1020
|
+
* frame will absorb is not worth announcing. Once up it stays until the
|
|
1021
|
+
* view is whole again, so it does not flicker through the catch-up.
|
|
1022
|
+
*/
|
|
1023
|
+
let scrollHint = null;
|
|
1024
|
+
if (virtualizing && ordered.length > 0 && view.height > 0) {
|
|
1025
|
+
const vFirst = heights.indexAt(view.top);
|
|
1026
|
+
const vLast = Math.min(ordered.length - 1, heights.indexAt(view.top + view.height));
|
|
1027
|
+
// Two ways in: placeholders covering enough of the viewport that it
|
|
1028
|
+
// would otherwise read as blank, or a scrub — the window teleporting
|
|
1029
|
+
// while the burst is still in flight, where every commit chases a
|
|
1030
|
+
// viewport that has already left and nothing useful can be on screen.
|
|
1031
|
+
// Either way only once the catch-up has already *lasted*: a jump the
|
|
1032
|
+
// next few frames absorb is not worth announcing, so the pill waits
|
|
1033
|
+
// out the show-delay against the catch-up clock. Latched once
|
|
1034
|
+
// triggered: `pending` bounces to zero between catch-up commits, and a
|
|
1035
|
+
// pill that blinked with it would read as a glitch. It goes when the
|
|
1036
|
+
// burst does.
|
|
1037
|
+
const engaged = (win.pending > 0 && win.pending * 2 >= vLast - vFirst + 1) ||
|
|
1038
|
+
(win.jumped && win.scrolling());
|
|
1039
|
+
const lasted = win.catchupSince !== null &&
|
|
1040
|
+
Date.now() - win.catchupSince >= scrollHintDelay;
|
|
1041
|
+
const show = (engaged && lasted) ||
|
|
1042
|
+
(hintShown.current && (win.pending > 0 || win.scrolling()));
|
|
1043
|
+
hintShown.current = show;
|
|
1044
|
+
if (show) {
|
|
1045
|
+
const hintState = {
|
|
1046
|
+
row: ordered[vFirst],
|
|
1047
|
+
from: vFirst + 1,
|
|
1048
|
+
to: vLast + 1,
|
|
1049
|
+
count: ordered.length,
|
|
1050
|
+
pending: win.pending,
|
|
1051
|
+
since: win.catchupSince ?? Date.now(),
|
|
1052
|
+
};
|
|
1053
|
+
const content = renderScrollHint
|
|
1054
|
+
? renderScrollHint(hintState)
|
|
1055
|
+
: hx('text', { style: { fontSize: 11, color: theme.hoverText } }, `${hintState.from.toLocaleString()} / ${hintState.count.toLocaleString()}`);
|
|
1056
|
+
if (content !== null && content !== undefined && content !== false) {
|
|
1057
|
+
scrollHint = hx('box', {
|
|
1058
|
+
key: 'scroll-hint',
|
|
1059
|
+
// The pill duplicates what the scrollbar already tells an
|
|
1060
|
+
// assistive technology, and it comes and goes with the catch-up
|
|
1061
|
+
// — chatter, not content.
|
|
1062
|
+
'aria-hidden': true,
|
|
1063
|
+
style: s.scrollHintLane,
|
|
1064
|
+
}, hx('box', {
|
|
1065
|
+
style: [s.scrollHint, { backgroundColor: theme.hoverBackground }],
|
|
1066
|
+
}, content));
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
else {
|
|
1071
|
+
hintShown.current = false;
|
|
783
1072
|
}
|
|
784
1073
|
return hx('box', {
|
|
785
1074
|
theme,
|
|
@@ -816,10 +1105,12 @@ export function Table(props) {
|
|
|
816
1105
|
ref: body,
|
|
817
1106
|
style: s.body,
|
|
818
1107
|
onScroll: (ev) => {
|
|
1108
|
+
// A scroll this component did not ask for is the user taking over,
|
|
1109
|
+
// and an owed `scrollToRow` must not yank the list back out from
|
|
1110
|
+
// under them on the next layout.
|
|
1111
|
+
reveal.heard(ev.scrollY);
|
|
819
1112
|
setScrollX((prev) => (prev === ev.scrollX ? prev : ev.scrollX));
|
|
820
|
-
|
|
821
|
-
setView((prev) => prev.top === ev.scrollY ? prev : { ...prev, top: ev.scrollY });
|
|
822
|
-
}
|
|
1113
|
+
win.scrolled(ev.scrollY);
|
|
823
1114
|
onScroll?.(ev);
|
|
824
1115
|
},
|
|
825
1116
|
// Layout, not scrolling, is what first tells a table how much of it
|
|
@@ -827,18 +1118,17 @@ export function Table(props) {
|
|
|
827
1118
|
// columns resolve against, so this is measured whether or not the
|
|
828
1119
|
// table virtualizes.
|
|
829
1120
|
onViewport: (ev) => {
|
|
830
|
-
|
|
831
|
-
//
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
: { ...prev, height: ev.height, width: ev.width });
|
|
1121
|
+
win.sized(ev.width, ev.height);
|
|
1122
|
+
// The content just changed size, which is both the moment an owed
|
|
1123
|
+
// scroll can reach further than the clamp let it and the moment the
|
|
1124
|
+
// container may have re-clamped the offset without saying so. It is
|
|
1125
|
+
// not a moment anything can be *settled* in while rows are still
|
|
1126
|
+
// being measured: this runs from layout, a tick before the pass that
|
|
1127
|
+
// reads those rows back.
|
|
1128
|
+
reveal.retry(virtualizing && !uniform);
|
|
1129
|
+
syncScroll();
|
|
840
1130
|
onViewport?.(ev);
|
|
841
1131
|
},
|
|
842
|
-
}, hx('box', { style: [s.rowsBox, { width: total }] }, bodyChildren)));
|
|
1132
|
+
}, hx('box', { style: [s.rowsBox, { width: total }] }, bodyChildren)), scrollHint);
|
|
843
1133
|
}
|
|
844
1134
|
//# sourceMappingURL=index.js.map
|