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.
Files changed (158) hide show
  1. package/README.md +330 -81
  2. package/package.json +132 -14
  3. package/src/ClickToComponent.js +9 -2
  4. package/src/DevToolsIntegration.js +440 -32
  5. package/src/Reconciler.js +463 -165
  6. package/src/a11y.js +1299 -0
  7. package/src/acceleratorhooks.js +86 -0
  8. package/src/accelerators.js +128 -0
  9. package/src/activate.js +168 -0
  10. package/src/anchor.js +360 -0
  11. package/src/appcontext.js +161 -0
  12. package/src/appearance.js +748 -0
  13. package/src/appearancehooks.js +96 -0
  14. package/src/apphooks.js +73 -0
  15. package/src/application.js +748 -0
  16. package/src/atspi.js +1953 -0
  17. package/src/bus.js +545 -0
  18. package/src/bushooks.js +136 -0
  19. package/src/clientmessage.js +140 -0
  20. package/src/clipboard.js +121 -0
  21. package/src/components/Button.js +148 -33
  22. package/src/components/Checkbox.js +66 -32
  23. package/src/components/Dialog.js +96 -29
  24. package/src/components/FileDialog.js +491 -0
  25. package/src/components/Icon.js +329 -0
  26. package/src/components/Menu.js +797 -133
  27. package/src/components/PasswordInput.js +434 -0
  28. package/src/components/ProgressBar.js +91 -12
  29. package/src/components/Radio.js +78 -35
  30. package/src/components/Select.js +255 -65
  31. package/src/components/Slider.js +102 -41
  32. package/src/components/SplitPane.js +206 -0
  33. package/src/components/Switch.js +100 -22
  34. package/src/components/Table.js +529 -0
  35. package/src/components/Tabs.js +211 -0
  36. package/src/components/Tooltip.js +528 -54
  37. package/src/components/anchor.js +154 -107
  38. package/src/components/change.js +34 -0
  39. package/src/components/dnd.js +103 -0
  40. package/src/components/index.js +16 -3
  41. package/src/components/keys.js +17 -20
  42. package/src/components/scribble.js +247 -0
  43. package/src/components/theme.js +350 -42
  44. package/src/components/typeahead.js +12 -2
  45. package/src/compose.js +868 -0
  46. package/src/compositing.js +224 -0
  47. package/src/dbusmenu.js +384 -0
  48. package/src/debug.d.ts +63 -0
  49. package/src/debug.js +642 -0
  50. package/src/decorations.js +486 -0
  51. package/src/desktopsettings.js +209 -0
  52. package/src/desktopsettingshooks.js +62 -0
  53. package/src/dnd.js +1725 -0
  54. package/src/editmenu.js +272 -0
  55. package/src/errors.js +98 -0
  56. package/src/events.js +1406 -162
  57. package/src/extensions.js +45 -0
  58. package/src/filedialog.js +375 -0
  59. package/src/filedialoghooks.js +132 -0
  60. package/src/fonthooks.js +64 -0
  61. package/src/fonts.js +301 -0
  62. package/src/foreignnodes.js +519 -0
  63. package/src/frame/child.js +33 -0
  64. package/src/frame/childmain.js +274 -0
  65. package/src/frame/env.js +140 -0
  66. package/src/frame/index.js +444 -0
  67. package/src/frame/lifecycle.js +67 -0
  68. package/src/frame/protocol.js +179 -0
  69. package/src/frames.js +73 -0
  70. package/src/glbackend.js +114 -0
  71. package/src/glnodes.js +125 -67
  72. package/src/globalmenu.js +683 -0
  73. package/src/host.d.ts +88 -0
  74. package/src/host.js +42 -0
  75. package/src/idle.js +490 -0
  76. package/src/idlehooks.js +100 -0
  77. package/src/imagesource.js +349 -0
  78. package/src/index.d.ts +475 -0
  79. package/src/index.js +83 -9
  80. package/src/inputtime.js +206 -0
  81. package/src/jsx-dev-runtime.d.ts +21 -0
  82. package/src/jsx-dev-runtime.js +2 -0
  83. package/src/jsx-runtime.d.ts +39 -0
  84. package/src/jsx-runtime.js +6 -0
  85. package/src/keyboard.js +256 -0
  86. package/src/keyboardstate.js +278 -0
  87. package/src/keyboardstatehooks.js +58 -0
  88. package/src/keysyms.d.ts +149 -0
  89. package/src/keysyms.js +270 -0
  90. package/src/locale.js +170 -0
  91. package/src/localehooks.js +47 -0
  92. package/src/menuitem.js +223 -0
  93. package/src/node.d.ts +597 -0
  94. package/src/node.js +44 -0
  95. package/src/nodes.js +9546 -690
  96. package/src/ntk.d.ts +44 -0
  97. package/src/ntk.js +25 -0
  98. package/src/paintcache.js +366 -0
  99. package/src/palette.js +380 -0
  100. package/src/pastestate.js +66 -0
  101. package/src/portal.js +461 -0
  102. package/src/priority.js +26 -0
  103. package/src/refresh/index.d.ts +40 -0
  104. package/src/refresh/index.js +122 -0
  105. package/src/refresh/loader.d.ts +37 -0
  106. package/src/refresh/loader.js +401 -0
  107. package/src/refresh/register.d.ts +5 -0
  108. package/src/refresh/register.js +13 -0
  109. package/src/registry.js +232 -0
  110. package/src/scale.js +626 -0
  111. package/src/scalehooks.js +27 -0
  112. package/src/screencolor.js +640 -0
  113. package/src/screencolorhooks.js +101 -0
  114. package/src/screens.js +754 -0
  115. package/src/screenshooks.js +137 -0
  116. package/src/startup.js +302 -0
  117. package/src/style.d.ts +126 -0
  118. package/src/style.js +33 -0
  119. package/src/styles.js +1482 -6
  120. package/src/svgnodes.js +306 -0
  121. package/src/testing/a11y.js +484 -0
  122. package/src/testing/components.js +414 -0
  123. package/src/testing/events.js +407 -0
  124. package/src/testing/harness.js +455 -0
  125. package/src/testing/index.d.ts +558 -0
  126. package/src/testing/index.js +85 -0
  127. package/src/testing/mock-app.js +463 -0
  128. package/src/testing/pixels.js +152 -0
  129. package/src/testing/queries.js +224 -0
  130. package/src/textrange.js +83 -0
  131. package/src/textselection.js +439 -0
  132. package/src/trace-registry.js +63 -0
  133. package/src/transfer.js +93 -0
  134. package/src/types/appearance.d.ts +84 -0
  135. package/src/types/application.d.ts +173 -0
  136. package/src/types/components.d.ts +820 -0
  137. package/src/types/dbus.d.ts +177 -0
  138. package/src/types/elements.d.ts +897 -0
  139. package/src/types/events.d.ts +549 -0
  140. package/src/types/filedialog.d.ts +260 -0
  141. package/src/types/fonts.d.ts +124 -0
  142. package/src/types/frame.d.ts +146 -0
  143. package/src/types/globalmenu.d.ts +42 -0
  144. package/src/types/nodes.d.ts +199 -0
  145. package/src/types/screencolor.d.ts +84 -0
  146. package/src/types/style.d.ts +434 -0
  147. package/src/types/system.d.ts +287 -0
  148. package/src/windowid.js +151 -0
  149. package/src/windowstate.js +393 -0
  150. package/src/xsettings.js +336 -0
  151. package/src/yoga.d.ts +55 -0
  152. package/src/yoga.js +135 -0
  153. package/src/components/Canvas3D.js +0 -28
  154. package/src/geometry3d.js +0 -223
  155. package/src/pointer3d.js +0 -158
  156. package/src/raycast3d.js +0 -146
  157. package/src/richnodes.js +0 -436
  158. 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
+ }