@react-x11/components 0.2.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- 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/qml/containers.d.ts +3 -0
- package/dist/qml/containers.d.ts.map +1 -0
- package/dist/qml/containers.js +331 -0
- package/dist/qml/containers.js.map +1 -0
- package/dist/qml/controls.d.ts +12 -0
- package/dist/qml/controls.d.ts.map +1 -0
- package/dist/qml/controls.js +21 -0
- package/dist/qml/controls.js.map +1 -0
- package/dist/qml/globals.d.ts +6 -0
- package/dist/qml/globals.d.ts.map +1 -0
- package/dist/qml/globals.js +25 -0
- package/dist/qml/globals.js.map +1 -0
- package/dist/qml/index.d.ts +15 -0
- package/dist/qml/index.d.ts.map +1 -0
- package/dist/qml/index.js +25 -0
- package/dist/qml/index.js.map +1 -0
- package/dist/qml/interaction.d.ts +12 -0
- package/dist/qml/interaction.d.ts.map +1 -0
- package/dist/qml/interaction.js +246 -0
- package/dist/qml/interaction.js.map +1 -0
- package/dist/qml/ir.d.ts +106 -0
- package/dist/qml/ir.d.ts.map +1 -0
- package/dist/qml/ir.js +7 -0
- package/dist/qml/ir.js.map +1 -0
- package/dist/qml/layouts.d.ts +3 -0
- package/dist/qml/layouts.d.ts.map +1 -0
- package/dist/qml/layouts.js +122 -0
- package/dist/qml/layouts.js.map +1 -0
- package/dist/qml/models.d.ts +14 -0
- package/dist/qml/models.d.ts.map +1 -0
- package/dist/qml/models.js +80 -0
- package/dist/qml/models.js.map +1 -0
- package/dist/qml/objects.d.ts +228 -0
- package/dist/qml/objects.d.ts.map +1 -0
- package/dist/qml/objects.js +1103 -0
- package/dist/qml/objects.js.map +1 -0
- package/dist/qml/parse.d.ts +10 -0
- package/dist/qml/parse.d.ts.map +1 -0
- package/dist/qml/parse.js +607 -0
- package/dist/qml/parse.js.map +1 -0
- package/dist/qml/qtquick.d.ts +7 -0
- package/dist/qml/qtquick.d.ts.map +1 -0
- package/dist/qml/qtquick.js +347 -0
- package/dist/qml/qtquick.js.map +1 -0
- package/dist/qml/react.d.ts +83 -0
- package/dist/qml/react.d.ts.map +1 -0
- package/dist/qml/react.js +323 -0
- package/dist/qml/react.js.map +1 -0
- package/dist/qml/resolver.d.ts +38 -0
- package/dist/qml/resolver.d.ts.map +1 -0
- package/dist/qml/resolver.js +64 -0
- package/dist/qml/resolver.js.map +1 -0
- package/dist/qml/slots.d.ts +65 -0
- package/dist/qml/slots.d.ts.map +1 -0
- package/dist/qml/slots.js +230 -0
- package/dist/qml/slots.js.map +1 -0
- package/dist/qml/states.d.ts +5 -0
- package/dist/qml/states.d.ts.map +1 -0
- package/dist/qml/states.js +276 -0
- package/dist/qml/states.js.map +1 -0
- package/dist/qml/view-utils.d.ts +42 -0
- package/dist/qml/view-utils.d.ts.map +1 -0
- package/dist/qml/view-utils.js +121 -0
- package/dist/qml/view-utils.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/tabs/hx.d.ts +18 -0
- package/dist/tabs/hx.d.ts.map +1 -0
- package/dist/tabs/hx.js +10 -0
- package/dist/tabs/hx.js.map +1 -0
- package/dist/tabs/index.d.ts +143 -0
- package/dist/tabs/index.d.ts.map +1 -0
- package/dist/tabs/index.js +602 -0
- package/dist/tabs/index.js.map +1 -0
- package/dist/terminal/vt/index.d.ts +1 -1
- package/dist/terminal/vt/index.d.ts.map +1 -1
- package/dist/terminal/vt/index.js +3 -3
- package/dist/terminal/vt/index.js.map +1 -1
- package/dist/terminal/vt/pty.d.ts +16 -0
- package/dist/terminal/vt/pty.d.ts.map +1 -1
- package/dist/terminal/vt/pty.js +186 -0
- package/dist/terminal/vt/pty.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 +11 -1
- package/src/index.ts +44 -0
- 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/qml/containers.tsx +380 -0
- package/src/qml/controls.tsx +47 -0
- package/src/qml/globals.ts +33 -0
- package/src/qml/index.ts +67 -0
- package/src/qml/interaction.tsx +317 -0
- package/src/qml/ir.ts +80 -0
- package/src/qml/layouts.tsx +141 -0
- package/src/qml/models.ts +101 -0
- package/src/qml/objects.ts +1431 -0
- package/src/qml/parse.ts +624 -0
- package/src/qml/qtquick.tsx +413 -0
- package/src/qml/react.tsx +454 -0
- package/src/qml/resolver.ts +99 -0
- package/src/qml/slots.ts +249 -0
- package/src/qml/states.ts +318 -0
- package/src/qml/view-utils.ts +140 -0
- package/src/table/index.ts +599 -178
- package/src/tabs/hx.ts +31 -0
- package/src/tabs/index.ts +850 -0
- package/src/terminal/vt/index.ts +4 -2
- package/src/terminal/vt/pty.ts +243 -0
- package/src/tree/index.ts +687 -260
package/dist/table/index.js
CHANGED
|
@@ -36,8 +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
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 { MIN_COLUMN, columnValue, orderRows, resolveGetId, resolveWidths, } from './rows.js';
|
|
42
43
|
export { MIN_COLUMN, UNSIZED_MIN, columnValue, defaultCompare, orderRows, resolveGetId, resolveWidths, } from './rows.js';
|
|
43
44
|
/** The header strip's height. Core's number — independent of the body's
|
|
@@ -55,16 +56,6 @@ const HALF = 3;
|
|
|
55
56
|
const GRIP = HALF + RULE + HALF;
|
|
56
57
|
/** What one Left/Right on a focused grip is worth. */
|
|
57
58
|
const STEP = 16;
|
|
58
|
-
/** Rows kept either side of the viewport, so a fast scroll does not show a
|
|
59
|
-
* gap before the next frame catches up. */
|
|
60
|
-
const OVERSCAN = 6;
|
|
61
|
-
/**
|
|
62
|
-
* What to build before the viewport has been measured. `onViewport` cannot
|
|
63
|
-
* arrive until layout has run, which is a frame after the first commit, so
|
|
64
|
-
* there is always one render that has to guess — and guessing "all of them"
|
|
65
|
-
* puts a hundred thousand rows in the tree for a frame.
|
|
66
|
-
*/
|
|
67
|
-
const ASSUMED_ROWS = 40;
|
|
68
59
|
/**
|
|
69
60
|
* Where `virtual="auto"` starts virtualizing.
|
|
70
61
|
*
|
|
@@ -125,6 +116,37 @@ const s = createStyles({
|
|
|
125
116
|
},
|
|
126
117
|
cellText: { fontSize: 12, textWrap: 'nowrap', textBoxTrim: 'cap-alphabetic' },
|
|
127
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
|
+
},
|
|
128
150
|
sortMark: { marginStart: 4 },
|
|
129
151
|
empty: {
|
|
130
152
|
flexGrow: 1,
|
|
@@ -134,6 +156,97 @@ const s = createStyles({
|
|
|
134
156
|
},
|
|
135
157
|
});
|
|
136
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);
|
|
137
250
|
/**
|
|
138
251
|
* `<Table columns rows />` — a data table with a header that stays put.
|
|
139
252
|
*
|
|
@@ -166,7 +279,7 @@ const EMPTY_SET = new Set();
|
|
|
166
279
|
* the same rows a sighted user can see.
|
|
167
280
|
*/
|
|
168
281
|
export function Table(props) {
|
|
169
|
-
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,
|
|
170
283
|
// ours to chain rather than to hand over: virtualization and the sticky
|
|
171
284
|
// header are measured through both of these
|
|
172
285
|
onScroll, onViewport, ...boxProps } = props;
|
|
@@ -199,7 +312,6 @@ export function Table(props) {
|
|
|
199
312
|
const [cursorId, setCursorId] = useState(null);
|
|
200
313
|
const [userWidths, setUserWidths] = useState({});
|
|
201
314
|
const [scrollX, setScrollX] = useState(0);
|
|
202
|
-
const [view, setView] = useState({ top: 0, height: 0, width: 0 });
|
|
203
315
|
// Bumped by a measurement pass that found a row taller or shorter than the
|
|
204
316
|
// index believed. It is the only reason the component re-renders for a
|
|
205
317
|
// measurement, and a pass that finds nothing new does not bump it — which
|
|
@@ -235,8 +347,6 @@ export function Table(props) {
|
|
|
235
347
|
selectedRef.current = selectedIds;
|
|
236
348
|
const cursorRef = useRef(cursor);
|
|
237
349
|
cursorRef.current = cursor;
|
|
238
|
-
const viewRef = useRef(view);
|
|
239
|
-
viewRef.current = view;
|
|
240
350
|
/** Where a Shift range grows from — the last plain click or plain step. */
|
|
241
351
|
const anchorRef = useRef(null);
|
|
242
352
|
const root = useRef(null);
|
|
@@ -245,6 +355,9 @@ export function Table(props) {
|
|
|
245
355
|
* measurement pass does not have to search the row list for it. */
|
|
246
356
|
const rowNodes = useRef(new Map());
|
|
247
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);
|
|
248
361
|
const userWidthsRef = useRef(userWidths);
|
|
249
362
|
userWidthsRef.current = userWidths;
|
|
250
363
|
/** Declared uniform: divide, never measure — core's model. */
|
|
@@ -260,23 +373,24 @@ export function Table(props) {
|
|
|
260
373
|
heights.sync(ordered, estimate);
|
|
261
374
|
const virtualizing = virtual === true ||
|
|
262
375
|
(virtual === 'auto' && ordered.length > VIRTUAL_THRESHOLD);
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
:
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
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;
|
|
280
394
|
/** Columns resolve to pixels once, at the table level, per (columns,
|
|
281
395
|
* viewport, resizes) — every row agrees on the grid by construction. */
|
|
282
396
|
const { widths, total } = useMemo(() => resolveWidths(columns, userWidths, view.width), [columns, userWidths, view.width]);
|
|
@@ -293,26 +407,20 @@ export function Table(props) {
|
|
|
293
407
|
heights,
|
|
294
408
|
});
|
|
295
409
|
/**
|
|
296
|
-
* Re-read the offset the body is *actually* at
|
|
297
|
-
*
|
|
298
|
-
*
|
|
299
|
-
*
|
|
300
|
-
* from the offset before those is drawn where the viewport is not: a blank
|
|
301
|
-
* band where the rows should be, and no way back until a scroll of your own
|
|
302
|
-
* re-syncs it by accident.
|
|
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.
|
|
303
414
|
*/
|
|
415
|
+
const winSync = win.sync;
|
|
304
416
|
const syncScroll = useCallback(() => {
|
|
305
417
|
const box = body.current;
|
|
306
418
|
if (!box)
|
|
307
419
|
return;
|
|
308
|
-
const
|
|
420
|
+
const x = box.scrollX;
|
|
309
421
|
setScrollX((prev) => (prev === x ? prev : x));
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
// would be work for nothing.
|
|
313
|
-
if (virtualizing)
|
|
314
|
-
setView((prev) => (prev.top === y ? prev : { ...prev, top: y }));
|
|
315
|
-
}, [virtualizing]);
|
|
422
|
+
winSync();
|
|
423
|
+
}, [winSync]);
|
|
316
424
|
/**
|
|
317
425
|
* Read back what the rows on screen actually laid out at.
|
|
318
426
|
*
|
|
@@ -360,14 +468,38 @@ export function Table(props) {
|
|
|
360
468
|
}
|
|
361
469
|
if (!changed)
|
|
362
470
|
return false;
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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);
|
|
366
475
|
setMeasured((n) => n + 1);
|
|
367
476
|
return true;
|
|
368
477
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- `heights` is a
|
|
369
478
|
// stable instance
|
|
370
479
|
}, [uniform, virtualizing]);
|
|
480
|
+
/**
|
|
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.
|
|
486
|
+
*/
|
|
487
|
+
const adaptEstimate = useCallback(() => {
|
|
488
|
+
if (uniform || !virtualizing)
|
|
489
|
+
return false;
|
|
490
|
+
const box = body.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
|
+
}, [uniform, virtualizing]);
|
|
371
503
|
/**
|
|
372
504
|
* The one tick after layout, and everything that can only be known there:
|
|
373
505
|
* what the rows measured, whether an owed scroll can go further now that
|
|
@@ -379,17 +511,47 @@ export function Table(props) {
|
|
|
379
511
|
* needs none of it: `onViewport` is when its content can have been
|
|
380
512
|
* re-clamped, and it rebuilds no slice anyway.
|
|
381
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;
|
|
523
|
+
}
|
|
524
|
+
return false;
|
|
525
|
+
}, [uniform]);
|
|
382
526
|
useEffect(() => {
|
|
383
527
|
if (!virtualizing)
|
|
384
528
|
return undefined;
|
|
385
|
-
|
|
529
|
+
let look = null;
|
|
530
|
+
let tries = 0;
|
|
531
|
+
const pass = () => {
|
|
386
532
|
// `measureRows` first, and its answer handed on: a pass that moved the
|
|
387
533
|
// heights has not settled anything, and an owed scroll judged against
|
|
388
|
-
// the layout it is about to invalidate is not owed any less.
|
|
389
|
-
|
|
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);
|
|
390
541
|
syncScroll();
|
|
391
|
-
|
|
392
|
-
|
|
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
|
+
};
|
|
393
555
|
});
|
|
394
556
|
/** Put a row in view, by the index its call site already has. */
|
|
395
557
|
const revealAt = useCallback((at) => {
|
|
@@ -618,109 +780,68 @@ export function Table(props) {
|
|
|
618
780
|
const cellStyleProp = styles?.cell;
|
|
619
781
|
const headerCellStyleProp = styles?.headerCell;
|
|
620
782
|
const selectable = selectionMode !== 'none';
|
|
621
|
-
|
|
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) => {
|
|
622
811
|
const isSelected = selectedIds.has(entry.id);
|
|
623
|
-
const
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
key: column.id,
|
|
629
|
-
role: 'cell',
|
|
630
|
-
style: [
|
|
631
|
-
s.cell,
|
|
632
|
-
{ width: widths[at] },
|
|
633
|
-
uniform && { height: rowHeight },
|
|
634
|
-
column.align === 'end' && {
|
|
635
|
-
alignItems: 'flex-end',
|
|
636
|
-
paddingEnd: 8,
|
|
637
|
-
},
|
|
638
|
-
column.align === 'center' && { alignItems: 'center' },
|
|
639
|
-
typeof cellStyleProp === 'function'
|
|
640
|
-
? cellStyleProp(cellState)
|
|
641
|
-
: cellStyleProp,
|
|
642
|
-
],
|
|
643
|
-
}, column.render
|
|
644
|
-
? // A cell that draws itself still has to know it is on the
|
|
645
|
-
// selected row — see the doc comment. Keyed by the component,
|
|
646
|
-
// the way every seam's return is.
|
|
647
|
-
React.createElement(React.Fragment, { key: 'content' }, column.render(entry.row, cellState))
|
|
648
|
-
: hx('text', { style: [s.cellText, { color }] }, String(columnValue(entry.row, column) ?? '')));
|
|
649
|
-
});
|
|
812
|
+
const state = {
|
|
813
|
+
...entry,
|
|
814
|
+
selected: isSelected,
|
|
815
|
+
color: isSelected ? theme.hoverText : theme.text,
|
|
816
|
+
};
|
|
650
817
|
return hx('box', {
|
|
651
818
|
key: String(entry.id),
|
|
652
|
-
|
|
653
|
-
'aria-selected': selectable ? isSelected : undefined,
|
|
654
|
-
'aria-posinset': entry.index + 1,
|
|
655
|
-
'aria-setsize': ordered.length,
|
|
656
|
-
// The index the row was drawn at travels with the node, so measuring
|
|
657
|
-
// does not have to search the row list for it. It can go stale — the
|
|
658
|
-
// rows may move before the tick that measures — and both this and
|
|
659
|
-
// the height index check it rather than trust it.
|
|
660
|
-
ref: (node) => {
|
|
661
|
-
if (node)
|
|
662
|
-
rowNodes.current.set(entry.id, { node, at: entry.index });
|
|
663
|
-
else
|
|
664
|
-
rowNodes.current.delete(entry.id);
|
|
665
|
-
},
|
|
666
|
-
onClick: (ev) => {
|
|
667
|
-
// A right-click also arrives here as a click; the selection it
|
|
668
|
-
// implies is `onContextMenu`'s to make (select-unless-selected),
|
|
669
|
-
// not the left button's replace.
|
|
670
|
-
if (ev.button !== 1)
|
|
671
|
-
return;
|
|
672
|
-
tap(entry, { ctrl: ev.ctrlKey, shift: ev.shiftKey });
|
|
673
|
-
// Select on the first click, open on the second — the gesture
|
|
674
|
-
// every file list has. `detail` is the click count the renderer
|
|
675
|
-
// already counts for text selection.
|
|
676
|
-
if (ev.detail === 2)
|
|
677
|
-
activate(entry);
|
|
678
|
-
},
|
|
679
|
-
onContextMenu: onRowContextMenu
|
|
680
|
-
? (ev) => {
|
|
681
|
-
// The menu applies to what is under the pointer, so the row is
|
|
682
|
-
// selected first — unless it is already part of the selection,
|
|
683
|
-
// which a menu over "the selected files" must not collapse.
|
|
684
|
-
if (selectable && !selectedRef.current.has(entry.id)) {
|
|
685
|
-
tap(entry, { ctrl: false, shift: false });
|
|
686
|
-
}
|
|
687
|
-
onRowContextMenu(entry.id, entry.row, ev);
|
|
688
|
-
}
|
|
689
|
-
: undefined,
|
|
819
|
+
'aria-hidden': true,
|
|
690
820
|
style: [
|
|
691
821
|
s.row,
|
|
692
|
-
//
|
|
693
|
-
//
|
|
694
|
-
|
|
695
|
-
uniform
|
|
696
|
-
? { height: rowHeight, alignItems: 'center' }
|
|
697
|
-
: { minHeight: estimate },
|
|
698
|
-
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) },
|
|
699
825
|
{
|
|
700
826
|
backgroundColor: isSelected ? theme.hoverBackground : 'transparent',
|
|
701
|
-
// The row's ink, said once: `color` inherits, so default cells
|
|
702
|
-
// take it without being handed it.
|
|
703
|
-
color,
|
|
704
|
-
},
|
|
705
|
-
selectable && {
|
|
706
|
-
// pressed even on the selected row: a re-press on the row that
|
|
707
|
-
// is already current is the one click in the table that would
|
|
708
|
-
// otherwise look ignored
|
|
709
|
-
':active': {
|
|
710
|
-
backgroundColor: isSelected
|
|
711
|
-
? theme.accentActive
|
|
712
|
-
: theme.surfaceActive,
|
|
713
|
-
},
|
|
714
|
-
},
|
|
715
|
-
selectable &&
|
|
716
|
-
!isSelected && {
|
|
717
|
-
':hover': { backgroundColor: theme.surfaceHover },
|
|
718
827
|
},
|
|
719
828
|
typeof rowStyleProp === 'function'
|
|
720
829
|
? rowStyleProp(state)
|
|
721
830
|
: rowStyleProp,
|
|
722
831
|
],
|
|
723
|
-
},
|
|
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
|
+
}));
|
|
724
845
|
};
|
|
725
846
|
const headerCells = columns.map((column, at) => {
|
|
726
847
|
const canSort = column.sortable !== false;
|
|
@@ -793,6 +914,73 @@ export function Table(props) {
|
|
|
793
914
|
],
|
|
794
915
|
}, hx('box', { style: [s.rule, { backgroundColor: theme.border }] })));
|
|
795
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
|
+
};
|
|
796
984
|
const bodyChildren = [];
|
|
797
985
|
if (ordered.length === 0) {
|
|
798
986
|
if (renderEmpty) {
|
|
@@ -806,14 +994,81 @@ export function Table(props) {
|
|
|
806
994
|
style: [s.spacer, { height: above }],
|
|
807
995
|
}));
|
|
808
996
|
}
|
|
809
|
-
for (let i = first; i < last; i++)
|
|
810
|
-
|
|
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
|
+
}
|
|
811
1003
|
if (virtualizing && last < ordered.length) {
|
|
812
1004
|
bodyChildren.push(hx('box', {
|
|
813
1005
|
key: 'spacer:after',
|
|
814
1006
|
style: [s.spacer, { height: below }],
|
|
815
1007
|
}));
|
|
816
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;
|
|
817
1072
|
}
|
|
818
1073
|
return hx('box', {
|
|
819
1074
|
theme,
|
|
@@ -855,9 +1110,7 @@ export function Table(props) {
|
|
|
855
1110
|
// under them on the next layout.
|
|
856
1111
|
reveal.heard(ev.scrollY);
|
|
857
1112
|
setScrollX((prev) => (prev === ev.scrollX ? prev : ev.scrollX));
|
|
858
|
-
|
|
859
|
-
setView((prev) => prev.top === ev.scrollY ? prev : { ...prev, top: ev.scrollY });
|
|
860
|
-
}
|
|
1113
|
+
win.scrolled(ev.scrollY);
|
|
861
1114
|
onScroll?.(ev);
|
|
862
1115
|
},
|
|
863
1116
|
// Layout, not scrolling, is what first tells a table how much of it
|
|
@@ -865,16 +1118,7 @@ export function Table(props) {
|
|
|
865
1118
|
// columns resolve against, so this is measured whether or not the
|
|
866
1119
|
// table virtualizes.
|
|
867
1120
|
onViewport: (ev) => {
|
|
868
|
-
|
|
869
|
-
// re-render this setState causes, and it must see this viewport.
|
|
870
|
-
viewRef.current = {
|
|
871
|
-
...viewRef.current,
|
|
872
|
-
height: ev.height,
|
|
873
|
-
width: ev.width,
|
|
874
|
-
};
|
|
875
|
-
setView((prev) => prev.height === ev.height && prev.width === ev.width
|
|
876
|
-
? prev
|
|
877
|
-
: { ...prev, height: ev.height, width: ev.width });
|
|
1121
|
+
win.sized(ev.width, ev.height);
|
|
878
1122
|
// The content just changed size, which is both the moment an owed
|
|
879
1123
|
// scroll can reach further than the clamp let it and the moment the
|
|
880
1124
|
// container may have re-clamped the offset without saying so. It is
|
|
@@ -885,6 +1129,6 @@ export function Table(props) {
|
|
|
885
1129
|
syncScroll();
|
|
886
1130
|
onViewport?.(ev);
|
|
887
1131
|
},
|
|
888
|
-
}, hx('box', { style: [s.rowsBox, { width: total }] }, bodyChildren)));
|
|
1132
|
+
}, hx('box', { style: [s.rowsBox, { width: total }] }, bodyChildren)), scrollHint);
|
|
889
1133
|
}
|
|
890
1134
|
//# sourceMappingURL=index.js.map
|