@vueye-table/core 3.0.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,1207 @@
1
+ //#region src/humanize.ts
2
+ /**
3
+ * Turn a column id into a readable header: `"name.first_name"` becomes `"Name first name"` and
4
+ * `"createdAt"` becomes `"Created at"`.
5
+ */
6
+ function humanize(id) {
7
+ const sentence = id.replace(/([a-z0-9])([A-Z])/gu, "$1 $2").split(/[\s._-]+/u).filter((word) => word.length > 0).map((word) => word.toLowerCase()).join(" ");
8
+ return sentence.charAt(0).toUpperCase() + sentence.slice(1);
9
+ }
10
+
11
+ //#endregion
12
+ //#region src/path.ts
13
+ function isRecord$1(value) {
14
+ return typeof value === "object" && value !== null;
15
+ }
16
+ /** Read the value at a dotted path, or `undefined` when a step is missing. */
17
+ function getPath(source, path) {
18
+ let current = source;
19
+ for (const segment of path.split(".")) {
20
+ if (!isRecord$1(current)) return;
21
+ current = current[segment];
22
+ }
23
+ return current;
24
+ }
25
+ /**
26
+ * Return a copy of `source` with `value` at `path`. Only the objects along the path are copied;
27
+ * everything else is shared. Missing steps are created as plain objects.
28
+ */
29
+ function setPath(source, path, value) {
30
+ const [head, ...rest] = path.split(".");
31
+ const base = isRecord$1(source) ? { ...source } : {};
32
+ if (head === void 0) return source;
33
+ base[head] = rest.length === 0 ? value : setPath(base[head], rest.join("."), value);
34
+ return base;
35
+ }
36
+
37
+ //#endregion
38
+ //#region src/column.ts
39
+ /** Declare columns for a row type with full inference, outside a table. */
40
+ function defineColumns(columns) {
41
+ return Object.freeze([...columns]);
42
+ }
43
+ const collator = new Intl.Collator(void 0, {
44
+ numeric: true,
45
+ sensitivity: "base"
46
+ });
47
+ function isEmpty(value) {
48
+ return value === null || value === void 0 || value === "";
49
+ }
50
+ /** Order any two values: empty last, then numbers, dates, booleans, and text naturally. */
51
+ function compareValues(left, right) {
52
+ const leftEmpty = isEmpty(left);
53
+ const rightEmpty = isEmpty(right);
54
+ if (leftEmpty || rightEmpty) return leftEmpty === rightEmpty ? 0 : leftEmpty ? 1 : -1;
55
+ if (typeof left === "number" && typeof right === "number") return left - right;
56
+ if (typeof left === "bigint" && typeof right === "bigint") return left < right ? -1 : left > right ? 1 : 0;
57
+ if (left instanceof Date && right instanceof Date) return left.getTime() - right.getTime();
58
+ if (typeof left === "boolean" && typeof right === "boolean") return Number(left) - Number(right);
59
+ return collator.compare(formatValue(left), formatValue(right));
60
+ }
61
+ /**
62
+ * Text for a value: empty for `null` and `undefined`, ISO for dates, comma-joined for arrays, and
63
+ * JSON for other objects. The result never depends on the runtime's locale, so server and client
64
+ * render the same text.
65
+ */
66
+ function formatValue(value) {
67
+ if (value === null || value === void 0) return "";
68
+ if (value instanceof Date) return Number.isNaN(value.getTime()) ? "" : value.toISOString();
69
+ if (Array.isArray(value)) return value.map(formatValue).join(", ");
70
+ if (typeof value === "object") return JSON.stringify(value);
71
+ switch (typeof value) {
72
+ case "string": return value;
73
+ case "number":
74
+ case "bigint":
75
+ case "boolean": return String(value);
76
+ case "symbol": return value.toString();
77
+ default: return "";
78
+ }
79
+ }
80
+ function toComparable(value) {
81
+ if (value instanceof Date) {
82
+ const time = value.getTime();
83
+ return Number.isNaN(time) ? void 0 : time;
84
+ }
85
+ if (typeof value === "number") return Number.isNaN(value) ? void 0 : value;
86
+ return typeof value === "string" ? value : void 0;
87
+ }
88
+ /**
89
+ * Read a range bound in the kind of the value it is compared with, so a bound typed into a text
90
+ * box (`"10"`, `"2024-01-31"`) compares with numbers as a number and with dates as a time. An
91
+ * empty or unreadable bound leaves that end of the range open.
92
+ */
93
+ function toBound(bound, value) {
94
+ if (isEmpty(bound)) return;
95
+ if (typeof bound === "string" && typeof value === "number") {
96
+ const number = Number(bound.trim());
97
+ return bound.trim() === "" || Number.isNaN(number) ? void 0 : number;
98
+ }
99
+ if (typeof bound === "string" && value instanceof Date) return toComparable(new Date(bound));
100
+ if (bound instanceof Date && typeof value === "string") return Number.isNaN(bound.getTime()) ? void 0 : bound.toISOString().slice(0, value.length);
101
+ return toComparable(bound);
102
+ }
103
+ function isRangeFilter(value) {
104
+ return typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof Date) && ("min" in value || "max" in value);
105
+ }
106
+ /**
107
+ * The default column filter:
108
+ *
109
+ * - an empty filter value (`undefined`, `null`, `""`, `[]`) lets every row through;
110
+ * - an array keeps rows whose value is one of its items;
111
+ * - `{ min, max }` keeps values inside the inclusive range; a bound given as text is read as a
112
+ * number or a date when the value is one, and an empty bound leaves that end open;
113
+ * - text keeps rows whose shown text contains it, ignoring case;
114
+ * - anything else keeps rows whose value is identical.
115
+ */
116
+ function matchesFilter(value, filterValue, display) {
117
+ if (isEmpty(filterValue) || Array.isArray(filterValue) && filterValue.length === 0) return true;
118
+ if (Array.isArray(filterValue)) return filterValue.some((item) => Object.is(item, value) || compareValues(item, value) === 0);
119
+ if (isRangeFilter(filterValue)) {
120
+ const min = toBound(filterValue.min, value);
121
+ const max = toBound(filterValue.max, value);
122
+ if (min === void 0 && max === void 0) return true;
123
+ const comparable = toComparable(value);
124
+ if (comparable === void 0) return false;
125
+ return (min === void 0 || comparable >= min) && (max === void 0 || comparable <= max);
126
+ }
127
+ if (typeof filterValue === "string") return display.toLowerCase().includes(filterValue.toLowerCase());
128
+ return Object.is(value, filterValue);
129
+ }
130
+ const TRUE_WORDS = /* @__PURE__ */ new Set([
131
+ "true",
132
+ "1",
133
+ "yes",
134
+ "y",
135
+ "on"
136
+ ]);
137
+ const FALSE_WORDS = /* @__PURE__ */ new Set([
138
+ "false",
139
+ "0",
140
+ "no",
141
+ "n",
142
+ "off"
143
+ ]);
144
+ /** The column type a value suggests, or `undefined` for an empty value. */
145
+ function typeOf(value) {
146
+ if (value === null || value === void 0) return;
147
+ if (typeof value === "number") return "number";
148
+ if (typeof value === "boolean") return "boolean";
149
+ return value instanceof Date ? "date" : "text";
150
+ }
151
+ /** Read text as a column type. Empty text is `null` for every type but text. */
152
+ function parseAs(input, type) {
153
+ const text = input.trim();
154
+ if (type === "text") return {
155
+ ok: true,
156
+ value: input
157
+ };
158
+ if (text === "") return {
159
+ ok: true,
160
+ value: null
161
+ };
162
+ if (type === "number") {
163
+ const value = Number(text);
164
+ return Number.isFinite(value) ? {
165
+ ok: true,
166
+ value
167
+ } : {
168
+ ok: false,
169
+ message: `"${input}" is not a number`
170
+ };
171
+ }
172
+ if (type === "boolean") {
173
+ const word = text.toLowerCase();
174
+ if (TRUE_WORDS.has(word)) return {
175
+ ok: true,
176
+ value: true
177
+ };
178
+ if (FALSE_WORDS.has(word)) return {
179
+ ok: true,
180
+ value: false
181
+ };
182
+ return {
183
+ ok: false,
184
+ message: `"${input}" is not a yes or no value`
185
+ };
186
+ }
187
+ const value = new Date(text);
188
+ return Number.isNaN(value.getTime()) ? {
189
+ ok: false,
190
+ message: `"${input}" is not a date`
191
+ } : {
192
+ ok: true,
193
+ value
194
+ };
195
+ }
196
+ /** Resolve a definition against its defaults. */
197
+ function resolveColumn(definition) {
198
+ const def = definition;
199
+ const { accessor } = def;
200
+ const getValue = accessor ?? ((row) => getPath(row, def.id));
201
+ const format = def.format ?? ((value) => formatValue(value));
202
+ const compare = def.compare ?? compareValues;
203
+ const customFilter = def.filter;
204
+ const customParse = def.parse;
205
+ const editable = def.editable ?? false;
206
+ const customSetValue = def.setValue;
207
+ const canWrite = accessor === void 0 || customSetValue !== void 0;
208
+ return Object.freeze({
209
+ id: def.id,
210
+ header: def.header ?? humanize(def.id),
211
+ align: def.align ?? "start",
212
+ width: def.width,
213
+ minWidth: def.minWidth,
214
+ sortable: def.sortable ?? true,
215
+ searchable: def.searchable ?? true,
216
+ initiallyHidden: def.hidden ?? false,
217
+ meta: Object.freeze({ ...def.meta }),
218
+ definition,
219
+ getValue,
220
+ format: (value, row) => format(value, row),
221
+ compare: (left, right) => compare(left, right),
222
+ matches(value, filterValue, row) {
223
+ return customFilter ? customFilter(value, filterValue, row) : matchesFilter(value, filterValue, format(value, row));
224
+ },
225
+ isEditable(row) {
226
+ if (!canWrite) return false;
227
+ return typeof editable === "function" ? editable(row) : editable;
228
+ },
229
+ parse(input, row, sample) {
230
+ if (customParse) try {
231
+ return {
232
+ ok: true,
233
+ value: customParse(input, row)
234
+ };
235
+ } catch (error) {
236
+ return {
237
+ ok: false,
238
+ message: error instanceof Error ? error.message : String(error)
239
+ };
240
+ }
241
+ return parseAs(input, def.type ?? typeOf(getValue(row)) ?? typeOf(sample) ?? "text");
242
+ },
243
+ setValue(row, value) {
244
+ if (customSetValue) return customSetValue(row, value);
245
+ return accessor === void 0 ? setPath(row, def.id, value) : void 0;
246
+ }
247
+ });
248
+ }
249
+
250
+ //#endregion
251
+ //#region src/delimited.ts
252
+ /**
253
+ * Delimited text: tab-separated for the clipboard, comma-separated for export.
254
+ *
255
+ * A field is quoted when it holds the delimiter, a quote, or a line break, and quotes inside it
256
+ * are doubled. This is what spreadsheet applications write and read.
257
+ */
258
+ function quote(field, delimiter) {
259
+ return field.includes(delimiter) || field.includes("\"") || field.includes("\n") || field.includes("\r") ? `"${field.replaceAll("\"", "\"\"")}"` : field;
260
+ }
261
+ /** Write a matrix of text as delimited lines joined with `\n`. */
262
+ function toDelimited(matrix, delimiter = " ") {
263
+ return matrix.map((line) => line.map((field) => quote(field, delimiter)).join(delimiter)).join("\n");
264
+ }
265
+ /**
266
+ * Read delimited text into a matrix. Accepts `\n` and `\r\n`, quoted fields with doubled quotes
267
+ * and embedded line breaks, and ignores one trailing line break.
268
+ */
269
+ function parseDelimited(text, delimiter = " ") {
270
+ const rows = [];
271
+ let row = [];
272
+ let field = "";
273
+ let quoted = false;
274
+ let index = 0;
275
+ const endField = () => {
276
+ row.push(field);
277
+ field = "";
278
+ };
279
+ const endRow = () => {
280
+ endField();
281
+ rows.push(row);
282
+ row = [];
283
+ };
284
+ while (index < text.length) {
285
+ const char = text.charAt(index);
286
+ if (quoted) {
287
+ if (char === "\"") {
288
+ if (text.charAt(index + 1) === "\"") {
289
+ field += "\"";
290
+ index += 1;
291
+ } else quoted = false;
292
+ } else field += char;
293
+ } else if (char === "\"" && field === "") quoted = true;
294
+ else if (char === delimiter) endField();
295
+ else if (char === "\n" || char === "\r") {
296
+ if (char === "\r" && text.charAt(index + 1) === "\n") index += 1;
297
+ endRow();
298
+ } else field += char;
299
+ index += 1;
300
+ }
301
+ if (field !== "" || row.length > 0) endRow();
302
+ return rows;
303
+ }
304
+
305
+ //#endregion
306
+ //#region src/grid.ts
307
+ function clamp(value, max) {
308
+ return Math.min(Math.max(0, value), Math.max(0, max - 1));
309
+ }
310
+ function clampPosition(position, bounds) {
311
+ return {
312
+ row: clamp(position.row, bounds.rows),
313
+ column: clamp(position.column, bounds.columns)
314
+ };
315
+ }
316
+ /** A selection of one cell. */
317
+ function selectCell(position) {
318
+ return {
319
+ anchor: position,
320
+ focus: position
321
+ };
322
+ }
323
+ /** Move the focus one step, or to the edge, staying inside the bounds. */
324
+ function moveSelection(selection, direction, bounds, options = {}) {
325
+ const { row, column } = selection.focus;
326
+ const far = options.toEdge === true;
327
+ const next = clampPosition({
328
+ row: direction === "up" ? far ? 0 : row - 1 : direction === "down" ? far ? bounds.rows - 1 : row + 1 : row,
329
+ column: direction === "left" ? far ? 0 : column - 1 : direction === "right" ? far ? bounds.columns - 1 : column + 1 : column
330
+ }, bounds);
331
+ return options.extend === true ? {
332
+ anchor: selection.anchor,
333
+ focus: next
334
+ } : {
335
+ anchor: next,
336
+ focus: next
337
+ };
338
+ }
339
+ /** The rectangle a selection covers. */
340
+ function selectionRange(selection) {
341
+ return {
342
+ top: Math.min(selection.anchor.row, selection.focus.row),
343
+ left: Math.min(selection.anchor.column, selection.focus.column),
344
+ bottom: Math.max(selection.anchor.row, selection.focus.row),
345
+ right: Math.max(selection.anchor.column, selection.focus.column)
346
+ };
347
+ }
348
+ function rangeContains(range, position) {
349
+ return position.row >= range.top && position.row <= range.bottom && position.column >= range.left && position.column <= range.right;
350
+ }
351
+ /** `0` is `"A"`, `25` is `"Z"`, `26` is `"AA"`. */
352
+ function columnLabel(index) {
353
+ let label = "";
354
+ let rest = index + 1;
355
+ while (rest > 0) {
356
+ const remainder = (rest - 1) % 26;
357
+ label = String.fromCharCode(65 + remainder) + label;
358
+ rest = Math.floor((rest - 1) / 26);
359
+ }
360
+ return label;
361
+ }
362
+ /** `{ row: 2, column: 1 }` is `"B3"`. */
363
+ function toA1(position) {
364
+ return `${columnLabel(position.column)}${position.row + 1}`;
365
+ }
366
+ /** `"B3"` is `{ row: 2, column: 1 }`. Returns `undefined` for anything else. */
367
+ function fromA1(reference) {
368
+ const match = /^([A-Z]+)([1-9]\d*)$/u.exec(reference.trim().toUpperCase());
369
+ if (!match?.[1] || !match[2]) return;
370
+ let column = 0;
371
+ for (const char of match[1]) column = column * 26 + (char.charCodeAt(0) - 64);
372
+ return {
373
+ row: Number(match[2]) - 1,
374
+ column: column - 1
375
+ };
376
+ }
377
+ const ARROWS = {
378
+ ArrowUp: "up",
379
+ ArrowDown: "down",
380
+ ArrowLeft: "left",
381
+ ArrowRight: "right"
382
+ };
383
+ /**
384
+ * Map a key press to a grid command, spreadsheet style: arrows move (Shift extends, Ctrl or Cmd
385
+ * jumps to the edge), Tab moves across, Home and End reach the row's ends, Enter or F2 edit,
386
+ * a printable character starts editing with that character, Delete and Backspace clear, Escape
387
+ * collapses the selection, and Ctrl or Cmd with A, Z, Y select all, undo, and redo.
388
+ */
389
+ function gridCommand(event) {
390
+ const mod = event.ctrlKey === true || event.metaKey === true;
391
+ const shift = event.shiftKey === true;
392
+ const direction = ARROWS[event.key];
393
+ if (direction) return {
394
+ type: "move",
395
+ direction,
396
+ options: {
397
+ extend: shift,
398
+ toEdge: mod
399
+ }
400
+ };
401
+ switch (event.key) {
402
+ case "Tab": return {
403
+ type: "move",
404
+ direction: shift ? "left" : "right",
405
+ options: {}
406
+ };
407
+ case "Home":
408
+ case "End": return {
409
+ type: "move",
410
+ direction: event.key === "Home" ? "left" : "right",
411
+ options: {
412
+ extend: shift,
413
+ toEdge: true
414
+ }
415
+ };
416
+ case "Enter":
417
+ case "F2": return { type: "edit" };
418
+ case "Delete":
419
+ case "Backspace": return { type: "clear" };
420
+ case "Escape": return { type: "cancel" };
421
+ }
422
+ if (mod) {
423
+ const key = event.key.toLowerCase();
424
+ if (key === "a") return { type: "selectAll" };
425
+ if (key === "z") return shift ? { type: "redo" } : { type: "undo" };
426
+ if (key === "y") return { type: "redo" };
427
+ return;
428
+ }
429
+ if (event.key.length === 1 && event.altKey !== true) return {
430
+ type: "edit",
431
+ initial: event.key
432
+ };
433
+ }
434
+
435
+ //#endregion
436
+ //#region src/undo-stack.ts
437
+ function createUndoStack(limit = 100) {
438
+ const past = [];
439
+ const future = [];
440
+ return {
441
+ get canUndo() {
442
+ return past.length > 0;
443
+ },
444
+ get canRedo() {
445
+ return future.length > 0;
446
+ },
447
+ record(entry) {
448
+ past.push(entry);
449
+ if (past.length > limit) past.shift();
450
+ future.length = 0;
451
+ },
452
+ undo() {
453
+ const entry = past.pop();
454
+ if (entry !== void 0) future.push(entry);
455
+ return entry;
456
+ },
457
+ redo() {
458
+ const entry = future.pop();
459
+ if (entry !== void 0) past.push(entry);
460
+ return entry;
461
+ },
462
+ clear() {
463
+ past.length = 0;
464
+ future.length = 0;
465
+ }
466
+ };
467
+ }
468
+
469
+ //#endregion
470
+ //#region src/infer.ts
471
+ function isPlainObject(value) {
472
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
473
+ const prototype = Object.getPrototypeOf(value);
474
+ return prototype === Object.prototype || prototype === null;
475
+ }
476
+ function collect(value, prefix, depth, into) {
477
+ if (!isPlainObject(value)) return;
478
+ for (const [key, child] of Object.entries(value)) {
479
+ const path = prefix === "" ? key : `${prefix}.${key}`;
480
+ if (isPlainObject(child) && depth < 4) collect(child, path, depth + 1, into);
481
+ else into.add(path);
482
+ }
483
+ }
484
+ /**
485
+ * Columns for data that has none declared: one path column per leaf of the first `sample` rows,
486
+ * nested objects flattened into dotted paths, in first-seen order.
487
+ */
488
+ function inferColumns(rows, sample = 20) {
489
+ const paths = /* @__PURE__ */ new Set();
490
+ for (const row of rows.slice(0, sample)) collect(row, "", 1, paths);
491
+ return Object.freeze([...paths].map((id) => ({ id })));
492
+ }
493
+
494
+ //#endregion
495
+ //#region src/pipeline.ts
496
+ /**
497
+ * The row pipeline: filter, then sort, then paginate. Each stage is a pure function, so a table
498
+ * recomputes only the stages whose inputs changed, and a server can run the same stages.
499
+ */
500
+ /** Split free text into lowercase terms. */
501
+ function searchTerms(search) {
502
+ return search.toLowerCase().split(/\s+/u).filter((term) => term.length > 0);
503
+ }
504
+ /**
505
+ * Keep rows that pass every column filter and contain every search term in at least one
506
+ * searchable column.
507
+ */
508
+ function filterRows(rows, columns, search, filters) {
509
+ const terms = searchTerms(search);
510
+ const byId = new Map(columns.map((column) => [column.id, column]));
511
+ const activeFilters = Object.entries(filters).flatMap(([id, value]) => {
512
+ const column = byId.get(id);
513
+ return column && value !== void 0 ? [{
514
+ column,
515
+ value
516
+ }] : [];
517
+ });
518
+ const searchable = columns.filter((column) => column.searchable);
519
+ if (terms.length === 0 && activeFilters.length === 0) return rows;
520
+ return rows.filter((row) => {
521
+ for (const { column, value } of activeFilters) if (!column.matches(row.getValue(column.id), value, row.original)) return false;
522
+ if (terms.length === 0) return true;
523
+ const haystack = searchable.map((column) => row.getDisplay(column.id).toLowerCase());
524
+ return terms.every((term) => haystack.some((text) => text.includes(term)));
525
+ });
526
+ }
527
+ /** Sort rows by each rule in turn. The sort is stable: ties keep their incoming order. */
528
+ function sortRows(rows, columns, sorting) {
529
+ const byId = new Map(columns.map((column) => [column.id, column]));
530
+ const rules = sorting.flatMap((rule) => {
531
+ const column = byId.get(rule.column);
532
+ return column ? [{
533
+ column,
534
+ sign: rule.direction === "desc" ? -1 : 1
535
+ }] : [];
536
+ });
537
+ if (rules.length === 0) return rows;
538
+ return rows.toSorted((left, right) => {
539
+ for (const { column, sign } of rules) {
540
+ const leftValue = left.getValue(column.id);
541
+ const rightValue = right.getValue(column.id);
542
+ const leftEmpty = leftValue === null || leftValue === void 0 || leftValue === "";
543
+ if (leftEmpty !== (rightValue === null || rightValue === void 0 || rightValue === "")) return leftEmpty ? 1 : -1;
544
+ const order = column.compare(leftValue, rightValue) * sign;
545
+ if (order !== 0) return order;
546
+ }
547
+ return 0;
548
+ });
549
+ }
550
+ /** The number of pages for a row count. An empty table still has one page. */
551
+ function countPages(rowCount, pageSize) {
552
+ return Math.max(1, Math.ceil(rowCount / pageSize));
553
+ }
554
+ /** Clamp a page into `1..pageCount`. */
555
+ function clampPage(page, pageCount) {
556
+ return Math.min(Math.max(1, Math.trunc(page) || 1), pageCount);
557
+ }
558
+ /** The rows on one page. `pagination.page` must already be clamped. */
559
+ function paginateRows(rows, pagination) {
560
+ const start = (pagination.page - 1) * pagination.pageSize;
561
+ return rows.slice(start, start + pagination.pageSize);
562
+ }
563
+ /**
564
+ * The page buttons to show: always the first and last page, `siblings` pages on each side of the
565
+ * current one, and a gap wherever pages are skipped. A gap never hides a single page.
566
+ */
567
+ function paginationItems(page, pageCount, siblings = 1) {
568
+ const pages = /* @__PURE__ */ new Set([1, pageCount]);
569
+ for (let offset = -siblings; offset <= siblings; offset += 1) {
570
+ const candidate = page + offset;
571
+ if (candidate >= 1 && candidate <= pageCount) pages.add(candidate);
572
+ }
573
+ const sorted = [...pages].toSorted((left, right) => left - right);
574
+ const items = [];
575
+ let previous = 0;
576
+ for (const value of sorted) {
577
+ if (value - previous === 2) items.push({
578
+ type: "page",
579
+ page: value - 1,
580
+ current: value - 1 === page
581
+ });
582
+ else if (value - previous > 2) items.push({
583
+ type: "gap",
584
+ key: `gap-${previous}-${value}`
585
+ });
586
+ items.push({
587
+ type: "page",
588
+ page: value,
589
+ current: value === page
590
+ });
591
+ previous = value;
592
+ }
593
+ return items;
594
+ }
595
+
596
+ //#endregion
597
+ //#region src/state.ts
598
+ const DEFAULT_PAGE_SIZE = 10;
599
+ function createInitialState(patch = {}) {
600
+ return freezeState({
601
+ sorting: patch.sorting ?? [],
602
+ search: patch.search ?? "",
603
+ filters: patch.filters ?? {},
604
+ pagination: patch.pagination ?? {
605
+ page: 1,
606
+ pageSize: 10
607
+ },
608
+ selection: patch.selection ?? [],
609
+ hiddenColumns: patch.hiddenColumns ?? [],
610
+ columnOrder: patch.columnOrder ?? []
611
+ });
612
+ }
613
+ function mergeState(state, patch) {
614
+ let changed = false;
615
+ const next = { ...state };
616
+ for (const [key, value] of Object.entries(patch)) {
617
+ if (value === void 0 || Object.is(next[key], value)) continue;
618
+ if (key === "pagination" && samePagination(next[key], value)) continue;
619
+ next[key] = value;
620
+ changed = true;
621
+ }
622
+ return changed ? freezeState(next) : state;
623
+ }
624
+ function samePagination(left, right) {
625
+ return left.page === right.page && left.pageSize === right.pageSize;
626
+ }
627
+ function freezeState(state) {
628
+ return Object.freeze({
629
+ sorting: Object.freeze(state.sorting.map((rule) => Object.freeze({ ...rule }))),
630
+ search: state.search,
631
+ filters: Object.freeze({ ...state.filters }),
632
+ pagination: Object.freeze({ ...state.pagination }),
633
+ selection: Object.freeze([...state.selection]),
634
+ hiddenColumns: Object.freeze([...state.hiddenColumns]),
635
+ columnOrder: Object.freeze([...state.columnOrder])
636
+ });
637
+ }
638
+
639
+ //#endregion
640
+ //#region src/issues.ts
641
+ function issue(code, message, details = {}) {
642
+ return Object.freeze({
643
+ code,
644
+ message,
645
+ ...details
646
+ });
647
+ }
648
+
649
+ //#endregion
650
+ //#region src/row.ts
651
+ function createRow(original, key, index, columns) {
652
+ const values = /* @__PURE__ */ new Map();
653
+ const displays = /* @__PURE__ */ new Map();
654
+ const getValue = (columnId) => {
655
+ if (values.has(columnId)) return values.get(columnId);
656
+ const value = columns.get(columnId)?.getValue(original);
657
+ values.set(columnId, value);
658
+ return value;
659
+ };
660
+ return Object.freeze({
661
+ key,
662
+ index,
663
+ original,
664
+ getValue,
665
+ getDisplay(columnId) {
666
+ const cached = displays.get(columnId);
667
+ if (cached !== void 0) return cached;
668
+ const column = columns.get(columnId);
669
+ const display = column ? column.format(getValue(columnId), original) : "";
670
+ displays.set(columnId, display);
671
+ return display;
672
+ }
673
+ });
674
+ }
675
+
676
+ //#endregion
677
+ //#region src/table.ts
678
+ function isRecord(value) {
679
+ return typeof value === "object" && value !== null;
680
+ }
681
+ function memo(compute) {
682
+ let last;
683
+ return (...deps) => {
684
+ if (last && last.deps.length === deps.length && last.deps.every((d, i) => Object.is(d, deps[i]))) return last.result;
685
+ const result = compute(...deps);
686
+ last = {
687
+ deps,
688
+ result
689
+ };
690
+ return result;
691
+ };
692
+ }
693
+ function isInputEdit(edit) {
694
+ return "input" in edit;
695
+ }
696
+ function coverage(selected, total) {
697
+ if (selected === 0 || total === 0) return "none";
698
+ return selected === total ? "all" : "some";
699
+ }
700
+ /** Create a data table. */
701
+ function createTable(options) {
702
+ const selectionMode = options.selectionMode ?? "multiple";
703
+ const selectScope = options.selectScope ?? "all";
704
+ const manual = options.manual ?? false;
705
+ const undoStack = createUndoStack(options.historyLimit);
706
+ const listeners = /* @__PURE__ */ new Set();
707
+ let data = options.data;
708
+ let definitions = options.columns;
709
+ let rowCount = options.rowCount;
710
+ const initialState = createInitialState({
711
+ ...options.initialState,
712
+ hiddenColumns: options.initialState?.hiddenColumns ?? options.columns.filter((column) => column.hidden === true).map((column) => column.id)
713
+ });
714
+ let state = initialState;
715
+ let snapshot;
716
+ const readKey = (row, index) => {
717
+ const { rowKey } = options;
718
+ if (typeof rowKey === "function") return rowKey(row, index);
719
+ const value = getPath(row, rowKey ?? "id");
720
+ if (typeof value === "string" || typeof value === "number") return value;
721
+ return rowKey === void 0 || !isRecord(row) ? index : String(value);
722
+ };
723
+ const resolveColumns = memo((defs) => {
724
+ const columns = defs.map((definition) => resolveColumn(definition));
725
+ return {
726
+ columns,
727
+ byId: new Map(columns.map((column) => [column.id, column]))
728
+ };
729
+ });
730
+ const indexRows = memo((rows, byId) => {
731
+ const byKey = /* @__PURE__ */ new Map();
732
+ const issues = [];
733
+ return {
734
+ rows: rows.map((original, index) => {
735
+ let key = readKey(original, index);
736
+ if (byKey.has(key)) {
737
+ issues.push(issue("duplicate_row_key", `Row ${index} repeats the key "${String(key)}"; it is keyed "${String(key)}#${index}" instead.`, { rowKey: key }));
738
+ key = `${String(key)}#${index}`;
739
+ }
740
+ const row = createRow(original, key, index, byId);
741
+ byKey.set(key, row);
742
+ return row;
743
+ }),
744
+ byKey,
745
+ issues
746
+ };
747
+ });
748
+ const orderColumns = memo((columns, columnOrder, hiddenColumns) => {
749
+ const position = new Map(columnOrder.map((id, index) => [id, index]));
750
+ const ordered = columns.map((column, index) => ({
751
+ column,
752
+ index
753
+ })).toSorted((left, right) => {
754
+ return (position.get(left.column.id) ?? columnOrder.length + left.index) - (position.get(right.column.id) ?? columnOrder.length + right.index);
755
+ }).map(({ column }) => column);
756
+ const hidden = new Set(hiddenColumns);
757
+ return {
758
+ all: ordered,
759
+ visible: ordered.filter((column) => !hidden.has(column.id))
760
+ };
761
+ });
762
+ const filterStage = memo(filterRows);
763
+ const sortStage = memo(sortRows);
764
+ const selectedSet = memo((selection) => new Set(selection));
765
+ function stateIssues(byId) {
766
+ const issues = [];
767
+ const referenced = /* @__PURE__ */ new Set([
768
+ ...state.sorting.map((rule) => rule.column),
769
+ ...Object.keys(state.filters),
770
+ ...state.hiddenColumns,
771
+ ...state.columnOrder
772
+ ]);
773
+ for (const id of referenced) if (!byId.has(id)) issues.push(issue("unknown_column", `The state refers to a column "${id}" that is not defined.`, { column: id }));
774
+ const { pageSize } = state.pagination;
775
+ if (!Number.isInteger(pageSize) || pageSize < 1) issues.push(issue("invalid_page_size", `Page size ${String(pageSize)} is not a positive whole number; ${10} is used.`));
776
+ return issues;
777
+ }
778
+ function computeSnapshot() {
779
+ const { columns, byId } = resolveColumns(definitions);
780
+ const indexed = indexRows(data, byId);
781
+ const ordered = orderColumns(columns, state.columnOrder, state.hiddenColumns);
782
+ const issues = [...indexed.issues, ...stateIssues(byId)];
783
+ const requestedSize = state.pagination.pageSize;
784
+ const pageSize = Number.isInteger(requestedSize) && requestedSize >= 1 ? requestedSize : 10;
785
+ let processedRows;
786
+ let rows;
787
+ let filteredCount;
788
+ let page;
789
+ let pageCount;
790
+ if (manual) {
791
+ processedRows = indexed.rows;
792
+ rows = indexed.rows;
793
+ filteredCount = rowCount ?? indexed.rows.length;
794
+ pageCount = countPages(filteredCount, pageSize);
795
+ page = clampPage(state.pagination.page, pageCount);
796
+ } else {
797
+ const filtered = filterStage(indexed.rows, columns, state.search, state.filters);
798
+ processedRows = sortStage(filtered, columns, state.sorting);
799
+ filteredCount = processedRows.length;
800
+ pageCount = countPages(filteredCount, pageSize);
801
+ page = clampPage(state.pagination.page, pageCount);
802
+ rows = paginateRows(processedRows, {
803
+ page,
804
+ pageSize
805
+ });
806
+ }
807
+ const selected = selectedSet(state.selection);
808
+ const pageSelected = rows.filter((row) => selected.has(row.key)).length;
809
+ const scopeRows = selectScope === "page" || manual ? rows : processedRows;
810
+ const scopeSelected = scopeRows === rows ? pageSelected : scopeRows.filter((row) => selected.has(row.key)).length;
811
+ const pageStart = rows.length === 0 ? 0 : (page - 1) * pageSize + 1;
812
+ const sortIndex = new Map(state.sorting.map((rule, priority) => [rule.column, {
813
+ direction: rule.direction,
814
+ priority
815
+ }]));
816
+ return Object.freeze({
817
+ state,
818
+ columns: ordered.visible,
819
+ allColumns: ordered.all,
820
+ rows,
821
+ processedRows,
822
+ page,
823
+ pageSize,
824
+ pageCount,
825
+ rowCount: filteredCount,
826
+ totalRowCount: manual ? filteredCount : indexed.rows.length,
827
+ pageStart,
828
+ pageEnd: rows.length === 0 ? 0 : pageStart + rows.length - 1,
829
+ pageItems: paginationItems(page, pageCount),
830
+ canPreviousPage: page > 1,
831
+ canNextPage: page < pageCount,
832
+ selectionMode,
833
+ selectedCount: state.selection.length,
834
+ pageSelection: coverage(pageSelected, rows.length),
835
+ allSelection: coverage(scopeSelected, scopeRows.length),
836
+ canUndo: undoStack.canUndo,
837
+ canRedo: undoStack.canRedo,
838
+ issues: Object.freeze(issues),
839
+ isSelected: (key) => selected.has(key),
840
+ getSort: (columnId) => sortIndex.get(columnId),
841
+ getColumn: (columnId) => byId.get(columnId),
842
+ getRow: (key) => indexed.byKey.get(key)
843
+ });
844
+ }
845
+ function getSnapshot() {
846
+ snapshot ??= computeSnapshot();
847
+ return snapshot;
848
+ }
849
+ function notify() {
850
+ snapshot = void 0;
851
+ if (listeners.size === 0) return;
852
+ const next = getSnapshot();
853
+ for (const listener of listeners) listener(next);
854
+ }
855
+ /** Apply a change made by a table operation. */
856
+ function commit(patch) {
857
+ const previous = state;
858
+ const next = mergeState(state, patch);
859
+ if (next === previous) return;
860
+ state = next;
861
+ options.onStateChange?.(state, previous);
862
+ notify();
863
+ }
864
+ const firstPage = () => ({
865
+ page: 1,
866
+ pageSize: state.pagination.pageSize
867
+ });
868
+ function setSort(columnId, direction, multi) {
869
+ if (!getSnapshot().getColumn(columnId)?.sortable) return;
870
+ const others = multi ? state.sorting.filter((rule) => rule.column !== columnId) : [];
871
+ const existing = state.sorting.findIndex((rule) => rule.column === columnId);
872
+ let sorting = others;
873
+ if (direction) {
874
+ const rule = {
875
+ column: columnId,
876
+ direction
877
+ };
878
+ sorting = multi && existing !== -1 ? state.sorting.map((current) => current.column === columnId ? rule : current) : [...others, rule];
879
+ }
880
+ commit({
881
+ sorting,
882
+ pagination: firstPage()
883
+ });
884
+ }
885
+ function applySelection(next) {
886
+ if (selectionMode === "none") return;
887
+ const unique = [...new Set(next)];
888
+ const selection = selectionMode === "single" ? unique.slice(-1) : unique;
889
+ const current = state.selection;
890
+ if (selection.length === current.length && selection.every((key, i) => key === current[i])) return;
891
+ commit({ selection });
892
+ }
893
+ function applyChanges(edits, record) {
894
+ const current = getSnapshot();
895
+ const working = [...data];
896
+ const issues = [];
897
+ const changes = [];
898
+ const samples = /* @__PURE__ */ new Map();
899
+ const sampleOf = (columnId) => {
900
+ if (!samples.has(columnId)) {
901
+ const other = current.processedRows.find((candidate) => {
902
+ const value = candidate.getValue(columnId);
903
+ return value !== null && value !== void 0;
904
+ });
905
+ samples.set(columnId, other?.getValue(columnId));
906
+ }
907
+ return samples.get(columnId);
908
+ };
909
+ for (const edit of edits) {
910
+ const row = current.getRow(edit.rowKey);
911
+ const column = current.getColumn(edit.column);
912
+ if (!row) {
913
+ issues.push(issue("unknown_row", `No row has the key "${String(edit.rowKey)}".`, {
914
+ rowKey: edit.rowKey,
915
+ column: edit.column
916
+ }));
917
+ continue;
918
+ }
919
+ if (!column) {
920
+ issues.push(issue("unknown_column", `No column has the id "${edit.column}".`, {
921
+ rowKey: edit.rowKey,
922
+ column: edit.column
923
+ }));
924
+ continue;
925
+ }
926
+ const original = working[row.index];
927
+ if (record && !column.isEditable(original)) {
928
+ issues.push(issue("read_only_cell", `The "${column.id}" cell of row "${String(row.key)}" is read-only.`, {
929
+ rowKey: row.key,
930
+ column: column.id
931
+ }));
932
+ continue;
933
+ }
934
+ let value;
935
+ if (isInputEdit(edit)) {
936
+ const parsed = column.parse(edit.input, original, sampleOf(column.id));
937
+ if (!parsed.ok) {
938
+ issues.push(issue("invalid_value", parsed.message, {
939
+ rowKey: row.key,
940
+ column: column.id
941
+ }));
942
+ continue;
943
+ }
944
+ value = parsed.value;
945
+ } else value = edit.value;
946
+ const previous = column.getValue(original);
947
+ if (Object.is(previous, value)) continue;
948
+ const updated = column.setValue(original, value);
949
+ if (updated === void 0) {
950
+ issues.push(issue("read_only_cell", `The "${column.id}" column has no way to write a value.`, {
951
+ rowKey: row.key,
952
+ column: column.id
953
+ }));
954
+ continue;
955
+ }
956
+ working[row.index] = updated;
957
+ changes.push({
958
+ rowKey: row.key,
959
+ column: column.id,
960
+ previous,
961
+ value,
962
+ row: updated
963
+ });
964
+ }
965
+ let status;
966
+ if (changes.length === 0) status = issues.length > 0 ? "rejected" : "unchanged";
967
+ else {
968
+ status = issues.length > 0 ? "partial" : "applied";
969
+ data = working;
970
+ if (record) undoStack.record(changes);
971
+ options.onDataChange?.(data, changes);
972
+ notify();
973
+ }
974
+ if (issues.length > 0) options.onEditIssues?.(issues);
975
+ return Object.freeze({
976
+ status,
977
+ changes: Object.freeze(changes),
978
+ issues: Object.freeze(issues)
979
+ });
980
+ }
981
+ function rangeEdits(origin, matrix) {
982
+ const { rows, columns } = getSnapshot();
983
+ const edits = [];
984
+ matrix.forEach((line, rowOffset) => {
985
+ const row = rows[origin.row + rowOffset];
986
+ if (!row) return;
987
+ line.forEach((input, columnOffset) => {
988
+ const column = columns[origin.column + columnOffset];
989
+ if (column) edits.push({
990
+ rowKey: row.key,
991
+ column: column.id,
992
+ input
993
+ });
994
+ });
995
+ });
996
+ return edits;
997
+ }
998
+ function replay(changes, direction) {
999
+ applyChanges(changes.map((change) => ({
1000
+ rowKey: change.rowKey,
1001
+ column: change.column,
1002
+ value: direction === "undo" ? change.previous : change.value
1003
+ })), false);
1004
+ }
1005
+ const table = {
1006
+ getSnapshot,
1007
+ getState: () => state,
1008
+ subscribe(listener) {
1009
+ listeners.add(listener);
1010
+ return () => {
1011
+ listeners.delete(listener);
1012
+ };
1013
+ },
1014
+ setState(patch) {
1015
+ const next = mergeState(state, patch);
1016
+ if (next !== state) {
1017
+ state = next;
1018
+ notify();
1019
+ }
1020
+ },
1021
+ setData(next) {
1022
+ if (next === data) return;
1023
+ data = next;
1024
+ undoStack.clear();
1025
+ notify();
1026
+ },
1027
+ setColumns(next) {
1028
+ if (next === definitions) return;
1029
+ definitions = next;
1030
+ notify();
1031
+ },
1032
+ setRowCount(next) {
1033
+ if (next === rowCount) return;
1034
+ rowCount = next;
1035
+ notify();
1036
+ },
1037
+ toggleSort(columnId, { multi = false } = {}) {
1038
+ const current = state.sorting.find((rule) => rule.column === columnId)?.direction;
1039
+ setSort(columnId, current === void 0 ? "asc" : current === "asc" ? "desc" : void 0, multi);
1040
+ },
1041
+ sort(columnId, direction, { multi = false } = {}) {
1042
+ setSort(columnId, direction, multi);
1043
+ },
1044
+ clearSorting() {
1045
+ commit({ sorting: [] });
1046
+ },
1047
+ search(text) {
1048
+ if (text === state.search) return;
1049
+ commit({
1050
+ search: text,
1051
+ pagination: firstPage()
1052
+ });
1053
+ },
1054
+ filter(columnId, value) {
1055
+ const filters = { ...state.filters };
1056
+ const empty = value === void 0 || value === null || value === "" || Array.isArray(value) && value.length === 0;
1057
+ if (empty ? !(columnId in filters) : Object.is(filters[columnId], value)) return;
1058
+ if (empty) delete filters[columnId];
1059
+ else filters[columnId] = value;
1060
+ commit({
1061
+ filters,
1062
+ pagination: firstPage()
1063
+ });
1064
+ },
1065
+ clearFilters() {
1066
+ if (Object.keys(state.filters).length > 0) commit({
1067
+ filters: {},
1068
+ pagination: firstPage()
1069
+ });
1070
+ },
1071
+ goToPage(page) {
1072
+ const { pageCount, page: currentPage } = getSnapshot();
1073
+ const next = clampPage(page, pageCount);
1074
+ if (next !== currentPage || next !== state.pagination.page) commit({ pagination: {
1075
+ page: next,
1076
+ pageSize: state.pagination.pageSize
1077
+ } });
1078
+ },
1079
+ nextPage() {
1080
+ table.goToPage(getSnapshot().page + 1);
1081
+ },
1082
+ previousPage() {
1083
+ table.goToPage(getSnapshot().page - 1);
1084
+ },
1085
+ setPageSize(pageSize) {
1086
+ if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize === state.pagination.pageSize) return;
1087
+ const { pageStart } = getSnapshot();
1088
+ commit({ pagination: {
1089
+ page: pageStart === 0 ? 1 : Math.floor((pageStart - 1) / pageSize) + 1,
1090
+ pageSize
1091
+ } });
1092
+ },
1093
+ select(keys) {
1094
+ applySelection([...state.selection, ...keys]);
1095
+ },
1096
+ deselect(keys) {
1097
+ const removed = new Set(keys);
1098
+ applySelection(state.selection.filter((key) => !removed.has(key)));
1099
+ },
1100
+ toggleRow(key, selected) {
1101
+ const isSelected = state.selection.includes(key);
1102
+ const next = selected ?? !isSelected;
1103
+ if (next === isSelected) return;
1104
+ if (next) table.select([key]);
1105
+ else table.deselect([key]);
1106
+ },
1107
+ toggleAll(scope = selectScope) {
1108
+ if (selectionMode !== "multiple") return;
1109
+ const current = getSnapshot();
1110
+ const keys = (scope === "page" || manual ? current.rows : current.processedRows).map((row) => row.key);
1111
+ if (keys.length > 0 && keys.every((key) => current.isSelected(key))) table.deselect(keys);
1112
+ else table.select(keys);
1113
+ },
1114
+ clearSelection() {
1115
+ applySelection([]);
1116
+ },
1117
+ getSelectedRows() {
1118
+ const current = getSnapshot();
1119
+ return state.selection.flatMap((key) => {
1120
+ const row = current.getRow(key);
1121
+ return row ? [row] : [];
1122
+ });
1123
+ },
1124
+ toggleColumn(columnId, visible) {
1125
+ if (!getSnapshot().getColumn(columnId)) return;
1126
+ const hidden = state.hiddenColumns.includes(columnId);
1127
+ const show = visible ?? hidden;
1128
+ if (show === !hidden) return;
1129
+ commit({ hiddenColumns: show ? state.hiddenColumns.filter((id) => id !== columnId) : [...state.hiddenColumns, columnId] });
1130
+ },
1131
+ moveColumn(columnId, toIndex) {
1132
+ const order = getSnapshot().allColumns.map((column) => column.id);
1133
+ const from = order.indexOf(columnId);
1134
+ if (from === -1) return;
1135
+ const target = Math.min(Math.max(0, Math.trunc(toIndex)), order.length - 1);
1136
+ if (target === from) return;
1137
+ order.splice(from, 1);
1138
+ order.splice(target, 0, columnId);
1139
+ commit({ columnOrder: order });
1140
+ },
1141
+ edit(edits) {
1142
+ return applyChanges(Array.isArray(edits) ? edits : [edits], true);
1143
+ },
1144
+ undo() {
1145
+ const changes = undoStack.undo();
1146
+ if (!changes) return false;
1147
+ replay(changes, "undo");
1148
+ return true;
1149
+ },
1150
+ redo() {
1151
+ const changes = undoStack.redo();
1152
+ if (!changes) return false;
1153
+ replay(changes, "redo");
1154
+ return true;
1155
+ },
1156
+ copy(range) {
1157
+ const { rows, columns } = getSnapshot();
1158
+ const matrix = [];
1159
+ rows.forEach((row, rowIndex) => {
1160
+ const line = [];
1161
+ columns.forEach((column, columnIndex) => {
1162
+ if (rangeContains(range, {
1163
+ row: rowIndex,
1164
+ column: columnIndex
1165
+ })) line.push(row.getDisplay(column.id));
1166
+ });
1167
+ if (line.length > 0) matrix.push(line);
1168
+ });
1169
+ return toDelimited(matrix);
1170
+ },
1171
+ paste(origin, text) {
1172
+ return applyChanges(rangeEdits(origin, parseDelimited(text)), true);
1173
+ },
1174
+ clear(range) {
1175
+ const matrix = Array.from({ length: range.bottom - range.top + 1 }, () => Array.from({ length: range.right - range.left + 1 }, () => ""));
1176
+ const current = getSnapshot();
1177
+ return applyChanges(rangeEdits({
1178
+ row: range.top,
1179
+ column: range.left
1180
+ }, matrix).filter((edit) => {
1181
+ const row = current.getRow(edit.rowKey);
1182
+ return row !== void 0 && current.getColumn(edit.column)?.isEditable(row.original) === true;
1183
+ }), true);
1184
+ },
1185
+ exportRows({ format = "csv", columns: ids, pageOnly = false, headers = true } = {}) {
1186
+ const current = getSnapshot();
1187
+ const columns = ids ? ids.flatMap((id) => {
1188
+ const column = current.getColumn(id);
1189
+ return column ? [column] : [];
1190
+ }) : current.columns;
1191
+ const matrix = (pageOnly ? current.rows : current.processedRows).map((row) => columns.map((column) => row.getDisplay(column.id)));
1192
+ if (headers) matrix.unshift(columns.map((column) => column.header));
1193
+ return toDelimited(matrix, format === "csv" ? "," : " ");
1194
+ },
1195
+ reset() {
1196
+ commit(initialState);
1197
+ },
1198
+ destroy() {
1199
+ listeners.clear();
1200
+ }
1201
+ };
1202
+ return table;
1203
+ }
1204
+
1205
+ //#endregion
1206
+ export { DEFAULT_PAGE_SIZE, clampPage, clampPosition, columnLabel, compareValues, countPages, createTable, createUndoStack, defineColumns, filterRows, formatValue, fromA1, getPath, gridCommand, humanize, inferColumns, matchesFilter, moveSelection, paginateRows, paginationItems, parseAs, parseDelimited, rangeContains, resolveColumn, selectCell, selectionRange, setPath, sortRows, toA1, toDelimited, typeOf };
1207
+ //# sourceMappingURL=index.js.map