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,421 @@
1
+ import { type FormEvent, use, useState, useTransition } from "react";
2
+ import {
3
+ type Column,
4
+ type ColumnFormat,
5
+ type ColumnSetting,
6
+ columnsOf,
7
+ type Expr,
8
+ formulaText,
9
+ joinParts,
10
+ type NumberFormat,
11
+ partsOf,
12
+ type StyleRule,
13
+ } from "vastgrid-spec";
14
+ import {
15
+ type ColumnFilter,
16
+ type FilterOperator,
17
+ FilterTextError,
18
+ filterParts,
19
+ operatorsFor,
20
+ readFilter,
21
+ takesNoValue,
22
+ } from "../core/filters.js";
23
+ import { isNumeric } from "../core/format.js";
24
+ import { LocaleContext } from "./context.js";
25
+ import { useGridSelector, useGridStore } from "./hooks.js";
26
+
27
+ const TEMPORAL = new Set(["date", "time", "timestamp", "timestamptz"]);
28
+
29
+ /** A style rule as the form edits it: a condition on one column, or a `when` it does not take apart. */
30
+ interface RuleDraft {
31
+ id: number;
32
+ column: string;
33
+ filter: ColumnFilter & { kind: "condition" };
34
+ /** A `when` that is not one condition on one column: kept as it is. */
35
+ kept?: Expr;
36
+ color: string | null;
37
+ background: string | null;
38
+ description: string;
39
+ }
40
+
41
+ let nextRule = 1;
42
+
43
+ function ruleId() {
44
+ return nextRule++;
45
+ }
46
+
47
+ function draftOf(rule: StyleRule, fallback: string): RuleDraft {
48
+ const names = columnsOf(rule.when);
49
+ const [name] = names;
50
+ const read = names.size === 1 ? readFilter(partsOf(rule.when), name) : null;
51
+ const filter =
52
+ read !== null && read !== "other" && read.kind === "condition"
53
+ ? read
54
+ : ({ kind: "condition", operator: "eq", first: "", second: "" } as const);
55
+ return {
56
+ id: ruleId(),
57
+ column: read !== null && read !== "other" && read.kind === "condition" ? name : fallback,
58
+ filter,
59
+ ...(read !== null && read !== "other" && read.kind === "condition" ? {} : { kept: rule.when }),
60
+ color: rule.color ?? null,
61
+ background: rule.background ?? null,
62
+ description: rule.description ?? "",
63
+ };
64
+ }
65
+
66
+ /** The `when` of a rule, or null when its text is not a value of its column; undefined without text. */
67
+ function whenOf(rule: RuleDraft, columns: readonly Column[]): Expr | null | undefined {
68
+ if (rule.kept) return rule.kept;
69
+ const column = columns.find((item) => item.name === rule.column);
70
+ if (!column) return undefined;
71
+ try {
72
+ return joinParts(filterParts(rule.filter, column));
73
+ } catch (error) {
74
+ if (error instanceof FilterTextError) return null;
75
+ throw error;
76
+ }
77
+ }
78
+
79
+ /** Whether Intl formats a currency code. */
80
+ function knownCurrency(code: string) {
81
+ try {
82
+ new Intl.NumberFormat(undefined, { style: "currency", currency: code });
83
+ return true;
84
+ } catch {
85
+ return false;
86
+ }
87
+ }
88
+
89
+ function formulaOf(expr: Expr) {
90
+ try {
91
+ return formulaText(expr);
92
+ } catch {
93
+ return JSON.stringify(expr);
94
+ }
95
+ }
96
+
97
+ /**
98
+ * A column's format (§5.1 of the specification): its label, alignment and wrapping, the number or date format of its
99
+ * type, and rules of conditional colors, each a condition on a column of the view with a text color, a background
100
+ * and what they mean. Applying dispatches `column.format`; Clear removes the format.
101
+ */
102
+ export function FormatForm({
103
+ column,
104
+ setting,
105
+ onDone,
106
+ }: {
107
+ column: Column;
108
+ setting: ColumnSetting;
109
+ onDone: () => void;
110
+ }) {
111
+ const store = useGridStore();
112
+ const { messages } = use(LocaleContext);
113
+ const view = useGridSelector((snapshot) => snapshot.view?.columns ?? []);
114
+ const format = setting.format ?? {};
115
+ const [label, setLabel] = useState(format.label ?? "");
116
+ const [align, setAlign] = useState<ColumnFormat["align"] | "">(format.align ?? "");
117
+ const [wrap, setWrap] = useState(format.wrap === true);
118
+ const [style, setStyle] = useState<NumberFormat["style"] | "">(
119
+ format.number ? (format.number.style ?? "decimal") : "",
120
+ );
121
+ const [currency, setCurrency] = useState(format.number?.currency ?? "");
122
+ const [digits, setDigits] = useState(
123
+ format.number?.fractionDigits === undefined ? "" : String(format.number.fractionDigits),
124
+ );
125
+ const [grouping, setGrouping] = useState(format.number?.grouping !== false);
126
+ const [pattern, setPattern] = useState(format.date?.pattern ?? "");
127
+ const [rules, setRules] = useState(() => (format.styles ?? []).map((rule) => draftOf(rule, column.name)));
128
+ const [error, setError] = useState<string | null>(null);
129
+ const [pending, startApply] = useTransition();
130
+ const numeric = isNumeric(column);
131
+ const temporal = TEMPORAL.has(column.type);
132
+
133
+ const dispatch = (next: ColumnFormat | null) =>
134
+ startApply(async () => {
135
+ const outcome = await store.dispatch({ command: "column.format", name: column.name, format: next });
136
+ if (!outcome.ok) {
137
+ setError(outcome.error.message);
138
+ return;
139
+ }
140
+ onDone();
141
+ });
142
+
143
+ const submit = (event: FormEvent<HTMLFormElement>) => {
144
+ event.preventDefault();
145
+ if (style === "currency" && !knownCurrency(currency)) {
146
+ setError(messages.invalidCurrency);
147
+ return;
148
+ }
149
+ const styles: StyleRule[] = [];
150
+ for (const rule of rules) {
151
+ const when = whenOf(rule, view);
152
+ if (when === null) {
153
+ setError(messages.invalidCondition);
154
+ return;
155
+ }
156
+ if (when === undefined || (!rule.color && !rule.background)) continue;
157
+ styles.push({
158
+ when,
159
+ ...(rule.color ? { color: rule.color } : {}),
160
+ ...(rule.background ? { background: rule.background } : {}),
161
+ ...(rule.description.trim() ? { description: rule.description.trim() } : {}),
162
+ });
163
+ }
164
+ const fraction = digits.trim() === "" ? undefined : Math.min(20, Math.max(0, Math.round(Number(digits))));
165
+ const number: NumberFormat | undefined =
166
+ numeric && style
167
+ ? {
168
+ style,
169
+ ...(style === "currency" ? { currency: currency.toUpperCase() } : {}),
170
+ ...(fraction === undefined || Number.isNaN(fraction) ? {} : { fractionDigits: fraction }),
171
+ ...(grouping ? {} : { grouping: false }),
172
+ }
173
+ : undefined;
174
+ const next: ColumnFormat = {
175
+ ...format,
176
+ ...(label.trim() ? { label: label.trim() } : { label: undefined }),
177
+ align: align === "" ? undefined : align,
178
+ wrap: wrap ? true : undefined,
179
+ number: numeric ? number : format.number,
180
+ date: temporal ? (pattern.trim() ? { ...format.date, pattern: pattern.trim() } : undefined) : format.date,
181
+ styles: styles.length > 0 ? styles : undefined,
182
+ };
183
+ const cleaned = Object.fromEntries(Object.entries(next).filter(([, value]) => value !== undefined)) as ColumnFormat;
184
+ dispatch(Object.keys(cleaned).length > 0 ? cleaned : null);
185
+ };
186
+
187
+ const change = (id: number, patch: Partial<RuleDraft>) =>
188
+ setRules((current) => current.map((rule) => (rule.id === id ? { ...rule, ...patch } : rule)));
189
+
190
+ return (
191
+ <form className="vg-filter vg-format" onSubmit={submit} aria-label={messages.formatOf(column.name)}>
192
+ <p className="vg-filter__title">{messages.formatOf(column.name)}</p>
193
+ <label className="vg-format__field">
194
+ <span>{messages.columnLabel}</span>
195
+ <input
196
+ className="vg-input"
197
+ value={label}
198
+ placeholder={column.name}
199
+ onChange={(event) => setLabel(event.target.value)}
200
+ />
201
+ </label>
202
+ <label className="vg-format__field">
203
+ <span>{messages.alignment}</span>
204
+ <select className="vg-input" value={align} onChange={(event) => setAlign(event.target.value as typeof align)}>
205
+ <option value="">{messages.alignments.auto}</option>
206
+ <option value="left">{messages.alignments.left}</option>
207
+ <option value="center">{messages.alignments.center}</option>
208
+ <option value="right">{messages.alignments.right}</option>
209
+ </select>
210
+ </label>
211
+ <label className="vg-filter__value">
212
+ <input type="checkbox" checked={wrap} onChange={(event) => setWrap(event.target.checked)} />
213
+ <span>{messages.wrapText}</span>
214
+ </label>
215
+ {numeric && (
216
+ <fieldset className="vg-format__group">
217
+ <legend>{messages.numberFormat}</legend>
218
+ <select
219
+ className="vg-input"
220
+ aria-label={messages.numberFormat}
221
+ value={style}
222
+ onChange={(event) => setStyle(event.target.value as typeof style)}
223
+ >
224
+ <option value="">{messages.numberStyles.none}</option>
225
+ <option value="decimal">{messages.numberStyles.decimal}</option>
226
+ <option value="percent">{messages.numberStyles.percent}</option>
227
+ <option value="currency">{messages.numberStyles.currency}</option>
228
+ </select>
229
+ {style === "currency" && (
230
+ <input
231
+ className="vg-input"
232
+ aria-label={messages.currency}
233
+ placeholder="JPY"
234
+ value={currency}
235
+ onChange={(event) => setCurrency(event.target.value)}
236
+ />
237
+ )}
238
+ {style !== "" && (
239
+ <>
240
+ <input
241
+ className="vg-input"
242
+ type="number"
243
+ min={0}
244
+ max={20}
245
+ aria-label={messages.fractionDigits}
246
+ placeholder={messages.fractionDigits}
247
+ value={digits}
248
+ onChange={(event) => setDigits(event.target.value)}
249
+ />
250
+ <label className="vg-filter__value">
251
+ <input type="checkbox" checked={grouping} onChange={(event) => setGrouping(event.target.checked)} />
252
+ <span>{messages.grouping}</span>
253
+ </label>
254
+ </>
255
+ )}
256
+ </fieldset>
257
+ )}
258
+ {temporal && (
259
+ <label className="vg-format__field">
260
+ <span>{messages.datePattern}</span>
261
+ <input
262
+ className="vg-input"
263
+ placeholder="yyyy/MM/dd HH:mm"
264
+ value={pattern}
265
+ onChange={(event) => setPattern(event.target.value)}
266
+ />
267
+ </label>
268
+ )}
269
+ <fieldset className="vg-format__group">
270
+ <legend>{messages.conditionalColors}</legend>
271
+ {rules.map((rule) => {
272
+ const target = view.find((item) => item.name === rule.column);
273
+ const operators = target ? operatorsFor(target.type) : [];
274
+ return (
275
+ <div key={rule.id} className="vg-format__rule">
276
+ {rule.kept ? (
277
+ <code className="vg-conditions__expression">{formulaOf(rule.kept)}</code>
278
+ ) : (
279
+ <div className="vg-conditions__row">
280
+ <select
281
+ className="vg-input"
282
+ aria-label={messages.conditionColumn}
283
+ value={rule.column}
284
+ onChange={(event) => {
285
+ const next = view.find((item) => item.name === event.target.value);
286
+ if (!next) return;
287
+ const kinds = operatorsFor(next.type);
288
+ const operator = kinds.includes(rule.filter.operator) ? rule.filter.operator : kinds[0];
289
+ change(rule.id, { column: next.name, filter: { ...rule.filter, operator } });
290
+ }}
291
+ >
292
+ {view.map((item) => (
293
+ <option key={item.name} value={item.name}>
294
+ {item.name}
295
+ </option>
296
+ ))}
297
+ </select>
298
+ <select
299
+ className="vg-input"
300
+ aria-label={messages.condition}
301
+ value={rule.filter.operator}
302
+ onChange={(event) =>
303
+ change(rule.id, { filter: { ...rule.filter, operator: event.target.value as FilterOperator } })
304
+ }
305
+ >
306
+ {operators.map((item) => (
307
+ <option key={item} value={item}>
308
+ {messages.operators[item]}
309
+ </option>
310
+ ))}
311
+ </select>
312
+ {!takesNoValue(rule.filter.operator) && (
313
+ <input
314
+ className="vg-input"
315
+ aria-label={rule.filter.operator === "between" ? messages.minimum : messages.value}
316
+ placeholder={rule.filter.operator === "between" ? messages.minimum : messages.value}
317
+ value={rule.filter.first}
318
+ onChange={(event) => change(rule.id, { filter: { ...rule.filter, first: event.target.value } })}
319
+ />
320
+ )}
321
+ {rule.filter.operator === "between" && (
322
+ <input
323
+ className="vg-input"
324
+ aria-label={messages.maximum}
325
+ placeholder={messages.maximum}
326
+ value={rule.filter.second}
327
+ onChange={(event) => change(rule.id, { filter: { ...rule.filter, second: event.target.value } })}
328
+ />
329
+ )}
330
+ </div>
331
+ )}
332
+ <div className="vg-conditions__row">
333
+ <label className="vg-filter__value">
334
+ <input
335
+ type="checkbox"
336
+ checked={rule.color !== null}
337
+ onChange={(event) => change(rule.id, { color: event.target.checked ? "#b42318" : null })}
338
+ />
339
+ <span>{messages.textColor}</span>
340
+ </label>
341
+ {rule.color !== null && (
342
+ <input
343
+ type="color"
344
+ aria-label={messages.textColor}
345
+ value={rule.color}
346
+ onChange={(event) => change(rule.id, { color: event.target.value })}
347
+ />
348
+ )}
349
+ <label className="vg-filter__value">
350
+ <input
351
+ type="checkbox"
352
+ checked={rule.background !== null}
353
+ onChange={(event) => change(rule.id, { background: event.target.checked ? "#fde68a" : null })}
354
+ />
355
+ <span>{messages.backgroundColor}</span>
356
+ </label>
357
+ {rule.background !== null && (
358
+ <input
359
+ type="color"
360
+ aria-label={messages.backgroundColor}
361
+ value={rule.background}
362
+ onChange={(event) => change(rule.id, { background: event.target.value })}
363
+ />
364
+ )}
365
+ </div>
366
+ <div className="vg-conditions__row">
367
+ <input
368
+ className="vg-input"
369
+ aria-label={messages.ruleDescription}
370
+ placeholder={messages.ruleDescription}
371
+ value={rule.description}
372
+ onChange={(event) => change(rule.id, { description: event.target.value })}
373
+ />
374
+ <button
375
+ type="button"
376
+ className="vg-button vg-button--small"
377
+ aria-label={messages.removeRule}
378
+ onClick={() => setRules((current) => current.filter((item) => item.id !== rule.id))}
379
+ >
380
+ ×
381
+ </button>
382
+ </div>
383
+ </div>
384
+ );
385
+ })}
386
+ <button
387
+ type="button"
388
+ className="vg-button vg-button--small"
389
+ onClick={() =>
390
+ setRules((current) => [
391
+ ...current,
392
+ {
393
+ id: ruleId(),
394
+ column: column.name,
395
+ filter: { kind: "condition", operator: operatorsFor(column.type)[0], first: "", second: "" },
396
+ color: null,
397
+ background: "#fde68a",
398
+ description: "",
399
+ },
400
+ ])
401
+ }
402
+ >
403
+ {messages.addRule}
404
+ </button>
405
+ </fieldset>
406
+ {error !== null && (
407
+ <p role="alert" className="vg-filter__error">
408
+ {error}
409
+ </p>
410
+ )}
411
+ <div className="vg-filter__actions">
412
+ <button type="button" className="vg-button" disabled={pending} onClick={() => dispatch(null)}>
413
+ {messages.clearFormat}
414
+ </button>
415
+ <button type="submit" className="vg-button vg-button--primary" disabled={pending}>
416
+ {messages.apply}
417
+ </button>
418
+ </div>
419
+ </form>
420
+ );
421
+ }
@@ -0,0 +1,163 @@
1
+ import { type FormEvent, use, useId, useRef, useState, useTransition } from "react";
2
+ import {
3
+ type CheckOptions,
4
+ type Column,
5
+ checkPlan,
6
+ compileFormula,
7
+ type Derived,
8
+ FormulaError,
9
+ formulaText,
10
+ type GridCommand,
11
+ planOptions,
12
+ } from "vastgrid-spec";
13
+ import { LocaleContext } from "./context.js";
14
+ import { useGridSelector, useGridStore } from "./hooks.js";
15
+
16
+ /** The text of a derived column's expression as a formula (§4.11), or its JSON when it has none. */
17
+ function textOf(derived: Derived): string {
18
+ try {
19
+ return formulaText(derived.expr);
20
+ } catch {
21
+ return JSON.stringify(derived.expr);
22
+ }
23
+ }
24
+
25
+ /**
26
+ * One derived column, or a new one when `derived` is null: its name and formula, compiled against the columns
27
+ * before it (§4.11) and dispatched as `derive.add` or `derive.update` (§6.2.2). A formula that does not compile is
28
+ * not dispatched: its error is shown, and the characters at fault are selected.
29
+ */
30
+ function FormulaForm({
31
+ derived,
32
+ columns,
33
+ options,
34
+ }: {
35
+ derived: Derived | null;
36
+ columns: readonly Column[];
37
+ options: CheckOptions;
38
+ }) {
39
+ const store = useGridStore();
40
+ const { messages } = use(LocaleContext);
41
+ const [name, setName] = useState(derived?.name ?? "");
42
+ const [text, setText] = useState(derived ? textOf(derived) : "");
43
+ const [error, setError] = useState<string | null>(null);
44
+ const [pending, startDispatch] = useTransition();
45
+ const formulaRef = useRef<HTMLInputElement>(null);
46
+ const errorId = useId();
47
+
48
+ const run = (command: GridCommand, reset: boolean) =>
49
+ startDispatch(async () => {
50
+ const outcome = await store.dispatch(command);
51
+ if (!outcome.ok) {
52
+ setError(outcome.error.message);
53
+ return;
54
+ }
55
+ setError(null);
56
+ if (reset) {
57
+ setName("");
58
+ setText("");
59
+ }
60
+ });
61
+
62
+ const submit = (event: FormEvent<HTMLFormElement>) => {
63
+ event.preventDefault();
64
+ let expr: Derived["expr"];
65
+ try {
66
+ expr = compileFormula(text, columns, options).expr;
67
+ } catch (failure) {
68
+ if (!(failure instanceof FormulaError)) throw failure;
69
+ setError(failure.message);
70
+ const input = formulaRef.current;
71
+ input?.focus();
72
+ input?.setSelectionRange(failure.start, Math.max(failure.start, failure.end));
73
+ return;
74
+ }
75
+ if (derived === null) run({ command: "derive.add", name, expr }, true);
76
+ else
77
+ run(
78
+ { command: "derive.update", name: derived.name, expr, ...(name === derived.name ? {} : { rename: name }) },
79
+ false,
80
+ );
81
+ };
82
+
83
+ return (
84
+ <form className="vg-formulas__row" onSubmit={submit} aria-label={derived?.name ?? messages.newFormula}>
85
+ <input
86
+ className="vg-input vg-formulas__name"
87
+ aria-label={messages.formulaName}
88
+ placeholder={messages.formulaName}
89
+ required
90
+ value={name}
91
+ onChange={(event) => setName(event.target.value)}
92
+ />
93
+ <input
94
+ ref={formulaRef}
95
+ className="vg-input vg-formulas__formula"
96
+ aria-label={messages.formula}
97
+ aria-invalid={error !== null}
98
+ aria-describedby={error === null ? undefined : errorId}
99
+ placeholder={messages.formulaExample}
100
+ required
101
+ spellCheck={false}
102
+ value={text}
103
+ onChange={(event) => setText(event.target.value)}
104
+ />
105
+ <button type="submit" className="vg-button" disabled={pending}>
106
+ {derived === null ? messages.addFormula : messages.applyFormula}
107
+ </button>
108
+ {derived !== null && (
109
+ <button
110
+ type="button"
111
+ className="vg-button"
112
+ disabled={pending}
113
+ onClick={() => run({ command: "derive.remove", name: derived.name }, false)}
114
+ >
115
+ {messages.removeFormula}
116
+ </button>
117
+ )}
118
+ {error !== null && (
119
+ <p id={errorId} role="alert" className="vg-formulas__error">
120
+ {error}
121
+ </p>
122
+ )}
123
+ </form>
124
+ );
125
+ }
126
+
127
+ /**
128
+ * The content of the toolbar's formula menu, loaded when it first opens: the derived columns of the plan (§4.1) as formulas (§4.11), which can be changed,
129
+ * renamed and removed, and a form that adds one. It is there when the engine derives columns.
130
+ */
131
+ export default function FormulaPanel() {
132
+ const table = useGridSelector((snapshot) => snapshot.table);
133
+ const derive = useGridSelector((snapshot) => snapshot.document.plan?.derive);
134
+ if (!table || table.capabilities.plan?.derive === false) return null;
135
+ const options = planOptions(table.capabilities);
136
+ const list = derive ?? [];
137
+ // Each formula uses the table's columns and the derived columns before it.
138
+ const scopes: Column[][] = [];
139
+ let rows: Column[] = table.columns;
140
+ for (const [index, derived] of list.entries()) {
141
+ scopes.push(rows);
142
+ try {
143
+ rows = checkPlan({ derive: list.slice(0, index + 1) }, table.columns, options).rows;
144
+ } catch {
145
+ rows = [...rows, { name: derived.name, type: "string" }];
146
+ }
147
+ }
148
+
149
+ return (
150
+ <>
151
+ {list.map((derived, index) => (
152
+ // A formula changed elsewhere (an agent, undo) shows anew.
153
+ <FormulaForm
154
+ key={`${derived.name}\u0000${textOf(derived)}`}
155
+ derived={derived}
156
+ columns={scopes[index]}
157
+ options={options}
158
+ />
159
+ ))}
160
+ <FormulaForm key={list.length} derived={null} columns={rows} options={options} />
161
+ </>
162
+ );
163
+ }