@ai-matrx/design-system 0.21.14 → 0.25.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 (97) hide show
  1. package/CHANGELOG.md +188 -0
  2. package/dist/data-table/cell-undo.cjs +98 -0
  3. package/dist/data-table/cell-undo.cjs.map +1 -0
  4. package/dist/data-table/cell-undo.d.cts +85 -0
  5. package/dist/data-table/cell-undo.d.ts +85 -0
  6. package/dist/data-table/cell-undo.js +78 -0
  7. package/dist/data-table/cell-undo.js.map +1 -0
  8. package/dist/data-table/copy-helpers.d.cts +6 -1
  9. package/dist/data-table/copy-helpers.d.ts +6 -1
  10. package/dist/data-table/coverage.cjs +116 -0
  11. package/dist/data-table/coverage.cjs.map +1 -0
  12. package/dist/data-table/coverage.d.cts +65 -0
  13. package/dist/data-table/coverage.d.ts +65 -0
  14. package/dist/data-table/coverage.js +96 -0
  15. package/dist/data-table/coverage.js.map +1 -0
  16. package/dist/data-table/fill.cjs +299 -0
  17. package/dist/data-table/fill.cjs.map +1 -0
  18. package/dist/data-table/fill.d.cts +129 -0
  19. package/dist/data-table/fill.d.ts +129 -0
  20. package/dist/data-table/fill.js +277 -0
  21. package/dist/data-table/fill.js.map +1 -0
  22. package/dist/data-table/filter-engine.d.cts +6 -1
  23. package/dist/data-table/filter-engine.d.ts +6 -1
  24. package/dist/data-table/grid-clipboard.cjs +165 -0
  25. package/dist/data-table/grid-clipboard.cjs.map +1 -0
  26. package/dist/data-table/grid-clipboard.d.cts +85 -0
  27. package/dist/data-table/grid-clipboard.d.ts +85 -0
  28. package/dist/data-table/grid-clipboard.js +145 -0
  29. package/dist/data-table/grid-clipboard.js.map +1 -0
  30. package/dist/data-table/grid-selection.cjs +258 -0
  31. package/dist/data-table/grid-selection.cjs.map +1 -0
  32. package/dist/data-table/grid-selection.d.cts +166 -0
  33. package/dist/data-table/grid-selection.d.ts +166 -0
  34. package/dist/data-table/grid-selection.js +238 -0
  35. package/dist/data-table/grid-selection.js.map +1 -0
  36. package/dist/data-table/grouping.cjs +302 -0
  37. package/dist/data-table/grouping.cjs.map +1 -0
  38. package/dist/data-table/grouping.d.cts +119 -0
  39. package/dist/data-table/grouping.d.ts +119 -0
  40. package/dist/data-table/grouping.js +282 -0
  41. package/dist/data-table/grouping.js.map +1 -0
  42. package/dist/data-table/host.d.cts +6 -1
  43. package/dist/data-table/host.d.ts +6 -1
  44. package/dist/data-table/index.cjs +4120 -806
  45. package/dist/data-table/index.cjs.map +1 -1
  46. package/dist/data-table/index.d.cts +99 -3
  47. package/dist/data-table/index.d.ts +99 -3
  48. package/dist/data-table/index.js +3971 -653
  49. package/dist/data-table/index.js.map +1 -1
  50. package/dist/data-table/infer-filter.d.cts +6 -1
  51. package/dist/data-table/infer-filter.d.ts +6 -1
  52. package/dist/data-table/layered-filters.d.cts +6 -1
  53. package/dist/data-table/layered-filters.d.ts +6 -1
  54. package/dist/data-table/query-control.d.cts +6 -1
  55. package/dist/data-table/query-control.d.ts +6 -1
  56. package/dist/data-table/spreadsheet.cjs +1014 -0
  57. package/dist/data-table/spreadsheet.cjs.map +1 -0
  58. package/dist/data-table/spreadsheet.d.cts +118 -0
  59. package/dist/data-table/spreadsheet.d.ts +118 -0
  60. package/dist/data-table/spreadsheet.js +994 -0
  61. package/dist/data-table/spreadsheet.js.map +1 -0
  62. package/dist/data-table/types.cjs.map +1 -1
  63. package/dist/data-table/types.d.cts +6 -1
  64. package/dist/data-table/types.d.ts +6 -1
  65. package/dist/data-table/url-state.d.cts +6 -1
  66. package/dist/data-table/url-state.d.ts +6 -1
  67. package/dist/data-table/validation.cjs +229 -0
  68. package/dist/data-table/validation.cjs.map +1 -0
  69. package/dist/data-table/validation.d.cts +125 -0
  70. package/dist/data-table/validation.d.ts +125 -0
  71. package/dist/data-table/validation.js +209 -0
  72. package/dist/data-table/validation.js.map +1 -0
  73. package/dist/data-table/virtual-window.cjs +203 -0
  74. package/dist/data-table/virtual-window.cjs.map +1 -0
  75. package/dist/data-table/virtual-window.d.cts +84 -0
  76. package/dist/data-table/virtual-window.d.ts +84 -0
  77. package/dist/data-table/virtual-window.js +183 -0
  78. package/dist/data-table/virtual-window.js.map +1 -0
  79. package/dist/data-table/xlsx.d.cts +6 -1
  80. package/dist/data-table/xlsx.d.ts +6 -1
  81. package/dist/field-formats/index.cjs +741 -0
  82. package/dist/field-formats/index.cjs.map +1 -0
  83. package/dist/field-formats/index.d.cts +45 -0
  84. package/dist/field-formats/index.d.ts +45 -0
  85. package/dist/field-formats/index.js +723 -0
  86. package/dist/field-formats/index.js.map +1 -0
  87. package/dist/formulas/index.cjs +1041 -0
  88. package/dist/formulas/index.cjs.map +1 -0
  89. package/dist/formulas/index.d.cts +389 -0
  90. package/dist/formulas/index.d.ts +389 -0
  91. package/dist/formulas/index.js +1019 -0
  92. package/dist/formulas/index.js.map +1 -0
  93. package/dist/types-CJWU1Gae.d.cts +204 -0
  94. package/dist/types-CJWU1Gae.d.ts +204 -0
  95. package/dist/{types-BgqIKbhq.d.cts → types-CmQrOz-T.d.cts} +382 -1
  96. package/dist/{types-emLxze_m.d.ts → types-nlLCMiqd.d.ts} +382 -1
  97. package/package.json +119 -2
@@ -0,0 +1,302 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/data-table/grouping.ts
21
+ var grouping_exports = {};
22
+ __export(grouping_exports, {
23
+ EMPTY_GROUP_KEY: () => EMPTY_GROUP_KEY,
24
+ coerceGroupNumber: () => coerceGroupNumber,
25
+ flattenGroups: () => flattenGroups,
26
+ formatGroupNumber: () => formatGroupNumber,
27
+ groupKeyOf: () => groupKeyOf,
28
+ groupRows: () => groupRows,
29
+ groupSummary: () => groupSummary,
30
+ isBlankGroupValue: () => isBlankGroupValue,
31
+ isGroupCollapsed: () => isGroupCollapsed,
32
+ toggleGroupCollapsed: () => toggleGroupCollapsed
33
+ });
34
+ module.exports = __toCommonJS(grouping_exports);
35
+ var EMPTY_GROUP_KEY = "empty";
36
+ var DEFAULT_EMPTY_LABEL = "(empty)";
37
+ function isBlankGroupValue(value) {
38
+ return value === null || value === void 0 || value === "";
39
+ }
40
+ function groupKeyOf(value) {
41
+ if (isBlankGroupValue(value)) return EMPTY_GROUP_KEY;
42
+ switch (typeof value) {
43
+ case "string":
44
+ return `s:${value}`;
45
+ case "number":
46
+ return `n:${String(value)}`;
47
+ case "boolean":
48
+ return `b:${value ? "true" : "false"}`;
49
+ case "bigint":
50
+ return `i:${String(value)}`;
51
+ case "symbol":
52
+ return `y:${String(value)}`;
53
+ case "function":
54
+ return `f:${safeString(value)}`;
55
+ default:
56
+ break;
57
+ }
58
+ if (value instanceof Date) {
59
+ const time = value.getTime();
60
+ return `d:${Number.isFinite(time) ? value.toISOString() : "invalid"}`;
61
+ }
62
+ return `o:${safeJson(value)}`;
63
+ }
64
+ function safeString(value) {
65
+ try {
66
+ return String(value);
67
+ } catch {
68
+ return "[unreadable]";
69
+ }
70
+ }
71
+ function safeJson(value) {
72
+ try {
73
+ const json = JSON.stringify(value);
74
+ return typeof json === "string" ? json : safeString(value);
75
+ } catch {
76
+ return safeString(value);
77
+ }
78
+ }
79
+ var CURRENCY_AND_SPACE = /[\s,$£€¥]/g;
80
+ function coerceGroupNumber(value) {
81
+ if (isBlankGroupValue(value)) return null;
82
+ if (typeof value === "number") return Number.isFinite(value) ? value : null;
83
+ if (typeof value === "boolean") return value ? 1 : 0;
84
+ if (typeof value === "bigint") {
85
+ const asNumber = Number(value);
86
+ return Number.isFinite(asNumber) ? asNumber : null;
87
+ }
88
+ if (typeof value !== "string") return null;
89
+ const cleaned = value.replace(CURRENCY_AND_SPACE, "");
90
+ if (cleaned === "" || cleaned === "-" || cleaned === "+") return null;
91
+ const parsed = Number(cleaned);
92
+ return Number.isFinite(parsed) ? parsed : null;
93
+ }
94
+ var NUMBER_FORMAT = new Intl.NumberFormat("en-US", { maximumFractionDigits: 2 });
95
+ function formatGroupNumber(value) {
96
+ if (!Number.isFinite(value)) return "\u2014";
97
+ try {
98
+ return NUMBER_FORMAT.format(value);
99
+ } catch {
100
+ return String(value);
101
+ }
102
+ }
103
+ function defaultLabelOf(value, emptyLabel) {
104
+ if (isBlankGroupValue(value)) return emptyLabel;
105
+ if (typeof value === "string") return value;
106
+ if (typeof value === "number") return formatGroupNumber(value);
107
+ if (typeof value === "boolean") return value ? "true" : "false";
108
+ if (value instanceof Date) {
109
+ const time = value.getTime();
110
+ return Number.isFinite(time) ? value.toISOString() : "Invalid Date";
111
+ }
112
+ if (typeof value === "object") return safeJson(value);
113
+ return safeString(value);
114
+ }
115
+ var AGGREGATE_NAMES = {
116
+ count: { name: "Count", position: "before" },
117
+ sum: { name: "Sum", position: "before" },
118
+ average: { name: "Average", position: "before" },
119
+ min: { name: "Min", position: "before" },
120
+ max: { name: "Max", position: "before" },
121
+ empty: { name: "empty", position: "after" },
122
+ filled: { name: "filled", position: "after" },
123
+ unique: { name: "unique", position: "after" },
124
+ checked: { name: "checked", position: "after" },
125
+ unchecked: { name: "unchecked", position: "after" },
126
+ percentFilled: { name: "filled", position: "after" }
127
+ };
128
+ function aggregateLabel(kind, value, override) {
129
+ const naming = AGGREGATE_NAMES[kind] ?? { name: kind, position: "before" };
130
+ const name = override && override.trim() !== "" ? override : naming.name;
131
+ const shown = value === null ? "\u2014" : kind === "percentFilled" ? `${formatGroupNumber(value)}%` : formatGroupNumber(value);
132
+ return naming.position === "before" ? `${name} ${shown}` : `${shown} ${name}`;
133
+ }
134
+ function readValue(row, reader) {
135
+ try {
136
+ return reader(row);
137
+ } catch {
138
+ return null;
139
+ }
140
+ }
141
+ function computeAggregate(rows, column) {
142
+ const kind = column.spec.kind;
143
+ const raw = rows.map((row) => readValue(row, column.readCell));
144
+ const total = raw.length;
145
+ const filled = raw.filter((value2) => !isBlankGroupValue(value2));
146
+ const numbers = filled.map((value2) => coerceGroupNumber(value2)).filter((value2) => value2 !== null);
147
+ let value;
148
+ switch (kind) {
149
+ case "count":
150
+ value = total;
151
+ break;
152
+ case "sum":
153
+ value = numbers.length === 0 ? null : numbers.reduce((acc, n) => acc + n, 0);
154
+ break;
155
+ case "average":
156
+ value = numbers.length === 0 ? null : numbers.reduce((acc, n) => acc + n, 0) / numbers.length;
157
+ break;
158
+ case "min":
159
+ value = numbers.length === 0 ? null : numbers.reduce((acc, n) => n < acc ? n : acc);
160
+ break;
161
+ case "max":
162
+ value = numbers.length === 0 ? null : numbers.reduce((acc, n) => n > acc ? n : acc);
163
+ break;
164
+ case "empty":
165
+ value = total - filled.length;
166
+ break;
167
+ case "filled":
168
+ value = filled.length;
169
+ break;
170
+ case "unique": {
171
+ const seen = /* @__PURE__ */ new Set();
172
+ for (const entry of filled) seen.add(groupKeyOf(entry));
173
+ value = seen.size;
174
+ break;
175
+ }
176
+ case "checked":
177
+ value = filled.filter((entry) => entry === true).length;
178
+ break;
179
+ case "unchecked":
180
+ value = filled.filter((entry) => entry === false).length;
181
+ break;
182
+ case "percentFilled":
183
+ value = total === 0 ? null : filled.length / total * 100;
184
+ break;
185
+ default:
186
+ value = null;
187
+ break;
188
+ }
189
+ return { kind, value, label: aggregateLabel(kind, value, column.spec.label) };
190
+ }
191
+ function compareLabels(a, b) {
192
+ if (a === b) return 0;
193
+ return a < b ? -1 : 1;
194
+ }
195
+ function compareValues(a, b) {
196
+ const aNumber = typeof a.value === "number" ? a.value : null;
197
+ const bNumber = typeof b.value === "number" ? b.value : null;
198
+ if (aNumber !== null && bNumber !== null && aNumber !== bNumber) return aNumber < bNumber ? -1 : 1;
199
+ if (typeof a.value === "boolean" && typeof b.value === "boolean" && a.value !== b.value) {
200
+ return a.value ? 1 : -1;
201
+ }
202
+ return compareLabels(a.label, b.label);
203
+ }
204
+ function groupRows(options) {
205
+ const rows = Array.isArray(options.rows) ? options.rows : [];
206
+ const emptyLabel = options.emptyLabel ?? DEFAULT_EMPTY_LABEL;
207
+ const order = options.order ?? "value-asc";
208
+ const aggregates = options.aggregates ?? {};
209
+ const labelFor = (value) => {
210
+ if (!options.labelOf) return defaultLabelOf(value, emptyLabel);
211
+ if (isBlankGroupValue(value)) {
212
+ try {
213
+ const custom = options.labelOf(value);
214
+ return typeof custom === "string" && custom !== "" ? custom : emptyLabel;
215
+ } catch {
216
+ return emptyLabel;
217
+ }
218
+ }
219
+ try {
220
+ const custom = options.labelOf(value);
221
+ return typeof custom === "string" ? custom : defaultLabelOf(value, emptyLabel);
222
+ } catch {
223
+ return defaultLabelOf(value, emptyLabel);
224
+ }
225
+ };
226
+ const buckets = /* @__PURE__ */ new Map();
227
+ for (const row of rows) {
228
+ const raw = readValue(row, options.readCell);
229
+ const blank = isBlankGroupValue(raw);
230
+ const value = blank ? null : raw;
231
+ const key = groupKeyOf(value);
232
+ let bucket = buckets.get(key);
233
+ if (!bucket) {
234
+ bucket = { key, value, label: labelFor(value), rows: [], seen: buckets.size };
235
+ buckets.set(key, bucket);
236
+ }
237
+ bucket.rows.push(row);
238
+ }
239
+ const ordered = [...buckets.values()].sort((a, b) => {
240
+ const aBlank = a.key === EMPTY_GROUP_KEY;
241
+ const bBlank = b.key === EMPTY_GROUP_KEY;
242
+ if (aBlank !== bBlank) return aBlank ? 1 : -1;
243
+ if (aBlank && bBlank) return 0;
244
+ switch (order) {
245
+ case "value-asc":
246
+ return compareValues(a, b) || a.seen - b.seen;
247
+ case "value-desc":
248
+ return -compareValues(a, b) || a.seen - b.seen;
249
+ case "count-desc":
250
+ return b.rows.length - a.rows.length || a.seen - b.seen;
251
+ case "first-seen":
252
+ default:
253
+ return a.seen - b.seen;
254
+ }
255
+ });
256
+ const aggregateEntries = Object.entries(aggregates);
257
+ return ordered.map((bucket, index) => {
258
+ const results = {};
259
+ for (const [columnId, column] of aggregateEntries) {
260
+ if (!column || typeof column.readCell !== "function" || !column.spec) continue;
261
+ results[columnId] = computeAggregate(bucket.rows, column);
262
+ }
263
+ return {
264
+ key: bucket.key,
265
+ value: bucket.value,
266
+ label: bucket.label,
267
+ rows: bucket.rows,
268
+ aggregates: results,
269
+ index
270
+ };
271
+ });
272
+ }
273
+ function toggleGroupCollapsed(collapsed, key) {
274
+ const list = Array.isArray(collapsed) ? collapsed : [];
275
+ return list.includes(key) ? list.filter((entry) => entry !== key) : [...list, key];
276
+ }
277
+ function isGroupCollapsed(collapsed, key) {
278
+ return Array.isArray(collapsed) ? collapsed.includes(key) : false;
279
+ }
280
+ function flattenGroups(groups, collapsed) {
281
+ const out = [];
282
+ let dataIndex = 0;
283
+ for (const group of Array.isArray(groups) ? groups : []) {
284
+ out.push({ kind: "group", group });
285
+ if (isGroupCollapsed(collapsed, group.key)) continue;
286
+ for (const row of group.rows) {
287
+ out.push({ kind: "row", group, row, dataIndex });
288
+ dataIndex += 1;
289
+ }
290
+ }
291
+ return out;
292
+ }
293
+ function groupSummary(group, rowNoun = "row") {
294
+ const count = group?.rows?.length ?? 0;
295
+ const noun = count === 1 ? rowNoun : `${rowNoun}s`;
296
+ const parts = [`${formatGroupNumber(count)} ${noun}`];
297
+ for (const result of Object.values(group?.aggregates ?? {})) {
298
+ if (result?.label) parts.push(result.label);
299
+ }
300
+ return parts.join(" \xB7 ");
301
+ }
302
+ //# sourceMappingURL=grouping.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/data-table/grouping.ts"],"sourcesContent":["/**\n * data-table/grouping.ts — THE grouping model for the canonical data table.\n *\n * \"Group these rows by Status and get collapsible sections with a count and a\n * subtotal on each.\" Champions: Airtable's group-by, Google Sheets' grouped\n * pivot rows, Notion's board/table groups.\n *\n * THE FOUR RULES THIS FILE OBEYS\n *\n * 1. **Pure.** No React, no DOM, no clock, no globals. Rows in, groups out.\n * That is what makes every clause below testable without a renderer.\n * 2. **Nothing throws.** A `readCell` written by a caller is hostile input: if\n * it throws, the row is treated as BLANK, never as a crashed table.\n * 3. **Nothing lies.** An aggregate with no values to speak about reports\n * `value: null` and a label that SAYS so (\"Sum —\"). It never reports `0`,\n * because \"the sum of nothing\" and \"the sum is zero\" are different facts\n * and a person reading a group header must be able to tell them apart.\n * 4. **Order is the caller's.** Grouping is stable: within a group the rows\n * keep the exact order they arrived in. The table already sorted them;\n * re-sorting here would silently override the user's sort.\n *\n * THE COERCION RULES (the same spirit as the formula engine in\n * `features/data-tables/formulas.ts`, implemented locally so this module stays\n * dependency-free)\n *\n * - **BLANK** is `null`, `undefined` and `\"\"` — all three, because a cleared\n * text cell holds `\"\"` and a cleared number cell holds `null`, and a person\n * does not distinguish them. All three land in ONE group, labelled\n * `(empty)`, and that group sorts LAST in every ordering: a section of\n * unanswered rows is not the first thing anyone wants to read.\n * - **Numeric strings coerce.** `\"1,234\"`, `\" 42 \"`, `\"$5\"` and `\"3.5\"` are\n * numbers to an aggregate; `\"n/a\"` is simply not a number and is skipped.\n * - **Booleans are 1 and 0**, so a sum over a checkbox column counts ticks.\n * - **Aggregates skip blanks and non-numbers.** `average` divides by the count\n * of values that were actually numeric.\n */\n\n// ─── Public types ────────────────────────────────────────────────────────────\n\nexport type GroupAggregateKind =\n | \"count\"\n | \"sum\"\n | \"average\"\n | \"min\"\n | \"max\"\n | \"empty\"\n | \"filled\"\n | \"unique\"\n | \"checked\"\n | \"unchecked\"\n | \"percentFilled\";\n\nexport interface GroupAggregateSpec {\n kind: GroupAggregateKind;\n label?: string;\n}\n\nexport interface GroupAggregateResult {\n kind: GroupAggregateKind;\n /** null when the aggregate has nothing to say (no numeric values at all). */\n value: number | null;\n /** A short human sentence fragment for the group row, e.g. \"Sum 4,812.5\". Never a bare number with no name. */\n label: string;\n}\n\nexport interface TableGroup<T> {\n /** Stable key for collapse state and React keys. Derived from the raw value, never the display text. */\n key: string;\n /** The raw grouping value. */\n value: unknown;\n /** What the group header shows. Empty values group together under one explicit label. */\n label: string;\n rows: T[];\n /** Per-column aggregate results, keyed by column id. */\n aggregates: Record<string, GroupAggregateResult>;\n /** Group order index. */\n index: number;\n}\n\nexport type GroupOrder = \"value-asc\" | \"value-desc\" | \"count-desc\" | \"first-seen\";\n\nexport interface GroupAggregateColumn<T> {\n spec: GroupAggregateSpec;\n readCell: (row: T) => unknown;\n}\n\nexport interface GroupRowsOptions<T> {\n rows: T[];\n /** Column id to group by. */\n columnId: string;\n /** Reads the grouping value off a row. */\n readCell: (row: T) => unknown;\n /** Turns a raw value into its display label. Defaults to a sensible string rendering. */\n labelOf?: (value: unknown) => string;\n /** Per-column aggregates and how to read that column's value. */\n aggregates?: Record<string, GroupAggregateColumn<T>>;\n /** Group ordering. Default \"value-asc\". \"count-desc\" is the Airtable default for a status column. */\n order?: GroupOrder;\n /** Label for rows whose grouping value is blank. Default \"(empty)\". */\n emptyLabel?: string;\n}\n\nexport interface GroupedRenderRow<T> {\n kind: \"group\" | \"row\";\n group: TableGroup<T>;\n /** Present only when kind === \"row\". */\n row?: T;\n /** Index within the flat list of DATA rows only (group headers do not consume one). */\n dataIndex?: number;\n}\n\n// ─── Blank, keys, coercion ───────────────────────────────────────────────────\n\nexport const EMPTY_GROUP_KEY = \"empty\";\nconst DEFAULT_EMPTY_LABEL = \"(empty)\";\n\n/** BLANK is `null`, `undefined` and `\"\"` — nothing else. */\nexport function isBlankGroupValue(value: unknown): boolean {\n return value === null || value === undefined || value === \"\";\n}\n\n/**\n * A key that is stable across renders and collision-free across values that\n * happen to render the same text: the number `3` is `n:3` and the string `\"3\"`\n * is `s:3`, so both are addressable and neither swallows the other.\n */\nexport function groupKeyOf(value: unknown): string {\n if (isBlankGroupValue(value)) return EMPTY_GROUP_KEY;\n switch (typeof value) {\n case \"string\":\n return `s:${value}`;\n case \"number\":\n return `n:${String(value)}`;\n case \"boolean\":\n return `b:${value ? \"true\" : \"false\"}`;\n case \"bigint\":\n return `i:${String(value)}`;\n case \"symbol\":\n return `y:${String(value)}`;\n case \"function\":\n return `f:${safeString(value)}`;\n default:\n break;\n }\n if (value instanceof Date) {\n const time = value.getTime();\n return `d:${Number.isFinite(time) ? value.toISOString() : \"invalid\"}`;\n }\n return `o:${safeJson(value)}`;\n}\n\nfunction safeString(value: unknown): string {\n try {\n return String(value);\n } catch {\n return \"[unreadable]\";\n }\n}\n\nfunction safeJson(value: unknown): string {\n try {\n const json = JSON.stringify(value);\n return typeof json === \"string\" ? json : safeString(value);\n } catch {\n return safeString(value);\n }\n}\n\nconst CURRENCY_AND_SPACE = /[\\s,$£€¥]/g;\n\n/**\n * The local coercion. Blanks and anything that is not a number are `null`\n * (an aggregate skips them); booleans are 1 and 0; a numeric string survives\n * thousands separators, padding and a currency symbol.\n */\nexport function coerceGroupNumber(value: unknown): number | null {\n if (isBlankGroupValue(value)) return null;\n if (typeof value === \"number\") return Number.isFinite(value) ? value : null;\n if (typeof value === \"boolean\") return value ? 1 : 0;\n if (typeof value === \"bigint\") {\n const asNumber = Number(value);\n return Number.isFinite(asNumber) ? asNumber : null;\n }\n if (typeof value !== \"string\") return null;\n const cleaned = value.replace(CURRENCY_AND_SPACE, \"\");\n if (cleaned === \"\" || cleaned === \"-\" || cleaned === \"+\") return null;\n const parsed = Number(cleaned);\n return Number.isFinite(parsed) ? parsed : null;\n}\n\n// ─── Labels ──────────────────────────────────────────────────────────────────\n\nconst NUMBER_FORMAT = new Intl.NumberFormat(\"en-US\", { maximumFractionDigits: 2 });\n\nexport function formatGroupNumber(value: number): string {\n if (!Number.isFinite(value)) return \"—\";\n try {\n return NUMBER_FORMAT.format(value);\n } catch {\n return String(value);\n }\n}\n\nfunction defaultLabelOf(value: unknown, emptyLabel: string): string {\n if (isBlankGroupValue(value)) return emptyLabel;\n if (typeof value === \"string\") return value;\n if (typeof value === \"number\") return formatGroupNumber(value);\n if (typeof value === \"boolean\") return value ? \"true\" : \"false\";\n if (value instanceof Date) {\n const time = value.getTime();\n return Number.isFinite(time) ? value.toISOString() : \"Invalid Date\";\n }\n if (typeof value === \"object\") return safeJson(value);\n return safeString(value);\n}\n\n/** The aggregate's own name, and which side of the number it sits on. */\nconst AGGREGATE_NAMES: Record<GroupAggregateKind, { name: string; position: \"before\" | \"after\" }> = {\n count: { name: \"Count\", position: \"before\" },\n sum: { name: \"Sum\", position: \"before\" },\n average: { name: \"Average\", position: \"before\" },\n min: { name: \"Min\", position: \"before\" },\n max: { name: \"Max\", position: \"before\" },\n empty: { name: \"empty\", position: \"after\" },\n filled: { name: \"filled\", position: \"after\" },\n unique: { name: \"unique\", position: \"after\" },\n checked: { name: \"checked\", position: \"after\" },\n unchecked: { name: \"unchecked\", position: \"after\" },\n percentFilled: { name: \"filled\", position: \"after\" },\n};\n\nfunction aggregateLabel(kind: GroupAggregateKind, value: number | null, override?: string): string {\n const naming = AGGREGATE_NAMES[kind] ?? { name: kind, position: \"before\" as const };\n const name = override && override.trim() !== \"\" ? override : naming.name;\n const shown = value === null ? \"—\" : kind === \"percentFilled\" ? `${formatGroupNumber(value)}%` : formatGroupNumber(value);\n return naming.position === \"before\" ? `${name} ${shown}` : `${shown} ${name}`;\n}\n\n// ─── Aggregates ──────────────────────────────────────────────────────────────\n\nfunction readValue<T>(row: T, reader: (row: T) => unknown): unknown {\n try {\n return reader(row);\n } catch {\n return null;\n }\n}\n\nfunction computeAggregate<T>(rows: T[], column: GroupAggregateColumn<T>): GroupAggregateResult {\n const kind = column.spec.kind;\n const raw = rows.map((row) => readValue(row, column.readCell));\n const total = raw.length;\n const filled = raw.filter((value) => !isBlankGroupValue(value));\n const numbers = filled\n .map((value) => coerceGroupNumber(value))\n .filter((value): value is number => value !== null);\n\n let value: number | null;\n switch (kind) {\n case \"count\":\n value = total;\n break;\n case \"sum\":\n value = numbers.length === 0 ? null : numbers.reduce((acc, n) => acc + n, 0);\n break;\n case \"average\":\n value = numbers.length === 0 ? null : numbers.reduce((acc, n) => acc + n, 0) / numbers.length;\n break;\n case \"min\":\n value = numbers.length === 0 ? null : numbers.reduce((acc, n) => (n < acc ? n : acc));\n break;\n case \"max\":\n value = numbers.length === 0 ? null : numbers.reduce((acc, n) => (n > acc ? n : acc));\n break;\n case \"empty\":\n value = total - filled.length;\n break;\n case \"filled\":\n value = filled.length;\n break;\n case \"unique\": {\n const seen = new Set<string>();\n for (const entry of filled) seen.add(groupKeyOf(entry));\n value = seen.size;\n break;\n }\n case \"checked\":\n value = filled.filter((entry) => entry === true).length;\n break;\n case \"unchecked\":\n value = filled.filter((entry) => entry === false).length;\n break;\n case \"percentFilled\":\n value = total === 0 ? null : (filled.length / total) * 100;\n break;\n default:\n value = null;\n break;\n }\n\n return { kind, value, label: aggregateLabel(kind, value, column.spec.label) };\n}\n\n// ─── Grouping ────────────────────────────────────────────────────────────────\n\ninterface Bucket<T> {\n key: string;\n value: unknown;\n label: string;\n rows: T[];\n seen: number;\n}\n\nfunction compareLabels(a: string, b: string): number {\n if (a === b) return 0;\n return a < b ? -1 : 1;\n}\n\nfunction compareValues<T>(a: Bucket<T>, b: Bucket<T>): number {\n const aNumber = typeof a.value === \"number\" ? a.value : null;\n const bNumber = typeof b.value === \"number\" ? b.value : null;\n if (aNumber !== null && bNumber !== null && aNumber !== bNumber) return aNumber < bNumber ? -1 : 1;\n if (typeof a.value === \"boolean\" && typeof b.value === \"boolean\" && a.value !== b.value) {\n return a.value ? 1 : -1;\n }\n return compareLabels(a.label, b.label);\n}\n\n/**\n * Groups rows by one column. Row order inside a group is the order they\n * arrived in; the blank group is always last.\n */\nexport function groupRows<T>(options: GroupRowsOptions<T>): TableGroup<T>[] {\n const rows = Array.isArray(options.rows) ? options.rows : [];\n const emptyLabel = options.emptyLabel ?? DEFAULT_EMPTY_LABEL;\n const order: GroupOrder = options.order ?? \"value-asc\";\n const aggregates = options.aggregates ?? {};\n\n const labelFor = (value: unknown): string => {\n if (!options.labelOf) return defaultLabelOf(value, emptyLabel);\n if (isBlankGroupValue(value)) {\n // The blank group's label is the caller's `emptyLabel` unless their\n // own `labelOf` answers with something non-empty for a blank.\n try {\n const custom = options.labelOf(value);\n return typeof custom === \"string\" && custom !== \"\" ? custom : emptyLabel;\n } catch {\n return emptyLabel;\n }\n }\n try {\n const custom = options.labelOf(value);\n return typeof custom === \"string\" ? custom : defaultLabelOf(value, emptyLabel);\n } catch {\n return defaultLabelOf(value, emptyLabel);\n }\n };\n\n const buckets = new Map<string, Bucket<T>>();\n for (const row of rows) {\n const raw = readValue(row, options.readCell);\n const blank = isBlankGroupValue(raw);\n const value = blank ? null : raw;\n const key = groupKeyOf(value);\n let bucket = buckets.get(key);\n if (!bucket) {\n bucket = { key, value, label: labelFor(value), rows: [], seen: buckets.size };\n buckets.set(key, bucket);\n }\n bucket.rows.push(row);\n }\n\n const ordered = [...buckets.values()].sort((a, b) => {\n // The blank group is last in EVERY ordering.\n const aBlank = a.key === EMPTY_GROUP_KEY;\n const bBlank = b.key === EMPTY_GROUP_KEY;\n if (aBlank !== bBlank) return aBlank ? 1 : -1;\n if (aBlank && bBlank) return 0;\n\n switch (order) {\n case \"value-asc\":\n return compareValues(a, b) || a.seen - b.seen;\n case \"value-desc\":\n return -compareValues(a, b) || a.seen - b.seen;\n case \"count-desc\":\n return b.rows.length - a.rows.length || a.seen - b.seen;\n case \"first-seen\":\n default:\n return a.seen - b.seen;\n }\n });\n\n const aggregateEntries = Object.entries(aggregates);\n return ordered.map((bucket, index) => {\n const results: Record<string, GroupAggregateResult> = {};\n for (const [columnId, column] of aggregateEntries) {\n if (!column || typeof column.readCell !== \"function\" || !column.spec) continue;\n results[columnId] = computeAggregate(bucket.rows, column);\n }\n return {\n key: bucket.key,\n value: bucket.value,\n label: bucket.label,\n rows: bucket.rows,\n aggregates: results,\n index,\n };\n });\n}\n\n// ─── Collapse state ──────────────────────────────────────────────────────────\n\n/** Collapse state as a pure set operation, so the consumer can persist it. */\nexport function toggleGroupCollapsed(collapsed: readonly string[], key: string): string[] {\n const list = Array.isArray(collapsed) ? collapsed : [];\n return list.includes(key) ? list.filter((entry) => entry !== key) : [...list, key];\n}\n\nexport function isGroupCollapsed(collapsed: readonly string[], key: string): boolean {\n return Array.isArray(collapsed) ? collapsed.includes(key) : false;\n}\n\n// ─── Flattening ──────────────────────────────────────────────────────────────\n\n/** Flattens groups into the exact sequence the table renders, honouring collapse. */\nexport function flattenGroups<T>(groups: TableGroup<T>[], collapsed: readonly string[]): GroupedRenderRow<T>[] {\n const out: GroupedRenderRow<T>[] = [];\n let dataIndex = 0;\n for (const group of Array.isArray(groups) ? groups : []) {\n out.push({ kind: \"group\", group });\n if (isGroupCollapsed(collapsed, group.key)) continue;\n for (const row of group.rows) {\n out.push({ kind: \"row\", group, row, dataIndex });\n dataIndex += 1;\n }\n }\n return out;\n}\n\n// ─── Header summary ──────────────────────────────────────────────────────────\n\n/** The one-line summary a group header shows after its label, e.g. \"12 rows · Sum 4,812.5\". */\nexport function groupSummary<T>(group: TableGroup<T>, rowNoun = \"row\"): string {\n const count = group?.rows?.length ?? 0;\n const noun = count === 1 ? rowNoun : `${rowNoun}s`;\n const parts = [`${formatGroupNumber(count)} ${noun}`];\n for (const result of Object.values(group?.aggregates ?? {})) {\n if (result?.label) parts.push(result.label);\n }\n return parts.join(\" · \");\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAiHO,IAAM,kBAAkB;AAC/B,IAAM,sBAAsB;AAGrB,SAAS,kBAAkB,OAAyB;AACvD,SAAO,UAAU,QAAQ,UAAU,UAAa,UAAU;AAC9D;AAOO,SAAS,WAAW,OAAwB;AAC/C,MAAI,kBAAkB,KAAK,EAAG,QAAO;AACrC,UAAQ,OAAO,OAAO;AAAA,IAClB,KAAK;AACD,aAAO,KAAK,KAAK;AAAA,IACrB,KAAK;AACD,aAAO,KAAK,OAAO,KAAK,CAAC;AAAA,IAC7B,KAAK;AACD,aAAO,KAAK,QAAQ,SAAS,OAAO;AAAA,IACxC,KAAK;AACD,aAAO,KAAK,OAAO,KAAK,CAAC;AAAA,IAC7B,KAAK;AACD,aAAO,KAAK,OAAO,KAAK,CAAC;AAAA,IAC7B,KAAK;AACD,aAAO,KAAK,WAAW,KAAK,CAAC;AAAA,IACjC;AACI;AAAA,EACR;AACA,MAAI,iBAAiB,MAAM;AACvB,UAAM,OAAO,MAAM,QAAQ;AAC3B,WAAO,KAAK,OAAO,SAAS,IAAI,IAAI,MAAM,YAAY,IAAI,SAAS;AAAA,EACvE;AACA,SAAO,KAAK,SAAS,KAAK,CAAC;AAC/B;AAEA,SAAS,WAAW,OAAwB;AACxC,MAAI;AACA,WAAO,OAAO,KAAK;AAAA,EACvB,QAAQ;AACJ,WAAO;AAAA,EACX;AACJ;AAEA,SAAS,SAAS,OAAwB;AACtC,MAAI;AACA,UAAM,OAAO,KAAK,UAAU,KAAK;AACjC,WAAO,OAAO,SAAS,WAAW,OAAO,WAAW,KAAK;AAAA,EAC7D,QAAQ;AACJ,WAAO,WAAW,KAAK;AAAA,EAC3B;AACJ;AAEA,IAAM,qBAAqB;AAOpB,SAAS,kBAAkB,OAA+B;AAC7D,MAAI,kBAAkB,KAAK,EAAG,QAAO;AACrC,MAAI,OAAO,UAAU,SAAU,QAAO,OAAO,SAAS,KAAK,IAAI,QAAQ;AACvE,MAAI,OAAO,UAAU,UAAW,QAAO,QAAQ,IAAI;AACnD,MAAI,OAAO,UAAU,UAAU;AAC3B,UAAM,WAAW,OAAO,KAAK;AAC7B,WAAO,OAAO,SAAS,QAAQ,IAAI,WAAW;AAAA,EAClD;AACA,MAAI,OAAO,UAAU,SAAU,QAAO;AACtC,QAAM,UAAU,MAAM,QAAQ,oBAAoB,EAAE;AACpD,MAAI,YAAY,MAAM,YAAY,OAAO,YAAY,IAAK,QAAO;AACjE,QAAM,SAAS,OAAO,OAAO;AAC7B,SAAO,OAAO,SAAS,MAAM,IAAI,SAAS;AAC9C;AAIA,IAAM,gBAAgB,IAAI,KAAK,aAAa,SAAS,EAAE,uBAAuB,EAAE,CAAC;AAE1E,SAAS,kBAAkB,OAAuB;AACrD,MAAI,CAAC,OAAO,SAAS,KAAK,EAAG,QAAO;AACpC,MAAI;AACA,WAAO,cAAc,OAAO,KAAK;AAAA,EACrC,QAAQ;AACJ,WAAO,OAAO,KAAK;AAAA,EACvB;AACJ;AAEA,SAAS,eAAe,OAAgB,YAA4B;AAChE,MAAI,kBAAkB,KAAK,EAAG,QAAO;AACrC,MAAI,OAAO,UAAU,SAAU,QAAO;AACtC,MAAI,OAAO,UAAU,SAAU,QAAO,kBAAkB,KAAK;AAC7D,MAAI,OAAO,UAAU,UAAW,QAAO,QAAQ,SAAS;AACxD,MAAI,iBAAiB,MAAM;AACvB,UAAM,OAAO,MAAM,QAAQ;AAC3B,WAAO,OAAO,SAAS,IAAI,IAAI,MAAM,YAAY,IAAI;AAAA,EACzD;AACA,MAAI,OAAO,UAAU,SAAU,QAAO,SAAS,KAAK;AACpD,SAAO,WAAW,KAAK;AAC3B;AAGA,IAAM,kBAA8F;AAAA,EAChG,OAAO,EAAE,MAAM,SAAS,UAAU,SAAS;AAAA,EAC3C,KAAK,EAAE,MAAM,OAAO,UAAU,SAAS;AAAA,EACvC,SAAS,EAAE,MAAM,WAAW,UAAU,SAAS;AAAA,EAC/C,KAAK,EAAE,MAAM,OAAO,UAAU,SAAS;AAAA,EACvC,KAAK,EAAE,MAAM,OAAO,UAAU,SAAS;AAAA,EACvC,OAAO,EAAE,MAAM,SAAS,UAAU,QAAQ;AAAA,EAC1C,QAAQ,EAAE,MAAM,UAAU,UAAU,QAAQ;AAAA,EAC5C,QAAQ,EAAE,MAAM,UAAU,UAAU,QAAQ;AAAA,EAC5C,SAAS,EAAE,MAAM,WAAW,UAAU,QAAQ;AAAA,EAC9C,WAAW,EAAE,MAAM,aAAa,UAAU,QAAQ;AAAA,EAClD,eAAe,EAAE,MAAM,UAAU,UAAU,QAAQ;AACvD;AAEA,SAAS,eAAe,MAA0B,OAAsB,UAA2B;AAC/F,QAAM,SAAS,gBAAgB,IAAI,KAAK,EAAE,MAAM,MAAM,UAAU,SAAkB;AAClF,QAAM,OAAO,YAAY,SAAS,KAAK,MAAM,KAAK,WAAW,OAAO;AACpE,QAAM,QAAQ,UAAU,OAAO,WAAM,SAAS,kBAAkB,GAAG,kBAAkB,KAAK,CAAC,MAAM,kBAAkB,KAAK;AACxH,SAAO,OAAO,aAAa,WAAW,GAAG,IAAI,IAAI,KAAK,KAAK,GAAG,KAAK,IAAI,IAAI;AAC/E;AAIA,SAAS,UAAa,KAAQ,QAAsC;AAChE,MAAI;AACA,WAAO,OAAO,GAAG;AAAA,EACrB,QAAQ;AACJ,WAAO;AAAA,EACX;AACJ;AAEA,SAAS,iBAAoB,MAAW,QAAuD;AAC3F,QAAM,OAAO,OAAO,KAAK;AACzB,QAAM,MAAM,KAAK,IAAI,CAAC,QAAQ,UAAU,KAAK,OAAO,QAAQ,CAAC;AAC7D,QAAM,QAAQ,IAAI;AAClB,QAAM,SAAS,IAAI,OAAO,CAACA,WAAU,CAAC,kBAAkBA,MAAK,CAAC;AAC9D,QAAM,UAAU,OACX,IAAI,CAACA,WAAU,kBAAkBA,MAAK,CAAC,EACvC,OAAO,CAACA,WAA2BA,WAAU,IAAI;AAEtD,MAAI;AACJ,UAAQ,MAAM;AAAA,IACV,KAAK;AACD,cAAQ;AACR;AAAA,IACJ,KAAK;AACD,cAAQ,QAAQ,WAAW,IAAI,OAAO,QAAQ,OAAO,CAAC,KAAK,MAAM,MAAM,GAAG,CAAC;AAC3E;AAAA,IACJ,KAAK;AACD,cAAQ,QAAQ,WAAW,IAAI,OAAO,QAAQ,OAAO,CAAC,KAAK,MAAM,MAAM,GAAG,CAAC,IAAI,QAAQ;AACvF;AAAA,IACJ,KAAK;AACD,cAAQ,QAAQ,WAAW,IAAI,OAAO,QAAQ,OAAO,CAAC,KAAK,MAAO,IAAI,MAAM,IAAI,GAAI;AACpF;AAAA,IACJ,KAAK;AACD,cAAQ,QAAQ,WAAW,IAAI,OAAO,QAAQ,OAAO,CAAC,KAAK,MAAO,IAAI,MAAM,IAAI,GAAI;AACpF;AAAA,IACJ,KAAK;AACD,cAAQ,QAAQ,OAAO;AACvB;AAAA,IACJ,KAAK;AACD,cAAQ,OAAO;AACf;AAAA,IACJ,KAAK,UAAU;AACX,YAAM,OAAO,oBAAI,IAAY;AAC7B,iBAAW,SAAS,OAAQ,MAAK,IAAI,WAAW,KAAK,CAAC;AACtD,cAAQ,KAAK;AACb;AAAA,IACJ;AAAA,IACA,KAAK;AACD,cAAQ,OAAO,OAAO,CAAC,UAAU,UAAU,IAAI,EAAE;AACjD;AAAA,IACJ,KAAK;AACD,cAAQ,OAAO,OAAO,CAAC,UAAU,UAAU,KAAK,EAAE;AAClD;AAAA,IACJ,KAAK;AACD,cAAQ,UAAU,IAAI,OAAQ,OAAO,SAAS,QAAS;AACvD;AAAA,IACJ;AACI,cAAQ;AACR;AAAA,EACR;AAEA,SAAO,EAAE,MAAM,OAAO,OAAO,eAAe,MAAM,OAAO,OAAO,KAAK,KAAK,EAAE;AAChF;AAYA,SAAS,cAAc,GAAW,GAAmB;AACjD,MAAI,MAAM,EAAG,QAAO;AACpB,SAAO,IAAI,IAAI,KAAK;AACxB;AAEA,SAAS,cAAiB,GAAc,GAAsB;AAC1D,QAAM,UAAU,OAAO,EAAE,UAAU,WAAW,EAAE,QAAQ;AACxD,QAAM,UAAU,OAAO,EAAE,UAAU,WAAW,EAAE,QAAQ;AACxD,MAAI,YAAY,QAAQ,YAAY,QAAQ,YAAY,QAAS,QAAO,UAAU,UAAU,KAAK;AACjG,MAAI,OAAO,EAAE,UAAU,aAAa,OAAO,EAAE,UAAU,aAAa,EAAE,UAAU,EAAE,OAAO;AACrF,WAAO,EAAE,QAAQ,IAAI;AAAA,EACzB;AACA,SAAO,cAAc,EAAE,OAAO,EAAE,KAAK;AACzC;AAMO,SAAS,UAAa,SAA+C;AACxE,QAAM,OAAO,MAAM,QAAQ,QAAQ,IAAI,IAAI,QAAQ,OAAO,CAAC;AAC3D,QAAM,aAAa,QAAQ,cAAc;AACzC,QAAM,QAAoB,QAAQ,SAAS;AAC3C,QAAM,aAAa,QAAQ,cAAc,CAAC;AAE1C,QAAM,WAAW,CAAC,UAA2B;AACzC,QAAI,CAAC,QAAQ,QAAS,QAAO,eAAe,OAAO,UAAU;AAC7D,QAAI,kBAAkB,KAAK,GAAG;AAG1B,UAAI;AACA,cAAM,SAAS,QAAQ,QAAQ,KAAK;AACpC,eAAO,OAAO,WAAW,YAAY,WAAW,KAAK,SAAS;AAAA,MAClE,QAAQ;AACJ,eAAO;AAAA,MACX;AAAA,IACJ;AACA,QAAI;AACA,YAAM,SAAS,QAAQ,QAAQ,KAAK;AACpC,aAAO,OAAO,WAAW,WAAW,SAAS,eAAe,OAAO,UAAU;AAAA,IACjF,QAAQ;AACJ,aAAO,eAAe,OAAO,UAAU;AAAA,IAC3C;AAAA,EACJ;AAEA,QAAM,UAAU,oBAAI,IAAuB;AAC3C,aAAW,OAAO,MAAM;AACpB,UAAM,MAAM,UAAU,KAAK,QAAQ,QAAQ;AAC3C,UAAM,QAAQ,kBAAkB,GAAG;AACnC,UAAM,QAAQ,QAAQ,OAAO;AAC7B,UAAM,MAAM,WAAW,KAAK;AAC5B,QAAI,SAAS,QAAQ,IAAI,GAAG;AAC5B,QAAI,CAAC,QAAQ;AACT,eAAS,EAAE,KAAK,OAAO,OAAO,SAAS,KAAK,GAAG,MAAM,CAAC,GAAG,MAAM,QAAQ,KAAK;AAC5E,cAAQ,IAAI,KAAK,MAAM;AAAA,IAC3B;AACA,WAAO,KAAK,KAAK,GAAG;AAAA,EACxB;AAEA,QAAM,UAAU,CAAC,GAAG,QAAQ,OAAO,CAAC,EAAE,KAAK,CAAC,GAAG,MAAM;AAEjD,UAAM,SAAS,EAAE,QAAQ;AACzB,UAAM,SAAS,EAAE,QAAQ;AACzB,QAAI,WAAW,OAAQ,QAAO,SAAS,IAAI;AAC3C,QAAI,UAAU,OAAQ,QAAO;AAE7B,YAAQ,OAAO;AAAA,MACX,KAAK;AACD,eAAO,cAAc,GAAG,CAAC,KAAK,EAAE,OAAO,EAAE;AAAA,MAC7C,KAAK;AACD,eAAO,CAAC,cAAc,GAAG,CAAC,KAAK,EAAE,OAAO,EAAE;AAAA,MAC9C,KAAK;AACD,eAAO,EAAE,KAAK,SAAS,EAAE,KAAK,UAAU,EAAE,OAAO,EAAE;AAAA,MACvD,KAAK;AAAA,MACL;AACI,eAAO,EAAE,OAAO,EAAE;AAAA,IAC1B;AAAA,EACJ,CAAC;AAED,QAAM,mBAAmB,OAAO,QAAQ,UAAU;AAClD,SAAO,QAAQ,IAAI,CAAC,QAAQ,UAAU;AAClC,UAAM,UAAgD,CAAC;AACvD,eAAW,CAAC,UAAU,MAAM,KAAK,kBAAkB;AAC/C,UAAI,CAAC,UAAU,OAAO,OAAO,aAAa,cAAc,CAAC,OAAO,KAAM;AACtE,cAAQ,QAAQ,IAAI,iBAAiB,OAAO,MAAM,MAAM;AAAA,IAC5D;AACA,WAAO;AAAA,MACH,KAAK,OAAO;AAAA,MACZ,OAAO,OAAO;AAAA,MACd,OAAO,OAAO;AAAA,MACd,MAAM,OAAO;AAAA,MACb,YAAY;AAAA,MACZ;AAAA,IACJ;AAAA,EACJ,CAAC;AACL;AAKO,SAAS,qBAAqB,WAA8B,KAAuB;AACtF,QAAM,OAAO,MAAM,QAAQ,SAAS,IAAI,YAAY,CAAC;AACrD,SAAO,KAAK,SAAS,GAAG,IAAI,KAAK,OAAO,CAAC,UAAU,UAAU,GAAG,IAAI,CAAC,GAAG,MAAM,GAAG;AACrF;AAEO,SAAS,iBAAiB,WAA8B,KAAsB;AACjF,SAAO,MAAM,QAAQ,SAAS,IAAI,UAAU,SAAS,GAAG,IAAI;AAChE;AAKO,SAAS,cAAiB,QAAyB,WAAqD;AAC3G,QAAM,MAA6B,CAAC;AACpC,MAAI,YAAY;AAChB,aAAW,SAAS,MAAM,QAAQ,MAAM,IAAI,SAAS,CAAC,GAAG;AACrD,QAAI,KAAK,EAAE,MAAM,SAAS,MAAM,CAAC;AACjC,QAAI,iBAAiB,WAAW,MAAM,GAAG,EAAG;AAC5C,eAAW,OAAO,MAAM,MAAM;AAC1B,UAAI,KAAK,EAAE,MAAM,OAAO,OAAO,KAAK,UAAU,CAAC;AAC/C,mBAAa;AAAA,IACjB;AAAA,EACJ;AACA,SAAO;AACX;AAKO,SAAS,aAAgB,OAAsB,UAAU,OAAe;AAC3E,QAAM,QAAQ,OAAO,MAAM,UAAU;AACrC,QAAM,OAAO,UAAU,IAAI,UAAU,GAAG,OAAO;AAC/C,QAAM,QAAQ,CAAC,GAAG,kBAAkB,KAAK,CAAC,IAAI,IAAI,EAAE;AACpD,aAAW,UAAU,OAAO,OAAO,OAAO,cAAc,CAAC,CAAC,GAAG;AACzD,QAAI,QAAQ,MAAO,OAAM,KAAK,OAAO,KAAK;AAAA,EAC9C;AACA,SAAO,MAAM,KAAK,QAAK;AAC3B;","names":["value"]}
@@ -0,0 +1,119 @@
1
+ /**
2
+ * data-table/grouping.ts — THE grouping model for the canonical data table.
3
+ *
4
+ * "Group these rows by Status and get collapsible sections with a count and a
5
+ * subtotal on each." Champions: Airtable's group-by, Google Sheets' grouped
6
+ * pivot rows, Notion's board/table groups.
7
+ *
8
+ * THE FOUR RULES THIS FILE OBEYS
9
+ *
10
+ * 1. **Pure.** No React, no DOM, no clock, no globals. Rows in, groups out.
11
+ * That is what makes every clause below testable without a renderer.
12
+ * 2. **Nothing throws.** A `readCell` written by a caller is hostile input: if
13
+ * it throws, the row is treated as BLANK, never as a crashed table.
14
+ * 3. **Nothing lies.** An aggregate with no values to speak about reports
15
+ * `value: null` and a label that SAYS so ("Sum —"). It never reports `0`,
16
+ * because "the sum of nothing" and "the sum is zero" are different facts
17
+ * and a person reading a group header must be able to tell them apart.
18
+ * 4. **Order is the caller's.** Grouping is stable: within a group the rows
19
+ * keep the exact order they arrived in. The table already sorted them;
20
+ * re-sorting here would silently override the user's sort.
21
+ *
22
+ * THE COERCION RULES (the same spirit as the formula engine in
23
+ * `features/data-tables/formulas.ts`, implemented locally so this module stays
24
+ * dependency-free)
25
+ *
26
+ * - **BLANK** is `null`, `undefined` and `""` — all three, because a cleared
27
+ * text cell holds `""` and a cleared number cell holds `null`, and a person
28
+ * does not distinguish them. All three land in ONE group, labelled
29
+ * `(empty)`, and that group sorts LAST in every ordering: a section of
30
+ * unanswered rows is not the first thing anyone wants to read.
31
+ * - **Numeric strings coerce.** `"1,234"`, `" 42 "`, `"$5"` and `"3.5"` are
32
+ * numbers to an aggregate; `"n/a"` is simply not a number and is skipped.
33
+ * - **Booleans are 1 and 0**, so a sum over a checkbox column counts ticks.
34
+ * - **Aggregates skip blanks and non-numbers.** `average` divides by the count
35
+ * of values that were actually numeric.
36
+ */
37
+ type GroupAggregateKind = "count" | "sum" | "average" | "min" | "max" | "empty" | "filled" | "unique" | "checked" | "unchecked" | "percentFilled";
38
+ interface GroupAggregateSpec {
39
+ kind: GroupAggregateKind;
40
+ label?: string;
41
+ }
42
+ interface GroupAggregateResult {
43
+ kind: GroupAggregateKind;
44
+ /** null when the aggregate has nothing to say (no numeric values at all). */
45
+ value: number | null;
46
+ /** A short human sentence fragment for the group row, e.g. "Sum 4,812.5". Never a bare number with no name. */
47
+ label: string;
48
+ }
49
+ interface TableGroup<T> {
50
+ /** Stable key for collapse state and React keys. Derived from the raw value, never the display text. */
51
+ key: string;
52
+ /** The raw grouping value. */
53
+ value: unknown;
54
+ /** What the group header shows. Empty values group together under one explicit label. */
55
+ label: string;
56
+ rows: T[];
57
+ /** Per-column aggregate results, keyed by column id. */
58
+ aggregates: Record<string, GroupAggregateResult>;
59
+ /** Group order index. */
60
+ index: number;
61
+ }
62
+ type GroupOrder = "value-asc" | "value-desc" | "count-desc" | "first-seen";
63
+ interface GroupAggregateColumn<T> {
64
+ spec: GroupAggregateSpec;
65
+ readCell: (row: T) => unknown;
66
+ }
67
+ interface GroupRowsOptions<T> {
68
+ rows: T[];
69
+ /** Column id to group by. */
70
+ columnId: string;
71
+ /** Reads the grouping value off a row. */
72
+ readCell: (row: T) => unknown;
73
+ /** Turns a raw value into its display label. Defaults to a sensible string rendering. */
74
+ labelOf?: (value: unknown) => string;
75
+ /** Per-column aggregates and how to read that column's value. */
76
+ aggregates?: Record<string, GroupAggregateColumn<T>>;
77
+ /** Group ordering. Default "value-asc". "count-desc" is the Airtable default for a status column. */
78
+ order?: GroupOrder;
79
+ /** Label for rows whose grouping value is blank. Default "(empty)". */
80
+ emptyLabel?: string;
81
+ }
82
+ interface GroupedRenderRow<T> {
83
+ kind: "group" | "row";
84
+ group: TableGroup<T>;
85
+ /** Present only when kind === "row". */
86
+ row?: T;
87
+ /** Index within the flat list of DATA rows only (group headers do not consume one). */
88
+ dataIndex?: number;
89
+ }
90
+ declare const EMPTY_GROUP_KEY = "empty";
91
+ /** BLANK is `null`, `undefined` and `""` — nothing else. */
92
+ declare function isBlankGroupValue(value: unknown): boolean;
93
+ /**
94
+ * A key that is stable across renders and collision-free across values that
95
+ * happen to render the same text: the number `3` is `n:3` and the string `"3"`
96
+ * is `s:3`, so both are addressable and neither swallows the other.
97
+ */
98
+ declare function groupKeyOf(value: unknown): string;
99
+ /**
100
+ * The local coercion. Blanks and anything that is not a number are `null`
101
+ * (an aggregate skips them); booleans are 1 and 0; a numeric string survives
102
+ * thousands separators, padding and a currency symbol.
103
+ */
104
+ declare function coerceGroupNumber(value: unknown): number | null;
105
+ declare function formatGroupNumber(value: number): string;
106
+ /**
107
+ * Groups rows by one column. Row order inside a group is the order they
108
+ * arrived in; the blank group is always last.
109
+ */
110
+ declare function groupRows<T>(options: GroupRowsOptions<T>): TableGroup<T>[];
111
+ /** Collapse state as a pure set operation, so the consumer can persist it. */
112
+ declare function toggleGroupCollapsed(collapsed: readonly string[], key: string): string[];
113
+ declare function isGroupCollapsed(collapsed: readonly string[], key: string): boolean;
114
+ /** Flattens groups into the exact sequence the table renders, honouring collapse. */
115
+ declare function flattenGroups<T>(groups: TableGroup<T>[], collapsed: readonly string[]): GroupedRenderRow<T>[];
116
+ /** The one-line summary a group header shows after its label, e.g. "12 rows · Sum 4,812.5". */
117
+ declare function groupSummary<T>(group: TableGroup<T>, rowNoun?: string): string;
118
+
119
+ export { EMPTY_GROUP_KEY, type GroupAggregateColumn, type GroupAggregateKind, type GroupAggregateResult, type GroupAggregateSpec, type GroupOrder, type GroupRowsOptions, type GroupedRenderRow, type TableGroup, coerceGroupNumber, flattenGroups, formatGroupNumber, groupKeyOf, groupRows, groupSummary, isBlankGroupValue, isGroupCollapsed, toggleGroupCollapsed };
@@ -0,0 +1,119 @@
1
+ /**
2
+ * data-table/grouping.ts — THE grouping model for the canonical data table.
3
+ *
4
+ * "Group these rows by Status and get collapsible sections with a count and a
5
+ * subtotal on each." Champions: Airtable's group-by, Google Sheets' grouped
6
+ * pivot rows, Notion's board/table groups.
7
+ *
8
+ * THE FOUR RULES THIS FILE OBEYS
9
+ *
10
+ * 1. **Pure.** No React, no DOM, no clock, no globals. Rows in, groups out.
11
+ * That is what makes every clause below testable without a renderer.
12
+ * 2. **Nothing throws.** A `readCell` written by a caller is hostile input: if
13
+ * it throws, the row is treated as BLANK, never as a crashed table.
14
+ * 3. **Nothing lies.** An aggregate with no values to speak about reports
15
+ * `value: null` and a label that SAYS so ("Sum —"). It never reports `0`,
16
+ * because "the sum of nothing" and "the sum is zero" are different facts
17
+ * and a person reading a group header must be able to tell them apart.
18
+ * 4. **Order is the caller's.** Grouping is stable: within a group the rows
19
+ * keep the exact order they arrived in. The table already sorted them;
20
+ * re-sorting here would silently override the user's sort.
21
+ *
22
+ * THE COERCION RULES (the same spirit as the formula engine in
23
+ * `features/data-tables/formulas.ts`, implemented locally so this module stays
24
+ * dependency-free)
25
+ *
26
+ * - **BLANK** is `null`, `undefined` and `""` — all three, because a cleared
27
+ * text cell holds `""` and a cleared number cell holds `null`, and a person
28
+ * does not distinguish them. All three land in ONE group, labelled
29
+ * `(empty)`, and that group sorts LAST in every ordering: a section of
30
+ * unanswered rows is not the first thing anyone wants to read.
31
+ * - **Numeric strings coerce.** `"1,234"`, `" 42 "`, `"$5"` and `"3.5"` are
32
+ * numbers to an aggregate; `"n/a"` is simply not a number and is skipped.
33
+ * - **Booleans are 1 and 0**, so a sum over a checkbox column counts ticks.
34
+ * - **Aggregates skip blanks and non-numbers.** `average` divides by the count
35
+ * of values that were actually numeric.
36
+ */
37
+ type GroupAggregateKind = "count" | "sum" | "average" | "min" | "max" | "empty" | "filled" | "unique" | "checked" | "unchecked" | "percentFilled";
38
+ interface GroupAggregateSpec {
39
+ kind: GroupAggregateKind;
40
+ label?: string;
41
+ }
42
+ interface GroupAggregateResult {
43
+ kind: GroupAggregateKind;
44
+ /** null when the aggregate has nothing to say (no numeric values at all). */
45
+ value: number | null;
46
+ /** A short human sentence fragment for the group row, e.g. "Sum 4,812.5". Never a bare number with no name. */
47
+ label: string;
48
+ }
49
+ interface TableGroup<T> {
50
+ /** Stable key for collapse state and React keys. Derived from the raw value, never the display text. */
51
+ key: string;
52
+ /** The raw grouping value. */
53
+ value: unknown;
54
+ /** What the group header shows. Empty values group together under one explicit label. */
55
+ label: string;
56
+ rows: T[];
57
+ /** Per-column aggregate results, keyed by column id. */
58
+ aggregates: Record<string, GroupAggregateResult>;
59
+ /** Group order index. */
60
+ index: number;
61
+ }
62
+ type GroupOrder = "value-asc" | "value-desc" | "count-desc" | "first-seen";
63
+ interface GroupAggregateColumn<T> {
64
+ spec: GroupAggregateSpec;
65
+ readCell: (row: T) => unknown;
66
+ }
67
+ interface GroupRowsOptions<T> {
68
+ rows: T[];
69
+ /** Column id to group by. */
70
+ columnId: string;
71
+ /** Reads the grouping value off a row. */
72
+ readCell: (row: T) => unknown;
73
+ /** Turns a raw value into its display label. Defaults to a sensible string rendering. */
74
+ labelOf?: (value: unknown) => string;
75
+ /** Per-column aggregates and how to read that column's value. */
76
+ aggregates?: Record<string, GroupAggregateColumn<T>>;
77
+ /** Group ordering. Default "value-asc". "count-desc" is the Airtable default for a status column. */
78
+ order?: GroupOrder;
79
+ /** Label for rows whose grouping value is blank. Default "(empty)". */
80
+ emptyLabel?: string;
81
+ }
82
+ interface GroupedRenderRow<T> {
83
+ kind: "group" | "row";
84
+ group: TableGroup<T>;
85
+ /** Present only when kind === "row". */
86
+ row?: T;
87
+ /** Index within the flat list of DATA rows only (group headers do not consume one). */
88
+ dataIndex?: number;
89
+ }
90
+ declare const EMPTY_GROUP_KEY = "empty";
91
+ /** BLANK is `null`, `undefined` and `""` — nothing else. */
92
+ declare function isBlankGroupValue(value: unknown): boolean;
93
+ /**
94
+ * A key that is stable across renders and collision-free across values that
95
+ * happen to render the same text: the number `3` is `n:3` and the string `"3"`
96
+ * is `s:3`, so both are addressable and neither swallows the other.
97
+ */
98
+ declare function groupKeyOf(value: unknown): string;
99
+ /**
100
+ * The local coercion. Blanks and anything that is not a number are `null`
101
+ * (an aggregate skips them); booleans are 1 and 0; a numeric string survives
102
+ * thousands separators, padding and a currency symbol.
103
+ */
104
+ declare function coerceGroupNumber(value: unknown): number | null;
105
+ declare function formatGroupNumber(value: number): string;
106
+ /**
107
+ * Groups rows by one column. Row order inside a group is the order they
108
+ * arrived in; the blank group is always last.
109
+ */
110
+ declare function groupRows<T>(options: GroupRowsOptions<T>): TableGroup<T>[];
111
+ /** Collapse state as a pure set operation, so the consumer can persist it. */
112
+ declare function toggleGroupCollapsed(collapsed: readonly string[], key: string): string[];
113
+ declare function isGroupCollapsed(collapsed: readonly string[], key: string): boolean;
114
+ /** Flattens groups into the exact sequence the table renders, honouring collapse. */
115
+ declare function flattenGroups<T>(groups: TableGroup<T>[], collapsed: readonly string[]): GroupedRenderRow<T>[];
116
+ /** The one-line summary a group header shows after its label, e.g. "12 rows · Sum 4,812.5". */
117
+ declare function groupSummary<T>(group: TableGroup<T>, rowNoun?: string): string;
118
+
119
+ export { EMPTY_GROUP_KEY, type GroupAggregateColumn, type GroupAggregateKind, type GroupAggregateResult, type GroupAggregateSpec, type GroupOrder, type GroupRowsOptions, type GroupedRenderRow, type TableGroup, coerceGroupNumber, flattenGroups, formatGroupNumber, groupKeyOf, groupRows, groupSummary, isBlankGroupValue, isGroupCollapsed, toggleGroupCollapsed };