vastgrid 0.0.0-stage → 0.1.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 (80) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +24 -2
  3. package/dist/components/bars.d.ts +14 -0
  4. package/dist/components/bars.d.ts.map +1 -0
  5. package/dist/components/bars.js +67 -0
  6. package/dist/components/bars.js.map +1 -0
  7. package/dist/components/cell-inspector.d.ts +11 -0
  8. package/dist/components/cell-inspector.d.ts.map +1 -0
  9. package/dist/components/cell-inspector.js +37 -0
  10. package/dist/components/cell-inspector.js.map +1 -0
  11. package/dist/components/columns-panel.d.ts +12 -0
  12. package/dist/components/columns-panel.d.ts.map +1 -0
  13. package/dist/components/columns-panel.js +11 -0
  14. package/dist/components/columns-panel.js.map +1 -0
  15. package/dist/components/filter-panel.d.ts +17 -0
  16. package/dist/components/filter-panel.d.ts.map +1 -0
  17. package/dist/components/filter-panel.js +165 -0
  18. package/dist/components/filter-panel.js.map +1 -0
  19. package/dist/components/find-bar.d.ts +9 -0
  20. package/dist/components/find-bar.d.ts.map +1 -0
  21. package/dist/components/find-bar.js +46 -0
  22. package/dist/components/find-bar.js.map +1 -0
  23. package/dist/components/popover.d.ts +38 -0
  24. package/dist/components/popover.d.ts.map +1 -0
  25. package/dist/components/popover.js +104 -0
  26. package/dist/components/popover.js.map +1 -0
  27. package/dist/components/stats-panel.d.ts +12 -0
  28. package/dist/components/stats-panel.d.ts.map +1 -0
  29. package/dist/components/stats-panel.js +40 -0
  30. package/dist/components/stats-panel.js.map +1 -0
  31. package/dist/controller.d.ts +218 -0
  32. package/dist/controller.d.ts.map +1 -0
  33. package/dist/controller.js +788 -0
  34. package/dist/controller.js.map +1 -0
  35. package/dist/data-grid.d.ts +51 -0
  36. package/dist/data-grid.d.ts.map +1 -0
  37. package/dist/data-grid.js +698 -0
  38. package/dist/data-grid.js.map +1 -0
  39. package/dist/index.d.ts +7 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +7 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/labels.d.ts +9 -0
  44. package/dist/labels.d.ts.map +1 -0
  45. package/dist/labels.js +38 -0
  46. package/dist/labels.js.map +1 -0
  47. package/dist/messages.d.ts +99 -0
  48. package/dist/messages.d.ts.map +1 -0
  49. package/dist/messages.js +276 -0
  50. package/dist/messages.js.map +1 -0
  51. package/dist/renderer.d.ts +52 -0
  52. package/dist/renderer.d.ts.map +1 -0
  53. package/dist/renderer.js +522 -0
  54. package/dist/renderer.js.map +1 -0
  55. package/dist/styles.css +694 -0
  56. package/dist/text.d.ts +14 -0
  57. package/dist/text.d.ts.map +1 -0
  58. package/dist/text.js +63 -0
  59. package/dist/text.js.map +1 -0
  60. package/dist/theme.d.ts +32 -0
  61. package/dist/theme.d.ts.map +1 -0
  62. package/dist/theme.js +54 -0
  63. package/dist/theme.js.map +1 -0
  64. package/package.json +52 -4
  65. package/src/components/bars.tsx +179 -0
  66. package/src/components/cell-inspector.tsx +70 -0
  67. package/src/components/columns-panel.tsx +59 -0
  68. package/src/components/filter-panel.tsx +290 -0
  69. package/src/components/find-bar.tsx +104 -0
  70. package/src/components/popover.tsx +194 -0
  71. package/src/components/stats-panel.tsx +153 -0
  72. package/src/controller.ts +887 -0
  73. package/src/data-grid.tsx +1029 -0
  74. package/src/index.ts +13 -0
  75. package/src/labels.ts +46 -0
  76. package/src/messages.ts +390 -0
  77. package/src/renderer.ts +535 -0
  78. package/src/styles.css +694 -0
  79. package/src/text.ts +55 -0
  80. package/src/theme.ts +89 -0
@@ -0,0 +1,698 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { forwardRef, useCallback, useEffect, useId, useImperativeHandle, useMemo, useRef, useState, useSyncExternalStore, } from "react";
3
+ import { plainText } from "vastgrid-core";
4
+ import { StatusBar, Toolbar } from "./components/bars.js";
5
+ import { CellInspector } from "./components/cell-inspector.js";
6
+ import { ColumnsPanel } from "./components/columns-panel.js";
7
+ import { FilterPanel } from "./components/filter-panel.js";
8
+ import { FindBar } from "./components/find-bar.js";
9
+ import { MenuList, Popover } from "./components/popover.js";
10
+ import { StatsPanel } from "./components/stats-panel.js";
11
+ import { CopyTooLargeError, GridController } from "./controller.js";
12
+ import { countFormat, numberFormat } from "./labels.js";
13
+ import { messagesFor } from "./messages.js";
14
+ import { CanvasRenderer } from "./renderer.js";
15
+ import { cssLength, resolveTheme } from "./theme.js";
16
+ const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
17
+ async function writeClipboard(text, html) {
18
+ const clipboard = typeof navigator === "undefined" ? undefined : navigator.clipboard;
19
+ if (!clipboard)
20
+ throw new Error("The clipboard is not available.");
21
+ if (clipboard.write && typeof ClipboardItem !== "undefined") {
22
+ const items = {
23
+ "text/plain": text.then((value) => new Blob([value], { type: "text/plain" })),
24
+ };
25
+ if (html)
26
+ items["text/html"] = html.then((value) => new Blob([value ?? ""], { type: "text/html" }));
27
+ await clipboard.write([new ClipboardItem(items)]);
28
+ return;
29
+ }
30
+ await clipboard.writeText(await text);
31
+ }
32
+ /**
33
+ * A data grid for very large tables. Draws on a canvas, reads rows through a `TableSource` as you
34
+ * scroll, and offers spreadsheet selection and keyboard navigation, copying, sorting, filters,
35
+ * search, column statistics and a selection summary.
36
+ */
37
+ export const DataGrid = forwardRef(function DataGrid(props, ref) {
38
+ const { source, locale, rowHeight, headerHeight, nullText = "", frozenColumns = 0, toolbar = true, statusBar = true, reloadKey, copyLimit = 1_000_000, onQueryChange, className, style, } = props;
39
+ const resolvedLocale = locale ?? (typeof navigator === "undefined" ? undefined : navigator.language);
40
+ const messages = useMemo(() => ({ ...messagesFor(resolvedLocale), ...props.messages }), [resolvedLocale, props.messages]);
41
+ const rootRef = useRef(null);
42
+ const bodyRef = useRef(null);
43
+ const canvasRef = useRef(null);
44
+ const scrollerRef = useRef(null);
45
+ const sizerRef = useRef(null);
46
+ const rendererRef = useRef(null);
47
+ const dragRef = useRef(null);
48
+ const autoScrollRef = useRef(0);
49
+ const cellId = useId();
50
+ const [overlay, setOverlay] = useState(null);
51
+ const [findOpen, setFindOpen] = useState(false);
52
+ const [notice, setNotice] = useState(null);
53
+ const noticeTimer = useRef(undefined);
54
+ // One controller per source. Options that can change later are applied in effects below.
55
+ // biome-ignore lint/correctness/useExhaustiveDependencies: the other options are applied without recreating the grid.
56
+ const controller = useMemo(() => new GridController(source, {
57
+ rowHeight: rowHeight ?? 24,
58
+ headerHeight: headerHeight ?? 32,
59
+ locale: resolvedLocale,
60
+ nullText,
61
+ frozenColumns,
62
+ minColumnWidth: 36,
63
+ maxAutoWidth: 360,
64
+ copyLimit,
65
+ }), [source]);
66
+ useSyncExternalStore(controller.subscribe, controller.getVersion, controller.getVersion);
67
+ const count = useMemo(() => countFormat(resolvedLocale), [resolvedLocale]);
68
+ const number = useMemo(() => numberFormat(resolvedLocale), [resolvedLocale]);
69
+ useEffect(() => {
70
+ if (resolvedLocale !== controller.options.locale || nullText !== controller.options.nullText) {
71
+ controller.setFormat(resolvedLocale, nullText);
72
+ }
73
+ }, [controller, resolvedLocale, nullText]);
74
+ const announce = useCallback((text) => {
75
+ setNotice(text);
76
+ clearTimeout(noticeTimer.current);
77
+ noticeTimer.current = setTimeout(() => setNotice(null), 3000);
78
+ }, []);
79
+ useEffect(() => () => clearTimeout(noticeTimer.current), []);
80
+ // Canvas, scrolling, sizes and theme.
81
+ useEffect(() => {
82
+ const root = rootRef.current;
83
+ const canvas = canvasRef.current;
84
+ const scroller = scrollerRef.current;
85
+ const sizer = sizerRef.current;
86
+ if (!root || !canvas || !scroller || !sizer)
87
+ return;
88
+ controller.options.rowHeight = rowHeight ?? cssLength(root, "--vg-row-height", 24);
89
+ controller.options.headerHeight = headerHeight ?? cssLength(root, "--vg-header-height", 32);
90
+ const renderer = new CanvasRenderer(canvas, controller, resolveTheme(root));
91
+ rendererRef.current = renderer;
92
+ controller.attach(scroller, sizer);
93
+ const stopRender = controller.onRender(() => renderer.schedule());
94
+ const resize = () => controller.setViewport(scroller.clientWidth, scroller.clientHeight);
95
+ resize();
96
+ const observer = new ResizeObserver(resize);
97
+ observer.observe(scroller);
98
+ const onScroll = () => controller.handleScroll();
99
+ scroller.addEventListener("scroll", onScroll, { passive: true });
100
+ // Without a text selection, Chromium and Safari disable the copy command unless "beforecopy" is
101
+ // cancelled; then ⌘C and the Edit menu fire "copy", which the grid answers while it has focus.
102
+ const onBeforeCopy = (event) => {
103
+ if (document.activeElement === scroller)
104
+ event.preventDefault();
105
+ };
106
+ const onCopy = (event) => {
107
+ if (document.activeElement === scroller)
108
+ copyHandler.current(event);
109
+ };
110
+ document.addEventListener("beforecopy", onBeforeCopy);
111
+ document.addEventListener("copy", onCopy);
112
+ const refreshTheme = () => renderer.setTheme(resolveTheme(root));
113
+ const media = window.matchMedia("(prefers-color-scheme: dark)");
114
+ media.addEventListener("change", refreshTheme);
115
+ const themeObserver = new MutationObserver(refreshTheme);
116
+ themeObserver.observe(document.documentElement, {
117
+ attributes: true,
118
+ attributeFilter: ["class", "style", "data-theme"],
119
+ });
120
+ void document.fonts?.ready.then(refreshTheme);
121
+ controller.start();
122
+ renderer.schedule();
123
+ return () => {
124
+ controller.stop();
125
+ stopRender();
126
+ observer.disconnect();
127
+ scroller.removeEventListener("scroll", onScroll);
128
+ document.removeEventListener("beforecopy", onBeforeCopy);
129
+ document.removeEventListener("copy", onCopy);
130
+ media.removeEventListener("change", refreshTheme);
131
+ themeObserver.disconnect();
132
+ renderer.dispose();
133
+ rendererRef.current = null;
134
+ };
135
+ }, [controller, rowHeight, headerHeight]);
136
+ // Theme changes through the grid's own class or style.
137
+ // biome-ignore lint/correctness/useExhaustiveDependencies: className and style are the triggers.
138
+ useEffect(() => {
139
+ const root = rootRef.current;
140
+ if (root && rendererRef.current)
141
+ rendererRef.current.setTheme(resolveTheme(root));
142
+ }, [className, style]);
143
+ const firstReload = useRef(true);
144
+ // biome-ignore lint/correctness/useExhaustiveDependencies: reloadKey is the trigger.
145
+ useEffect(() => {
146
+ if (firstReload.current) {
147
+ firstReload.current = false;
148
+ return;
149
+ }
150
+ controller.reload();
151
+ }, [reloadKey, controller]);
152
+ const lastQuery = useRef(controller.query);
153
+ useEffect(() => {
154
+ if (lastQuery.current !== controller.query) {
155
+ lastQuery.current = controller.query;
156
+ onQueryChange?.(controller.query);
157
+ }
158
+ });
159
+ useImperativeHandle(ref, () => ({
160
+ focus: () => scrollerRef.current?.focus(),
161
+ reload: () => controller.reload(),
162
+ getQuery: () => controller.query,
163
+ setQuery: (query) => void controller.setQuery(query),
164
+ goToRow: (row) => {
165
+ controller.selection.select({ row, col: controller.selection.active.col });
166
+ controller.selectionChanged({ row, col: controller.selection.active.col });
167
+ },
168
+ controller,
169
+ get renderer() {
170
+ return rendererRef.current;
171
+ },
172
+ }), [controller]);
173
+ // ----- helpers -----
174
+ function local(event) {
175
+ const rect = scrollerRef.current?.getBoundingClientRect();
176
+ return { x: event.clientX - (rect?.left ?? 0), y: event.clientY - (rect?.top ?? 0) };
177
+ }
178
+ /** A popover anchor at a point of the grid body. */
179
+ function anchorAt(x, y, height = 0) {
180
+ const offsetX = bodyRef.current?.offsetLeft ?? 0;
181
+ const offsetY = bodyRef.current?.offsetTop ?? 0;
182
+ return { x: x + offsetX, y: y + offsetY, height };
183
+ }
184
+ function headerAnchor(col) {
185
+ const x = Math.max(controller.gutterWidth, controller.columnX(col));
186
+ return anchorAt(x, 0, controller.options.headerHeight);
187
+ }
188
+ function cellAnchor(row, col) {
189
+ const x = Math.max(controller.gutterWidth, controller.columnX(col));
190
+ const y = Math.max(controller.options.headerHeight, Math.min(controller.height - 8, controller.rowY(row)));
191
+ return anchorAt(x, y, controller.options.rowHeight);
192
+ }
193
+ function focusGrid() {
194
+ scrollerRef.current?.focus({ preventScroll: true });
195
+ }
196
+ const copy = useCallback(async (options) => {
197
+ const task = controller.copySelection(options);
198
+ try {
199
+ await writeClipboard(task.then((result) => result.text), options.markdown ? null : task.then((result) => result.html));
200
+ const result = await task;
201
+ announce(messages.copied(count(result.cells)));
202
+ }
203
+ catch (error) {
204
+ if (error instanceof CopyTooLargeError)
205
+ announce(messages.copyTooLarge(count(error.limit)));
206
+ else
207
+ announce(messages.copyFailed);
208
+ }
209
+ }, [announce, controller, count, messages]);
210
+ /** Options of a copy started from the keyboard, answered by the browser's own copy event. */
211
+ const pendingCopy = useRef(null);
212
+ const copyHandler = useRef(() => undefined);
213
+ // ----- pointer -----
214
+ function selectMode(event) {
215
+ if (event.shiftKey)
216
+ return "extend";
217
+ if (isMac ? event.metaKey : event.ctrlKey)
218
+ return "add";
219
+ return "replace";
220
+ }
221
+ function dragTo(x, y) {
222
+ const drag = dragRef.current;
223
+ if (!drag || (drag.kind !== "cells" && drag.kind !== "rows" && drag.kind !== "cols"))
224
+ return;
225
+ const c = controller;
226
+ const clampedX = Math.min(Math.max(x, c.gutterWidth + 1), c.width - 1);
227
+ const clampedY = Math.min(Math.max(y, c.options.headerHeight + 1), c.height - 1);
228
+ const col = c.columnAt(clampedX);
229
+ const row = c.rowAt(clampedY);
230
+ if (drag.kind === "rows" && row >= 0)
231
+ c.selection.selectRows(row, row, "extend");
232
+ else if (drag.kind === "cols" && col >= 0)
233
+ c.selection.selectCols(col, col, "extend");
234
+ else if (drag.kind === "cells" && row >= 0 && col >= 0)
235
+ c.selection.select({ row, col }, "extend");
236
+ c.selectionChanged();
237
+ }
238
+ function autoScroll() {
239
+ const drag = dragRef.current;
240
+ if (!drag || (drag.kind !== "cells" && drag.kind !== "rows" && drag.kind !== "cols")) {
241
+ autoScrollRef.current = 0;
242
+ return;
243
+ }
244
+ const c = controller;
245
+ const speed = (distance) => Math.sign(distance) * Math.min(80, Math.abs(distance) * 0.6);
246
+ let dx = 0;
247
+ let dy = 0;
248
+ if (drag.y < c.options.headerHeight)
249
+ dy = speed(drag.y - c.options.headerHeight);
250
+ else if (drag.y > c.height)
251
+ dy = speed(drag.y - c.height);
252
+ if (drag.kind !== "rows") {
253
+ if (drag.x < c.gutterWidth + c.frozenWidth && c.scrollX.position > 0)
254
+ dx = speed(drag.x - c.gutterWidth - c.frozenWidth);
255
+ else if (drag.x > c.width)
256
+ dx = speed(drag.x - c.width);
257
+ }
258
+ if (dx !== 0 || dy !== 0) {
259
+ c.scrollToVirtual(dx === 0 ? null : c.scrollX.position + dx, dy === 0 ? null : c.scrollY.position + dy);
260
+ dragTo(drag.x, drag.y);
261
+ }
262
+ autoScrollRef.current = requestAnimationFrame(autoScroll);
263
+ }
264
+ function onPointerDown(event) {
265
+ const scroller = scrollerRef.current;
266
+ if (!scroller || event.button !== 0)
267
+ return;
268
+ const { x, y } = local(event);
269
+ // Leave the native scrollbars alone.
270
+ if (x > scroller.clientWidth || y > scroller.clientHeight)
271
+ return;
272
+ focusGrid();
273
+ const c = controller;
274
+ const hit = c.hitTest(x, y);
275
+ const mode = selectMode(event);
276
+ if (hit.resize >= 0) {
277
+ event.preventDefault();
278
+ scroller.setPointerCapture(event.pointerId);
279
+ dragRef.current = {
280
+ kind: "resize",
281
+ pointerId: event.pointerId,
282
+ col: hit.resize,
283
+ startX: x,
284
+ startWidth: c.layout.width(hit.resize),
285
+ };
286
+ c.resizing = hit.resize;
287
+ c.requestRender();
288
+ return;
289
+ }
290
+ if (hit.menu && hit.col >= 0) {
291
+ event.preventDefault();
292
+ setOverlay({ kind: "columnMenu", col: hit.col, anchor: headerAnchor(hit.col) });
293
+ return;
294
+ }
295
+ switch (hit.zone) {
296
+ case "cell":
297
+ c.selection.select({ row: hit.row, col: hit.col }, mode);
298
+ dragRef.current = { kind: "cells", pointerId: event.pointerId, x, y };
299
+ break;
300
+ case "header":
301
+ if (mode === "replace" && c.selection.colSelected(hit.col)) {
302
+ dragRef.current = { kind: "move", pointerId: event.pointerId, col: hit.col, x, y, moved: false };
303
+ }
304
+ else {
305
+ c.selection.selectCols(hit.col, hit.col, mode);
306
+ dragRef.current = { kind: "cols", pointerId: event.pointerId, x, y };
307
+ }
308
+ break;
309
+ case "gutter":
310
+ c.selection.selectRows(hit.row, hit.row, mode);
311
+ dragRef.current = { kind: "rows", pointerId: event.pointerId, x, y };
312
+ break;
313
+ case "corner":
314
+ c.selection.selectAll();
315
+ break;
316
+ default:
317
+ return;
318
+ }
319
+ event.preventDefault();
320
+ scroller.setPointerCapture(event.pointerId);
321
+ c.selectionChanged();
322
+ }
323
+ function onPointerMove(event) {
324
+ const scroller = scrollerRef.current;
325
+ if (!scroller)
326
+ return;
327
+ const { x, y } = local(event);
328
+ const c = controller;
329
+ const drag = dragRef.current;
330
+ if (drag && drag.pointerId === event.pointerId) {
331
+ if (drag.kind === "resize") {
332
+ c.resizeColumn(drag.col, drag.startWidth + (x - drag.startX));
333
+ return;
334
+ }
335
+ if (drag.kind === "move") {
336
+ drag.moved = drag.moved || Math.abs(x - drag.x) > 4;
337
+ drag.x = x;
338
+ drag.y = y;
339
+ scroller.style.cursor = drag.moved ? "grabbing" : "";
340
+ return;
341
+ }
342
+ drag.x = x;
343
+ drag.y = y;
344
+ dragTo(x, y);
345
+ if (!autoScrollRef.current)
346
+ autoScrollRef.current = requestAnimationFrame(autoScroll);
347
+ return;
348
+ }
349
+ const hit = x > scroller.clientWidth || y > scroller.clientHeight
350
+ ? { zone: "outside", row: -1, col: -1, resize: -1, menu: false }
351
+ : c.hitTest(x, y);
352
+ const previous = c.hover;
353
+ c.hover = hit;
354
+ scroller.style.cursor = hit.resize >= 0 ? "col-resize" : hit.menu ? "pointer" : "";
355
+ if (previous?.zone !== hit.zone || previous.col !== hit.col || previous.menu !== hit.menu)
356
+ c.requestRender();
357
+ }
358
+ function endDrag(event) {
359
+ const drag = dragRef.current;
360
+ if (!drag || drag.pointerId !== event.pointerId)
361
+ return;
362
+ dragRef.current = null;
363
+ cancelAnimationFrame(autoScrollRef.current);
364
+ autoScrollRef.current = 0;
365
+ const scroller = scrollerRef.current;
366
+ if (scroller?.hasPointerCapture(event.pointerId))
367
+ scroller.releasePointerCapture(event.pointerId);
368
+ if (scroller)
369
+ scroller.style.cursor = "";
370
+ const c = controller;
371
+ if (drag.kind === "resize") {
372
+ c.resizing = -1;
373
+ c.requestRender();
374
+ }
375
+ else if (drag.kind === "move") {
376
+ const target = c.columnAt(local(event).x);
377
+ if (drag.moved && target >= 0 && target !== drag.col) {
378
+ c.moveColumn(drag.col, target);
379
+ c.selection.selectCols(target, target);
380
+ c.selectionChanged();
381
+ }
382
+ else if (!drag.moved) {
383
+ c.selection.selectCols(drag.col, drag.col);
384
+ c.selectionChanged();
385
+ }
386
+ }
387
+ }
388
+ function onPointerLeave() {
389
+ if (controller.hover && !dragRef.current) {
390
+ controller.hover = null;
391
+ controller.requestRender();
392
+ }
393
+ }
394
+ function onDoubleClick(event) {
395
+ const { x, y } = local(event);
396
+ const hit = controller.hitTest(x, y);
397
+ if (hit.resize >= 0) {
398
+ controller.autoFit(hit.resize);
399
+ controller.emit();
400
+ }
401
+ else if (hit.zone === "cell") {
402
+ setOverlay({ kind: "inspector", row: hit.row, col: hit.col, anchor: cellAnchor(hit.row, hit.col) });
403
+ }
404
+ }
405
+ function onContextMenu(event) {
406
+ const { x, y } = local(event);
407
+ const c = controller;
408
+ const hit = c.hitTest(x, y);
409
+ if (hit.zone === "header") {
410
+ event.preventDefault();
411
+ setOverlay({ kind: "columnMenu", col: hit.col, anchor: anchorAt(x, y) });
412
+ return;
413
+ }
414
+ if (hit.zone !== "cell")
415
+ return;
416
+ event.preventDefault();
417
+ if (!c.selection.contains(hit.row, hit.col)) {
418
+ c.selection.select({ row: hit.row, col: hit.col });
419
+ c.selectionChanged();
420
+ }
421
+ setOverlay({ kind: "contextMenu", row: hit.row, col: hit.col, anchor: anchorAt(x, y) });
422
+ }
423
+ // ----- keyboard -----
424
+ function onKeyDown(event) {
425
+ if (event.target !== scrollerRef.current)
426
+ return;
427
+ const c = controller;
428
+ const primary = isMac ? event.metaKey : event.ctrlKey;
429
+ const extend = event.shiftKey;
430
+ const lastRow = Math.max(0, c.rowCount - 1);
431
+ const lastCol = Math.max(0, c.visible.length - 1);
432
+ const focus = extend ? c.selection.focus : c.selection.active;
433
+ let target = null;
434
+ switch (event.key) {
435
+ case "ArrowDown":
436
+ if (event.altKey) {
437
+ setOverlay({ kind: "columnMenu", col: c.selection.active.col, anchor: headerAnchor(c.selection.active.col) });
438
+ break;
439
+ }
440
+ target = c.selection.moveTo({ row: primary ? lastRow : focus.row + 1, col: focus.col }, extend);
441
+ break;
442
+ case "ArrowUp":
443
+ target = c.selection.moveTo({ row: primary ? 0 : focus.row - 1, col: focus.col }, extend);
444
+ break;
445
+ case "ArrowRight":
446
+ target = c.selection.moveTo({ row: focus.row, col: primary ? lastCol : focus.col + 1 }, extend);
447
+ break;
448
+ case "ArrowLeft":
449
+ target = c.selection.moveTo({ row: focus.row, col: primary ? 0 : focus.col - 1 }, extend);
450
+ break;
451
+ case "PageDown":
452
+ target = event.altKey
453
+ ? c.selection.moveTo({ row: focus.row, col: focus.col + 5 }, extend)
454
+ : c.selection.moveTo({ row: focus.row + c.pageRows, col: focus.col }, extend);
455
+ break;
456
+ case "PageUp":
457
+ target = event.altKey
458
+ ? c.selection.moveTo({ row: focus.row, col: focus.col - 5 }, extend)
459
+ : c.selection.moveTo({ row: focus.row - c.pageRows, col: focus.col }, extend);
460
+ break;
461
+ case "Home":
462
+ target = c.selection.moveTo({ row: event.ctrlKey || event.metaKey ? 0 : focus.row, col: 0 }, extend);
463
+ break;
464
+ case "End":
465
+ target = c.selection.moveTo({ row: event.ctrlKey || event.metaKey ? lastRow : focus.row, col: lastCol }, extend);
466
+ break;
467
+ case "Tab":
468
+ target = c.selection.advance(0, event.shiftKey ? -1 : 1);
469
+ break;
470
+ case "Enter":
471
+ target = c.selection.advance(event.shiftKey ? -1 : 1, 0);
472
+ break;
473
+ case " ":
474
+ if (event.shiftKey)
475
+ c.selection.selectRows(c.selection.active.row, c.selection.active.row);
476
+ else if (event.ctrlKey)
477
+ c.selection.selectCols(c.selection.active.col, c.selection.active.col);
478
+ else
479
+ return;
480
+ c.selectionChanged();
481
+ break;
482
+ case "F2":
483
+ setOverlay({
484
+ kind: "inspector",
485
+ ...c.selection.active,
486
+ anchor: cellAnchor(c.selection.active.row, c.selection.active.col),
487
+ });
488
+ break;
489
+ case "Escape":
490
+ if (findOpen)
491
+ setFindOpen(false);
492
+ else if (!c.selection.single) {
493
+ c.selection.select(c.selection.active);
494
+ c.selectionChanged();
495
+ }
496
+ else
497
+ return;
498
+ break;
499
+ case "ContextMenu":
500
+ setOverlay({
501
+ kind: "contextMenu",
502
+ ...c.selection.active,
503
+ anchor: cellAnchor(c.selection.active.row, c.selection.active.col),
504
+ });
505
+ break;
506
+ default:
507
+ if (primary && event.key.toLowerCase() === "a") {
508
+ c.selection.selectAll();
509
+ c.selectionChanged();
510
+ }
511
+ else if (primary && event.key.toLowerCase() === "c") {
512
+ // Let the browser run its own copy command: it needs no clipboard permission, and onCopy answers
513
+ // it with the loaded cells. Shortcuts the browser does not treat as copying fall back below.
514
+ const options = { headers: event.shiftKey };
515
+ pendingCopy.current = options;
516
+ setTimeout(() => {
517
+ if (pendingCopy.current !== options)
518
+ return;
519
+ pendingCopy.current = null;
520
+ void copy(options);
521
+ }, 0);
522
+ return;
523
+ }
524
+ else if (primary && event.key.toLowerCase() === "f") {
525
+ setFindOpen(true);
526
+ }
527
+ else if (event.shiftKey && event.key === "F10") {
528
+ setOverlay({
529
+ kind: "contextMenu",
530
+ ...c.selection.active,
531
+ anchor: cellAnchor(c.selection.active.row, c.selection.active.col),
532
+ });
533
+ }
534
+ else {
535
+ return;
536
+ }
537
+ }
538
+ event.preventDefault();
539
+ if (target)
540
+ c.selectionChanged(target);
541
+ }
542
+ /** The browser's own copy command (Edit menu): answer synchronously when the cells are loaded. */
543
+ /**
544
+ * The browser's copy command (⌘C, or Copy in the app's Edit menu) while the grid has focus. Loaded
545
+ * cells are written here, synchronously, which needs no clipboard permission; larger selections are
546
+ * read first and written with the asynchronous clipboard API.
547
+ */
548
+ copyHandler.current = (event) => {
549
+ if (!event.clipboardData)
550
+ return;
551
+ event.preventDefault();
552
+ const options = pendingCopy.current ?? {};
553
+ pendingCopy.current = null;
554
+ const result = controller.copySelectionSync(options);
555
+ if (result) {
556
+ event.clipboardData.setData("text/plain", result.text);
557
+ if (result.html)
558
+ event.clipboardData.setData("text/html", result.html);
559
+ announce(messages.copied(count(result.cells)));
560
+ }
561
+ else {
562
+ void copy(options);
563
+ }
564
+ };
565
+ // ----- menus -----
566
+ const c = controller;
567
+ const capabilities = c.info?.capabilities;
568
+ const ready = c.info?.status === "ready";
569
+ function columnMenuItems(col) {
570
+ const state = c.visible[col];
571
+ const schema = c.columnSchema(col);
572
+ if (!state || !schema)
573
+ return [];
574
+ const sort = c.sortOf(state.index);
575
+ const otherSorts = (c.query.sort ?? []).filter((key) => key.column !== state.index).length > 0;
576
+ const items = [
577
+ {
578
+ label: messages.sortAscending,
579
+ onSelect: () => c.sortBy(col, "asc"),
580
+ disabled: !ready || !capabilities?.sort,
581
+ checked: sort?.direction === "asc" && !otherSorts,
582
+ },
583
+ {
584
+ label: messages.sortDescending,
585
+ onSelect: () => c.sortBy(col, "desc"),
586
+ disabled: !ready || !capabilities?.sort,
587
+ checked: sort?.direction === "desc" && !otherSorts,
588
+ },
589
+ ];
590
+ if ((c.query.sort?.length ?? 0) > 0 && !(sort && !otherSorts)) {
591
+ items.push({ label: messages.addSortAscending, onSelect: () => c.sortBy(col, "asc", true), disabled: !ready }, { label: messages.addSortDescending, onSelect: () => c.sortBy(col, "desc", true), disabled: !ready });
592
+ }
593
+ if ((c.query.sort?.length ?? 0) > 0)
594
+ items.push({ label: messages.clearSort, onSelect: () => c.clearSort() });
595
+ items.push({
596
+ label: messages.filter,
597
+ separator: true,
598
+ disabled: !ready || !capabilities?.filter,
599
+ onSelect: () => setOverlay({ kind: "filter", column: state.index, anchor: headerAnchor(col) }),
600
+ }, {
601
+ label: messages.statistics,
602
+ disabled: !ready || !capabilities?.stats,
603
+ onSelect: () => setOverlay({ kind: "stats", col, anchor: headerAnchor(col) }),
604
+ }, {
605
+ label: messages.autoFit,
606
+ separator: true,
607
+ onSelect: () => {
608
+ c.autoFit(col);
609
+ c.emit();
610
+ },
611
+ }, col < c.frozen && c.frozen > 0
612
+ ? { label: messages.unfreeze, onSelect: () => c.freezeUpTo(-1) }
613
+ : { label: messages.freezeUpTo, onSelect: () => c.freezeUpTo(col) }, { label: messages.hideColumn, disabled: c.visible.length <= 1, onSelect: () => c.hideColumn(col) }, {
614
+ label: messages.copyColumnName,
615
+ onSelect: () => void writeClipboard(Promise.resolve(schema.name), null).then(() => announce(messages.copied("1")), (error) => announce(error instanceof Error ? error.message : String(error))),
616
+ });
617
+ return items;
618
+ }
619
+ function contextMenuItems(row, col) {
620
+ const state = c.visible[col];
621
+ const value = c.value(row, col);
622
+ const items = [
623
+ { label: messages.copy, shortcut: isMac ? "⌘C" : "Ctrl+C", onSelect: () => void copy({}) },
624
+ {
625
+ label: messages.copyWithHeaders,
626
+ shortcut: isMac ? "⇧⌘C" : "Ctrl+Shift+C",
627
+ onSelect: () => void copy({ headers: true }),
628
+ },
629
+ { label: messages.copyAsMarkdown, onSelect: () => void copy({ markdown: true }) },
630
+ ];
631
+ if (state && value !== undefined) {
632
+ items.push({
633
+ label: messages.filterToValue,
634
+ separator: true,
635
+ disabled: !ready || !capabilities?.filter,
636
+ onSelect: () => c.setFilter(state.index, { type: "values", column: state.index, values: [value] }),
637
+ }, {
638
+ label: messages.excludeValue,
639
+ disabled: !ready || !capabilities?.filter,
640
+ onSelect: () => c.setFilter(state.index, { type: "values", column: state.index, values: [value], exclude: true }),
641
+ });
642
+ }
643
+ items.push({
644
+ label: messages.sortAscending,
645
+ separator: true,
646
+ disabled: !ready || !capabilities?.sort,
647
+ onSelect: () => c.sortBy(col, "asc"),
648
+ }, {
649
+ label: messages.sortDescending,
650
+ disabled: !ready || !capabilities?.sort,
651
+ onSelect: () => c.sortBy(col, "desc"),
652
+ }, {
653
+ label: messages.inspectCell,
654
+ separator: true,
655
+ shortcut: "F2",
656
+ onSelect: () => setOverlay({ kind: "inspector", row, col, anchor: cellAnchor(row, col) }),
657
+ });
658
+ return items;
659
+ }
660
+ const closeOverlay = useCallback(() => setOverlay(null), []);
661
+ const loadDistinct = useCallback((search, signal) => overlay?.kind === "filter" ? controller.distinct(overlay.column, search, signal) : null, [controller, overlay]);
662
+ const loadStats = useCallback((signal) => (overlay?.kind === "stats" ? controller.stats(overlay.col, signal) : null), [controller, overlay]);
663
+ const loadCell = useCallback((signal) => overlay?.kind === "inspector" ? controller.fullValue(overlay.row, overlay.col, signal) : Promise.resolve(null), [controller, overlay]);
664
+ // ----- accessibility -----
665
+ const active = c.selection.active;
666
+ const activeSchema = c.columnSchema(active.col);
667
+ const activeValue = c.value(active.row, active.col);
668
+ const activeText = activeValue === undefined ? messages.loading : activeValue === null ? messages.blank : plainText(activeValue);
669
+ let overlayMessage = null;
670
+ if (c.loadError)
671
+ overlayMessage = `${messages.error} ${c.loadError}`;
672
+ else if (c.info?.status === "error")
673
+ overlayMessage = `${messages.error} ${c.info.error ?? ""}`;
674
+ else if (c.viewError)
675
+ overlayMessage = c.viewError;
676
+ else if (!c.info || (c.info.status === "loading" && c.readableRows === 0))
677
+ overlayMessage = messages.loading;
678
+ else if (c.rowCount === 0 && !c.viewPending)
679
+ overlayMessage = messages.noRows;
680
+ return (_jsxs("div", { ref: rootRef, className: `vg-root${className ? ` ${className}` : ""}`, style: style, "data-status": c.status, children: [toolbar && (_jsx(Toolbar, { controller: c, messages: messages, onColumns: (button) => {
681
+ const root = rootRef.current?.getBoundingClientRect();
682
+ const rect = button.getBoundingClientRect();
683
+ setOverlay({
684
+ kind: "columns",
685
+ anchor: { x: rect.right - (root?.left ?? 0) - 260, y: rect.top - (root?.top ?? 0), height: rect.height },
686
+ });
687
+ } })), (c.viewPending || c.info?.status === "loading") && (_jsx("div", { className: "vg-progress", role: "progressbar", "aria-label": messages.loading, "aria-valuemin": 0, "aria-valuemax": 100, "aria-valuenow": c.info?.progress ? Math.round(c.info.progress * 100) : undefined, children: _jsx("span", { style: c.info?.progress && !c.viewPending ? { width: `${Math.round(c.info.progress * 100)}%` } : undefined, "data-indeterminate": c.viewPending || !c.info?.progress || undefined }) })), _jsxs("div", { ref: bodyRef, className: "vg-body", children: [_jsx("canvas", { ref: canvasRef, className: "vg-canvas" }), _jsxs("div", { ref: scrollerRef, className: "vg-scroller", role: "grid", tabIndex: 0, "aria-label": props["aria-label"] ?? messages.gridLabel, "aria-rowcount": c.rowCount + 1, "aria-colcount": c.visible.length + 1, "aria-multiselectable": "true", "aria-activedescendant": c.rowCount > 0 && activeSchema ? cellId : undefined, onPointerDown: onPointerDown, onPointerMove: onPointerMove, onPointerUp: endDrag, onPointerCancel: endDrag, onPointerLeave: onPointerLeave, onDoubleClick: onDoubleClick, onContextMenu: onContextMenu, onKeyDown: onKeyDown, children: [_jsx("div", { ref: sizerRef, className: "vg-sizer" }), activeSchema && c.rowCount > 0 && (_jsxs("div", { className: "vg-sr-only", role: "rowgroup", children: [_jsx("div", { role: "row", "aria-rowindex": 1, children: _jsx("div", { role: "columnheader", "aria-colindex": active.col + 2, children: activeSchema.name }) }), _jsx("div", { role: "row", "aria-rowindex": active.row + 2, children: _jsx("div", { role: "gridcell", id: cellId, "aria-colindex": active.col + 2, "aria-selected": "true", children: activeText }) })] }))] }), overlayMessage && (_jsxs("div", { className: "vg-message", role: c.loadError || c.viewError || c.info?.status === "error" ? "alert" : "status", children: [_jsx("span", { children: overlayMessage }), (c.loadError || c.info?.status === "error") && (_jsx("button", { type: "button", className: "vg-button", onClick: () => controller.reload(), children: messages.retry }))] })), findOpen && (_jsx(FindBar, { messages: messages, initial: c.findText, onFind: (text, direction, caseSensitive, signal) => controller.find(text, direction, caseSensitive, signal), onClose: () => {
688
+ setFindOpen(false);
689
+ controller.findText = "";
690
+ controller.requestRender();
691
+ focusGrid();
692
+ } })), notice && (_jsx("div", { className: "vg-toast", role: "status", children: notice }))] }), statusBar && _jsx(StatusBar, { controller: c, messages: messages }), overlay?.kind === "columnMenu" && (_jsx(Popover, { anchor: overlay.anchor, container: rootRef, onClose: closeOverlay, returnFocus: scrollerRef, label: c.columnSchema(overlay.col)?.name ?? "", role: "menu", children: _jsx(MenuList, { items: columnMenuItems(overlay.col), onClose: closeOverlay }) })), overlay?.kind === "contextMenu" && (_jsx(Popover, { anchor: overlay.anchor, container: rootRef, onClose: closeOverlay, returnFocus: scrollerRef, label: messages.copy, role: "menu", children: _jsx(MenuList, { items: contextMenuItems(overlay.row, overlay.col), onClose: closeOverlay }) })), overlay?.kind === "filter" && c.schema[overlay.column] && (_jsx(Popover, { anchor: overlay.anchor, container: rootRef, onClose: closeOverlay, returnFocus: scrollerRef, label: messages.filterBy(c.schema[overlay.column].name), className: "vg-popover--panel", children: _jsx(FilterPanel, { column: overlay.column, schema: c.schema[overlay.column], filter: c.filterOf(overlay.column), messages: messages, formatter: c.formatter, count: count, load: loadDistinct, onApply: (filter) => {
693
+ c.setFilter(overlay.column, filter);
694
+ setOverlay(null);
695
+ focusGrid();
696
+ } }) })), overlay?.kind === "stats" && c.columnSchema(overlay.col) && (_jsx(Popover, { anchor: overlay.anchor, container: rootRef, onClose: closeOverlay, returnFocus: scrollerRef, label: messages.statistics, className: "vg-popover--panel", children: _jsx(StatsPanel, { schema: c.columnSchema(overlay.col), messages: messages, formatter: c.formatter, count: count, number: number, load: loadStats }) })), overlay?.kind === "inspector" && c.columnSchema(overlay.col) && (_jsx(Popover, { anchor: overlay.anchor, container: rootRef, onClose: closeOverlay, returnFocus: scrollerRef, label: messages.inspectCell, className: "vg-popover--panel vg-popover--wide", children: _jsx(CellInspector, { schema: c.columnSchema(overlay.col), messages: messages, location: messages.cell(count(overlay.row + 1), c.columnSchema(overlay.col)?.name ?? ""), load: loadCell, onCopy: (text) => void writeClipboard(Promise.resolve(text), null).then(() => announce(messages.copied("1")), (error) => announce(error instanceof Error ? error.message : String(error))) }) })), overlay?.kind === "columns" && (_jsx(Popover, { anchor: overlay.anchor, container: rootRef, onClose: closeOverlay, label: messages.columns, className: "vg-popover--panel", children: _jsx(ColumnsPanel, { columns: c.columns, schema: c.schema, messages: messages, onToggle: (index, hidden) => c.setColumnHidden(index, hidden), onShowAll: () => c.showAllColumns() }) }))] }));
697
+ });
698
+ //# sourceMappingURL=data-grid.js.map