vastgrid 0.1.0 → 0.2.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 (312) hide show
  1. package/README.md +175 -13
  2. package/dist/canvas/geometry.d.ts +92 -0
  3. package/dist/canvas/geometry.d.ts.map +1 -0
  4. package/dist/canvas/geometry.js +197 -0
  5. package/dist/canvas/geometry.js.map +1 -0
  6. package/dist/canvas/grid-canvas.d.ts +161 -0
  7. package/dist/canvas/grid-canvas.d.ts.map +1 -0
  8. package/dist/canvas/grid-canvas.js +1146 -0
  9. package/dist/canvas/grid-canvas.js.map +1 -0
  10. package/dist/canvas/hooks.d.ts +61 -0
  11. package/dist/canvas/hooks.d.ts.map +1 -0
  12. package/dist/canvas/hooks.js +2 -0
  13. package/dist/canvas/hooks.js.map +1 -0
  14. package/dist/canvas/keys.d.ts +26 -0
  15. package/dist/canvas/keys.d.ts.map +1 -0
  16. package/dist/canvas/keys.js +133 -0
  17. package/dist/canvas/keys.js.map +1 -0
  18. package/dist/canvas/layout.d.ts +26 -0
  19. package/dist/canvas/layout.d.ts.map +1 -0
  20. package/dist/canvas/layout.js +73 -0
  21. package/dist/canvas/layout.js.map +1 -0
  22. package/dist/canvas/scroll.d.ts +31 -0
  23. package/dist/canvas/scroll.d.ts.map +1 -0
  24. package/dist/canvas/scroll.js +111 -0
  25. package/dist/canvas/scroll.js.map +1 -0
  26. package/dist/canvas/text.d.ts +23 -0
  27. package/dist/canvas/text.d.ts.map +1 -0
  28. package/dist/canvas/text.js +142 -0
  29. package/dist/canvas/text.js.map +1 -0
  30. package/dist/{theme.d.ts → canvas/theme.d.ts} +8 -0
  31. package/dist/canvas/theme.d.ts.map +1 -0
  32. package/dist/canvas/theme.js +62 -0
  33. package/dist/canvas/theme.js.map +1 -0
  34. package/dist/core/cache.d.ts +48 -0
  35. package/dist/core/cache.d.ts.map +1 -0
  36. package/dist/core/cache.js +222 -0
  37. package/dist/core/cache.js.map +1 -0
  38. package/dist/core/checked.d.ts +20 -0
  39. package/dist/core/checked.d.ts.map +1 -0
  40. package/dist/core/checked.js +61 -0
  41. package/dist/core/checked.js.map +1 -0
  42. package/dist/core/client.d.ts +33 -0
  43. package/dist/core/client.d.ts.map +1 -0
  44. package/dist/core/client.js +103 -0
  45. package/dist/core/client.js.map +1 -0
  46. package/dist/core/conditions.d.ts +48 -0
  47. package/dist/core/conditions.d.ts.map +1 -0
  48. package/dist/core/conditions.js +105 -0
  49. package/dist/core/conditions.js.map +1 -0
  50. package/dist/core/evaluate.d.ts +20 -0
  51. package/dist/core/evaluate.d.ts.map +1 -0
  52. package/dist/core/evaluate.js +210 -0
  53. package/dist/core/evaluate.js.map +1 -0
  54. package/dist/core/filters.d.ts +51 -0
  55. package/dist/core/filters.d.ts.map +1 -0
  56. package/dist/core/filters.js +303 -0
  57. package/dist/core/filters.js.map +1 -0
  58. package/dist/core/format.d.ts +14 -0
  59. package/dist/core/format.d.ts.map +1 -0
  60. package/dist/core/format.js +303 -0
  61. package/dist/core/format.js.map +1 -0
  62. package/dist/core/measures.d.ts +26 -0
  63. package/dist/core/measures.d.ts.map +1 -0
  64. package/dist/core/measures.js +105 -0
  65. package/dist/core/measures.js.map +1 -0
  66. package/dist/core/request-ids.d.ts +20 -0
  67. package/dist/core/request-ids.d.ts.map +1 -0
  68. package/dist/core/request-ids.js +55 -0
  69. package/dist/core/request-ids.js.map +1 -0
  70. package/dist/core/rows.d.ts +79 -0
  71. package/dist/core/rows.d.ts.map +1 -0
  72. package/dist/core/rows.js +173 -0
  73. package/dist/core/rows.js.map +1 -0
  74. package/dist/core/store.d.ts +233 -0
  75. package/dist/core/store.d.ts.map +1 -0
  76. package/dist/core/store.js +1162 -0
  77. package/dist/core/store.js.map +1 -0
  78. package/dist/core/totals.d.ts +24 -0
  79. package/dist/core/totals.d.ts.map +1 -0
  80. package/dist/core/totals.js +49 -0
  81. package/dist/core/totals.js.map +1 -0
  82. package/dist/core/trace.d.ts +7 -0
  83. package/dist/core/trace.d.ts.map +1 -0
  84. package/dist/core/trace.js +26 -0
  85. package/dist/core/trace.js.map +1 -0
  86. package/dist/core/transports.d.ts +50 -0
  87. package/dist/core/transports.d.ts.map +1 -0
  88. package/dist/core/transports.js +160 -0
  89. package/dist/core/transports.js.map +1 -0
  90. package/dist/core/types.d.ts +150 -0
  91. package/dist/core/types.d.ts.map +1 -0
  92. package/dist/core/types.js +45 -0
  93. package/dist/core/types.js.map +1 -0
  94. package/dist/core.d.ts +12 -0
  95. package/dist/core.d.ts.map +1 -0
  96. package/dist/core.js +11 -0
  97. package/dist/core.js.map +1 -0
  98. package/dist/index.d.ts +15 -6
  99. package/dist/index.d.ts.map +1 -1
  100. package/dist/index.js +13 -7
  101. package/dist/index.js.map +1 -1
  102. package/dist/react/accessible-rows.d.ts +13 -0
  103. package/dist/react/accessible-rows.d.ts.map +1 -0
  104. package/dist/react/accessible-rows.js +320 -0
  105. package/dist/react/accessible-rows.js.map +1 -0
  106. package/dist/react/cell-editor.d.ts +22 -0
  107. package/dist/react/cell-editor.d.ts.map +1 -0
  108. package/dist/react/cell-editor.js +431 -0
  109. package/dist/react/cell-editor.js.map +1 -0
  110. package/dist/react/cell-menu.d.ts +18 -0
  111. package/dist/react/cell-menu.d.ts.map +1 -0
  112. package/dist/react/cell-menu.js +299 -0
  113. package/dist/react/cell-menu.js.map +1 -0
  114. package/dist/react/column-menu.d.ts +16 -0
  115. package/dist/react/column-menu.d.ts.map +1 -0
  116. package/dist/react/column-menu.js +1311 -0
  117. package/dist/react/column-menu.js.map +1 -0
  118. package/dist/react/columns-menu.d.ts +7 -0
  119. package/dist/react/columns-menu.d.ts.map +1 -0
  120. package/dist/react/columns-menu.js +249 -0
  121. package/dist/react/columns-menu.js.map +1 -0
  122. package/dist/react/conditions-menu.d.ts +10 -0
  123. package/dist/react/conditions-menu.d.ts.map +1 -0
  124. package/dist/react/conditions-menu.js +795 -0
  125. package/dist/react/conditions-menu.js.map +1 -0
  126. package/dist/react/context.d.ts +29 -0
  127. package/dist/react/context.d.ts.map +1 -0
  128. package/dist/react/context.js +18 -0
  129. package/dist/react/context.js.map +1 -0
  130. package/dist/react/data-grid.d.ts +79 -0
  131. package/dist/react/data-grid.d.ts.map +1 -0
  132. package/dist/react/data-grid.js +653 -0
  133. package/dist/react/data-grid.js.map +1 -0
  134. package/dist/react/detail-panel.d.ts +22 -0
  135. package/dist/react/detail-panel.d.ts.map +1 -0
  136. package/dist/react/detail-panel.js +242 -0
  137. package/dist/react/detail-panel.js.map +1 -0
  138. package/dist/react/export-menu.d.ts +11 -0
  139. package/dist/react/export-menu.d.ts.map +1 -0
  140. package/dist/react/export-menu.js +304 -0
  141. package/dist/react/export-menu.js.map +1 -0
  142. package/dist/react/find-bar.d.ts +11 -0
  143. package/dist/react/find-bar.d.ts.map +1 -0
  144. package/dist/react/find-bar.js +303 -0
  145. package/dist/react/find-bar.js.map +1 -0
  146. package/dist/react/format-form.d.ts +12 -0
  147. package/dist/react/format-form.d.ts.map +1 -0
  148. package/dist/react/format-form.js +863 -0
  149. package/dist/react/format-form.js.map +1 -0
  150. package/dist/react/formula-menu.d.ts +6 -0
  151. package/dist/react/formula-menu.d.ts.map +1 -0
  152. package/dist/react/formula-menu.js +343 -0
  153. package/dist/react/formula-menu.js.map +1 -0
  154. package/dist/react/grid-body.d.ts +33 -0
  155. package/dist/react/grid-body.d.ts.map +1 -0
  156. package/dist/react/grid-body.js +1389 -0
  157. package/dist/react/grid-body.js.map +1 -0
  158. package/dist/react/group-menu.d.ts +7 -0
  159. package/dist/react/group-menu.d.ts.map +1 -0
  160. package/dist/react/group-menu.js +508 -0
  161. package/dist/react/group-menu.js.map +1 -0
  162. package/dist/react/hooks.d.ts +33 -0
  163. package/dist/react/hooks.d.ts.map +1 -0
  164. package/dist/react/hooks.js +161 -0
  165. package/dist/react/hooks.js.map +1 -0
  166. package/dist/react/messages.d.ts +180 -0
  167. package/dist/react/messages.d.ts.map +1 -0
  168. package/dist/react/messages.js +414 -0
  169. package/dist/react/messages.js.map +1 -0
  170. package/dist/react/save.d.ts +9 -0
  171. package/dist/react/save.d.ts.map +1 -0
  172. package/dist/react/save.js +102 -0
  173. package/dist/react/save.js.map +1 -0
  174. package/dist/react/static-rows.d.ts +9 -0
  175. package/dist/react/static-rows.d.ts.map +1 -0
  176. package/dist/react/static-rows.js +176 -0
  177. package/dist/react/static-rows.js.map +1 -0
  178. package/dist/react/status-bar.d.ts +12 -0
  179. package/dist/react/status-bar.d.ts.map +1 -0
  180. package/dist/react/status-bar.js +413 -0
  181. package/dist/react/status-bar.js.map +1 -0
  182. package/dist/react/toolbar-popover.d.ts +14 -0
  183. package/dist/react/toolbar-popover.d.ts.map +1 -0
  184. package/dist/react/toolbar-popover.js +143 -0
  185. package/dist/react/toolbar-popover.js.map +1 -0
  186. package/dist/react/toolbar.d.ts +13 -0
  187. package/dist/react/toolbar.d.ts.map +1 -0
  188. package/dist/react/toolbar.js +344 -0
  189. package/dist/react/toolbar.js.map +1 -0
  190. package/dist/server.d.ts +22 -0
  191. package/dist/server.d.ts.map +1 -0
  192. package/dist/server.js +45 -0
  193. package/dist/server.js.map +1 -0
  194. package/dist/styles.css +496 -316
  195. package/package.json +43 -18
  196. package/src/canvas/geometry.ts +197 -0
  197. package/src/canvas/grid-canvas.ts +1222 -0
  198. package/src/canvas/hooks.ts +61 -0
  199. package/src/canvas/keys.ts +96 -0
  200. package/src/canvas/layout.ts +77 -0
  201. package/src/canvas/scroll.ts +120 -0
  202. package/src/canvas/text.ts +155 -0
  203. package/src/{theme.ts → canvas/theme.ts} +14 -1
  204. package/src/core/cache.ts +247 -0
  205. package/src/core/checked.ts +64 -0
  206. package/src/core/client.ts +103 -0
  207. package/src/core/conditions.ts +89 -0
  208. package/src/core/evaluate.ts +221 -0
  209. package/src/core/filters.ts +268 -0
  210. package/src/core/format.ts +309 -0
  211. package/src/core/measures.ts +99 -0
  212. package/src/core/request-ids.ts +54 -0
  213. package/src/core/rows.ts +213 -0
  214. package/src/core/store.ts +1122 -0
  215. package/src/core/totals.ts +59 -0
  216. package/src/core/trace.ts +44 -0
  217. package/src/core/transports.ts +177 -0
  218. package/src/core/types.ts +168 -0
  219. package/src/core.ts +39 -0
  220. package/src/index.ts +24 -12
  221. package/src/react/accessible-rows.tsx +242 -0
  222. package/src/react/cell-editor.tsx +216 -0
  223. package/src/react/cell-menu.tsx +166 -0
  224. package/src/react/column-menu.tsx +619 -0
  225. package/src/react/columns-menu.tsx +116 -0
  226. package/src/react/conditions-menu.tsx +289 -0
  227. package/src/react/context.ts +33 -0
  228. package/src/react/data-grid.tsx +286 -0
  229. package/src/react/detail-panel.tsx +92 -0
  230. package/src/react/export-menu.tsx +112 -0
  231. package/src/react/find-bar.tsx +112 -0
  232. package/src/react/format-form.tsx +421 -0
  233. package/src/react/formula-menu.tsx +163 -0
  234. package/src/react/grid-body.tsx +744 -0
  235. package/src/react/group-menu.tsx +214 -0
  236. package/src/react/hooks.ts +104 -0
  237. package/src/react/messages.ts +556 -0
  238. package/src/react/save.ts +42 -0
  239. package/src/react/static-rows.tsx +59 -0
  240. package/src/react/status-bar.tsx +175 -0
  241. package/src/react/toolbar-popover.tsx +69 -0
  242. package/src/react/toolbar.tsx +152 -0
  243. package/src/server.ts +58 -0
  244. package/src/styles.css +496 -316
  245. package/dist/components/bars.d.ts +0 -14
  246. package/dist/components/bars.d.ts.map +0 -1
  247. package/dist/components/bars.js +0 -67
  248. package/dist/components/bars.js.map +0 -1
  249. package/dist/components/cell-inspector.d.ts +0 -11
  250. package/dist/components/cell-inspector.d.ts.map +0 -1
  251. package/dist/components/cell-inspector.js +0 -37
  252. package/dist/components/cell-inspector.js.map +0 -1
  253. package/dist/components/columns-panel.d.ts +0 -12
  254. package/dist/components/columns-panel.d.ts.map +0 -1
  255. package/dist/components/columns-panel.js +0 -11
  256. package/dist/components/columns-panel.js.map +0 -1
  257. package/dist/components/filter-panel.d.ts +0 -17
  258. package/dist/components/filter-panel.d.ts.map +0 -1
  259. package/dist/components/filter-panel.js +0 -165
  260. package/dist/components/filter-panel.js.map +0 -1
  261. package/dist/components/find-bar.d.ts +0 -9
  262. package/dist/components/find-bar.d.ts.map +0 -1
  263. package/dist/components/find-bar.js +0 -46
  264. package/dist/components/find-bar.js.map +0 -1
  265. package/dist/components/popover.d.ts +0 -38
  266. package/dist/components/popover.d.ts.map +0 -1
  267. package/dist/components/popover.js +0 -104
  268. package/dist/components/popover.js.map +0 -1
  269. package/dist/components/stats-panel.d.ts +0 -12
  270. package/dist/components/stats-panel.d.ts.map +0 -1
  271. package/dist/components/stats-panel.js +0 -40
  272. package/dist/components/stats-panel.js.map +0 -1
  273. package/dist/controller.d.ts +0 -218
  274. package/dist/controller.d.ts.map +0 -1
  275. package/dist/controller.js +0 -788
  276. package/dist/controller.js.map +0 -1
  277. package/dist/data-grid.d.ts +0 -51
  278. package/dist/data-grid.d.ts.map +0 -1
  279. package/dist/data-grid.js +0 -698
  280. package/dist/data-grid.js.map +0 -1
  281. package/dist/labels.d.ts +0 -9
  282. package/dist/labels.d.ts.map +0 -1
  283. package/dist/labels.js +0 -38
  284. package/dist/labels.js.map +0 -1
  285. package/dist/messages.d.ts +0 -99
  286. package/dist/messages.d.ts.map +0 -1
  287. package/dist/messages.js +0 -276
  288. package/dist/messages.js.map +0 -1
  289. package/dist/renderer.d.ts +0 -52
  290. package/dist/renderer.d.ts.map +0 -1
  291. package/dist/renderer.js +0 -522
  292. package/dist/renderer.js.map +0 -1
  293. package/dist/text.d.ts +0 -14
  294. package/dist/text.d.ts.map +0 -1
  295. package/dist/text.js +0 -63
  296. package/dist/text.js.map +0 -1
  297. package/dist/theme.d.ts.map +0 -1
  298. package/dist/theme.js +0 -54
  299. package/dist/theme.js.map +0 -1
  300. package/src/components/bars.tsx +0 -179
  301. package/src/components/cell-inspector.tsx +0 -70
  302. package/src/components/columns-panel.tsx +0 -59
  303. package/src/components/filter-panel.tsx +0 -290
  304. package/src/components/find-bar.tsx +0 -104
  305. package/src/components/popover.tsx +0 -194
  306. package/src/components/stats-panel.tsx +0 -153
  307. package/src/controller.ts +0 -887
  308. package/src/data-grid.tsx +0 -1029
  309. package/src/labels.ts +0 -46
  310. package/src/messages.ts +0 -390
  311. package/src/renderer.ts +0 -535
  312. package/src/text.ts +0 -55
package/src/data-grid.tsx DELETED
@@ -1,1029 +0,0 @@
1
- import {
2
- type CSSProperties,
3
- forwardRef,
4
- type KeyboardEvent,
5
- type MouseEvent,
6
- type PointerEvent,
7
- useCallback,
8
- useEffect,
9
- useId,
10
- useImperativeHandle,
11
- useMemo,
12
- useRef,
13
- useState,
14
- useSyncExternalStore,
15
- } from "react";
16
- import { type CellValue, plainText, type TableSource, type ViewQuery } from "vastgrid-core";
17
- import { StatusBar, Toolbar } from "./components/bars.js";
18
- import { CellInspector } from "./components/cell-inspector.js";
19
- import { ColumnsPanel } from "./components/columns-panel.js";
20
- import { FilterPanel } from "./components/filter-panel.js";
21
- import { FindBar } from "./components/find-bar.js";
22
- import { type Anchor, type MenuItem, MenuList, Popover } from "./components/popover.js";
23
- import { StatsPanel } from "./components/stats-panel.js";
24
- import { CopyTooLargeError, GridController, type Hit } from "./controller.js";
25
- import { countFormat, numberFormat } from "./labels.js";
26
- import { type Messages, messagesFor } from "./messages.js";
27
- import { CanvasRenderer } from "./renderer.js";
28
- import { cssLength, resolveTheme } from "./theme.js";
29
-
30
- export interface DataGridProps {
31
- /** The table to show. A new source starts a new grid. */
32
- source: TableSource;
33
- /** Locale for numbers and the built-in texts (`ja` or `en`). Defaults to the browser's language. */
34
- locale?: string;
35
- /** Replaces some of the built-in texts. */
36
- messages?: Partial<Messages>;
37
- /** Row height in pixels. Defaults to `--vg-row-height` or 24. */
38
- rowHeight?: number;
39
- /** Header height in pixels. Defaults to `--vg-header-height` or 32. */
40
- headerHeight?: number;
41
- /** Text shown for null cells. Defaults to an empty cell. */
42
- nullText?: string;
43
- /** Columns frozen at the left when the table opens. */
44
- frozenColumns?: number;
45
- /** Shows the search box, active filters and row count. Defaults to `true`. */
46
- toolbar?: boolean;
47
- /** Shows the active cell and the selection summary. Defaults to `true`. */
48
- statusBar?: boolean;
49
- /** Changing this value reads the table again, for example when the file changed on disk. */
50
- reloadKey?: unknown;
51
- /** Cells that can be copied at once. Defaults to 1,000,000. */
52
- copyLimit?: number;
53
- onQueryChange?: (query: ViewQuery) => void;
54
- className?: string;
55
- style?: CSSProperties;
56
- "aria-label"?: string;
57
- }
58
-
59
- export interface DataGridHandle {
60
- focus(): void;
61
- reload(): void;
62
- getQuery(): ViewQuery;
63
- setQuery(query: ViewQuery): void;
64
- /** Scrolls to a row of the current view and makes it active. */
65
- goToRow(row: number): void;
66
- readonly controller: GridController;
67
- /** The canvas renderer, for diagnostics such as `renderer.timings()`. Null before the grid mounts. */
68
- readonly renderer: CanvasRenderer | null;
69
- }
70
-
71
- type Overlay =
72
- | { kind: "columnMenu"; col: number; anchor: Anchor }
73
- | { kind: "contextMenu"; row: number; col: number; anchor: Anchor }
74
- | { kind: "filter"; column: number; anchor: Anchor }
75
- | { kind: "stats"; col: number; anchor: Anchor }
76
- | { kind: "inspector"; row: number; col: number; anchor: Anchor }
77
- | { kind: "columns"; anchor: Anchor };
78
-
79
- type Drag =
80
- | { kind: "cells" | "rows" | "cols"; pointerId: number; x: number; y: number }
81
- | { kind: "resize"; pointerId: number; col: number; startX: number; startWidth: number }
82
- | { kind: "move"; pointerId: number; col: number; x: number; y: number; moved: boolean };
83
-
84
- const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
85
-
86
- async function writeClipboard(text: Promise<string>, html: Promise<string | null> | null) {
87
- const clipboard = typeof navigator === "undefined" ? undefined : navigator.clipboard;
88
- if (!clipboard) throw new Error("The clipboard is not available.");
89
- if (clipboard.write && typeof ClipboardItem !== "undefined") {
90
- const items: Record<string, Promise<Blob>> = {
91
- "text/plain": text.then((value) => new Blob([value], { type: "text/plain" })),
92
- };
93
- if (html) items["text/html"] = html.then((value) => new Blob([value ?? ""], { type: "text/html" }));
94
- await clipboard.write([new ClipboardItem(items)]);
95
- return;
96
- }
97
- await clipboard.writeText(await text);
98
- }
99
-
100
- /**
101
- * A data grid for very large tables. Draws on a canvas, reads rows through a `TableSource` as you
102
- * scroll, and offers spreadsheet selection and keyboard navigation, copying, sorting, filters,
103
- * search, column statistics and a selection summary.
104
- */
105
- export const DataGrid = forwardRef<DataGridHandle, DataGridProps>(function DataGrid(props, ref) {
106
- const {
107
- source,
108
- locale,
109
- rowHeight,
110
- headerHeight,
111
- nullText = "",
112
- frozenColumns = 0,
113
- toolbar = true,
114
- statusBar = true,
115
- reloadKey,
116
- copyLimit = 1_000_000,
117
- onQueryChange,
118
- className,
119
- style,
120
- } = props;
121
- const resolvedLocale = locale ?? (typeof navigator === "undefined" ? undefined : navigator.language);
122
- const messages = useMemo(
123
- () => ({ ...messagesFor(resolvedLocale), ...props.messages }),
124
- [resolvedLocale, props.messages],
125
- );
126
- const rootRef = useRef<HTMLDivElement>(null);
127
- const bodyRef = useRef<HTMLDivElement>(null);
128
- const canvasRef = useRef<HTMLCanvasElement>(null);
129
- const scrollerRef = useRef<HTMLDivElement>(null);
130
- const sizerRef = useRef<HTMLDivElement>(null);
131
- const rendererRef = useRef<CanvasRenderer | null>(null);
132
- const dragRef = useRef<Drag | null>(null);
133
- const autoScrollRef = useRef(0);
134
- const cellId = useId();
135
- const [overlay, setOverlay] = useState<Overlay | null>(null);
136
- const [findOpen, setFindOpen] = useState(false);
137
- const [notice, setNotice] = useState<string | null>(null);
138
- const noticeTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
139
-
140
- // One controller per source. Options that can change later are applied in effects below.
141
- // biome-ignore lint/correctness/useExhaustiveDependencies: the other options are applied without recreating the grid.
142
- const controller = useMemo(
143
- () =>
144
- new GridController(source, {
145
- rowHeight: rowHeight ?? 24,
146
- headerHeight: headerHeight ?? 32,
147
- locale: resolvedLocale,
148
- nullText,
149
- frozenColumns,
150
- minColumnWidth: 36,
151
- maxAutoWidth: 360,
152
- copyLimit,
153
- }),
154
- [source],
155
- );
156
- useSyncExternalStore(controller.subscribe, controller.getVersion, controller.getVersion);
157
-
158
- const count = useMemo(() => countFormat(resolvedLocale), [resolvedLocale]);
159
- const number = useMemo(() => numberFormat(resolvedLocale), [resolvedLocale]);
160
-
161
- useEffect(() => {
162
- if (resolvedLocale !== controller.options.locale || nullText !== controller.options.nullText) {
163
- controller.setFormat(resolvedLocale, nullText);
164
- }
165
- }, [controller, resolvedLocale, nullText]);
166
-
167
- const announce = useCallback((text: string) => {
168
- setNotice(text);
169
- clearTimeout(noticeTimer.current);
170
- noticeTimer.current = setTimeout(() => setNotice(null), 3000);
171
- }, []);
172
- useEffect(() => () => clearTimeout(noticeTimer.current), []);
173
-
174
- // Canvas, scrolling, sizes and theme.
175
- useEffect(() => {
176
- const root = rootRef.current;
177
- const canvas = canvasRef.current;
178
- const scroller = scrollerRef.current;
179
- const sizer = sizerRef.current;
180
- if (!root || !canvas || !scroller || !sizer) return;
181
- controller.options.rowHeight = rowHeight ?? cssLength(root, "--vg-row-height", 24);
182
- controller.options.headerHeight = headerHeight ?? cssLength(root, "--vg-header-height", 32);
183
- const renderer = new CanvasRenderer(canvas, controller, resolveTheme(root));
184
- rendererRef.current = renderer;
185
- controller.attach(scroller, sizer);
186
- const stopRender = controller.onRender(() => renderer.schedule());
187
- const resize = () => controller.setViewport(scroller.clientWidth, scroller.clientHeight);
188
- resize();
189
- const observer = new ResizeObserver(resize);
190
- observer.observe(scroller);
191
- const onScroll = () => controller.handleScroll();
192
- scroller.addEventListener("scroll", onScroll, { passive: true });
193
- // Without a text selection, Chromium and Safari disable the copy command unless "beforecopy" is
194
- // cancelled; then ⌘C and the Edit menu fire "copy", which the grid answers while it has focus.
195
- const onBeforeCopy = (event: Event) => {
196
- if (document.activeElement === scroller) event.preventDefault();
197
- };
198
- const onCopy = (event: ClipboardEvent) => {
199
- if (document.activeElement === scroller) copyHandler.current(event);
200
- };
201
- document.addEventListener("beforecopy", onBeforeCopy);
202
- document.addEventListener("copy", onCopy);
203
- const refreshTheme = () => renderer.setTheme(resolveTheme(root));
204
- const media = window.matchMedia("(prefers-color-scheme: dark)");
205
- media.addEventListener("change", refreshTheme);
206
- const themeObserver = new MutationObserver(refreshTheme);
207
- themeObserver.observe(document.documentElement, {
208
- attributes: true,
209
- attributeFilter: ["class", "style", "data-theme"],
210
- });
211
- void document.fonts?.ready.then(refreshTheme);
212
- controller.start();
213
- renderer.schedule();
214
- return () => {
215
- controller.stop();
216
- stopRender();
217
- observer.disconnect();
218
- scroller.removeEventListener("scroll", onScroll);
219
- document.removeEventListener("beforecopy", onBeforeCopy);
220
- document.removeEventListener("copy", onCopy);
221
- media.removeEventListener("change", refreshTheme);
222
- themeObserver.disconnect();
223
- renderer.dispose();
224
- rendererRef.current = null;
225
- };
226
- }, [controller, rowHeight, headerHeight]);
227
-
228
- // Theme changes through the grid's own class or style.
229
- // biome-ignore lint/correctness/useExhaustiveDependencies: className and style are the triggers.
230
- useEffect(() => {
231
- const root = rootRef.current;
232
- if (root && rendererRef.current) rendererRef.current.setTheme(resolveTheme(root));
233
- }, [className, style]);
234
-
235
- const firstReload = useRef(true);
236
- // biome-ignore lint/correctness/useExhaustiveDependencies: reloadKey is the trigger.
237
- useEffect(() => {
238
- if (firstReload.current) {
239
- firstReload.current = false;
240
- return;
241
- }
242
- controller.reload();
243
- }, [reloadKey, controller]);
244
-
245
- const lastQuery = useRef(controller.query);
246
- useEffect(() => {
247
- if (lastQuery.current !== controller.query) {
248
- lastQuery.current = controller.query;
249
- onQueryChange?.(controller.query);
250
- }
251
- });
252
-
253
- useImperativeHandle(
254
- ref,
255
- () => ({
256
- focus: () => scrollerRef.current?.focus(),
257
- reload: () => controller.reload(),
258
- getQuery: () => controller.query,
259
- setQuery: (query) => void controller.setQuery(query),
260
- goToRow: (row) => {
261
- controller.selection.select({ row, col: controller.selection.active.col });
262
- controller.selectionChanged({ row, col: controller.selection.active.col });
263
- },
264
- controller,
265
- get renderer() {
266
- return rendererRef.current;
267
- },
268
- }),
269
- [controller],
270
- );
271
-
272
- // ----- helpers -----
273
-
274
- function local(event: { clientX: number; clientY: number }) {
275
- const rect = scrollerRef.current?.getBoundingClientRect();
276
- return { x: event.clientX - (rect?.left ?? 0), y: event.clientY - (rect?.top ?? 0) };
277
- }
278
-
279
- /** A popover anchor at a point of the grid body. */
280
- function anchorAt(x: number, y: number, height = 0): Anchor {
281
- const offsetX = bodyRef.current?.offsetLeft ?? 0;
282
- const offsetY = bodyRef.current?.offsetTop ?? 0;
283
- return { x: x + offsetX, y: y + offsetY, height };
284
- }
285
-
286
- function headerAnchor(col: number): Anchor {
287
- const x = Math.max(controller.gutterWidth, controller.columnX(col));
288
- return anchorAt(x, 0, controller.options.headerHeight);
289
- }
290
-
291
- function cellAnchor(row: number, col: number): Anchor {
292
- const x = Math.max(controller.gutterWidth, controller.columnX(col));
293
- const y = Math.max(controller.options.headerHeight, Math.min(controller.height - 8, controller.rowY(row)));
294
- return anchorAt(x, y, controller.options.rowHeight);
295
- }
296
-
297
- function focusGrid() {
298
- scrollerRef.current?.focus({ preventScroll: true });
299
- }
300
-
301
- const copy = useCallback(
302
- async (options: { headers?: boolean; markdown?: boolean }) => {
303
- const task = controller.copySelection(options);
304
- try {
305
- await writeClipboard(
306
- task.then((result) => result.text),
307
- options.markdown ? null : task.then((result) => result.html),
308
- );
309
- const result = await task;
310
- announce(messages.copied(count(result.cells)));
311
- } catch (error) {
312
- if (error instanceof CopyTooLargeError) announce(messages.copyTooLarge(count(error.limit)));
313
- else announce(messages.copyFailed);
314
- }
315
- },
316
- [announce, controller, count, messages],
317
- );
318
- /** Options of a copy started from the keyboard, answered by the browser's own copy event. */
319
- const pendingCopy = useRef<{ headers?: boolean } | null>(null);
320
- const copyHandler = useRef<(event: ClipboardEvent) => void>(() => undefined);
321
-
322
- // ----- pointer -----
323
-
324
- function selectMode(event: { shiftKey: boolean; metaKey: boolean; ctrlKey: boolean }) {
325
- if (event.shiftKey) return "extend" as const;
326
- if (isMac ? event.metaKey : event.ctrlKey) return "add" as const;
327
- return "replace" as const;
328
- }
329
-
330
- function dragTo(x: number, y: number) {
331
- const drag = dragRef.current;
332
- if (!drag || (drag.kind !== "cells" && drag.kind !== "rows" && drag.kind !== "cols")) return;
333
- const c = controller;
334
- const clampedX = Math.min(Math.max(x, c.gutterWidth + 1), c.width - 1);
335
- const clampedY = Math.min(Math.max(y, c.options.headerHeight + 1), c.height - 1);
336
- const col = c.columnAt(clampedX);
337
- const row = c.rowAt(clampedY);
338
- if (drag.kind === "rows" && row >= 0) c.selection.selectRows(row, row, "extend");
339
- else if (drag.kind === "cols" && col >= 0) c.selection.selectCols(col, col, "extend");
340
- else if (drag.kind === "cells" && row >= 0 && col >= 0) c.selection.select({ row, col }, "extend");
341
- c.selectionChanged();
342
- }
343
-
344
- function autoScroll() {
345
- const drag = dragRef.current;
346
- if (!drag || (drag.kind !== "cells" && drag.kind !== "rows" && drag.kind !== "cols")) {
347
- autoScrollRef.current = 0;
348
- return;
349
- }
350
- const c = controller;
351
- const speed = (distance: number) => Math.sign(distance) * Math.min(80, Math.abs(distance) * 0.6);
352
- let dx = 0;
353
- let dy = 0;
354
- if (drag.y < c.options.headerHeight) dy = speed(drag.y - c.options.headerHeight);
355
- else if (drag.y > c.height) dy = speed(drag.y - c.height);
356
- if (drag.kind !== "rows") {
357
- if (drag.x < c.gutterWidth + c.frozenWidth && c.scrollX.position > 0)
358
- dx = speed(drag.x - c.gutterWidth - c.frozenWidth);
359
- else if (drag.x > c.width) dx = speed(drag.x - c.width);
360
- }
361
- if (dx !== 0 || dy !== 0) {
362
- c.scrollToVirtual(dx === 0 ? null : c.scrollX.position + dx, dy === 0 ? null : c.scrollY.position + dy);
363
- dragTo(drag.x, drag.y);
364
- }
365
- autoScrollRef.current = requestAnimationFrame(autoScroll);
366
- }
367
-
368
- function onPointerDown(event: PointerEvent<HTMLDivElement>) {
369
- const scroller = scrollerRef.current;
370
- if (!scroller || event.button !== 0) return;
371
- const { x, y } = local(event);
372
- // Leave the native scrollbars alone.
373
- if (x > scroller.clientWidth || y > scroller.clientHeight) return;
374
- focusGrid();
375
- const c = controller;
376
- const hit = c.hitTest(x, y);
377
- const mode = selectMode(event);
378
- if (hit.resize >= 0) {
379
- event.preventDefault();
380
- scroller.setPointerCapture(event.pointerId);
381
- dragRef.current = {
382
- kind: "resize",
383
- pointerId: event.pointerId,
384
- col: hit.resize,
385
- startX: x,
386
- startWidth: c.layout.width(hit.resize),
387
- };
388
- c.resizing = hit.resize;
389
- c.requestRender();
390
- return;
391
- }
392
- if (hit.menu && hit.col >= 0) {
393
- event.preventDefault();
394
- setOverlay({ kind: "columnMenu", col: hit.col, anchor: headerAnchor(hit.col) });
395
- return;
396
- }
397
- switch (hit.zone) {
398
- case "cell":
399
- c.selection.select({ row: hit.row, col: hit.col }, mode);
400
- dragRef.current = { kind: "cells", pointerId: event.pointerId, x, y };
401
- break;
402
- case "header":
403
- if (mode === "replace" && c.selection.colSelected(hit.col)) {
404
- dragRef.current = { kind: "move", pointerId: event.pointerId, col: hit.col, x, y, moved: false };
405
- } else {
406
- c.selection.selectCols(hit.col, hit.col, mode);
407
- dragRef.current = { kind: "cols", pointerId: event.pointerId, x, y };
408
- }
409
- break;
410
- case "gutter":
411
- c.selection.selectRows(hit.row, hit.row, mode);
412
- dragRef.current = { kind: "rows", pointerId: event.pointerId, x, y };
413
- break;
414
- case "corner":
415
- c.selection.selectAll();
416
- break;
417
- default:
418
- return;
419
- }
420
- event.preventDefault();
421
- scroller.setPointerCapture(event.pointerId);
422
- c.selectionChanged();
423
- }
424
-
425
- function onPointerMove(event: PointerEvent<HTMLDivElement>) {
426
- const scroller = scrollerRef.current;
427
- if (!scroller) return;
428
- const { x, y } = local(event);
429
- const c = controller;
430
- const drag = dragRef.current;
431
- if (drag && drag.pointerId === event.pointerId) {
432
- if (drag.kind === "resize") {
433
- c.resizeColumn(drag.col, drag.startWidth + (x - drag.startX));
434
- return;
435
- }
436
- if (drag.kind === "move") {
437
- drag.moved = drag.moved || Math.abs(x - drag.x) > 4;
438
- drag.x = x;
439
- drag.y = y;
440
- scroller.style.cursor = drag.moved ? "grabbing" : "";
441
- return;
442
- }
443
- drag.x = x;
444
- drag.y = y;
445
- dragTo(x, y);
446
- if (!autoScrollRef.current) autoScrollRef.current = requestAnimationFrame(autoScroll);
447
- return;
448
- }
449
- const hit: Hit =
450
- x > scroller.clientWidth || y > scroller.clientHeight
451
- ? { zone: "outside", row: -1, col: -1, resize: -1, menu: false }
452
- : c.hitTest(x, y);
453
- const previous = c.hover;
454
- c.hover = hit;
455
- scroller.style.cursor = hit.resize >= 0 ? "col-resize" : hit.menu ? "pointer" : "";
456
- if (previous?.zone !== hit.zone || previous.col !== hit.col || previous.menu !== hit.menu) c.requestRender();
457
- }
458
-
459
- function endDrag(event: PointerEvent<HTMLDivElement>) {
460
- const drag = dragRef.current;
461
- if (!drag || drag.pointerId !== event.pointerId) return;
462
- dragRef.current = null;
463
- cancelAnimationFrame(autoScrollRef.current);
464
- autoScrollRef.current = 0;
465
- const scroller = scrollerRef.current;
466
- if (scroller?.hasPointerCapture(event.pointerId)) scroller.releasePointerCapture(event.pointerId);
467
- if (scroller) scroller.style.cursor = "";
468
- const c = controller;
469
- if (drag.kind === "resize") {
470
- c.resizing = -1;
471
- c.requestRender();
472
- } else if (drag.kind === "move") {
473
- const target = c.columnAt(local(event).x);
474
- if (drag.moved && target >= 0 && target !== drag.col) {
475
- c.moveColumn(drag.col, target);
476
- c.selection.selectCols(target, target);
477
- c.selectionChanged();
478
- } else if (!drag.moved) {
479
- c.selection.selectCols(drag.col, drag.col);
480
- c.selectionChanged();
481
- }
482
- }
483
- }
484
-
485
- function onPointerLeave() {
486
- if (controller.hover && !dragRef.current) {
487
- controller.hover = null;
488
- controller.requestRender();
489
- }
490
- }
491
-
492
- function onDoubleClick(event: MouseEvent<HTMLDivElement>) {
493
- const { x, y } = local(event);
494
- const hit = controller.hitTest(x, y);
495
- if (hit.resize >= 0) {
496
- controller.autoFit(hit.resize);
497
- controller.emit();
498
- } else if (hit.zone === "cell") {
499
- setOverlay({ kind: "inspector", row: hit.row, col: hit.col, anchor: cellAnchor(hit.row, hit.col) });
500
- }
501
- }
502
-
503
- function onContextMenu(event: MouseEvent<HTMLDivElement>) {
504
- const { x, y } = local(event);
505
- const c = controller;
506
- const hit = c.hitTest(x, y);
507
- if (hit.zone === "header") {
508
- event.preventDefault();
509
- setOverlay({ kind: "columnMenu", col: hit.col, anchor: anchorAt(x, y) });
510
- return;
511
- }
512
- if (hit.zone !== "cell") return;
513
- event.preventDefault();
514
- if (!c.selection.contains(hit.row, hit.col)) {
515
- c.selection.select({ row: hit.row, col: hit.col });
516
- c.selectionChanged();
517
- }
518
- setOverlay({ kind: "contextMenu", row: hit.row, col: hit.col, anchor: anchorAt(x, y) });
519
- }
520
-
521
- // ----- keyboard -----
522
-
523
- function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {
524
- if (event.target !== scrollerRef.current) return;
525
- const c = controller;
526
- const primary = isMac ? event.metaKey : event.ctrlKey;
527
- const extend = event.shiftKey;
528
- const lastRow = Math.max(0, c.rowCount - 1);
529
- const lastCol = Math.max(0, c.visible.length - 1);
530
- const focus = extend ? c.selection.focus : c.selection.active;
531
- let target: { row: number; col: number } | null = null;
532
- switch (event.key) {
533
- case "ArrowDown":
534
- if (event.altKey) {
535
- setOverlay({ kind: "columnMenu", col: c.selection.active.col, anchor: headerAnchor(c.selection.active.col) });
536
- break;
537
- }
538
- target = c.selection.moveTo({ row: primary ? lastRow : focus.row + 1, col: focus.col }, extend);
539
- break;
540
- case "ArrowUp":
541
- target = c.selection.moveTo({ row: primary ? 0 : focus.row - 1, col: focus.col }, extend);
542
- break;
543
- case "ArrowRight":
544
- target = c.selection.moveTo({ row: focus.row, col: primary ? lastCol : focus.col + 1 }, extend);
545
- break;
546
- case "ArrowLeft":
547
- target = c.selection.moveTo({ row: focus.row, col: primary ? 0 : focus.col - 1 }, extend);
548
- break;
549
- case "PageDown":
550
- target = event.altKey
551
- ? c.selection.moveTo({ row: focus.row, col: focus.col + 5 }, extend)
552
- : c.selection.moveTo({ row: focus.row + c.pageRows, col: focus.col }, extend);
553
- break;
554
- case "PageUp":
555
- target = event.altKey
556
- ? c.selection.moveTo({ row: focus.row, col: focus.col - 5 }, extend)
557
- : c.selection.moveTo({ row: focus.row - c.pageRows, col: focus.col }, extend);
558
- break;
559
- case "Home":
560
- target = c.selection.moveTo({ row: event.ctrlKey || event.metaKey ? 0 : focus.row, col: 0 }, extend);
561
- break;
562
- case "End":
563
- target = c.selection.moveTo(
564
- { row: event.ctrlKey || event.metaKey ? lastRow : focus.row, col: lastCol },
565
- extend,
566
- );
567
- break;
568
- case "Tab":
569
- target = c.selection.advance(0, event.shiftKey ? -1 : 1);
570
- break;
571
- case "Enter":
572
- target = c.selection.advance(event.shiftKey ? -1 : 1, 0);
573
- break;
574
- case " ":
575
- if (event.shiftKey) c.selection.selectRows(c.selection.active.row, c.selection.active.row);
576
- else if (event.ctrlKey) c.selection.selectCols(c.selection.active.col, c.selection.active.col);
577
- else return;
578
- c.selectionChanged();
579
- break;
580
- case "F2":
581
- setOverlay({
582
- kind: "inspector",
583
- ...c.selection.active,
584
- anchor: cellAnchor(c.selection.active.row, c.selection.active.col),
585
- });
586
- break;
587
- case "Escape":
588
- if (findOpen) setFindOpen(false);
589
- else if (!c.selection.single) {
590
- c.selection.select(c.selection.active);
591
- c.selectionChanged();
592
- } else return;
593
- break;
594
- case "ContextMenu":
595
- setOverlay({
596
- kind: "contextMenu",
597
- ...c.selection.active,
598
- anchor: cellAnchor(c.selection.active.row, c.selection.active.col),
599
- });
600
- break;
601
- default:
602
- if (primary && event.key.toLowerCase() === "a") {
603
- c.selection.selectAll();
604
- c.selectionChanged();
605
- } else if (primary && event.key.toLowerCase() === "c") {
606
- // Let the browser run its own copy command: it needs no clipboard permission, and onCopy answers
607
- // it with the loaded cells. Shortcuts the browser does not treat as copying fall back below.
608
- const options = { headers: event.shiftKey };
609
- pendingCopy.current = options;
610
- setTimeout(() => {
611
- if (pendingCopy.current !== options) return;
612
- pendingCopy.current = null;
613
- void copy(options);
614
- }, 0);
615
- return;
616
- } else if (primary && event.key.toLowerCase() === "f") {
617
- setFindOpen(true);
618
- } else if (event.shiftKey && event.key === "F10") {
619
- setOverlay({
620
- kind: "contextMenu",
621
- ...c.selection.active,
622
- anchor: cellAnchor(c.selection.active.row, c.selection.active.col),
623
- });
624
- } else {
625
- return;
626
- }
627
- }
628
- event.preventDefault();
629
- if (target) c.selectionChanged(target);
630
- }
631
-
632
- /** The browser's own copy command (Edit menu): answer synchronously when the cells are loaded. */
633
- /**
634
- * The browser's copy command (⌘C, or Copy in the app's Edit menu) while the grid has focus. Loaded
635
- * cells are written here, synchronously, which needs no clipboard permission; larger selections are
636
- * read first and written with the asynchronous clipboard API.
637
- */
638
- copyHandler.current = (event: ClipboardEvent) => {
639
- if (!event.clipboardData) return;
640
- event.preventDefault();
641
- const options = pendingCopy.current ?? {};
642
- pendingCopy.current = null;
643
- const result = controller.copySelectionSync(options);
644
- if (result) {
645
- event.clipboardData.setData("text/plain", result.text);
646
- if (result.html) event.clipboardData.setData("text/html", result.html);
647
- announce(messages.copied(count(result.cells)));
648
- } else {
649
- void copy(options);
650
- }
651
- };
652
-
653
- // ----- menus -----
654
-
655
- const c = controller;
656
- const capabilities = c.info?.capabilities;
657
- const ready = c.info?.status === "ready";
658
-
659
- function columnMenuItems(col: number): MenuItem[] {
660
- const state = c.visible[col];
661
- const schema = c.columnSchema(col);
662
- if (!state || !schema) return [];
663
- const sort = c.sortOf(state.index);
664
- const otherSorts = (c.query.sort ?? []).filter((key) => key.column !== state.index).length > 0;
665
- const items: MenuItem[] = [
666
- {
667
- label: messages.sortAscending,
668
- onSelect: () => c.sortBy(col, "asc"),
669
- disabled: !ready || !capabilities?.sort,
670
- checked: sort?.direction === "asc" && !otherSorts,
671
- },
672
- {
673
- label: messages.sortDescending,
674
- onSelect: () => c.sortBy(col, "desc"),
675
- disabled: !ready || !capabilities?.sort,
676
- checked: sort?.direction === "desc" && !otherSorts,
677
- },
678
- ];
679
- if ((c.query.sort?.length ?? 0) > 0 && !(sort && !otherSorts)) {
680
- items.push(
681
- { label: messages.addSortAscending, onSelect: () => c.sortBy(col, "asc", true), disabled: !ready },
682
- { label: messages.addSortDescending, onSelect: () => c.sortBy(col, "desc", true), disabled: !ready },
683
- );
684
- }
685
- if ((c.query.sort?.length ?? 0) > 0) items.push({ label: messages.clearSort, onSelect: () => c.clearSort() });
686
- items.push(
687
- {
688
- label: messages.filter,
689
- separator: true,
690
- disabled: !ready || !capabilities?.filter,
691
- onSelect: () => setOverlay({ kind: "filter", column: state.index, anchor: headerAnchor(col) }),
692
- },
693
- {
694
- label: messages.statistics,
695
- disabled: !ready || !capabilities?.stats,
696
- onSelect: () => setOverlay({ kind: "stats", col, anchor: headerAnchor(col) }),
697
- },
698
- {
699
- label: messages.autoFit,
700
- separator: true,
701
- onSelect: () => {
702
- c.autoFit(col);
703
- c.emit();
704
- },
705
- },
706
- col < c.frozen && c.frozen > 0
707
- ? { label: messages.unfreeze, onSelect: () => c.freezeUpTo(-1) }
708
- : { label: messages.freezeUpTo, onSelect: () => c.freezeUpTo(col) },
709
- { label: messages.hideColumn, disabled: c.visible.length <= 1, onSelect: () => c.hideColumn(col) },
710
- {
711
- label: messages.copyColumnName,
712
- onSelect: () =>
713
- void writeClipboard(Promise.resolve(schema.name), null).then(
714
- () => announce(messages.copied("1")),
715
- (error: unknown) => announce(error instanceof Error ? error.message : String(error)),
716
- ),
717
- },
718
- );
719
- return items;
720
- }
721
-
722
- function contextMenuItems(row: number, col: number): MenuItem[] {
723
- const state = c.visible[col];
724
- const value: CellValue | undefined = c.value(row, col);
725
- const items: MenuItem[] = [
726
- { label: messages.copy, shortcut: isMac ? "⌘C" : "Ctrl+C", onSelect: () => void copy({}) },
727
- {
728
- label: messages.copyWithHeaders,
729
- shortcut: isMac ? "⇧⌘C" : "Ctrl+Shift+C",
730
- onSelect: () => void copy({ headers: true }),
731
- },
732
- { label: messages.copyAsMarkdown, onSelect: () => void copy({ markdown: true }) },
733
- ];
734
- if (state && value !== undefined) {
735
- items.push(
736
- {
737
- label: messages.filterToValue,
738
- separator: true,
739
- disabled: !ready || !capabilities?.filter,
740
- onSelect: () => c.setFilter(state.index, { type: "values", column: state.index, values: [value] }),
741
- },
742
- {
743
- label: messages.excludeValue,
744
- disabled: !ready || !capabilities?.filter,
745
- onSelect: () =>
746
- c.setFilter(state.index, { type: "values", column: state.index, values: [value], exclude: true }),
747
- },
748
- );
749
- }
750
- items.push(
751
- {
752
- label: messages.sortAscending,
753
- separator: true,
754
- disabled: !ready || !capabilities?.sort,
755
- onSelect: () => c.sortBy(col, "asc"),
756
- },
757
- {
758
- label: messages.sortDescending,
759
- disabled: !ready || !capabilities?.sort,
760
- onSelect: () => c.sortBy(col, "desc"),
761
- },
762
- {
763
- label: messages.inspectCell,
764
- separator: true,
765
- shortcut: "F2",
766
- onSelect: () => setOverlay({ kind: "inspector", row, col, anchor: cellAnchor(row, col) }),
767
- },
768
- );
769
- return items;
770
- }
771
-
772
- const closeOverlay = useCallback(() => setOverlay(null), []);
773
- const loadDistinct = useCallback(
774
- (search: string, signal: AbortSignal) =>
775
- overlay?.kind === "filter" ? controller.distinct(overlay.column, search, signal) : null,
776
- [controller, overlay],
777
- );
778
- const loadStats = useCallback(
779
- (signal: AbortSignal) => (overlay?.kind === "stats" ? controller.stats(overlay.col, signal) : null),
780
- [controller, overlay],
781
- );
782
- const loadCell = useCallback(
783
- (signal: AbortSignal) =>
784
- overlay?.kind === "inspector" ? controller.fullValue(overlay.row, overlay.col, signal) : Promise.resolve(null),
785
- [controller, overlay],
786
- );
787
-
788
- // ----- accessibility -----
789
-
790
- const active = c.selection.active;
791
- const activeSchema = c.columnSchema(active.col);
792
- const activeValue = c.value(active.row, active.col);
793
- const activeText =
794
- activeValue === undefined ? messages.loading : activeValue === null ? messages.blank : plainText(activeValue);
795
-
796
- let overlayMessage: string | null = null;
797
- if (c.loadError) overlayMessage = `${messages.error} ${c.loadError}`;
798
- else if (c.info?.status === "error") overlayMessage = `${messages.error} ${c.info.error ?? ""}`;
799
- else if (c.viewError) overlayMessage = c.viewError;
800
- else if (!c.info || (c.info.status === "loading" && c.readableRows === 0)) overlayMessage = messages.loading;
801
- else if (c.rowCount === 0 && !c.viewPending) overlayMessage = messages.noRows;
802
-
803
- return (
804
- <div ref={rootRef} className={`vg-root${className ? ` ${className}` : ""}`} style={style} data-status={c.status}>
805
- {toolbar && (
806
- <Toolbar
807
- controller={c}
808
- messages={messages}
809
- onColumns={(button) => {
810
- const root = rootRef.current?.getBoundingClientRect();
811
- const rect = button.getBoundingClientRect();
812
- setOverlay({
813
- kind: "columns",
814
- anchor: { x: rect.right - (root?.left ?? 0) - 260, y: rect.top - (root?.top ?? 0), height: rect.height },
815
- });
816
- }}
817
- />
818
- )}
819
- {(c.viewPending || c.info?.status === "loading") && (
820
- <div
821
- className="vg-progress"
822
- role="progressbar"
823
- aria-label={messages.loading}
824
- aria-valuemin={0}
825
- aria-valuemax={100}
826
- aria-valuenow={c.info?.progress ? Math.round(c.info.progress * 100) : undefined}
827
- >
828
- <span
829
- style={c.info?.progress && !c.viewPending ? { width: `${Math.round(c.info.progress * 100)}%` } : undefined}
830
- data-indeterminate={c.viewPending || !c.info?.progress || undefined}
831
- />
832
- </div>
833
- )}
834
- <div ref={bodyRef} className="vg-body">
835
- <canvas ref={canvasRef} className="vg-canvas" />
836
- {/* biome-ignore lint/a11y/useSemanticElements: a canvas grid cannot use table elements; ARIA grid roles describe it. */}
837
- <div
838
- ref={scrollerRef}
839
- className="vg-scroller"
840
- role="grid"
841
- tabIndex={0}
842
- aria-label={props["aria-label"] ?? messages.gridLabel}
843
- aria-rowcount={c.rowCount + 1}
844
- aria-colcount={c.visible.length + 1}
845
- aria-multiselectable="true"
846
- aria-activedescendant={c.rowCount > 0 && activeSchema ? cellId : undefined}
847
- onPointerDown={onPointerDown}
848
- onPointerMove={onPointerMove}
849
- onPointerUp={endDrag}
850
- onPointerCancel={endDrag}
851
- onPointerLeave={onPointerLeave}
852
- onDoubleClick={onDoubleClick}
853
- onContextMenu={onContextMenu}
854
- onKeyDown={onKeyDown}
855
- >
856
- <div ref={sizerRef} className="vg-sizer" />
857
- {/*
858
- The canvas has no accessible content. Screen readers follow the active cell through
859
- aria-activedescendant on the grid; these hidden elements describe that cell and its column.
860
- Focus stays on the grid, so the rows and cells are not focusable themselves.
861
- */}
862
- {activeSchema && c.rowCount > 0 && (
863
- // biome-ignore lint/a11y/useSemanticElements: ARIA grid pattern over a canvas (see above).
864
- <div className="vg-sr-only" role="rowgroup">
865
- {/* biome-ignore lint/a11y/useSemanticElements: ARIA grid pattern over a canvas. */}
866
- {/* biome-ignore lint/a11y/useFocusableInteractive: focus stays on the grid (aria-activedescendant). */}
867
- <div role="row" aria-rowindex={1}>
868
- {/* biome-ignore lint/a11y/useSemanticElements: ARIA grid pattern over a canvas. */}
869
- {/* biome-ignore lint/a11y/useFocusableInteractive: focus stays on the grid (aria-activedescendant). */}
870
- <div role="columnheader" aria-colindex={active.col + 2}>
871
- {activeSchema.name}
872
- </div>
873
- </div>
874
- {/* biome-ignore lint/a11y/useSemanticElements: ARIA grid pattern over a canvas. */}
875
- {/* biome-ignore lint/a11y/useFocusableInteractive: focus stays on the grid (aria-activedescendant). */}
876
- <div role="row" aria-rowindex={active.row + 2}>
877
- {/* biome-ignore lint/a11y/useSemanticElements: ARIA grid pattern over a canvas. */}
878
- {/* biome-ignore lint/a11y/useFocusableInteractive: focus stays on the grid (aria-activedescendant). */}
879
- <div role="gridcell" id={cellId} aria-colindex={active.col + 2} aria-selected="true">
880
- {activeText}
881
- </div>
882
- </div>
883
- </div>
884
- )}
885
- </div>
886
- {overlayMessage && (
887
- <div
888
- className="vg-message"
889
- role={c.loadError || c.viewError || c.info?.status === "error" ? "alert" : "status"}
890
- >
891
- <span>{overlayMessage}</span>
892
- {(c.loadError || c.info?.status === "error") && (
893
- <button type="button" className="vg-button" onClick={() => controller.reload()}>
894
- {messages.retry}
895
- </button>
896
- )}
897
- </div>
898
- )}
899
- {findOpen && (
900
- <FindBar
901
- messages={messages}
902
- initial={c.findText}
903
- onFind={(text, direction, caseSensitive, signal) => controller.find(text, direction, caseSensitive, signal)}
904
- onClose={() => {
905
- setFindOpen(false);
906
- controller.findText = "";
907
- controller.requestRender();
908
- focusGrid();
909
- }}
910
- />
911
- )}
912
- {notice && (
913
- <div className="vg-toast" role="status">
914
- {notice}
915
- </div>
916
- )}
917
- </div>
918
- {statusBar && <StatusBar controller={c} messages={messages} />}
919
- {overlay?.kind === "columnMenu" && (
920
- <Popover
921
- anchor={overlay.anchor}
922
- container={rootRef}
923
- onClose={closeOverlay}
924
- returnFocus={scrollerRef}
925
- label={c.columnSchema(overlay.col)?.name ?? ""}
926
- role="menu"
927
- >
928
- <MenuList items={columnMenuItems(overlay.col)} onClose={closeOverlay} />
929
- </Popover>
930
- )}
931
- {overlay?.kind === "contextMenu" && (
932
- <Popover
933
- anchor={overlay.anchor}
934
- container={rootRef}
935
- onClose={closeOverlay}
936
- returnFocus={scrollerRef}
937
- label={messages.copy}
938
- role="menu"
939
- >
940
- <MenuList items={contextMenuItems(overlay.row, overlay.col)} onClose={closeOverlay} />
941
- </Popover>
942
- )}
943
- {overlay?.kind === "filter" && c.schema[overlay.column] && (
944
- <Popover
945
- anchor={overlay.anchor}
946
- container={rootRef}
947
- onClose={closeOverlay}
948
- returnFocus={scrollerRef}
949
- label={messages.filterBy(c.schema[overlay.column].name)}
950
- className="vg-popover--panel"
951
- >
952
- <FilterPanel
953
- column={overlay.column}
954
- schema={c.schema[overlay.column]}
955
- filter={c.filterOf(overlay.column)}
956
- messages={messages}
957
- formatter={c.formatter}
958
- count={count}
959
- load={loadDistinct}
960
- onApply={(filter) => {
961
- c.setFilter(overlay.column, filter);
962
- setOverlay(null);
963
- focusGrid();
964
- }}
965
- />
966
- </Popover>
967
- )}
968
- {overlay?.kind === "stats" && c.columnSchema(overlay.col) && (
969
- <Popover
970
- anchor={overlay.anchor}
971
- container={rootRef}
972
- onClose={closeOverlay}
973
- returnFocus={scrollerRef}
974
- label={messages.statistics}
975
- className="vg-popover--panel"
976
- >
977
- <StatsPanel
978
- schema={c.columnSchema(overlay.col) as NonNullable<ReturnType<typeof c.columnSchema>>}
979
- messages={messages}
980
- formatter={c.formatter}
981
- count={count}
982
- number={number}
983
- load={loadStats}
984
- />
985
- </Popover>
986
- )}
987
- {overlay?.kind === "inspector" && c.columnSchema(overlay.col) && (
988
- <Popover
989
- anchor={overlay.anchor}
990
- container={rootRef}
991
- onClose={closeOverlay}
992
- returnFocus={scrollerRef}
993
- label={messages.inspectCell}
994
- className="vg-popover--panel vg-popover--wide"
995
- >
996
- <CellInspector
997
- schema={c.columnSchema(overlay.col) as NonNullable<ReturnType<typeof c.columnSchema>>}
998
- messages={messages}
999
- location={messages.cell(count(overlay.row + 1), c.columnSchema(overlay.col)?.name ?? "")}
1000
- load={loadCell}
1001
- onCopy={(text) =>
1002
- void writeClipboard(Promise.resolve(text), null).then(
1003
- () => announce(messages.copied("1")),
1004
- (error: unknown) => announce(error instanceof Error ? error.message : String(error)),
1005
- )
1006
- }
1007
- />
1008
- </Popover>
1009
- )}
1010
- {overlay?.kind === "columns" && (
1011
- <Popover
1012
- anchor={overlay.anchor}
1013
- container={rootRef}
1014
- onClose={closeOverlay}
1015
- label={messages.columns}
1016
- className="vg-popover--panel"
1017
- >
1018
- <ColumnsPanel
1019
- columns={c.columns}
1020
- schema={c.schema}
1021
- messages={messages}
1022
- onToggle={(index, hidden) => c.setColumnHidden(index, hidden)}
1023
- onShowAll={() => c.showAllColumns()}
1024
- />
1025
- </Popover>
1026
- )}
1027
- </div>
1028
- );
1029
- });