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,619 @@
1
+ import {
2
+ type FormEvent,
3
+ type KeyboardEvent,
4
+ type ToggleEvent,
5
+ use,
6
+ useEffect,
7
+ useId,
8
+ useLayoutEffect,
9
+ useRef,
10
+ useState,
11
+ useTransition,
12
+ } from "react";
13
+ import {
14
+ type Column,
15
+ type Expr,
16
+ type GridCommand,
17
+ isBlank,
18
+ type JsonValue,
19
+ joinParts,
20
+ measureTakes,
21
+ type SortDirection,
22
+ textOf,
23
+ } from "vastgrid-spec";
24
+ import {
25
+ type ColumnFilter,
26
+ conditionsOf,
27
+ type FilterOperator,
28
+ FilterTextError,
29
+ filterParts,
30
+ operatorsFor,
31
+ readFilter,
32
+ takesNoValue,
33
+ } from "../core/filters.js";
34
+ import { measureName, offers, statisticsOf, TOTALS } from "../core/measures.js";
35
+ import { LocaleContext } from "./context.js";
36
+ import { FormatForm } from "./format-form.js";
37
+ import { useGridSelector, useGridStore } from "./hooks.js";
38
+
39
+ /** Where the column menu opens: a column of the view, at a point of the page. */
40
+ export interface MenuTarget {
41
+ column: string;
42
+ x: number;
43
+ y: number;
44
+ }
45
+
46
+ /** The key of a value in the list; blank values (§3.2) share one. */
47
+ const BLANK = "\u0000blank";
48
+
49
+ function keyOf(value: JsonValue, column: Column) {
50
+ return isBlank(value, column) ? BLANK : JSON.stringify(value);
51
+ }
52
+
53
+ /** `filter.column` with the parts of a filter, or null when its text is not a value of the column. */
54
+ function filterCommand(filter: ColumnFilter | null, column: Column): GridCommand | null {
55
+ let parts: Expr[] = [];
56
+ try {
57
+ if (filter) parts = filterParts(filter, column);
58
+ } catch (cause) {
59
+ if (cause instanceof FilterTextError) return null;
60
+ throw cause;
61
+ }
62
+ return { command: "filter.column", column: column.name, where: joinParts(parts) ?? null };
63
+ }
64
+
65
+ /** How long typing in the values' search box must pause before the list is read again. */
66
+ const SEARCH_DELAY = 250;
67
+
68
+ /**
69
+ * The filter of one column (§6.2.1): a list of its values with how many rows have each, read under the other
70
+ * filters (`GridStore.values`) when the engine groups rows, and a condition on typed text. Applying dispatches
71
+ * `filter.column` with the parts it makes, replacing the column's conditions.
72
+ */
73
+ function FilterForm({ column, onDone }: { column: Column; onDone: () => void }) {
74
+ const store = useGridStore();
75
+ const { messages, number } = use(LocaleContext);
76
+ const id = useId();
77
+ const parts = useGridSelector((snapshot) => snapshot.document.plan?.where);
78
+ const lists = useGridSelector((snapshot) => {
79
+ const capabilities = snapshot.table?.capabilities;
80
+ return capabilities?.plan?.aggregate !== false && (capabilities?.measures?.includes("count") ?? true);
81
+ });
82
+ const [initial] = useState(() => readFilter(conditionsOf(parts, column.name), column.name));
83
+ const read = initial === "other" ? null : initial;
84
+ const operators = operatorsFor(column.type);
85
+ const [search, setSearch] = useState("");
86
+ const [list, setList] = useState<{ values: { value: JsonValue; count: number }[]; truncated: boolean } | null>(null);
87
+ const [failure, setFailure] = useState<string | null>(null);
88
+ // Values not toggled follow `base`; `toggled` holds the values changed from it.
89
+ const [base, setBase] = useState(() => !(read?.kind === "values" && !read.exclude));
90
+ const [toggled, setToggled] = useState(() => {
91
+ const map = new Map<string, JsonValue>();
92
+ if (read?.kind !== "values") return map;
93
+ for (const value of read.values) map.set(keyOf(value, column), value);
94
+ if (read.blank) map.set(BLANK, null);
95
+ return map;
96
+ });
97
+ const [operator, setOperator] = useState<FilterOperator | "none">(
98
+ read?.kind === "condition" && operators.includes(read.operator) ? read.operator : "none",
99
+ );
100
+ const [first, setFirst] = useState(read?.kind === "condition" ? read.first : "");
101
+ const [second, setSecond] = useState(read?.kind === "condition" ? read.second : "");
102
+ const [error, setError] = useState<string | null>(null);
103
+ const [pending, startApply] = useTransition();
104
+
105
+ useEffect(() => {
106
+ if (!lists) return;
107
+ const abort = new AbortController();
108
+ const timer = setTimeout(
109
+ () => {
110
+ store
111
+ .values(column.name, { search }, abort.signal)
112
+ .then((result) => {
113
+ setList(result);
114
+ setFailure(null);
115
+ })
116
+ .catch((cause: unknown) => {
117
+ if (!abort.signal.aborted) setFailure(cause instanceof Error ? cause.message : String(cause));
118
+ });
119
+ },
120
+ search ? SEARCH_DELAY : 0,
121
+ );
122
+ return () => {
123
+ clearTimeout(timer);
124
+ abort.abort();
125
+ };
126
+ }, [store, lists, column.name, search]);
127
+
128
+ // Blank values (null and the empty string) are one item.
129
+ const items: { key: string; value: JsonValue; count: number }[] = [];
130
+ for (const item of list?.values ?? []) {
131
+ const key = keyOf(item.value, column);
132
+ const same = items.find((other) => other.key === key);
133
+ if (same) same.count += item.count;
134
+ else items.push({ key, value: item.value, count: item.count });
135
+ }
136
+ const checked = (key: string) => (toggled.has(key) ? !base : base);
137
+ const conditionOn = operator !== "none";
138
+ const allChecked = toggled.size === 0 ? base : base && items.every((item) => checked(item.key));
139
+
140
+ const toggle = (key: string, value: JsonValue) =>
141
+ setToggled((current) => {
142
+ const next = new Map(current);
143
+ if (next.has(key)) next.delete(key);
144
+ else next.set(key, value);
145
+ return next;
146
+ });
147
+
148
+ const filterOf = (): ColumnFilter | null => {
149
+ if (conditionOn) return { kind: "condition", operator, first, second };
150
+ if (base && toggled.size === 0) return null;
151
+ const values = [...toggled].flatMap(([key, value]) => (key === BLANK ? [] : [value]));
152
+ return { kind: "values", values, blank: toggled.has(BLANK), exclude: base };
153
+ };
154
+
155
+ const apply = (filter: ColumnFilter | null) => {
156
+ const command = filterCommand(filter, column);
157
+ if (!command) {
158
+ setError(messages.invalidValue(column.name));
159
+ return;
160
+ }
161
+ startApply(async () => {
162
+ const outcome = await store.dispatch(command);
163
+ if (!outcome.ok) {
164
+ setError(outcome.error.message);
165
+ return;
166
+ }
167
+ onDone();
168
+ });
169
+ };
170
+
171
+ const submit = (event: FormEvent<HTMLFormElement>) => {
172
+ event.preventDefault();
173
+ apply(filterOf());
174
+ };
175
+
176
+ const label = (value: JsonValue) => (isBlank(value, column) ? messages.blank : (textOf(value, column) ?? ""));
177
+
178
+ return (
179
+ <form className="vg-filter" onSubmit={submit} aria-labelledby={`${id}-title`}>
180
+ <p className="vg-filter__title" id={`${id}-title`}>
181
+ {messages.filterBy(column.name)}
182
+ </p>
183
+ {initial === "other" && <p className="vg-filter__note">{messages.otherConditions}</p>}
184
+ {lists && (
185
+ <>
186
+ <input
187
+ type="search"
188
+ className="vg-input"
189
+ aria-label={messages.valuesSearch}
190
+ placeholder={messages.valuesSearch}
191
+ value={search}
192
+ disabled={conditionOn}
193
+ onChange={(event) => setSearch(event.target.value)}
194
+ />
195
+ <fieldset
196
+ className="vg-filter__values"
197
+ aria-labelledby={`${id}-title`}
198
+ aria-busy={list === null}
199
+ disabled={conditionOn}
200
+ >
201
+ {!search && (
202
+ <label className="vg-filter__value">
203
+ <input
204
+ type="checkbox"
205
+ checked={allChecked}
206
+ onChange={(event) => {
207
+ setBase(event.target.checked);
208
+ setToggled(new Map());
209
+ }}
210
+ />
211
+ <span>{messages.selectAll}</span>
212
+ </label>
213
+ )}
214
+ {items.map((item) => (
215
+ <label className="vg-filter__value" key={item.key}>
216
+ <input type="checkbox" checked={checked(item.key)} onChange={() => toggle(item.key, item.value)} />
217
+ <span className="vg-filter__label">{label(item.value)}</span>
218
+ <span className="vg-filter__count">{number.format(item.count)}</span>
219
+ </label>
220
+ ))}
221
+ {list === null && failure === null && <p className="vg-filter__note">{messages.loading}</p>}
222
+ {failure !== null && <p className="vg-filter__error">{messages.failed(failure)}</p>}
223
+ </fieldset>
224
+ {list?.truncated && (
225
+ <p className="vg-filter__note">{messages.valuesTruncated(number.format(list.values.length))}</p>
226
+ )}
227
+ </>
228
+ )}
229
+ <fieldset className="vg-filter__condition">
230
+ <legend>{messages.condition}</legend>
231
+ <select
232
+ className="vg-input"
233
+ aria-label={messages.condition}
234
+ value={operator}
235
+ onChange={(event) => setOperator(event.target.value as FilterOperator | "none")}
236
+ >
237
+ <option value="none">{messages.conditionNone}</option>
238
+ {operators.map((item) => (
239
+ <option key={item} value={item}>
240
+ {messages.operators[item]}
241
+ </option>
242
+ ))}
243
+ </select>
244
+ {conditionOn && !takesNoValue(operator) && (
245
+ <input
246
+ className="vg-input"
247
+ aria-label={operator === "between" ? messages.minimum : messages.value}
248
+ placeholder={operator === "between" ? messages.minimum : messages.value}
249
+ aria-invalid={error !== null}
250
+ value={first}
251
+ onChange={(event) => setFirst(event.target.value)}
252
+ />
253
+ )}
254
+ {operator === "between" && (
255
+ <input
256
+ className="vg-input"
257
+ aria-label={messages.maximum}
258
+ placeholder={messages.maximum}
259
+ aria-invalid={error !== null}
260
+ value={second}
261
+ onChange={(event) => setSecond(event.target.value)}
262
+ />
263
+ )}
264
+ </fieldset>
265
+ {error !== null && (
266
+ <p role="alert" className="vg-filter__error">
267
+ {error}
268
+ </p>
269
+ )}
270
+ <div className="vg-filter__actions">
271
+ <button type="button" className="vg-button" disabled={pending} onClick={() => apply(null)}>
272
+ {messages.clearFilter}
273
+ </button>
274
+ <button type="submit" className="vg-button vg-button--primary" disabled={pending}>
275
+ {messages.apply}
276
+ </button>
277
+ </div>
278
+ </form>
279
+ );
280
+ }
281
+
282
+ /**
283
+ * The statistics of a column over the rows of the view (`GridStore.aggregate`): the rows, the values and the empty
284
+ * ones, the distinct values, and the measures that take its type, read by the engine when the panel opens.
285
+ */
286
+ function Statistics({ column }: { column: Column }) {
287
+ const store = useGridStore();
288
+ const { messages, number } = use(LocaleContext);
289
+ const capabilities = useGridSelector((snapshot) => snapshot.table?.capabilities);
290
+ const [result, setResult] = useState<{
291
+ values: Record<string, JsonValue>;
292
+ columns: Record<string, Column>;
293
+ } | null>(null);
294
+ const [failure, setFailure] = useState<string | null>(null);
295
+ const statistics = statisticsOf(column, capabilities);
296
+
297
+ useEffect(() => {
298
+ const abort = new AbortController();
299
+ store
300
+ .aggregate(
301
+ statisticsOf(column, capabilities).map((statistic) => statistic.measure),
302
+ abort.signal,
303
+ )
304
+ .then((read) => {
305
+ setResult(read);
306
+ setFailure(null);
307
+ })
308
+ .catch((cause: unknown) => {
309
+ if (!abort.signal.aborted) setFailure(cause instanceof Error ? cause.message : String(cause));
310
+ });
311
+ return () => abort.abort();
312
+ }, [store, column, capabilities]);
313
+
314
+ const text = (name: string) => {
315
+ const value = result?.values[name];
316
+ if (value === undefined || value === null) return "";
317
+ if (typeof value === "number") return number.format(value);
318
+ return textOf(value, result?.columns[name] ?? column) ?? "";
319
+ };
320
+ const rows = Number(result?.values.rows ?? 0);
321
+ const values = Number(result?.values.values ?? 0);
322
+ return (
323
+ <section className="vg-statistics" aria-label={messages.statisticsOf(column.name)} aria-busy={result === null}>
324
+ <p className="vg-filter__title">{messages.statisticsOf(column.name)}</p>
325
+ {failure !== null && <p className="vg-filter__error">{messages.failed(failure)}</p>}
326
+ {failure === null && result === null && <p className="vg-filter__note">{messages.loading}</p>}
327
+ {result && (
328
+ <dl className="vg-statistics__list">
329
+ {statistics.flatMap((statistic) => [
330
+ <div key={statistic.kind}>
331
+ <dt>{messages.statisticNames[statistic.kind]}</dt>
332
+ <dd>{text(statistic.kind)}</dd>
333
+ </div>,
334
+ // The empty values (null) follow the values.
335
+ ...(statistic.kind === "values"
336
+ ? [
337
+ <div key="nulls">
338
+ <dt>{messages.statisticNames.nulls}</dt>
339
+ <dd>{number.format(rows - values)}</dd>
340
+ </div>,
341
+ ]
342
+ : []),
343
+ ])}
344
+ </dl>
345
+ )}
346
+ </section>
347
+ );
348
+ }
349
+
350
+ /**
351
+ * The menu of a column of the view, opened from its header (the arrow at its right, a right click, or Alt+↓ on the
352
+ * active cell): sorting by it (§6.2), its filter, and its pin, width and visibility (§6.3), all as commands. It is
353
+ * a popover: Esc and a click outside close it, and the focus goes back to the grid.
354
+ */
355
+ export function ColumnMenu({ target, onClose }: { target: MenuTarget | null; onClose: () => void }) {
356
+ const store = useGridStore();
357
+ const { messages } = use(LocaleContext);
358
+ const ref = useRef<HTMLDivElement>(null);
359
+ const [panel, setPanel] = useState<"filter" | "statistics" | "total" | "format" | null>(null);
360
+ const [shown, setShown] = useState(target);
361
+ if (shown !== target) {
362
+ setShown(target);
363
+ setPanel(null);
364
+ }
365
+ const name = target?.column;
366
+ const item = useGridSelector((snapshot) => snapshot.displayed.find((entry) => entry.column.name === name));
367
+ const displayed = useGridSelector((snapshot) => snapshot.displayed.length);
368
+ const hidden = useGridSelector((snapshot) => (snapshot.view?.columns.length ?? 0) > snapshot.displayed.length);
369
+ const sort = useGridSelector((snapshot) => snapshot.document.plan?.sort);
370
+ const aggregate = useGridSelector((snapshot) => snapshot.document.plan?.aggregate);
371
+ const grouped = aggregate !== undefined;
372
+ const capabilities = useGridSelector((snapshot) => snapshot.table?.capabilities);
373
+ const ready = useGridSelector((snapshot) => snapshot.table?.status === "ready");
374
+
375
+ useLayoutEffect(() => {
376
+ const menu = ref.current;
377
+ if (!menu || !target) return;
378
+ if (!menu.matches(":popover-open")) menu.showPopover();
379
+ menu.querySelector<HTMLElement>("button:not(:disabled), input, select")?.focus();
380
+ }, [target]);
381
+
382
+ // The menu opens at its point, kept inside the window.
383
+ useLayoutEffect(() => {
384
+ const menu = ref.current;
385
+ if (!menu || !target) return;
386
+ const rect = menu.getBoundingClientRect();
387
+ menu.style.left = `${Math.max(8, Math.min(target.x, window.innerWidth - rect.width - 8))}px`;
388
+ menu.style.top = `${Math.max(8, Math.min(target.y, window.innerHeight - rect.height - 8))}px`;
389
+ });
390
+
391
+ const onToggle = (event: ToggleEvent<HTMLDivElement>) => {
392
+ if (event.newState === "closed") onClose();
393
+ };
394
+
395
+ if (!target || !item) {
396
+ return <div ref={ref} popover="auto" className="vg-menu vg-column-menu" onToggle={onToggle} />;
397
+ }
398
+ const column = item.column;
399
+ const close = () => ref.current?.hidePopover();
400
+ const run = (command: GridCommand) => {
401
+ close();
402
+ void store.dispatch(command);
403
+ };
404
+ const sorts = capabilities?.plan?.sort !== false;
405
+ const own = sort?.find((key) => key.column === column.name);
406
+ const others = (sort ?? []).filter((key) => key.column !== column.name);
407
+ const sortBy = (direction: SortDirection, add: boolean) =>
408
+ run({ command: "sort.set", keys: [...(add ? others : []), { column: column.name, direction }] });
409
+ const filters = ready && !grouped && capabilities?.plan?.where !== false;
410
+ const pin = item.setting.pin;
411
+ const measures = ready && !grouped && offers(capabilities, "count");
412
+ const totals = TOTALS.filter((fn) => offers(capabilities, fn) && measureTakes(fn, column));
413
+ const keys = aggregate?.keys ?? [];
414
+ const groupBy = () =>
415
+ run({
416
+ command: "group.set",
417
+ aggregate: {
418
+ keys: [column.name],
419
+ measures: [{ name: measureName("count", undefined, new Set([column.name])), fn: "count" }],
420
+ },
421
+ });
422
+ const ungroupBy = () => {
423
+ const rest = keys.filter((key) => key !== column.name);
424
+ run(
425
+ rest.length === 0 || !aggregate
426
+ ? { command: "group.clear" }
427
+ : { command: "group.set", aggregate: { ...aggregate, keys: rest } },
428
+ );
429
+ };
430
+ const back = (
431
+ <button type="button" className="vg-menu__item" onClick={() => setPanel(null)}>
432
+ ← {messages.back}
433
+ </button>
434
+ );
435
+
436
+ return (
437
+ <div
438
+ ref={ref}
439
+ popover="auto"
440
+ role="dialog"
441
+ className="vg-menu vg-column-menu"
442
+ aria-label={messages.columnMenu(column.name)}
443
+ onToggle={onToggle}
444
+ >
445
+ {panel === "filter" ? (
446
+ <>
447
+ {back}
448
+ <FilterForm key={column.name} column={column} onDone={close} />
449
+ </>
450
+ ) : panel === "format" ? (
451
+ <>
452
+ {back}
453
+ <FormatForm key={column.name} column={column} setting={item.setting} onDone={close} />
454
+ </>
455
+ ) : panel === "statistics" ? (
456
+ <>
457
+ {back}
458
+ <Statistics key={column.name} column={column} />
459
+ </>
460
+ ) : panel === "total" ? (
461
+ <>
462
+ {back}
463
+ <div role="menu" aria-label={messages.total} onKeyDown={moveFocus}>
464
+ <MenuItem
465
+ radio
466
+ checked={item.setting.total === undefined}
467
+ onClick={() => run({ command: "column.total", name: column.name, total: null })}
468
+ >
469
+ {messages.noTotal}
470
+ </MenuItem>
471
+ {totals.map((fn) => (
472
+ <MenuItem
473
+ key={fn}
474
+ radio
475
+ checked={item.setting.total?.fn === fn}
476
+ onClick={() => run({ command: "column.total", name: column.name, total: { fn } })}
477
+ >
478
+ {messages.measureNames[fn]}
479
+ </MenuItem>
480
+ ))}
481
+ </div>
482
+ </>
483
+ ) : (
484
+ <div role="menu" aria-label={messages.columnMenu(column.name)} onKeyDown={moveFocus}>
485
+ {sorts && (
486
+ <>
487
+ <MenuItem checked={own?.direction === "asc" && others.length === 0} onClick={() => sortBy("asc", false)}>
488
+ {messages.sortAscending}
489
+ </MenuItem>
490
+ <MenuItem
491
+ checked={own?.direction === "desc" && others.length === 0}
492
+ onClick={() => sortBy("desc", false)}
493
+ >
494
+ {messages.sortDescending}
495
+ </MenuItem>
496
+ {others.length > 0 && (
497
+ <>
498
+ <MenuItem onClick={() => sortBy("asc", true)}>{messages.thenSortAscending}</MenuItem>
499
+ <MenuItem onClick={() => sortBy("desc", true)}>{messages.thenSortDescending}</MenuItem>
500
+ </>
501
+ )}
502
+ {sort && sort.length > 0 && (
503
+ <MenuItem onClick={() => run({ command: "sort.clear" })}>{messages.clearSort}</MenuItem>
504
+ )}
505
+ <hr className="vg-menu__line" />
506
+ </>
507
+ )}
508
+ {filters && <MenuItem onClick={() => setPanel("filter")}>{messages.filter}</MenuItem>}
509
+ {measures && <MenuItem onClick={() => setPanel("statistics")}>{messages.statistics}</MenuItem>}
510
+ {!grouped && totals.length > 0 && (
511
+ <MenuItem onClick={() => setPanel("total")}>
512
+ {item.setting.total
513
+ ? `${messages.total}: ${messages.measureNames[item.setting.total.fn]}`
514
+ : `${messages.total}…`}
515
+ </MenuItem>
516
+ )}
517
+ {measures && capabilities?.plan?.aggregate !== false && (
518
+ <MenuItem onClick={groupBy}>{messages.groupByColumn}</MenuItem>
519
+ )}
520
+ {keys.includes(column.name) && <MenuItem onClick={ungroupBy}>{messages.removeFromGroups}</MenuItem>}
521
+ {grouped && <MenuItem onClick={() => run({ command: "group.clear" })}>{messages.ungroup}</MenuItem>}
522
+ {(filters || measures || grouped || totals.length > 0) && <hr className="vg-menu__line" />}
523
+ <MenuItem onClick={() => setPanel("format")}>{messages.format}</MenuItem>
524
+ <MenuItem onClick={() => run({ command: "column.resize", name: column.name, width: null })}>
525
+ {messages.autoFit}
526
+ </MenuItem>
527
+ {pin !== "left" && (
528
+ <MenuItem onClick={() => run({ command: "column.pin", name: column.name, side: "left" })}>
529
+ {messages.pinLeft}
530
+ </MenuItem>
531
+ )}
532
+ {pin !== "right" && (
533
+ <MenuItem onClick={() => run({ command: "column.pin", name: column.name, side: "right" })}>
534
+ {messages.pinRight}
535
+ </MenuItem>
536
+ )}
537
+ {pin !== undefined && (
538
+ <MenuItem onClick={() => run({ command: "column.pin", name: column.name, side: null })}>
539
+ {messages.unpin}
540
+ </MenuItem>
541
+ )}
542
+ <MenuItem disabled={displayed <= 1} onClick={() => run({ command: "column.hide", names: [column.name] })}>
543
+ {messages.hideColumn}
544
+ </MenuItem>
545
+ {hidden && (
546
+ <MenuItem onClick={() => run({ command: "column.show", all: true })}>{messages.showAllColumns}</MenuItem>
547
+ )}
548
+ </div>
549
+ )}
550
+ </div>
551
+ );
552
+ }
553
+
554
+ /** Arrows, Home and End move the focus among the items of a menu (the menu pattern of the ARIA Authoring Practices). */
555
+ function moveFocus(event: KeyboardEvent<HTMLDivElement>) {
556
+ const items = [...event.currentTarget.querySelectorAll<HTMLButtonElement>("button:not(:disabled)")];
557
+ const at = items.indexOf(document.activeElement as HTMLButtonElement);
558
+ const next =
559
+ event.key === "ArrowDown"
560
+ ? (at + 1) % items.length
561
+ : event.key === "ArrowUp"
562
+ ? (at - 1 + items.length) % items.length
563
+ : event.key === "Home"
564
+ ? 0
565
+ : event.key === "End"
566
+ ? items.length - 1
567
+ : -1;
568
+ if (next < 0 || items.length === 0) return;
569
+ event.preventDefault();
570
+ items[next].focus();
571
+ }
572
+
573
+ function MenuItem({
574
+ checked,
575
+ radio,
576
+ disabled,
577
+ onClick,
578
+ children,
579
+ }: {
580
+ checked?: boolean;
581
+ radio?: boolean;
582
+ disabled?: boolean;
583
+ onClick: () => void;
584
+ children: string;
585
+ }) {
586
+ if (checked === undefined) {
587
+ return (
588
+ <button type="button" role="menuitem" className="vg-menu__item" disabled={disabled} onClick={onClick}>
589
+ {children}
590
+ </button>
591
+ );
592
+ }
593
+ if (radio) {
594
+ return (
595
+ <button
596
+ type="button"
597
+ role="menuitemradio"
598
+ aria-checked={checked}
599
+ className="vg-menu__item"
600
+ disabled={disabled}
601
+ onClick={onClick}
602
+ >
603
+ {children}
604
+ </button>
605
+ );
606
+ }
607
+ return (
608
+ <button
609
+ type="button"
610
+ role="menuitemcheckbox"
611
+ aria-checked={checked}
612
+ className="vg-menu__item"
613
+ disabled={disabled}
614
+ onClick={onClick}
615
+ >
616
+ {children}
617
+ </button>
618
+ );
619
+ }