react-x11 1.2.0 → 2.0.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 +330 -81
- package/package.json +132 -14
- package/src/ClickToComponent.js +9 -2
- package/src/DevToolsIntegration.js +440 -32
- package/src/Reconciler.js +463 -165
- package/src/a11y.js +1299 -0
- package/src/acceleratorhooks.js +86 -0
- package/src/accelerators.js +128 -0
- package/src/activate.js +168 -0
- package/src/anchor.js +360 -0
- package/src/appcontext.js +161 -0
- package/src/appearance.js +748 -0
- package/src/appearancehooks.js +96 -0
- package/src/apphooks.js +73 -0
- package/src/application.js +748 -0
- package/src/atspi.js +1953 -0
- package/src/bus.js +545 -0
- package/src/bushooks.js +136 -0
- package/src/clientmessage.js +140 -0
- package/src/clipboard.js +121 -0
- package/src/components/Button.js +148 -33
- package/src/components/Checkbox.js +66 -32
- package/src/components/Dialog.js +96 -29
- package/src/components/FileDialog.js +491 -0
- package/src/components/Icon.js +329 -0
- package/src/components/Menu.js +797 -133
- package/src/components/PasswordInput.js +434 -0
- package/src/components/ProgressBar.js +91 -12
- package/src/components/Radio.js +78 -35
- package/src/components/Select.js +255 -65
- package/src/components/Slider.js +102 -41
- package/src/components/SplitPane.js +206 -0
- package/src/components/Switch.js +100 -22
- package/src/components/Table.js +529 -0
- package/src/components/Tabs.js +211 -0
- package/src/components/Tooltip.js +528 -54
- package/src/components/anchor.js +154 -107
- package/src/components/change.js +34 -0
- package/src/components/dnd.js +103 -0
- package/src/components/index.js +16 -3
- package/src/components/keys.js +17 -20
- package/src/components/scribble.js +247 -0
- package/src/components/theme.js +350 -42
- package/src/components/typeahead.js +12 -2
- package/src/compose.js +868 -0
- package/src/compositing.js +224 -0
- package/src/dbusmenu.js +384 -0
- package/src/debug.d.ts +63 -0
- package/src/debug.js +642 -0
- package/src/decorations.js +486 -0
- package/src/desktopsettings.js +209 -0
- package/src/desktopsettingshooks.js +62 -0
- package/src/dnd.js +1725 -0
- package/src/editmenu.js +272 -0
- package/src/errors.js +98 -0
- package/src/events.js +1406 -162
- package/src/extensions.js +45 -0
- package/src/filedialog.js +375 -0
- package/src/filedialoghooks.js +132 -0
- package/src/fonthooks.js +64 -0
- package/src/fonts.js +301 -0
- package/src/foreignnodes.js +519 -0
- package/src/frame/child.js +33 -0
- package/src/frame/childmain.js +274 -0
- package/src/frame/env.js +140 -0
- package/src/frame/index.js +444 -0
- package/src/frame/lifecycle.js +67 -0
- package/src/frame/protocol.js +179 -0
- package/src/frames.js +73 -0
- package/src/glbackend.js +114 -0
- package/src/glnodes.js +125 -67
- package/src/globalmenu.js +683 -0
- package/src/host.d.ts +88 -0
- package/src/host.js +42 -0
- package/src/idle.js +490 -0
- package/src/idlehooks.js +100 -0
- package/src/imagesource.js +349 -0
- package/src/index.d.ts +475 -0
- package/src/index.js +83 -9
- package/src/inputtime.js +206 -0
- package/src/jsx-dev-runtime.d.ts +21 -0
- package/src/jsx-dev-runtime.js +2 -0
- package/src/jsx-runtime.d.ts +39 -0
- package/src/jsx-runtime.js +6 -0
- package/src/keyboard.js +256 -0
- package/src/keyboardstate.js +278 -0
- package/src/keyboardstatehooks.js +58 -0
- package/src/keysyms.d.ts +149 -0
- package/src/keysyms.js +270 -0
- package/src/locale.js +170 -0
- package/src/localehooks.js +47 -0
- package/src/menuitem.js +223 -0
- package/src/node.d.ts +597 -0
- package/src/node.js +44 -0
- package/src/nodes.js +9546 -690
- package/src/ntk.d.ts +44 -0
- package/src/ntk.js +25 -0
- package/src/paintcache.js +366 -0
- package/src/palette.js +380 -0
- package/src/pastestate.js +66 -0
- package/src/portal.js +461 -0
- package/src/priority.js +26 -0
- package/src/refresh/index.d.ts +40 -0
- package/src/refresh/index.js +122 -0
- package/src/refresh/loader.d.ts +37 -0
- package/src/refresh/loader.js +401 -0
- package/src/refresh/register.d.ts +5 -0
- package/src/refresh/register.js +13 -0
- package/src/registry.js +232 -0
- package/src/scale.js +626 -0
- package/src/scalehooks.js +27 -0
- package/src/screencolor.js +640 -0
- package/src/screencolorhooks.js +101 -0
- package/src/screens.js +754 -0
- package/src/screenshooks.js +137 -0
- package/src/startup.js +302 -0
- package/src/style.d.ts +126 -0
- package/src/style.js +33 -0
- package/src/styles.js +1482 -6
- package/src/svgnodes.js +306 -0
- package/src/testing/a11y.js +484 -0
- package/src/testing/components.js +414 -0
- package/src/testing/events.js +407 -0
- package/src/testing/harness.js +455 -0
- package/src/testing/index.d.ts +558 -0
- package/src/testing/index.js +85 -0
- package/src/testing/mock-app.js +463 -0
- package/src/testing/pixels.js +152 -0
- package/src/testing/queries.js +224 -0
- package/src/textrange.js +83 -0
- package/src/textselection.js +439 -0
- package/src/trace-registry.js +63 -0
- package/src/transfer.js +93 -0
- package/src/types/appearance.d.ts +84 -0
- package/src/types/application.d.ts +173 -0
- package/src/types/components.d.ts +820 -0
- package/src/types/dbus.d.ts +177 -0
- package/src/types/elements.d.ts +897 -0
- package/src/types/events.d.ts +549 -0
- package/src/types/filedialog.d.ts +260 -0
- package/src/types/fonts.d.ts +124 -0
- package/src/types/frame.d.ts +146 -0
- package/src/types/globalmenu.d.ts +42 -0
- package/src/types/nodes.d.ts +199 -0
- package/src/types/screencolor.d.ts +84 -0
- package/src/types/style.d.ts +434 -0
- package/src/types/system.d.ts +287 -0
- package/src/windowid.js +151 -0
- package/src/windowstate.js +393 -0
- package/src/xsettings.js +336 -0
- package/src/yoga.d.ts +55 -0
- package/src/yoga.js +135 -0
- package/src/components/Canvas3D.js +0 -28
- package/src/geometry3d.js +0 -223
- package/src/pointer3d.js +0 -158
- package/src/raycast3d.js +0 -146
- package/src/richnodes.js +0 -436
- package/src/scene3d.js +0 -683
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
// Widget components built purely on the host primitives — no reconciler
|
|
2
|
+
// support needed. Plain createElement (no JSX) so the library stays
|
|
3
|
+
// build-step-free for consumers.
|
|
4
|
+
|
|
5
|
+
import React, { useMemo, useRef, useState } from 'react';
|
|
6
|
+
import { createStyles } from '../styles.js';
|
|
7
|
+
import { capTrim, useDirection, useTheme } from './theme.js';
|
|
8
|
+
import { Icon } from './Icon.js';
|
|
9
|
+
import {
|
|
10
|
+
XK_DOWN,
|
|
11
|
+
XK_END,
|
|
12
|
+
XK_HOME,
|
|
13
|
+
XK_LEFT,
|
|
14
|
+
XK_PAGE_DOWN,
|
|
15
|
+
XK_PAGE_UP,
|
|
16
|
+
XK_RETURN,
|
|
17
|
+
XK_RIGHT,
|
|
18
|
+
XK_UP,
|
|
19
|
+
} from './keys.js';
|
|
20
|
+
|
|
21
|
+
const h = React.createElement;
|
|
22
|
+
|
|
23
|
+
const ROW_HEIGHT = 24;
|
|
24
|
+
// The band you can grab, and the line you can see — two different sizes on
|
|
25
|
+
// purpose. A separator wants to be a hairline; a resize handle wants to be
|
|
26
|
+
// wide enough to hit without aiming. Drawing the handle is what made every
|
|
27
|
+
// column boundary a 6px bar.
|
|
28
|
+
//
|
|
29
|
+
// The band is centred **on** the rule rather than sitting beside it: it is
|
|
30
|
+
// the boundary that is being moved, so a band lying to one side of the line
|
|
31
|
+
// lights up off-centre and reads as belonging to the column it covers.
|
|
32
|
+
// `HALF` either side, which is why the columns on both sides of a rule give
|
|
33
|
+
// up a little width to it.
|
|
34
|
+
const RULE = 1;
|
|
35
|
+
const HALF = 3;
|
|
36
|
+
const GRIP = HALF + RULE + HALF;
|
|
37
|
+
const MIN_COLUMN = 40;
|
|
38
|
+
// what one Left/Right on a focused handle is worth
|
|
39
|
+
const STEP = 16;
|
|
40
|
+
// rows kept either side of the viewport, so a fast scroll does not show a
|
|
41
|
+
// gap before the next frame catches up
|
|
42
|
+
const OVERSCAN = 4;
|
|
43
|
+
// What to build before the viewport has been measured. onViewport cannot
|
|
44
|
+
// arrive until layout has run, which is a frame after the first commit, so
|
|
45
|
+
// there is always one render that has to guess — and guessing "all of them"
|
|
46
|
+
// means ten thousand rows land in the tree for a frame.
|
|
47
|
+
const ASSUMED_ROWS = 40;
|
|
48
|
+
|
|
49
|
+
const s = createStyles({
|
|
50
|
+
root: { flexGrow: 1, minHeight: 0, minWidth: 0 },
|
|
51
|
+
headerClip: { flexShrink: 0, overflow: 'hidden' },
|
|
52
|
+
headerRow: { flexDirection: 'row', flexShrink: 0 },
|
|
53
|
+
header: {
|
|
54
|
+
flexDirection: 'row',
|
|
55
|
+
alignItems: 'center',
|
|
56
|
+
height: ROW_HEIGHT,
|
|
57
|
+
paddingStart: 8,
|
|
58
|
+
// Matching the leading inset, because the sort mark sits against this
|
|
59
|
+
// edge now — without it the chevron touches the rule between the columns.
|
|
60
|
+
paddingEnd: 8,
|
|
61
|
+
flexShrink: 0,
|
|
62
|
+
cursor: 'pointer',
|
|
63
|
+
transition: { backgroundColor: 80 },
|
|
64
|
+
},
|
|
65
|
+
headerLabel: { fontSize: 12, textWrap: 'nowrap', textOverflow: 'ellipsis' },
|
|
66
|
+
grip: {
|
|
67
|
+
flexShrink: 0,
|
|
68
|
+
cursor: 'col-resize',
|
|
69
|
+
alignSelf: 'stretch',
|
|
70
|
+
alignItems: 'center',
|
|
71
|
+
flexDirection: 'row',
|
|
72
|
+
justifyContent: 'center',
|
|
73
|
+
transition: { backgroundColor: 80 },
|
|
74
|
+
},
|
|
75
|
+
rule: { width: RULE, flexShrink: 0, alignSelf: 'stretch' },
|
|
76
|
+
body: { flexGrow: 1, minHeight: 0, overflow: 'scroll' },
|
|
77
|
+
rows: { flexDirection: 'column', flexShrink: 0 },
|
|
78
|
+
row: {
|
|
79
|
+
flexDirection: 'row',
|
|
80
|
+
height: ROW_HEIGHT,
|
|
81
|
+
flexShrink: 0,
|
|
82
|
+
alignItems: 'center',
|
|
83
|
+
cursor: 'pointer',
|
|
84
|
+
},
|
|
85
|
+
cell: {
|
|
86
|
+
height: ROW_HEIGHT,
|
|
87
|
+
justifyContent: 'center',
|
|
88
|
+
paddingStart: 8,
|
|
89
|
+
flexShrink: 0,
|
|
90
|
+
overflow: 'hidden',
|
|
91
|
+
},
|
|
92
|
+
// one line, always: a row is a fixed height, so a cell that wrapped
|
|
93
|
+
// would be sliced rather than shown — `textWrap` in styling.md. And it
|
|
94
|
+
// ends in a `…` when the column is too narrow for it, because a column is
|
|
95
|
+
// resizable: the reader is the one who decided how much of a name fits,
|
|
96
|
+
// and a clip gives them no way to tell a cut name from a short one.
|
|
97
|
+
cellText: { fontSize: 12, textWrap: 'nowrap', textOverflow: 'ellipsis' },
|
|
98
|
+
spacer: { flexShrink: 0 },
|
|
99
|
+
// The mark is pushed to the far end of the header by a spacer, so this is
|
|
100
|
+
// the floor on the gap rather than the gap: it only does anything once a
|
|
101
|
+
// long caption has eaten the space between them.
|
|
102
|
+
sortMark: { marginStart: 4 },
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
/** The sort chevron, matched to the capitals of the 12px header it follows —
|
|
106
|
+
* a chevron is as wide as its box, so this is a width rather than a height. */
|
|
107
|
+
const SORT_MARK = 9;
|
|
108
|
+
|
|
109
|
+
const value = (row, column) =>
|
|
110
|
+
column.value ? column.value(row) : row[column.id];
|
|
111
|
+
|
|
112
|
+
function compare(a, b) {
|
|
113
|
+
if (typeof a === 'number' && typeof b === 'number') return a - b;
|
|
114
|
+
return String(a ?? '').localeCompare(String(b ?? ''));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* <Table columns rows/> — a grid with a header that stays put, resizable
|
|
119
|
+
* columns, and only the rows in view actually built.
|
|
120
|
+
*
|
|
121
|
+
* <Table
|
|
122
|
+
* columns={[{ id: 'name', label: 'Name', width: 220 }]}
|
|
123
|
+
* rows={[{ id: 1, name: 'index.js' }]}
|
|
124
|
+
* />
|
|
125
|
+
*
|
|
126
|
+
* Columns are `{ id, label, width, align, value, render }`. `value(row)`
|
|
127
|
+
* feeds sorting and the default cell text; `render(row, { selected, column })`
|
|
128
|
+
* replaces the cell entirely — and gets told whether it is on the selected
|
|
129
|
+
* row, because that row is a filled bar and a colour chosen against the
|
|
130
|
+
* resting background is unreadable on it.
|
|
131
|
+
*
|
|
132
|
+
* **Rows must all be `rowHeight` tall** (24 by default). That is what lets
|
|
133
|
+
* the table skip building the rows nobody can see: with ten thousand rows
|
|
134
|
+
* it mounts the thirty or so in the viewport, and scrolling swaps them.
|
|
135
|
+
* Everything above and below is one spacer box apiece, so the scrollbar
|
|
136
|
+
* still measures the whole list.
|
|
137
|
+
*
|
|
138
|
+
* Sorting is uncontrolled unless you pass `sort`; the header reports
|
|
139
|
+
* `onSortChange({ column, direction })` either way. Selection is
|
|
140
|
+
* `selected` + `onSelect`, or uncontrolled with `defaultSelected`, and
|
|
141
|
+
* `onActivate(id, row)` is the *open* gesture on top of it — a double click
|
|
142
|
+
* or Enter.
|
|
143
|
+
*/
|
|
144
|
+
export function Table({
|
|
145
|
+
columns = [],
|
|
146
|
+
rows = [],
|
|
147
|
+
rowHeight = ROW_HEIGHT,
|
|
148
|
+
sort,
|
|
149
|
+
defaultSort,
|
|
150
|
+
onSortChange,
|
|
151
|
+
selected,
|
|
152
|
+
defaultSelected,
|
|
153
|
+
onSelect,
|
|
154
|
+
onActivate,
|
|
155
|
+
onColumnResize,
|
|
156
|
+
style,
|
|
157
|
+
...boxProps
|
|
158
|
+
}) {
|
|
159
|
+
const theme = useTheme();
|
|
160
|
+
const rtl = useDirection() === 'rtl';
|
|
161
|
+
const [ownSort, setOwnSort] = useState(defaultSort ?? null);
|
|
162
|
+
const [ownSelected, setOwnSelected] = useState(defaultSelected);
|
|
163
|
+
const [widths, setWidths] = useState(() =>
|
|
164
|
+
Object.fromEntries(columns.map((c) => [c.id, c.width ?? 120])),
|
|
165
|
+
);
|
|
166
|
+
const [scrollX, setScrollX] = useState(0);
|
|
167
|
+
const [view, setView] = useState({ top: 0, height: 0 });
|
|
168
|
+
|
|
169
|
+
const body = useRef(null);
|
|
170
|
+
const widthsRef = useRef(widths);
|
|
171
|
+
widthsRef.current = widths;
|
|
172
|
+
const drag = useRef(null);
|
|
173
|
+
|
|
174
|
+
const activeSort = sort === undefined ? ownSort : sort;
|
|
175
|
+
const current = selected ?? ownSelected;
|
|
176
|
+
|
|
177
|
+
const sorted = useMemo(() => {
|
|
178
|
+
if (!activeSort) return rows;
|
|
179
|
+
const column = columns.find((c) => c.id === activeSort.column);
|
|
180
|
+
if (!column) return rows;
|
|
181
|
+
const sign = activeSort.direction === 'desc' ? -1 : 1;
|
|
182
|
+
return [...rows].sort(
|
|
183
|
+
(a, b) => sign * compare(value(a, column), value(b, column)),
|
|
184
|
+
);
|
|
185
|
+
}, [rows, columns, activeSort]);
|
|
186
|
+
|
|
187
|
+
const currentRef = useRef(current);
|
|
188
|
+
currentRef.current = current;
|
|
189
|
+
|
|
190
|
+
const columnWidth = (c) => widths[c.id] ?? c.width ?? 120;
|
|
191
|
+
const totalWidth = columns.reduce((n, c) => n + columnWidth(c), 0);
|
|
192
|
+
|
|
193
|
+
// the slice worth building: what is on screen, plus a little either side
|
|
194
|
+
const first = Math.max(0, Math.floor(view.top / rowHeight) - OVERSCAN);
|
|
195
|
+
const visibleCount =
|
|
196
|
+
view.height > 0
|
|
197
|
+
? Math.ceil(view.height / rowHeight) + OVERSCAN * 2
|
|
198
|
+
: ASSUMED_ROWS;
|
|
199
|
+
const last = Math.min(sorted.length, first + visibleCount);
|
|
200
|
+
const slice = sorted.slice(first, last);
|
|
201
|
+
|
|
202
|
+
const toggleSort = (column) => {
|
|
203
|
+
const next =
|
|
204
|
+
activeSort?.column === column.id && activeSort.direction === 'asc'
|
|
205
|
+
? { column: column.id, direction: 'desc' }
|
|
206
|
+
: { column: column.id, direction: 'asc' };
|
|
207
|
+
if (sort === undefined) setOwnSort(next);
|
|
208
|
+
onSortChange?.(next);
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const pick = (row) => {
|
|
212
|
+
if (!row) return;
|
|
213
|
+
currentRef.current = row.id;
|
|
214
|
+
if (selected === undefined) setOwnSelected(row.id);
|
|
215
|
+
onSelect?.(row.id, row);
|
|
216
|
+
// keep the selection on screen without building the rows in between
|
|
217
|
+
const index = sorted.findIndex((r) => r.id === row.id);
|
|
218
|
+
const top = index * rowHeight;
|
|
219
|
+
const node = body.current;
|
|
220
|
+
if (!node) return;
|
|
221
|
+
// `top` is logical (rows are style heights); the node's offset and
|
|
222
|
+
// viewport are device fields, divided here so all three share a unit
|
|
223
|
+
const scrollY = node.scrollY / node.scale;
|
|
224
|
+
const viewH = node.abs.height / node.scale;
|
|
225
|
+
if (top < scrollY) node.scrollTo({ y: top });
|
|
226
|
+
else if (top + rowHeight > scrollY + viewH) {
|
|
227
|
+
node.scrollTo({ y: top + rowHeight - viewH });
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const step = (delta) => {
|
|
232
|
+
const index = sorted.findIndex((r) => r.id === currentRef.current);
|
|
233
|
+
const next = Math.min(
|
|
234
|
+
Math.max(0, (index === -1 ? -1 : index) + delta),
|
|
235
|
+
sorted.length - 1,
|
|
236
|
+
);
|
|
237
|
+
return sorted[next];
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const onKeyDown = (ev) => {
|
|
241
|
+
const page = Math.max(1, Math.floor(view.height / rowHeight) - 1);
|
|
242
|
+
switch (ev.keysym) {
|
|
243
|
+
case XK_UP:
|
|
244
|
+
pick(step(-1));
|
|
245
|
+
return;
|
|
246
|
+
case XK_DOWN:
|
|
247
|
+
pick(step(1));
|
|
248
|
+
return;
|
|
249
|
+
case XK_PAGE_UP:
|
|
250
|
+
pick(step(-page));
|
|
251
|
+
return;
|
|
252
|
+
case XK_PAGE_DOWN:
|
|
253
|
+
pick(step(page));
|
|
254
|
+
return;
|
|
255
|
+
case XK_HOME:
|
|
256
|
+
pick(sorted[0]);
|
|
257
|
+
return;
|
|
258
|
+
case XK_END:
|
|
259
|
+
pick(sorted[sorted.length - 1]);
|
|
260
|
+
return;
|
|
261
|
+
case XK_RETURN: {
|
|
262
|
+
const row = sorted.find((r) => r.id === currentRef.current);
|
|
263
|
+
if (row) onActivate?.(row.id, row);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
default:
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
const resize = (id, width) => {
|
|
271
|
+
const next = Math.max(MIN_COLUMN, width);
|
|
272
|
+
if (next === widthsRef.current[id]) return;
|
|
273
|
+
widthsRef.current = { ...widthsRef.current, [id]: next };
|
|
274
|
+
setWidths(widthsRef.current);
|
|
275
|
+
onColumnResize?.(id, next);
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
const resizeProps = (column) => ({
|
|
279
|
+
focusable: true,
|
|
280
|
+
onMouseDown: (ev) => {
|
|
281
|
+
drag.current = { id: column.id, from: ev.x, width: columnWidth(column) };
|
|
282
|
+
ev.capturePointer();
|
|
283
|
+
},
|
|
284
|
+
onMouseMove: (ev) => {
|
|
285
|
+
const d = drag.current;
|
|
286
|
+
if (!d) return;
|
|
287
|
+
// dragging the grip away from the column's own start edge widens it,
|
|
288
|
+
// and which way that is follows the layout
|
|
289
|
+
resize(d.id, d.width + (rtl ? d.from - ev.x : ev.x - d.from));
|
|
290
|
+
},
|
|
291
|
+
onMouseUp: () => {
|
|
292
|
+
drag.current = null;
|
|
293
|
+
},
|
|
294
|
+
// The handle takes focus on the press, so it is in the tab order whether
|
|
295
|
+
// or not it answers a key — and a focus stop that does nothing is worse
|
|
296
|
+
// than no stop at all. Left/Right, the same pair `SplitPane`'s divider
|
|
297
|
+
// takes; the table's own keys are Up/Down and never collide.
|
|
298
|
+
onKeyDown: (ev) => {
|
|
299
|
+
const narrower = rtl ? XK_RIGHT : XK_LEFT;
|
|
300
|
+
const wider = rtl ? XK_LEFT : XK_RIGHT;
|
|
301
|
+
if (ev.keysym === narrower) resize(column.id, columnWidth(column) - STEP);
|
|
302
|
+
else if (ev.keysym === wider)
|
|
303
|
+
resize(column.id, columnWidth(column) + STEP);
|
|
304
|
+
},
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
const cell = (row, column, isSelected) =>
|
|
308
|
+
h(
|
|
309
|
+
'box',
|
|
310
|
+
{
|
|
311
|
+
key: column.id,
|
|
312
|
+
style: [
|
|
313
|
+
s.cell,
|
|
314
|
+
{ width: columnWidth(column) },
|
|
315
|
+
// `'right'` is what a numeric column asks for and what it means is
|
|
316
|
+
// "the end of the row" — a column of figures lines up on the edge
|
|
317
|
+
// the row finishes at, which is the left one in a mirrored table
|
|
318
|
+
column.align === 'right' && {
|
|
319
|
+
alignItems: 'flex-end',
|
|
320
|
+
paddingEnd: 8,
|
|
321
|
+
},
|
|
322
|
+
],
|
|
323
|
+
},
|
|
324
|
+
column.render
|
|
325
|
+
? // A cell that draws itself still has to know it is on the selected
|
|
326
|
+
// row: the selection is a filled bar, and a `render` that picked a
|
|
327
|
+
// colour for the resting background — a directory in the accent,
|
|
328
|
+
// a warning in red — paints it onto that bar unreadably otherwise.
|
|
329
|
+
column.render(row, { selected: isSelected, column })
|
|
330
|
+
: h(
|
|
331
|
+
'text',
|
|
332
|
+
{
|
|
333
|
+
style: [
|
|
334
|
+
capTrim,
|
|
335
|
+
s.cellText,
|
|
336
|
+
{ color: isSelected ? theme.hoverText : theme.text },
|
|
337
|
+
],
|
|
338
|
+
},
|
|
339
|
+
String(value(row, column) ?? ''),
|
|
340
|
+
),
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
return h(
|
|
344
|
+
'box',
|
|
345
|
+
{
|
|
346
|
+
theme,
|
|
347
|
+
// The grid role, with the honest caveat that virtualization keeps
|
|
348
|
+
// only the rendered rows in the accessible tree — the same rows a
|
|
349
|
+
// sighted user can see. `aria-label` via boxProps names the table.
|
|
350
|
+
role: 'table',
|
|
351
|
+
// the *table* takes focus, not the row: a virtualized row is
|
|
352
|
+
// unmounted as soon as it scrolls out, and focus would go with it
|
|
353
|
+
focusable: true,
|
|
354
|
+
...boxProps,
|
|
355
|
+
style: [s.root, style],
|
|
356
|
+
onKeyDown,
|
|
357
|
+
},
|
|
358
|
+
// the header scrolls sideways with the body but never vertically, so it
|
|
359
|
+
// lives outside the scrolling pane and is shifted by the body's scrollX
|
|
360
|
+
h(
|
|
361
|
+
'box',
|
|
362
|
+
{ style: [s.headerClip, { backgroundColor: theme.surfaceHover }] },
|
|
363
|
+
h(
|
|
364
|
+
'box',
|
|
365
|
+
// `marginStart`, so the header tracks the body's scroll in the
|
|
366
|
+
// direction the columns actually run
|
|
367
|
+
{ style: [s.headerRow, { marginStart: -scrollX, width: totalWidth }] },
|
|
368
|
+
columns.map((column, index) =>
|
|
369
|
+
// The header cell and the grab band are **siblings**, not a cell
|
|
370
|
+
// with a handle inside it. A click fires on the nearest common
|
|
371
|
+
// ancestor of press and release, so a grip nested in the header
|
|
372
|
+
// made every resize end in a sort: press the handle, release
|
|
373
|
+
// anywhere over the header, and the click lands on the header. As
|
|
374
|
+
// siblings the release cannot reach it — the pointer is captured by
|
|
375
|
+
// the grip for the whole drag, and even without the capture the
|
|
376
|
+
// common ancestor is the row rather than either header.
|
|
377
|
+
//
|
|
378
|
+
// A band centred on the rule takes `HALF` from the column to its
|
|
379
|
+
// left as well, so every header but the first is inset by that
|
|
380
|
+
// much; the last band has no column to its right and is the half
|
|
381
|
+
// that fits.
|
|
382
|
+
h(
|
|
383
|
+
React.Fragment,
|
|
384
|
+
{ key: column.id },
|
|
385
|
+
h(
|
|
386
|
+
'box',
|
|
387
|
+
{
|
|
388
|
+
role: 'columnheader',
|
|
389
|
+
style: [
|
|
390
|
+
s.header,
|
|
391
|
+
{
|
|
392
|
+
width: Math.max(
|
|
393
|
+
0,
|
|
394
|
+
columnWidth(column) - HALF - RULE - (index ? HALF : 0),
|
|
395
|
+
),
|
|
396
|
+
},
|
|
397
|
+
],
|
|
398
|
+
focusable: true,
|
|
399
|
+
onClick: () => toggleSort(column),
|
|
400
|
+
},
|
|
401
|
+
h(
|
|
402
|
+
'text',
|
|
403
|
+
{ style: [capTrim, s.headerLabel, { color: theme.text }] },
|
|
404
|
+
column.label ?? column.id,
|
|
405
|
+
),
|
|
406
|
+
// The spacer goes *between* them: the caption belongs to the
|
|
407
|
+
// left edge of its column, where the eye scans for it, and the
|
|
408
|
+
// sort mark to the right edge, where it lines up with the
|
|
409
|
+
// marks of every other sortable column instead of drifting
|
|
410
|
+
// with the length of each caption.
|
|
411
|
+
h('box', { style: { flexGrow: 1 } }),
|
|
412
|
+
activeSort?.column === column.id &&
|
|
413
|
+
h(Icon, {
|
|
414
|
+
name:
|
|
415
|
+
activeSort.direction === 'asc'
|
|
416
|
+
? 'chevronUp'
|
|
417
|
+
: 'chevronDown',
|
|
418
|
+
size: SORT_MARK,
|
|
419
|
+
color: theme.textMuted,
|
|
420
|
+
style: s.sortMark,
|
|
421
|
+
}),
|
|
422
|
+
),
|
|
423
|
+
// The band is invisible until the pointer is on it, and answers
|
|
424
|
+
// the press itself — a captured press keeps `:active` for the
|
|
425
|
+
// whole drag, wherever the pointer wanders, so the handle stays
|
|
426
|
+
// lit while the column follows it. The rule is its child, so the
|
|
427
|
+
// lit band is symmetric about the line it moves.
|
|
428
|
+
h(
|
|
429
|
+
'box',
|
|
430
|
+
{
|
|
431
|
+
...resizeProps(column),
|
|
432
|
+
role: 'separator',
|
|
433
|
+
'aria-orientation': 'vertical',
|
|
434
|
+
'aria-label': `Resize ${column.label || column.id}`,
|
|
435
|
+
'aria-valuenow': columnWidth(column),
|
|
436
|
+
'aria-valuemin': MIN_COLUMN,
|
|
437
|
+
style: [
|
|
438
|
+
s.grip,
|
|
439
|
+
{
|
|
440
|
+
width: index === columns.length - 1 ? HALF + RULE : GRIP,
|
|
441
|
+
justifyContent:
|
|
442
|
+
index === columns.length - 1 ? 'flex-end' : 'center',
|
|
443
|
+
},
|
|
444
|
+
{ backgroundColor: 'transparent' },
|
|
445
|
+
{ ':hover': { backgroundColor: theme.track } },
|
|
446
|
+
{ ':active': { backgroundColor: theme.accent } },
|
|
447
|
+
],
|
|
448
|
+
},
|
|
449
|
+
h('box', { style: [s.rule, { backgroundColor: theme.border }] }),
|
|
450
|
+
),
|
|
451
|
+
),
|
|
452
|
+
),
|
|
453
|
+
),
|
|
454
|
+
),
|
|
455
|
+
h(
|
|
456
|
+
'box',
|
|
457
|
+
{
|
|
458
|
+
ref: body,
|
|
459
|
+
style: s.body,
|
|
460
|
+
onScroll: (ev) => {
|
|
461
|
+
setScrollX(ev.scrollX);
|
|
462
|
+
setView((v) =>
|
|
463
|
+
v.top === ev.scrollY ? v : { ...v, top: ev.scrollY },
|
|
464
|
+
);
|
|
465
|
+
},
|
|
466
|
+
// layout, not scrolling, is what first tells a list how much of it
|
|
467
|
+
// is worth building
|
|
468
|
+
onViewport: (v) =>
|
|
469
|
+
setView((prev) =>
|
|
470
|
+
prev.height === v.height ? prev : { ...prev, height: v.height },
|
|
471
|
+
),
|
|
472
|
+
},
|
|
473
|
+
h(
|
|
474
|
+
'box',
|
|
475
|
+
{ style: [s.rows, { width: totalWidth }] },
|
|
476
|
+
first > 0 &&
|
|
477
|
+
h('box', { style: [s.spacer, { height: first * rowHeight }] }),
|
|
478
|
+
slice.map((row, sliceIndex) => {
|
|
479
|
+
const isSelected = row.id === current;
|
|
480
|
+
return h(
|
|
481
|
+
'box',
|
|
482
|
+
{
|
|
483
|
+
key: row.id,
|
|
484
|
+
role: 'row',
|
|
485
|
+
'aria-selected': isSelected,
|
|
486
|
+
'aria-posinset': first + sliceIndex + 1,
|
|
487
|
+
'aria-setsize': sorted.length,
|
|
488
|
+
style: [
|
|
489
|
+
s.row,
|
|
490
|
+
{ height: rowHeight },
|
|
491
|
+
{
|
|
492
|
+
backgroundColor: isSelected
|
|
493
|
+
? theme.hoverBackground
|
|
494
|
+
: 'transparent',
|
|
495
|
+
},
|
|
496
|
+
// pressed even on the selected row: a re-press on the row
|
|
497
|
+
// that is already current is the one click in the table
|
|
498
|
+
// that would otherwise look ignored
|
|
499
|
+
{
|
|
500
|
+
':active': {
|
|
501
|
+
backgroundColor: isSelected
|
|
502
|
+
? theme.accentActive
|
|
503
|
+
: theme.surfaceActive,
|
|
504
|
+
},
|
|
505
|
+
},
|
|
506
|
+
!isSelected && {
|
|
507
|
+
':hover': { backgroundColor: theme.surfaceHover },
|
|
508
|
+
},
|
|
509
|
+
],
|
|
510
|
+
// Select on the first click, open on the second — the gesture
|
|
511
|
+
// every file list has. `detail` is the click count the renderer
|
|
512
|
+
// already counts for text selection, so a double click here is
|
|
513
|
+
// exactly the one a `<textinput>` calls a word select.
|
|
514
|
+
onClick: (ev) => {
|
|
515
|
+
pick(row);
|
|
516
|
+
if (ev.detail === 2) onActivate?.(row.id, row);
|
|
517
|
+
},
|
|
518
|
+
},
|
|
519
|
+
columns.map((column) => cell(row, column, isSelected)),
|
|
520
|
+
);
|
|
521
|
+
}),
|
|
522
|
+
last < sorted.length &&
|
|
523
|
+
h('box', {
|
|
524
|
+
style: [s.spacer, { height: (sorted.length - last) * rowHeight }],
|
|
525
|
+
}),
|
|
526
|
+
),
|
|
527
|
+
),
|
|
528
|
+
);
|
|
529
|
+
}
|