vastgrid 0.1.1 → 0.2.1

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 +151 -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 +42 -17
  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 +169 -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 -68
  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 -22
  258. package/dist/components/filter-panel.d.ts.map +0 -1
  259. package/dist/components/filter-panel.js +0 -169
  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 -227
  274. package/dist/controller.d.ts.map +0 -1
  275. package/dist/controller.js +0 -845
  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 -704
  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 -180
  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 -293
  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 -945
  308. package/src/data-grid.tsx +0 -1035
  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
@@ -0,0 +1,744 @@
1
+ import {
2
+ type KeyboardEvent,
3
+ lazy,
4
+ type MouseEvent,
5
+ type PointerEvent,
6
+ type RefObject,
7
+ Suspense,
8
+ use,
9
+ useEffect,
10
+ useEffectEvent,
11
+ useId,
12
+ useLayoutEffect,
13
+ useRef,
14
+ useState,
15
+ } from "react";
16
+ import { browser } from "react-dom";
17
+ import { CommandError, type GridCommand, type RowKey } from "vastgrid-spec";
18
+ import type { Hit } from "../canvas/geometry.js";
19
+ import { GridCanvas } from "../canvas/grid-canvas.js";
20
+ import type { CellAction, CellHooks, ColumnGroup, GridCell } from "../canvas/hooks.js";
21
+ import { isApple, type KeyInput, keyCommand, typesText } from "../canvas/keys.js";
22
+ import { allState, checkedCount, checkState } from "../core/checked.js";
23
+ import type { GridStore } from "../core/store.js";
24
+ import { AccessibleRows, cellId } from "./accessible-rows.js";
25
+ import { CellEditor, type EditorControl } from "./cell-editor.js";
26
+ import type { CellMenuTarget } from "./cell-menu.js";
27
+ import type { MenuTarget } from "./column-menu.js";
28
+ import { LocaleContext, SaveContext } from "./context.js";
29
+ import { useGridSelector, useGridStore } from "./hooks.js";
30
+
31
+ // The menus load when they first open (§10 of react.md).
32
+ const ColumnMenu = lazy(() => import("./column-menu.js").then((module) => ({ default: module.ColumnMenu })));
33
+ const CellMenu = lazy(() => import("./cell-menu.js").then((module) => ({ default: module.CellMenu })));
34
+
35
+ interface Drag {
36
+ kind: "cells" | "rows" | "resize" | "move";
37
+ pointer: number;
38
+ /** The last cell or row the pointer was over, so that moves within it dispatch nothing. */
39
+ last: string;
40
+ /** For a resize: the column, its width and the pointer's x when the drag started. */
41
+ column: number;
42
+ width: number;
43
+ x: number;
44
+ /** For a move: the columns dragged, whether the pointer moved enough to drag, and where they would go. */
45
+ names?: string[];
46
+ moving?: boolean;
47
+ before?: string | null;
48
+ /** Shift was held when a header was pressed: a click adds a sort key. */
49
+ shift?: boolean;
50
+ }
51
+
52
+ /** The members of a keyboard event that the grid's keys read. */
53
+ function pick(event: KeyInput): KeyInput {
54
+ return {
55
+ key: event.key,
56
+ shiftKey: event.shiftKey,
57
+ altKey: event.altKey,
58
+ ctrlKey: event.ctrlKey,
59
+ metaKey: event.metaKey,
60
+ };
61
+ }
62
+
63
+ /** Writes the output of `copy` (§6.6) into a clipboard. */
64
+ function writeClipboard(data: DataTransfer, output: unknown) {
65
+ const { tsv, html } = output as { tsv: string; html: string };
66
+ data.setData("text/plain", tsv);
67
+ data.setData("text/html", html);
68
+ }
69
+
70
+ async function writeClipboardLater(output: unknown) {
71
+ const { tsv, html } = output as { tsv: string; html: string };
72
+ const clipboard = typeof navigator === "undefined" ? undefined : navigator.clipboard;
73
+ if (!clipboard) return;
74
+ if (typeof ClipboardItem === "function" && clipboard.write) {
75
+ await clipboard.write([
76
+ new ClipboardItem({
77
+ "text/plain": new Blob([tsv], { type: "text/plain" }),
78
+ "text/html": new Blob([html], { type: "text/html" }),
79
+ }),
80
+ ]);
81
+ } else {
82
+ await clipboard.writeText(tsv);
83
+ }
84
+ }
85
+
86
+ /** Copies the selection: at once when its rows are read, after reading them otherwise. */
87
+ function useClipboard(store: GridStore, scroller: RefObject<HTMLDivElement | null>, announce: (text: string) => void) {
88
+ const { messages, number } = use(LocaleContext);
89
+ const report = useEffectEvent((output: unknown, failure: string | null) => {
90
+ announce(failure === null ? messages.copied(number.format(countCells(output))) : messages.failed(failure));
91
+ });
92
+ useEffect(() => {
93
+ const element = scroller.current;
94
+ if (!element) return;
95
+ // Without selected text, browsers disable copying unless "beforecopy" is cancelled.
96
+ const onBeforeCopy = (event: Event) => {
97
+ if (document.activeElement === element) event.preventDefault();
98
+ };
99
+ const onCopy = (event: ClipboardEvent) => {
100
+ if (document.activeElement !== element || !event.clipboardData) return;
101
+ event.preventDefault();
102
+ const outcome = store.dispatchSync({ command: "copy" });
103
+ if (outcome.ok) {
104
+ writeClipboard(event.clipboardData, outcome.output);
105
+ report(outcome.output, null);
106
+ return;
107
+ }
108
+ if (!(outcome.error instanceof CommandError) || outcome.error.reason !== "not_loaded") {
109
+ report(null, outcome.error.message);
110
+ return;
111
+ }
112
+ // The rows are read first; the clipboard is then written through the asynchronous API.
113
+ void store.dispatch({ command: "copy" }).then(async (later) => {
114
+ if (!later.ok) {
115
+ report(null, later.error.message);
116
+ return;
117
+ }
118
+ await writeClipboardLater(later.output);
119
+ report(later.output, null);
120
+ });
121
+ };
122
+ document.addEventListener("beforecopy", onBeforeCopy);
123
+ // Pasting writes the clipboard into the selection (§6.5.1).
124
+ const onPaste = (event: ClipboardEvent) => {
125
+ if (document.activeElement !== element || !event.clipboardData) return;
126
+ event.preventDefault();
127
+ const text = event.clipboardData.getData("text/plain");
128
+ const html = event.clipboardData.getData("text/html");
129
+ const clipboard = text ? { text } : html ? { html } : {};
130
+ void store.dispatch({ command: "edit.paste" }, { clipboard }).then((outcome) => {
131
+ if (!outcome.ok) report(null, outcome.error.message);
132
+ });
133
+ };
134
+ document.addEventListener("copy", onCopy);
135
+ document.addEventListener("paste", onPaste);
136
+ return () => {
137
+ document.removeEventListener("beforecopy", onBeforeCopy);
138
+ document.removeEventListener("copy", onCopy);
139
+ document.removeEventListener("paste", onPaste);
140
+ };
141
+ }, [store, scroller]);
142
+ }
143
+
144
+ /** The cells of a copy: its TSV's fields. */
145
+ function countCells(output: unknown) {
146
+ const { tsv } = output as { tsv: string };
147
+ if (tsv === "") return 0;
148
+ return tsv.split("\n").reduce((count, line) => count + line.split("\t").length, 0);
149
+ }
150
+
151
+ /** A click or Space on a cell that is a link or a button (`cellAction`). */
152
+ export interface CellActionEvent extends GridCell {
153
+ action: CellAction;
154
+ /** The key of the cell's row (§5.3), when it is read. */
155
+ key: RowKey | undefined;
156
+ }
157
+
158
+ export interface GridBodyProps {
159
+ rowHeight?: number;
160
+ headerHeight?: number;
161
+ /** A check box for each row in the gutter (§5.2.1 of the specification). */
162
+ checkboxes?: boolean;
163
+ columnGroups?: readonly ColumnGroup[];
164
+ groupHeaderHeight?: number;
165
+ wrapLines?: number;
166
+ hooks: CellHooks;
167
+ onCellAction?: (event: CellActionEvent) => void;
168
+ /** Opens the find bar (⌘F or Ctrl+F). */
169
+ onFind?: () => void;
170
+ label: string;
171
+ /** Receives the scroller, which has the focus of the grid. */
172
+ scrollerRef: RefObject<HTMLDivElement | null>;
173
+ announce: (text: string) => void;
174
+ }
175
+
176
+ /**
177
+ * The canvas and the scroller over it (§6 of react.md): keys and pointers become commands, the canvas draws
178
+ * the store without rendering React, and the accessibility rows follow when scrolling pauses. Rendered only in
179
+ * the browser: on the server, the nearest Suspense boundary sends its fallback, the first rows as HTML.
180
+ */
181
+ export function GridBody({
182
+ rowHeight,
183
+ headerHeight,
184
+ checkboxes = false,
185
+ columnGroups,
186
+ groupHeaderHeight,
187
+ wrapLines,
188
+ hooks,
189
+ onCellAction,
190
+ onFind,
191
+ label,
192
+ scrollerRef,
193
+ announce,
194
+ }: GridBodyProps) {
195
+ use(browser("The grid draws on a canvas, which only the browser has."));
196
+ const store = useGridStore();
197
+ const [canvas] = useState(() => new GridCanvas({ rowHeight, headerHeight }));
198
+ const { geometry } = canvas;
199
+ const rootRef = useRef<HTMLDivElement>(null);
200
+ const canvasRef = useRef<HTMLCanvasElement>(null);
201
+ const sizerRef = useRef<HTMLDivElement>(null);
202
+ const drag = useRef<Drag | null>(null);
203
+ const editor = useRef<EditorControl>(null);
204
+ const { messages, locale, number } = use(LocaleContext);
205
+ const saving = use(SaveContext);
206
+ const follow = useRef<{ frame: number; x: number; y: number } | null>(null);
207
+ const id = useId();
208
+ const rowCount = useGridSelector((snapshot) => snapshot.view?.rowCount ?? 0);
209
+ const columns = useGridSelector((snapshot) => snapshot.displayed);
210
+ const busy = useGridSelector((snapshot) => snapshot.view === null || snapshot.pending !== null);
211
+ const active = useGridSelector((snapshot) => snapshot.session.active);
212
+ const [menu, setMenu] = useState<MenuTarget | null>(null);
213
+ const totalsRow = useGridSelector(
214
+ (snapshot) =>
215
+ !snapshot.document.plan?.aggregate && snapshot.displayed.some((item) => item.setting.total !== undefined),
216
+ );
217
+ const pressed = useRef(false);
218
+ const later = useRef<(() => void) | null>(null);
219
+ const [cellMenu, setCellMenu] = useState<CellMenuTarget | null>(null);
220
+
221
+ useLayoutEffect(() => {
222
+ canvas.setSizes(rowHeight, headerHeight);
223
+ }, [canvas, rowHeight, headerHeight]);
224
+
225
+ useLayoutEffect(() => {
226
+ canvas.setLocale(locale);
227
+ }, [canvas, locale]);
228
+
229
+ useLayoutEffect(() => {
230
+ canvas.setCheckboxes(checkboxes);
231
+ }, [canvas, checkboxes]);
232
+
233
+ useLayoutEffect(() => {
234
+ canvas.setWrapLines(wrapLines);
235
+ }, [canvas, wrapLines]);
236
+
237
+ useLayoutEffect(() => {
238
+ canvas.setColumnGroups(columnGroups, groupHeaderHeight);
239
+ }, [canvas, columnGroups, groupHeaderHeight]);
240
+
241
+ const { cellText, cellStyle, drawCell, cellAction } = hooks;
242
+ useLayoutEffect(() => {
243
+ canvas.setHooks({ cellText, cellStyle, drawCell, cellAction });
244
+ }, [canvas, cellText, cellStyle, drawCell, cellAction]);
245
+
246
+ useLayoutEffect(() => {
247
+ const root = rootRef.current;
248
+ const element = canvasRef.current;
249
+ const scroller = scrollerRef.current;
250
+ const sizer = sizerRef.current;
251
+ if (!root || !element || !scroller || !sizer) return;
252
+ const detach = canvas.attach({ root, canvas: element, scroller, sizer }, store);
253
+ // The first frame is drawn before the browser paints, with the rows the store already has.
254
+ canvas.draw();
255
+ return () => {
256
+ if (follow.current) cancelAnimationFrame(follow.current.frame);
257
+ follow.current = null;
258
+ detach();
259
+ };
260
+ }, [canvas, store, scrollerRef]);
261
+
262
+ useClipboard(store, scrollerRef, announce);
263
+
264
+ const point = (event: PointerEvent<HTMLDivElement>) => {
265
+ const rect = event.currentTarget.getBoundingClientRect();
266
+ return { x: event.clientX - rect.left, y: event.clientY - rect.top };
267
+ };
268
+
269
+ const nameOf = (column: number) => store.getSnapshot().displayed[column]?.column.name;
270
+
271
+ const onPointerDown = (event: PointerEvent<HTMLDivElement>) => {
272
+ pressed.current = event.buttons !== 0;
273
+ if (event.button !== 0) return;
274
+ const scroller = event.currentTarget;
275
+ const { x, y } = point(event);
276
+ // Clicks on the scroll bars scroll.
277
+ if (x > scroller.clientWidth || y > scroller.clientHeight) return;
278
+ scroller.focus({ preventScroll: true });
279
+ event.preventDefault();
280
+ const hit: Hit = geometry.hit(x, y);
281
+ const primary = isApple() ? event.metaKey : event.ctrlKey;
282
+ const mode = event.shiftKey ? "extend" : primary ? "add" : "replace";
283
+ const start = (kind: Drag["kind"], last: string, extra: Partial<Drag> = {}) => {
284
+ drag.current = { kind, pointer: event.pointerId, last, column: 0, width: 0, x: event.clientX, ...extra };
285
+ scroller.setPointerCapture(event.pointerId);
286
+ };
287
+ if (hit.area === "cell") {
288
+ const column = nameOf(hit.column);
289
+ if (column === undefined) return;
290
+ void store.dispatch({ command: "select", row: hit.row, column, mode });
291
+ start("cells", `${hit.row}:${hit.column}`);
292
+ } else if (hit.area === "gutter" && hit.check) {
293
+ toggleCheck(hit.row);
294
+ } else if (hit.area === "corner" && hit.check) {
295
+ const { session, document, view } = store.getSnapshot();
296
+ const all = allState(session.checked, document.plan, view?.rowCount ?? 0);
297
+ // The header's box checks every row of the view, or none once they all are.
298
+ dispatchCheck(all === "all" ? { command: "check.clear" } : { command: "check.all" });
299
+ } else if (hit.area === "group" && hit.level !== undefined) {
300
+ // A group's header selects its columns.
301
+ const span = canvas.groupSpan(hit.column, hit.level);
302
+ const from = span && nameOf(span[0]);
303
+ const to = span && nameOf(span[1]);
304
+ if (from && to && span) {
305
+ void store.dispatch({ command: "select.columns", from, to, mode });
306
+ // Dragging a group's header moves its columns.
307
+ const names = columns.slice(span[0], span[1] + 1).map((item) => item.column.name);
308
+ start("move", "", { column: span[0], names });
309
+ }
310
+ } else if (hit.area === "gutter") {
311
+ void store.dispatch({ command: "select.rows", from: hit.row, to: hit.row, mode });
312
+ start("rows", String(hit.row));
313
+ } else if (hit.area === "corner") {
314
+ void store.dispatch({ command: "select.all" });
315
+ } else if (hit.area === "header") {
316
+ const column = nameOf(hit.column);
317
+ if (column === undefined) return;
318
+ // The menu opens on the click, after the pointer is up: a popover shown before closes as the pointer goes up.
319
+ if (hit.menu) return;
320
+ if (hit.edge) start("resize", "", { column: hit.column, width: geometry.columnWidth(hit.column) });
321
+ // A click sorts by the column; dragging it moves it (with the other columns selected whole with it).
322
+ else start("move", "", { column: hit.column, names: movedWith(column), shift: event.shiftKey });
323
+ }
324
+ };
325
+
326
+ /** Extends the selection being dragged to the cell or the row at a point, kept within the body. */
327
+ const extendTo = (scroller: HTMLElement, current: Drag, x: number, y: number) => {
328
+ const hit = geometry.hit(
329
+ Math.max(geometry.gutterWidth + 1, Math.min(scroller.clientWidth - 1, x)),
330
+ Math.max(geometry.headerHeight + 1, Math.min(geometry.bottom - 1, y)),
331
+ );
332
+ if (current.kind === "cells" && hit.area === "cell") {
333
+ const key = `${hit.row}:${hit.column}`;
334
+ const column = nameOf(hit.column);
335
+ if (key === current.last || column === undefined) return;
336
+ current.last = key;
337
+ void store.dispatch({ command: "select", row: hit.row, column, mode: "extend" });
338
+ } else if (current.kind === "rows" && (hit.area === "cell" || hit.area === "gutter")) {
339
+ const key = String(hit.row);
340
+ if (key === current.last) return;
341
+ current.last = key;
342
+ const anchor = store.getSnapshot().session.anchor?.row ?? hit.row;
343
+ void store.dispatch({ command: "select.rows", from: anchor, to: hit.row, mode: "extend" });
344
+ }
345
+ };
346
+
347
+ const stopFollowing = () => {
348
+ if (follow.current) cancelAnimationFrame(follow.current.frame);
349
+ follow.current = null;
350
+ };
351
+
352
+ /** While a drag is beyond the body, scrolls towards the pointer every frame and extends the selection. */
353
+ const followPointer = (scroller: HTMLElement, x: number, y: number) => {
354
+ const beyondY =
355
+ y < geometry.headerHeight ? y - geometry.headerHeight : y > geometry.bottom ? y - geometry.bottom : 0;
356
+ const beyondX =
357
+ x < geometry.gutterWidth ? x - geometry.gutterWidth : x > scroller.clientWidth ? x - scroller.clientWidth : 0;
358
+ if (beyondX === 0 && beyondY === 0) {
359
+ stopFollowing();
360
+ return;
361
+ }
362
+ if (follow.current) {
363
+ follow.current.x = x;
364
+ follow.current.y = y;
365
+ return;
366
+ }
367
+ const state = { frame: 0, x, y };
368
+ follow.current = state;
369
+ const step = () => {
370
+ const current = drag.current;
371
+ if (follow.current !== state || !current) return;
372
+ const dy =
373
+ state.y < geometry.headerHeight ? state.y - geometry.headerHeight : Math.max(0, state.y - geometry.bottom);
374
+ const dx =
375
+ state.x < geometry.gutterWidth ? state.x - geometry.gutterWidth : Math.max(0, state.x - scroller.clientWidth);
376
+ // Faster the farther the pointer is.
377
+ scroller.scrollTop += Math.sign(dy) * Math.min(80, Math.abs(dy) / 2 + 4);
378
+ if (current.kind === "cells") scroller.scrollLeft += Math.sign(dx) * Math.min(80, Math.abs(dx) / 2 + 4);
379
+ extendTo(scroller, current, state.x, state.y);
380
+ state.frame = requestAnimationFrame(step);
381
+ };
382
+ state.frame = requestAnimationFrame(step);
383
+ };
384
+
385
+ const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {
386
+ const current = drag.current;
387
+ const { x, y } = point(event);
388
+ if (!current) {
389
+ const hit = geometry.hit(x, y);
390
+ const action = hit.area === "cell" ? canvas.actionAt(hit.row, hit.column)?.action : undefined;
391
+ event.currentTarget.style.cursor =
392
+ hit.area === "header" && hit.edge ? "col-resize" : action ? (action.cursor ?? "pointer") : "";
393
+ return;
394
+ }
395
+ if (current.kind === "resize") {
396
+ canvas.previewWidth(current.column, current.width + event.clientX - current.x);
397
+ return;
398
+ }
399
+ if (current.kind === "move") {
400
+ if (!current.moving && Math.abs(event.clientX - current.x) < 5) return;
401
+ current.moving = true;
402
+ const target = dropAt(x, current.names ?? []);
403
+ current.before = target?.before;
404
+ canvas.previewDrop(target ? target.x : null);
405
+ return;
406
+ }
407
+ extendTo(event.currentTarget, current, x, y);
408
+ followPointer(event.currentTarget, x, y);
409
+ };
410
+
411
+ const onPointerUp = (event: PointerEvent<HTMLDivElement>) => {
412
+ pressed.current = false;
413
+ if (later.current && event.type === "pointerup") later.current();
414
+ later.current = null;
415
+ const current = drag.current;
416
+ if (!current || current.pointer !== event.pointerId) return;
417
+ drag.current = null;
418
+ stopFollowing();
419
+ event.currentTarget.releasePointerCapture(event.pointerId);
420
+ if (current.kind === "move") {
421
+ canvas.previewDrop(null);
422
+ const name = nameOf(current.column);
423
+ if (!current.moving) {
424
+ // A click on a column's name sorts by it; a click on a group's header only selected its columns.
425
+ if (current.names?.length === 1 && name !== undefined && event.type === "pointerup") {
426
+ void store.dispatch({ command: "sort.toggle", column: name, ...(current.shift ? { add: true } : {}) });
427
+ }
428
+ return;
429
+ }
430
+ if (current.before !== undefined && current.names && event.type === "pointerup") {
431
+ void store.dispatch({ command: "column.move", names: current.names, before: current.before });
432
+ }
433
+ return;
434
+ }
435
+ if (current.kind !== "resize") return;
436
+ const name = nameOf(current.column);
437
+ const width = Math.max(40, Math.min(100_000, Math.round(current.width + event.clientX - current.x)));
438
+ if (name !== undefined) {
439
+ void store
440
+ .dispatch({ command: "column.resize", name, width })
441
+ .then(() => canvas.previewWidth(current.column, null));
442
+ } else {
443
+ canvas.previewWidth(current.column, null);
444
+ }
445
+ };
446
+
447
+ /** Dispatches a command of checked rows, and announces how many rows are checked, or why it failed. */
448
+ const dispatchCheck = (command: GridCommand) => {
449
+ void store.dispatch(command).then((outcome) => {
450
+ if (!outcome.ok) {
451
+ announce(
452
+ outcome.error instanceof CommandError && outcome.error.reason === "unknown_state"
453
+ ? messages.checkedUnknown
454
+ : messages.failed(outcome.error.message),
455
+ );
456
+ return;
457
+ }
458
+ const { session, document, view } = store.getSnapshot();
459
+ const count = checkedCount(session.checked, document.plan, view?.rowCount ?? 0);
460
+ announce(count === null ? messages.checkedUnknown : messages.checkedRows(number.format(count)));
461
+ });
462
+ };
463
+
464
+ /** Checks a row of the view, or unchecks it when it is checked. */
465
+ const toggleCheck = (row: number) => {
466
+ const key = store.rowKey(row);
467
+ if (!key) return;
468
+ const { session, document } = store.getSnapshot();
469
+ const state = checkState(session.checked, document.plan, key);
470
+ dispatchCheck({ command: state === "checked" ? "uncheck" : "check", keys: [key] });
471
+ };
472
+
473
+ /** The columns a header drags: the column, or every column of a selection of whole columns that holds it. */
474
+ const movedWith = (name: string): string[] => {
475
+ const last = store.getSnapshot().session.selection?.at(-1);
476
+ if (last && last.rows === "all" && last.columns !== "all" && last.columns.includes(name)) {
477
+ return columns.map((item) => item.column.name).filter((item) => (last.columns as string[]).includes(item));
478
+ }
479
+ return [name];
480
+ };
481
+
482
+ /**
483
+ * Where dragged columns would go for a pointer at `x`: before the column under it (after it past its middle), as
484
+ * `column.move`'s `before` (null after the last), and the x of the line that shows it; undefined where they stay.
485
+ */
486
+ const dropAt = (x: number, names: readonly string[]): { before: string | null; x: number } | undefined => {
487
+ if (columns.length === 0) return undefined;
488
+ let at = geometry.columnAt(Math.max(geometry.gutterWidth, x));
489
+ if (at < 0) at = x < geometry.gutterWidth ? 0 : columns.length - 1;
490
+ const middle = geometry.columnX(at) + geometry.columnWidth(at) / 2;
491
+ const index = x < middle ? at : at + 1;
492
+ const before = index < columns.length ? columns[index].column.name : null;
493
+ const moved = names.map((name) => columns.findIndex((item) => item.column.name === name));
494
+ // Before one of the moved columns, or right after the last of them, they stay.
495
+ if (before !== null && names.includes(before)) return undefined;
496
+ if (moved.length > 0 && index === Math.max(...moved) + 1 && moved.every((item, i) => item === moved[0] + i)) {
497
+ return undefined;
498
+ }
499
+ const lineX = index < columns.length ? geometry.columnX(index) : geometry.columnX(at) + geometry.columnWidth(at);
500
+ return { before, x: lineX };
501
+ };
502
+
503
+ /** Opens the menu of a column under its header. */
504
+ const openMenu = (column: string, index: number) => {
505
+ const scroller = scrollerRef.current;
506
+ if (!scroller) return;
507
+ const rect = scroller.getBoundingClientRect();
508
+ setMenu({
509
+ column,
510
+ x: rect.left + Math.max(0, geometry.columnX(index) + geometry.columnWidth(index) - 200),
511
+ y: rect.top + geometry.headerHeight,
512
+ });
513
+ };
514
+
515
+ const onClick = (event: MouseEvent<HTMLDivElement>) => {
516
+ const rect = event.currentTarget.getBoundingClientRect();
517
+ const hit = geometry.hit(event.clientX - rect.left, event.clientY - rect.top);
518
+ const column = nameOf(hit.column);
519
+ if (hit.area === "header" && hit.menu && column !== undefined) openMenu(column, hit.column);
520
+ // A click on a link or a button of a cell acts, after selecting the cell.
521
+ if (hit.area === "cell" && !event.shiftKey && !event.metaKey && !event.ctrlKey) act(hit.row, hit.column);
522
+ };
523
+
524
+ /** Calls `onCellAction` for a cell that is a link or a button. */
525
+ const act = (row: number, column: number) => {
526
+ const found = canvas.actionAt(row, column);
527
+ if (!found || !onCellAction) return false;
528
+ onCellAction({ ...found.cell, action: found.action, key: store.rowKey(row) });
529
+ return true;
530
+ };
531
+
532
+ const onContextMenu = (event: MouseEvent<HTMLDivElement>) => {
533
+ const rect = event.currentTarget.getBoundingClientRect();
534
+ const hit = geometry.hit(event.clientX - rect.left, event.clientY - rect.top);
535
+ const column = nameOf(hit.column);
536
+ if ((hit.area !== "header" && hit.area !== "cell") || column === undefined) return;
537
+ event.preventDefault();
538
+ const at = { x: event.clientX, y: event.clientY };
539
+ let open: () => void;
540
+ if (hit.area === "header") {
541
+ open = () => setMenu({ column, ...at });
542
+ } else {
543
+ // A cell outside the selection is selected first, as spreadsheets do.
544
+ if (!isSelected(hit.row, column)) void store.dispatch({ command: "select", row: hit.row, column });
545
+ open = () => setCellMenu({ row: hit.row, column, ...at });
546
+ }
547
+ // Where the menu comes as the button goes down (macOS), it opens once the button is up.
548
+ if (pressed.current) later.current = open;
549
+ else open();
550
+ };
551
+
552
+ /** Whether a cell is in the selection. */
553
+ const isSelected = (row: number, column: string) => {
554
+ const { selection, active } = store.getSnapshot().session;
555
+ if (!selection) return active?.row === row && active.column === column;
556
+ return selection.some(
557
+ (range) =>
558
+ (range.rows === "all" || (row >= range.rows[0] && row < range.rows[1])) &&
559
+ (range.columns === "all" || range.columns.includes(column)),
560
+ );
561
+ };
562
+
563
+ /** Copies the selection from a menu, through the asynchronous clipboard. */
564
+ const copySelection = () => {
565
+ void store.dispatch({ command: "copy" }).then(async (outcome) => {
566
+ if (!outcome.ok) {
567
+ announce(messages.failed(outcome.error.message));
568
+ return;
569
+ }
570
+ try {
571
+ await writeClipboardLater(outcome.output);
572
+ announce(messages.copied(number.format(countCells(outcome.output))));
573
+ } catch (error) {
574
+ announce(messages.failed(error instanceof Error ? error.message : String(error)));
575
+ }
576
+ });
577
+ };
578
+
579
+ /** Opens the cell menu of the active cell, under it. */
580
+ const openCellMenu = () => {
581
+ const scroller = scrollerRef.current;
582
+ const active = store.getSnapshot().session.active;
583
+ if (!scroller || !active) return;
584
+ const index = columns.findIndex((item) => item.column.name === active.column);
585
+ if (index < 0) return;
586
+ const box = geometry.cellBox(active.row, index);
587
+ const rect = scroller.getBoundingClientRect();
588
+ setCellMenu({
589
+ row: active.row,
590
+ column: active.column,
591
+ x: rect.left + box.left,
592
+ y: rect.top + box.top + box.height,
593
+ });
594
+ };
595
+
596
+ const closeCellMenu = () => {
597
+ setCellMenu(null);
598
+ scrollerRef.current?.focus({ preventScroll: true });
599
+ };
600
+
601
+ const closeMenu = () => {
602
+ setMenu(null);
603
+ scrollerRef.current?.focus({ preventScroll: true });
604
+ };
605
+
606
+ const onDoubleClick = (event: MouseEvent<HTMLDivElement>) => {
607
+ const rect = event.currentTarget.getBoundingClientRect();
608
+ const hit = geometry.hit(event.clientX - rect.left, event.clientY - rect.top);
609
+ const name = nameOf(hit.column);
610
+ // Double-clicking a column's edge fits it to its values again; double-clicking a cell edits it.
611
+ if (hit.area === "header" && hit.edge && name !== undefined)
612
+ void store.dispatch({ command: "column.resize", name, width: null });
613
+ else if (hit.area === "cell") startEditing(undefined);
614
+ };
615
+
616
+ /**
617
+ * Opens the editor on the active cell (`edit.begin`, §6.5): with its value, or empty for a key that types text,
618
+ * whose text, an IME's included, then goes into the editor, which takes the focus before the browser handles
619
+ * the key. Tells why when the cell is not written.
620
+ */
621
+ const startEditing = (text: string | undefined) => {
622
+ const outcome = store.dispatchSync(
623
+ text === undefined ? { command: "edit.begin" } : { command: "edit.begin", text },
624
+ );
625
+ if (outcome.ok) {
626
+ if (store.getSnapshot().session.editor) editor.current?.open();
627
+ return true;
628
+ }
629
+ if (outcome.error instanceof CommandError && outcome.error.reason === "read_only") announce(messages.readOnly);
630
+ else announce(messages.failed(outcome.error.message));
631
+ return false;
632
+ };
633
+
634
+ const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
635
+ if (event.target !== event.currentTarget) return;
636
+ // While an IME composes, keys are its own (the editor takes them, §6.6 of react.md).
637
+ const composing = event.nativeEvent.isComposing || event.keyCode === 229;
638
+ // Space acts on a link or a button of the active cell; otherwise it checks the active row, or unchecks it.
639
+ if (!composing && event.key === " " && !event.altKey && !event.ctrlKey && !event.metaKey && !event.shiftKey) {
640
+ const active = store.getSnapshot().session.active;
641
+ if (!active) return;
642
+ const column = columns.findIndex((item) => item.column.name === active.column);
643
+ if (act(active.row, column)) {
644
+ event.preventDefault();
645
+ return;
646
+ }
647
+ if (!checkboxes) return;
648
+ event.preventDefault();
649
+ toggleCheck(active.row);
650
+ return;
651
+ }
652
+ // ⌘F or Ctrl+F opens the find bar, when the engine finds.
653
+ if ((isApple() ? event.metaKey : event.ctrlKey) && (event.key === "f" || event.key === "F") && !event.altKey) {
654
+ if (!onFind || store.getSnapshot().table?.capabilities.find !== true) return;
655
+ event.preventDefault();
656
+ onFind();
657
+ return;
658
+ }
659
+ // Shift+F10 and the context menu key open the menu of the active cell.
660
+ if (event.key === "ContextMenu" || (event.key === "F10" && event.shiftKey)) {
661
+ event.preventDefault();
662
+ openCellMenu();
663
+ return;
664
+ }
665
+ // Alt+Enter opens the detail panel of the active row, or closes it.
666
+ if (!composing && event.altKey && event.key === "Enter" && !event.ctrlKey && !event.metaKey && !event.shiftKey) {
667
+ event.preventDefault();
668
+ const open = store.getSnapshot().session.detail?.open === true;
669
+ void store.dispatch({ command: open ? "detail.close" : "detail.open" });
670
+ return;
671
+ }
672
+ // Alt+↓ opens the menu of the active cell's column.
673
+ if (event.altKey && event.key === "ArrowDown" && !event.ctrlKey && !event.metaKey) {
674
+ const column = store.getSnapshot().session.active?.column;
675
+ const index = columns.findIndex((item) => item.column.name === column);
676
+ if (column === undefined || index < 0) return;
677
+ event.preventDefault();
678
+ openMenu(column, index);
679
+ return;
680
+ }
681
+ if (typesText({ ...pick(event), keyCode: event.keyCode, isComposing: event.nativeEvent.isComposing })) {
682
+ // Not prevented: the key's text goes into the editor that now has the focus.
683
+ if (!startEditing("")) event.preventDefault();
684
+ return;
685
+ }
686
+ const command = keyCommand(event, isApple());
687
+ if (!command) return;
688
+ if (command.command === "save") {
689
+ // Without saving, the key is the browser's.
690
+ if (!saving || store.getSnapshot().table?.capabilities.save !== true) return;
691
+ event.preventDefault();
692
+ saving.save();
693
+ return;
694
+ }
695
+ event.preventDefault();
696
+ void store.dispatch(command);
697
+ };
698
+
699
+ // The accessibility rows always hold the active cell (§6.4).
700
+ const activeColumn = active ? columns.findIndex((item) => item.column.name === active.column) : 0;
701
+ const activeRow = active?.row ?? 0;
702
+ const activeShown = rowCount > 0 && activeColumn >= 0;
703
+
704
+ return (
705
+ <div className="vg-body" ref={rootRef}>
706
+ <canvas className="vg-canvas" ref={canvasRef} />
707
+ {/* biome-ignore lint/a11y/useSemanticElements: a canvas grid cannot use table elements; ARIA grid roles describe it. */}
708
+ <div
709
+ className="vg-scroller"
710
+ ref={scrollerRef}
711
+ role="grid"
712
+ tabIndex={0}
713
+ aria-label={label}
714
+ aria-rowcount={rowCount + 1 + (totalsRow ? 1 : 0)}
715
+ aria-colcount={columns.length}
716
+ aria-multiselectable="true"
717
+ aria-busy={busy}
718
+ aria-activedescendant={activeShown ? cellId(id, activeRow, activeColumn) : undefined}
719
+ onKeyDown={onKeyDown}
720
+ onPointerDown={onPointerDown}
721
+ onPointerMove={onPointerMove}
722
+ onPointerUp={onPointerUp}
723
+ onPointerCancel={onPointerUp}
724
+ onDoubleClick={onDoubleClick}
725
+ onClick={onClick}
726
+ onContextMenu={onContextMenu}
727
+ >
728
+ <div className="vg-sizer" ref={sizerRef} />
729
+ <AccessibleRows canvas={canvas} id={id} />
730
+ </div>
731
+ <CellEditor canvas={canvas} scrollerRef={scrollerRef} announce={announce} ref={editor} />
732
+ {menu && (
733
+ <Suspense fallback={null}>
734
+ <ColumnMenu target={menu} onClose={closeMenu} />
735
+ </Suspense>
736
+ )}
737
+ {cellMenu && (
738
+ <Suspense fallback={null}>
739
+ <CellMenu target={cellMenu} onClose={closeCellMenu} copy={copySelection} />
740
+ </Suspense>
741
+ )}
742
+ </div>
743
+ );
744
+ }