@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/LICENSE +21 -0
- package/README.md +30 -0
- package/dist/index.d.ts +554 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1207 -0
- package/dist/index.js.map +1 -0
- package/package.json +53 -0
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
|