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
@@ -0,0 +1,286 @@
1
+ import {
2
+ type ComponentType,
3
+ type CSSProperties,
4
+ lazy,
5
+ type ReactNode,
6
+ type Ref,
7
+ Suspense,
8
+ use,
9
+ useEffect,
10
+ useEffectEvent,
11
+ useImperativeHandle,
12
+ useLayoutEffect,
13
+ useRef,
14
+ useState,
15
+ useSyncExternalStore,
16
+ } from "react";
17
+ import type { GridDocument, GridSession } from "vastgrid-spec";
18
+ import type { CellHooks, ColumnGroup } from "../canvas/hooks.js";
19
+ import type { Transport } from "../core/client.js";
20
+ import { createGridStore, type GridStore } from "../core/store.js";
21
+ import type { GridError, InitialView, OpenOptions } from "../core/types.js";
22
+ import { GridContext, LocaleContext, SaveContext } from "./context.js";
23
+ import type { DetailPanelProps } from "./detail-panel.js";
24
+ import { type CellActionEvent, GridBody } from "./grid-body.js";
25
+ import { type GridMessages, messagesFor } from "./messages.js";
26
+ import { useSaveAction } from "./save.js";
27
+ import { StaticRows } from "./static-rows.js";
28
+ import { StatusBar } from "./status-bar.js";
29
+ import { Toolbar } from "./toolbar.js";
30
+
31
+ // The find bar and the detail panel load when they first open (§10 of react.md).
32
+ const FindBar = lazy(() => import("./find-bar.js").then((module) => ({ default: module.FindBar })));
33
+ const DetailPanel = lazy(() => import("./detail-panel.js").then((module) => ({ default: module.DetailPanel })));
34
+
35
+ /** What `ref` gives (§5.5 of react.md). */
36
+ export interface DataGridHandle {
37
+ store: GridStore;
38
+ dispatch: GridStore["dispatch"];
39
+ /** Moves the focus to the grid. */
40
+ focus(): void;
41
+ }
42
+
43
+ export interface DataGridProps {
44
+ /** Where the rows come from, with `table`; a new transport or table makes a new store. */
45
+ transport?: Transport;
46
+ /** The `ref` of the table to open (§8.2). */
47
+ table?: string;
48
+ /** The `read` options of `open`. */
49
+ open?: OpenOptions;
50
+ /** A store made outside, instead of `transport` and `table`. */
51
+ store?: GridStore;
52
+ /** The view and its columns (§5.1), controlled. */
53
+ document?: GridDocument;
54
+ defaultDocument?: GridDocument;
55
+ onDocumentChange?: (document: GridDocument) => void;
56
+ /** Where the person is (§5.2), controlled. */
57
+ session?: GridSession;
58
+ defaultSession?: GridSession;
59
+ onSessionChange?: (session: GridSession) => void;
60
+ /** Rows read on a server, shown before the first read; a promise streams them (§7). */
61
+ initial?: InitialView | Promise<InitialView>;
62
+ toolbar?: boolean;
63
+ statusBar?: boolean;
64
+ /** A check box for each row in the gutter, and Space to check the active row (§5.2.1). */
65
+ checkboxes?: boolean;
66
+ /** Groups of columns named in header rows above the column names; nested groups add rows. */
67
+ columnGroups?: readonly ColumnGroup[];
68
+ /** The height of each row of groups, instead of `--vg-group-header-height`. */
69
+ groupHeaderHeight?: number;
70
+ /** How many lines rows have while a displayed column wraps its text (2 by default). */
71
+ wrapLines?: number;
72
+ /** The text of a cell instead of the grid's (§5.1: functions win over the document's formats). */
73
+ cellText?: CellHooks["cellText"];
74
+ /** The colors of a cell instead of the column's style rules. */
75
+ cellStyle?: CellHooks["cellStyle"];
76
+ /** Draws cells on the canvas, with the grid's drawing at hand (`drawDefault`). */
77
+ drawCell?: CellHooks["drawCell"];
78
+ /** Makes cells links or buttons; a click or Space on them calls `onCellAction`. */
79
+ cellAction?: CellHooks["cellAction"];
80
+ onCellAction?: (event: CellActionEvent) => void;
81
+ /** Shows the active row in the detail panel your own way, instead of a list of its columns. */
82
+ detailPanel?: ComponentType<DetailPanelProps>;
83
+ rowHeight?: number;
84
+ headerHeight?: number;
85
+ /** The language of the UI, a BCP 47 tag; the browser's by default. */
86
+ locale?: string;
87
+ messages?: Partial<GridMessages>;
88
+ /** Failures that belong to no command (§4.2). */
89
+ onError?: (error: GridError) => void;
90
+ ref?: Ref<DataGridHandle>;
91
+ className?: string;
92
+ style?: CSSProperties;
93
+ "aria-label"?: string;
94
+ }
95
+
96
+ const transportIds = new WeakMap<object, number>();
97
+ let nextTransportId = 1;
98
+
99
+ /** A number for each transport, for the key that makes a new store when the transport changes. */
100
+ function transportId(transport: object) {
101
+ let id = transportIds.get(transport);
102
+ if (id === undefined) {
103
+ id = nextTransportId++;
104
+ transportIds.set(transport, id);
105
+ }
106
+ return id;
107
+ }
108
+
109
+ function isPromise<T>(value: T | Promise<T> | undefined): value is Promise<T> {
110
+ return typeof (value as { then?: unknown } | undefined)?.then === "function";
111
+ }
112
+
113
+ const noSubscription = () => () => {};
114
+ const browserLocale = () => navigator.language;
115
+ const serverLocale = () => undefined;
116
+
117
+ /**
118
+ * A data grid for tables of any size (§5.1 of react.md): it draws on a canvas, reads rows through a
119
+ * transport as you scroll, and is driven by the commands of the specification.
120
+ */
121
+ export function DataGrid(props: DataGridProps) {
122
+ if (props.store) return <Frame {...props} store={props.store} />;
123
+ const { transport, table } = props;
124
+ if (!transport || table === undefined) throw new Error("DataGrid needs a store, or a transport and a table.");
125
+ const key = `${transportId(transport)}\u0000${table}`;
126
+ if (isPromise(props.initial)) {
127
+ return (
128
+ <Suspense fallback={<div className="vg-root" aria-busy="true" />}>
129
+ <OwnStore key={key} {...props} transport={transport} table={table} />
130
+ </Suspense>
131
+ );
132
+ }
133
+ return <OwnStore key={key} {...props} transport={transport} table={table} />;
134
+ }
135
+
136
+ /** A grid with a store of its own, made once and connected while it is mounted. */
137
+ function OwnStore(props: DataGridProps & { transport: Transport; table: string }) {
138
+ const initial = isPromise(props.initial) ? use(props.initial) : props.initial;
139
+ const [store] = useState(() =>
140
+ createGridStore({
141
+ transport: props.transport,
142
+ table: props.table,
143
+ ...(props.open ? { open: props.open } : {}),
144
+ ...((props.document ?? props.defaultDocument) ? { document: props.document ?? props.defaultDocument } : {}),
145
+ ...((props.session ?? props.defaultSession) ? { session: props.session ?? props.defaultSession } : {}),
146
+ ...(initial ? { initial } : {}),
147
+ }),
148
+ );
149
+ useEffect(() => store.connect(), [store]);
150
+ return <Frame {...props} store={store} />;
151
+ }
152
+
153
+ /** Makes a part of the state controlled by a prop (§5.2), and reports the changes that commands make. */
154
+ function useControlled<T extends GridDocument | GridSession>(
155
+ store: GridStore,
156
+ part: "document" | "session",
157
+ value: T | undefined,
158
+ onChange: ((value: T) => void) | undefined,
159
+ ) {
160
+ const controlled = value !== undefined;
161
+ const report = useEffectEvent((next: T) => onChange?.(next));
162
+ useLayoutEffect(() => {
163
+ store.control(part, controlled);
164
+ }, [store, part, controlled]);
165
+ // In the commit that brings the prop, before the canvas draws.
166
+ useLayoutEffect(() => {
167
+ if (value === undefined) return;
168
+ if (part === "document") store.setDocument(value as GridDocument);
169
+ else store.setSession(value as GridSession);
170
+ }, [store, part, value]);
171
+ useEffect(() => store.on(part, (next) => report(next as T)), [store, part]);
172
+ }
173
+
174
+ function Frame({
175
+ store,
176
+ toolbar = true,
177
+ statusBar = true,
178
+ checkboxes = false,
179
+ columnGroups,
180
+ groupHeaderHeight,
181
+ wrapLines,
182
+ cellText,
183
+ cellStyle,
184
+ drawCell,
185
+ cellAction,
186
+ onCellAction,
187
+ detailPanel,
188
+ className,
189
+ style,
190
+ ref,
191
+ rowHeight,
192
+ headerHeight,
193
+ locale: given,
194
+ messages: replaced,
195
+ document,
196
+ onDocumentChange,
197
+ session,
198
+ onSessionChange,
199
+ onError,
200
+ "aria-label": ariaLabel,
201
+ }: DataGridProps & { store: GridStore }) {
202
+ const detected = useSyncExternalStore(noSubscription, browserLocale, serverLocale);
203
+ const locale = given ?? detected;
204
+ const base = messagesFor(locale);
205
+ const messages = replaced ? { ...base, ...replaced } : base;
206
+ const language = { messages, locale, number: new Intl.NumberFormat(locale) };
207
+ const label = ariaLabel ?? messages.grid;
208
+ const scrollerRef = useRef<HTMLDivElement>(null);
209
+ const [notice, setNotice] = useState("");
210
+ const [finding, setFinding] = useState(false);
211
+ const detailOpen = useGridSelectorOf(store, (snapshot) => snapshot.session.detail?.open === true);
212
+ const saving = useSaveAction(store, language, setNotice);
213
+ const status = useGridSelectorOf(store, (snapshot) => snapshot.status);
214
+ useControlled(store, "document", document, onDocumentChange);
215
+ useControlled(store, "session", session, onSessionChange);
216
+ const reportError = useEffectEvent((error: GridError) => onError?.(error));
217
+ useEffect(() => store.on("error", (error) => reportError(error)), [store]);
218
+ useImperativeHandle(
219
+ ref,
220
+ () => ({
221
+ store,
222
+ dispatch: store.dispatch,
223
+ focus: () => scrollerRef.current?.focus(),
224
+ }),
225
+ [store],
226
+ );
227
+ return (
228
+ <GridContext value={store}>
229
+ <LocaleContext value={language}>
230
+ <SaveContext value={saving}>
231
+ <div className={className ? `vg-root ${className}` : "vg-root"} style={style} data-status={status}>
232
+ {toolbar && <Toolbar announce={setNotice} />}
233
+ {finding && (
234
+ <Suspense fallback={null}>
235
+ <FindBar onClose={() => setFinding(false)} scrollerRef={scrollerRef} />
236
+ </Suspense>
237
+ )}
238
+ <div className="vg-main">
239
+ <Suspense fallback={<StaticRows label={label} />}>
240
+ <GridBody
241
+ rowHeight={rowHeight}
242
+ headerHeight={headerHeight}
243
+ checkboxes={checkboxes}
244
+ columnGroups={columnGroups}
245
+ groupHeaderHeight={groupHeaderHeight}
246
+ wrapLines={wrapLines}
247
+ hooks={{ cellText, cellStyle, drawCell, cellAction }}
248
+ onCellAction={onCellAction}
249
+ onFind={() => setFinding(true)}
250
+ label={label}
251
+ scrollerRef={scrollerRef}
252
+ announce={setNotice}
253
+ />
254
+ </Suspense>
255
+ {detailOpen && (
256
+ <Suspense fallback={null}>
257
+ <DetailPanel
258
+ custom={detailPanel}
259
+ onClosed={() => scrollerRef.current?.focus({ preventScroll: true })}
260
+ />
261
+ </Suspense>
262
+ )}
263
+ </div>
264
+ {statusBar && (
265
+ <StatusBar announce={setNotice} onDone={() => scrollerRef.current?.focus({ preventScroll: true })} />
266
+ )}
267
+ <div className="vg-sr-only" aria-live="polite">
268
+ {notice}
269
+ </div>
270
+ </div>
271
+ </SaveContext>
272
+ </LocaleContext>
273
+ </GridContext>
274
+ );
275
+ }
276
+
277
+ /** `useGridSelector` for a store given directly, before the context holds it. */
278
+ function useGridSelectorOf<T>(store: GridStore, selector: (snapshot: ReturnType<GridStore["getSnapshot"]>) => T): T {
279
+ const read = () => selector(store.getSnapshot());
280
+ return useSyncExternalStore(store.subscribe, read, read);
281
+ }
282
+
283
+ /** Gives a store to the hooks of the components inside (§5.4 of react.md). */
284
+ export function GridProvider({ store, children }: { store: GridStore; children?: ReactNode }) {
285
+ return <GridContext value={store}>{children}</GridContext>;
286
+ }
@@ -0,0 +1,92 @@
1
+ import { type ComponentType, use } from "react";
2
+ import { type Column, type JsonValue, type RowKey, textOf } from "vastgrid-spec";
3
+ import { formatterFor } from "../core/format.js";
4
+ import { LocaleContext } from "./context.js";
5
+ import { useGridRows, useGridSelector, useGridStore } from "./hooks.js";
6
+
7
+ /**
8
+ * The detail panel of the active row (§5.2 of the specification, `detail`): every column of the view, hidden ones
9
+ * included, with its label and its value in full, by the column's format. It follows the active cell, reads the
10
+ * row's values that the canvas did not (`useGridRows`), and is opened and closed by `detail.open` and
11
+ * `detail.close`; the focus stays in the grid, and goes back to it when the panel is closed from inside (× or Esc).
12
+ */
13
+ export function DetailPanel({
14
+ custom,
15
+ onClosed,
16
+ }: {
17
+ custom?: ComponentType<DetailPanelProps>;
18
+ /** Called when the panel is closed from inside it, to give the focus back to the grid. */
19
+ onClosed: () => void;
20
+ }) {
21
+ const open = useGridSelector((snapshot) => snapshot.session.detail?.open === true);
22
+ // Closed, it follows no rows: scrolling commits nothing (§10 of react.md).
23
+ return open ? <OpenDetail Custom={custom} onClosed={onClosed} /> : null;
24
+ }
25
+
26
+ /** What a host's own detail panel (`detailPanel`) gets: the active row's position, the view's columns and values. */
27
+ export interface DetailPanelProps {
28
+ row: number;
29
+ columns: readonly Column[];
30
+ values: readonly JsonValue[];
31
+ /** The row's key (§5.3), when it is read. */
32
+ rowKey: RowKey | undefined;
33
+ }
34
+
35
+ function OpenDetail({ Custom, onClosed }: { Custom?: ComponentType<DetailPanelProps>; onClosed: () => void }) {
36
+ const store = useGridStore();
37
+ const { messages, number, locale } = use(LocaleContext);
38
+ const row = useGridSelector((snapshot) => snapshot.session.active?.row ?? 0);
39
+ const rowCount = useGridSelector((snapshot) => snapshot.view?.rowCount ?? 0);
40
+ const columns = useGridSelector((snapshot) => snapshot.view?.columns);
41
+ const settings = useGridSelector((snapshot) => snapshot.document.columns);
42
+ const names = columns && rowCount > 0 ? columns.map((column) => column.name) : [];
43
+ const [values] = useGridRows({ start: row, end: names.length > 0 ? row + 1 : row, columns: names });
44
+ if (!columns) return null;
45
+ const byName = new Map((settings ?? []).map((setting) => [setting.name, setting]));
46
+ const text = (value: JsonValue | undefined, index: number) => {
47
+ if (value === undefined) return messages.loading;
48
+ if (value === null) return "";
49
+ const column = columns[index];
50
+ const format = byName.get(column.name)?.format;
51
+ return formatterFor(column, format, locale)?.(value) ?? textOf(value, column) ?? "";
52
+ };
53
+ const title = rowCount > 0 ? messages.detailOf(number.format(row + 1)) : messages.details;
54
+ const close = () => {
55
+ void store.dispatch({ command: "detail.close" });
56
+ onClosed();
57
+ };
58
+ return (
59
+ <aside
60
+ className="vg-detail"
61
+ aria-label={title}
62
+ onKeyDown={(event) => {
63
+ if (event.key !== "Escape" || event.nativeEvent.isComposing) return;
64
+ event.preventDefault();
65
+ close();
66
+ }}
67
+ >
68
+ <header className="vg-detail__header">
69
+ <h2 className="vg-detail__title">{title}</h2>
70
+ <button type="button" className="vg-button vg-button--small" aria-label={messages.closeDetails} onClick={close}>
71
+ ×
72
+ </button>
73
+ </header>
74
+ {rowCount > 0 && Custom && values?.every((value) => value !== undefined) ? (
75
+ <Custom row={row} columns={columns} values={values as JsonValue[]} rowKey={store.rowKey(row)} />
76
+ ) : rowCount > 0 && Custom ? (
77
+ <p className="vg-filter__note">{messages.loading}</p>
78
+ ) : (
79
+ rowCount > 0 && (
80
+ <dl className="vg-detail__list">
81
+ {columns.map((column, index) => (
82
+ <div key={column.name} className="vg-detail__item">
83
+ <dt>{byName.get(column.name)?.format?.label ?? column.name}</dt>
84
+ <dd>{text(values?.[index], index)}</dd>
85
+ </div>
86
+ ))}
87
+ </dl>
88
+ )
89
+ )}
90
+ </aside>
91
+ );
92
+ }
@@ -0,0 +1,112 @@
1
+ import { type ToggleEvent, use, useId, useRef, useState, useTransition } from "react";
2
+ import { CommandError } from "vastgrid-spec";
3
+ import { LocaleContext } from "./context.js";
4
+ import { useGridSelector, useGridStore } from "./hooks.js";
5
+
6
+ /** The media types of the formats of §8.2, for files that an engine answers as data. */
7
+ const MEDIA_TYPES: Record<string, string> = {
8
+ csv: "text/csv;charset=utf-8",
9
+ tsv: "text/tab-separated-values;charset=utf-8",
10
+ json: "application/json;charset=utf-8",
11
+ parquet: "application/vnd.apache.parquet",
12
+ xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
13
+ };
14
+
15
+ /** Downloads a file at a URL, as a link with `download` does: under `name`, or the name the server gives. */
16
+ function download(url: string, name = "") {
17
+ const link = document.createElement("a");
18
+ link.href = url;
19
+ link.download = name;
20
+ link.rel = "noopener";
21
+ document.body.append(link);
22
+ link.click();
23
+ link.remove();
24
+ }
25
+
26
+ /**
27
+ * The toolbar's export menu (§6.6 of the specification): the rows of the view or the whole table, with the hidden
28
+ * columns or not, in one of the formats the engine exports. It runs as an Action: the button shows that it runs, and
29
+ * the outcome is announced. An export without a target is downloaded from where the transport gives it
30
+ * (`Transport.exportUrl`), or, from an engine that answers the file's bytes (`delivery` `data`), as a file made of
31
+ * them.
32
+ */
33
+ export function ExportMenu({ announce }: { announce: (text: string) => void }) {
34
+ const store = useGridStore();
35
+ const { messages, number } = use(LocaleContext);
36
+ const formats = useGridSelector((snapshot) => snapshot.table?.capabilities.export?.formats);
37
+ const [rows, setRows] = useState<"view" | "table">("view");
38
+ const [allColumns, setAllColumns] = useState(false);
39
+ const [pending, startExport] = useTransition();
40
+ const id = useId();
41
+ const buttonRef = useRef<HTMLButtonElement>(null);
42
+ if (!formats || formats.length === 0) return null;
43
+
44
+ // The menu opens under its button, its right edge at the button's (popovers open in the middle otherwise).
45
+ const place = (event: ToggleEvent<HTMLDivElement>) => {
46
+ const button = buttonRef.current;
47
+ if (event.newState !== "open" || !button) return;
48
+ const rect = button.getBoundingClientRect();
49
+ const menu = event.currentTarget;
50
+ menu.style.top = `${rect.bottom + 4}px`;
51
+ menu.style.left = `${Math.max(8, rect.right - menu.offsetWidth)}px`;
52
+ };
53
+
54
+ const run = (format: string) => {
55
+ document.getElementById(id)?.hidePopover?.();
56
+ startExport(async () => {
57
+ const outcome = await store.dispatch({
58
+ command: "export",
59
+ format,
60
+ rows,
61
+ columns: allColumns ? "all" : "displayed",
62
+ });
63
+ if (!outcome.ok) {
64
+ const reason = outcome.error instanceof CommandError ? outcome.error.reason : null;
65
+ announce(messages.failed(reason === "not_loaded" ? messages.loading : outcome.error.message));
66
+ return;
67
+ }
68
+ const result = outcome.output as { target: string | null; rows: number; data?: { bytes: Uint8Array } };
69
+ if (result.data) {
70
+ const url = URL.createObjectURL(
71
+ new Blob([result.data.bytes as Uint8Array<ArrayBuffer>], { type: MEDIA_TYPES[format] ?? "" }),
72
+ );
73
+ download(url, store.exportName(format));
74
+ // The link has started the download; the browser keeps the file's bytes until it is done.
75
+ setTimeout(() => URL.revokeObjectURL(url), 60_000);
76
+ } else {
77
+ const url = result.target === null ? null : store.exportUrl(result.target);
78
+ if (url) download(url);
79
+ }
80
+ announce(messages.exported(number.format(result.rows)));
81
+ });
82
+ };
83
+
84
+ return (
85
+ <>
86
+ <button ref={buttonRef} type="button" className="vg-button" popoverTarget={id} disabled={pending}>
87
+ {pending ? messages.exporting : messages.export}
88
+ </button>
89
+ <div id={id} popover="auto" role="dialog" className="vg-menu" aria-label={messages.export} onToggle={place}>
90
+ <fieldset className="vg-menu__group">
91
+ <label>
92
+ <input type="radio" name={`${id}-rows`} checked={rows === "view"} onChange={() => setRows("view")} />
93
+ {messages.exportView}
94
+ </label>
95
+ <label>
96
+ <input type="radio" name={`${id}-rows`} checked={rows === "table"} onChange={() => setRows("table")} />
97
+ {messages.exportTable}
98
+ </label>
99
+ <label>
100
+ <input type="checkbox" checked={allColumns} onChange={(event) => setAllColumns(event.target.checked)} />
101
+ {messages.exportAllColumns}
102
+ </label>
103
+ </fieldset>
104
+ {formats.map((format) => (
105
+ <button key={format} type="button" className="vg-menu__item" onClick={() => run(format)}>
106
+ {messages.exportFormats[format] ?? format}
107
+ </button>
108
+ ))}
109
+ </div>
110
+ </>
111
+ );
112
+ }
@@ -0,0 +1,112 @@
1
+ import { type KeyboardEvent, type RefObject, use, useLayoutEffect, useRef, useState, useTransition } from "react";
2
+ import { LocaleContext } from "./context.js";
3
+ import { useGridSelector, useGridStore } from "./hooks.js";
4
+
5
+ /**
6
+ * The find bar (⌘F or Ctrl+F on the grid): finds the next cell of the view whose text contains what is typed, from
7
+ * the active cell, in the displayed columns (`GridStore.find`, `find` of §8.2), and makes it the active cell. Enter
8
+ * finds the next one, Shift+Enter the one before, and Esc closes the bar and gives the focus back to the grid.
9
+ */
10
+ export function FindBar({
11
+ onClose,
12
+ scrollerRef,
13
+ }: {
14
+ onClose: () => void;
15
+ scrollerRef: RefObject<HTMLDivElement | null>;
16
+ }) {
17
+ const store = useGridStore();
18
+ const { messages } = use(LocaleContext);
19
+ const finds = useGridSelector((snapshot) => snapshot.table?.capabilities.find === true);
20
+ const [text, setText] = useState("");
21
+ const [caseSensitive, setCaseSensitive] = useState(false);
22
+ const [status, setStatus] = useState("");
23
+ const [pending, startFind] = useTransition();
24
+ const inputRef = useRef<HTMLInputElement>(null);
25
+
26
+ useLayoutEffect(() => {
27
+ inputRef.current?.focus();
28
+ inputRef.current?.select();
29
+ }, []);
30
+
31
+ const close = () => {
32
+ onClose();
33
+ scrollerRef.current?.focus({ preventScroll: true });
34
+ };
35
+
36
+ const find = (direction: 1 | -1) => {
37
+ if (text === "") return;
38
+ startFind(async () => {
39
+ const { session, displayed } = store.getSnapshot();
40
+ const from = session.active ?? { row: 0, column: displayed[0]?.column.name ?? "" };
41
+ try {
42
+ const found = await store.find(text, { from, direction, caseSensitive });
43
+ if (!found) {
44
+ setStatus(messages.noMatches);
45
+ return;
46
+ }
47
+ setStatus("");
48
+ await store.dispatch({ command: "select", row: found.row, column: found.column });
49
+ } catch (error) {
50
+ setStatus(messages.failed(error instanceof Error ? error.message : String(error)));
51
+ }
52
+ });
53
+ };
54
+
55
+ const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
56
+ // Keys that end an IME's composition are the IME's.
57
+ if (event.nativeEvent.isComposing || event.keyCode === 229) return;
58
+ if (event.key === "Escape") {
59
+ event.preventDefault();
60
+ close();
61
+ } else if (event.key === "Enter" && event.target === inputRef.current) {
62
+ event.preventDefault();
63
+ find(event.shiftKey ? -1 : 1);
64
+ }
65
+ };
66
+
67
+ if (!finds) return null;
68
+ return (
69
+ <search className="vg-find" aria-label={messages.find} onKeyDown={onKeyDown}>
70
+ <input
71
+ ref={inputRef}
72
+ type="search"
73
+ className="vg-input vg-find__input"
74
+ aria-label={messages.find}
75
+ placeholder={messages.find}
76
+ value={text}
77
+ onChange={(event) => {
78
+ setText(event.target.value);
79
+ setStatus("");
80
+ }}
81
+ />
82
+ <label className="vg-filter__value">
83
+ <input type="checkbox" checked={caseSensitive} onChange={(event) => setCaseSensitive(event.target.checked)} />
84
+ <span>{messages.matchCase}</span>
85
+ </label>
86
+ <button
87
+ type="button"
88
+ className="vg-button vg-button--small"
89
+ aria-label={messages.findPrevious}
90
+ disabled={pending || text === ""}
91
+ onClick={() => find(-1)}
92
+ >
93
+ ↑
94
+ </button>
95
+ <button
96
+ type="button"
97
+ className="vg-button vg-button--small"
98
+ aria-label={messages.findNext}
99
+ disabled={pending || text === ""}
100
+ onClick={() => find(1)}
101
+ >
102
+ ↓
103
+ </button>
104
+ <span className="vg-find__status" role="status">
105
+ {status}
106
+ </span>
107
+ <button type="button" className="vg-button vg-button--small" aria-label={messages.closeFind} onClick={close}>
108
+ ×
109
+ </button>
110
+ </search>
111
+ );
112
+ }