@bendyline/squisq-grid-react 2.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,1778 @@
1
+ // src/store/columns.ts
2
+ import { isNumericCellText } from "@bendyline/squisq/table";
3
+ function inferKind(cells, col) {
4
+ let sawNumber = false;
5
+ let sawBoolean = false;
6
+ let sawValue = false;
7
+ for (const row of cells) {
8
+ const cell = row[col];
9
+ if (cell === null || cell === void 0 || cell === "") continue;
10
+ sawValue = true;
11
+ if (typeof cell === "number") {
12
+ sawNumber = true;
13
+ continue;
14
+ }
15
+ if (typeof cell === "boolean") {
16
+ sawBoolean = true;
17
+ continue;
18
+ }
19
+ const text = String(cell);
20
+ if (isNumericCellText(text)) {
21
+ sawNumber = true;
22
+ continue;
23
+ }
24
+ if (/^(?:true|false)$/i.test(text.trim())) {
25
+ sawBoolean = true;
26
+ continue;
27
+ }
28
+ return "string";
29
+ }
30
+ if (!sawValue) return "string";
31
+ if (sawNumber && !sawBoolean) return "number";
32
+ if (sawBoolean && !sawNumber) return "boolean";
33
+ return "string";
34
+ }
35
+ function isBlank(cell) {
36
+ return cell === null || cell === void 0 || typeof cell === "string" && cell.trim() === "";
37
+ }
38
+ function buildColumnarTable(table) {
39
+ const rowCount = table.cells.length;
40
+ const columns = table.headers.map((name, col) => {
41
+ const kind = table.hints?.[col]?.kind ?? inferKind(table.cells, col);
42
+ if (kind === "number") {
43
+ const data = new Float64Array(rowCount);
44
+ const valid = new Uint8Array(rowCount);
45
+ for (let row = 0; row < rowCount; row++) {
46
+ const cell = table.cells[row]?.[col];
47
+ if (isBlank(cell)) continue;
48
+ const value = typeof cell === "number" ? cell : Number(String(cell).trim());
49
+ if (!Number.isFinite(value)) continue;
50
+ data[row] = value;
51
+ valid[row] = 1;
52
+ }
53
+ return { kind, name, data, valid };
54
+ }
55
+ if (kind === "boolean") {
56
+ const data = new Uint8Array(rowCount);
57
+ const valid = new Uint8Array(rowCount);
58
+ for (let row = 0; row < rowCount; row++) {
59
+ const cell = table.cells[row]?.[col];
60
+ if (isBlank(cell)) continue;
61
+ const truthy = typeof cell === "boolean" ? cell : /^true$/i.test(String(cell).trim());
62
+ data[row] = truthy ? 1 : 0;
63
+ valid[row] = 1;
64
+ }
65
+ return { kind, name, data, valid };
66
+ }
67
+ const codes = new Int32Array(rowCount);
68
+ const dict = [];
69
+ const codeByValue = /* @__PURE__ */ new Map();
70
+ for (let row = 0; row < rowCount; row++) {
71
+ const cell = table.cells[row]?.[col];
72
+ if (isBlank(cell)) {
73
+ codes[row] = -1;
74
+ continue;
75
+ }
76
+ const text = typeof cell === "string" ? cell : String(cell);
77
+ let code = codeByValue.get(text);
78
+ if (code === void 0) {
79
+ code = dict.length;
80
+ dict.push(text);
81
+ codeByValue.set(text, code);
82
+ }
83
+ codes[row] = code;
84
+ }
85
+ return { kind, name, codes, dict };
86
+ });
87
+ return { columns, rowCount };
88
+ }
89
+ function columnCellValue(column, row) {
90
+ if (column.kind === "number") {
91
+ return column.valid[row] ? column.data[row] : null;
92
+ }
93
+ if (column.kind === "boolean") {
94
+ return column.valid[row] ? column.data[row] === 1 : null;
95
+ }
96
+ const code = column.codes[row];
97
+ return code < 0 ? null : column.dict[code];
98
+ }
99
+
100
+ // src/store/kernel.ts
101
+ function tableKernel(scope) {
102
+ let columns = [];
103
+ let rowCount = 0;
104
+ let perm = [];
105
+ let activeSort = [];
106
+ let activeFilter = [];
107
+ const collator = new Intl.Collator(void 0, { numeric: true });
108
+ function isNumericText(value) {
109
+ const trimmed = value.trim();
110
+ if (trimmed === "") return false;
111
+ if (/^0\d/.test(trimmed)) return false;
112
+ return Number.isFinite(Number(trimmed));
113
+ }
114
+ function ensureRank(column) {
115
+ if (!column.rank || column.rank.length !== (column.dict?.length ?? 0)) {
116
+ const dict = column.dict ?? [];
117
+ const order = dict.map((_, index) => index);
118
+ order.sort((a, b) => collator.compare(dict[a], dict[b]));
119
+ const rank = new Int32Array(dict.length);
120
+ for (let position = 0; position < order.length; position++) {
121
+ rank[order[position]] = position;
122
+ }
123
+ column.rank = rank;
124
+ }
125
+ return column.rank;
126
+ }
127
+ function ensureLower(column) {
128
+ if (!column.lower || column.lower.length !== (column.dict?.length ?? 0)) {
129
+ column.lower = (column.dict ?? []).map((value) => value.toLowerCase());
130
+ }
131
+ return column.lower;
132
+ }
133
+ function isBlank2(column, row) {
134
+ if (column.kind === "number" || column.kind === "boolean") {
135
+ return !column.valid || column.valid[row] === 0;
136
+ }
137
+ return column.data[row] < 0;
138
+ }
139
+ function cellText(column, row) {
140
+ if (isBlank2(column, row)) return "";
141
+ if (column.kind === "number") return String(column.data[row]);
142
+ if (column.kind === "boolean") return column.data[row] === 1 ? "true" : "false";
143
+ return column.dict[column.data[row]];
144
+ }
145
+ function cellValue(column, row) {
146
+ if (isBlank2(column, row)) return null;
147
+ if (column.kind === "number") return column.data[row];
148
+ if (column.kind === "boolean") return column.data[row] === 1;
149
+ return column.dict[column.data[row]];
150
+ }
151
+ function matches(row, clause) {
152
+ const column = columns[clause.col];
153
+ if (!column) return true;
154
+ const valueTrim = clause.value.trim();
155
+ const blank = isBlank2(column, row);
156
+ const fold = (text) => clause.caseSensitive ? text : text.toLowerCase();
157
+ const textFor = () => {
158
+ if (!clause.caseSensitive && (column.kind === "string" || column.kind === "date") && !blank) {
159
+ return ensureLower(column)[column.data[row]].trim();
160
+ }
161
+ return fold(cellText(column, row).trim());
162
+ };
163
+ switch (clause.op) {
164
+ case "=": {
165
+ if (valueTrim === "") return blank;
166
+ if (blank) return false;
167
+ if (column.kind === "number" && isNumericText(valueTrim)) {
168
+ return column.data[row] === Number(valueTrim);
169
+ }
170
+ return textFor() === fold(valueTrim);
171
+ }
172
+ case "!=":
173
+ return !matches(row, { ...clause, op: "=" });
174
+ case "~":
175
+ if (blank) return false;
176
+ return textFor().includes(fold(valueTrim));
177
+ case "!~":
178
+ return !matches(row, { ...clause, op: "~" });
179
+ case "^~":
180
+ if (blank) return false;
181
+ return textFor().startsWith(fold(valueTrim));
182
+ case "$~":
183
+ if (blank) return false;
184
+ return textFor().endsWith(fold(valueTrim));
185
+ default: {
186
+ if (blank) return false;
187
+ let comparison;
188
+ if (column.kind === "number" && isNumericText(valueTrim)) {
189
+ comparison = column.data[row] - Number(valueTrim);
190
+ } else {
191
+ comparison = collator.compare(cellText(column, row).trim(), valueTrim);
192
+ }
193
+ if (clause.op === ">") return comparison > 0;
194
+ if (clause.op === "<") return comparison < 0;
195
+ if (clause.op === ">=") return comparison >= 0;
196
+ return comparison <= 0;
197
+ }
198
+ }
199
+ }
200
+ function recomputeView() {
201
+ const ids = [];
202
+ for (let row = 0; row < rowCount; row++) {
203
+ let pass = true;
204
+ for (const clause of activeFilter) {
205
+ if (!matches(row, clause)) {
206
+ pass = false;
207
+ break;
208
+ }
209
+ }
210
+ if (pass) ids.push(row);
211
+ }
212
+ if (activeSort.length > 0) {
213
+ for (const term of activeSort) {
214
+ const column = columns[term.col];
215
+ if (column && (column.kind === "string" || column.kind === "date")) ensureRank(column);
216
+ }
217
+ ids.sort((a, b) => {
218
+ for (const term of activeSort) {
219
+ const column = columns[term.col];
220
+ if (!column) continue;
221
+ const aBlank = isBlank2(column, a);
222
+ const bBlank = isBlank2(column, b);
223
+ if (aBlank || bBlank) {
224
+ if (aBlank && bBlank) continue;
225
+ return aBlank ? 1 : -1;
226
+ }
227
+ let comparison;
228
+ if (column.kind === "number") {
229
+ comparison = column.data[a] - column.data[b];
230
+ } else if (column.kind === "boolean") {
231
+ comparison = column.data[a] - column.data[b];
232
+ } else {
233
+ const rank = column.rank;
234
+ comparison = rank[column.data[a]] - rank[column.data[b]];
235
+ }
236
+ if (comparison !== 0) return term.dir === "desc" ? -comparison : comparison;
237
+ }
238
+ return a - b;
239
+ });
240
+ }
241
+ perm = ids;
242
+ }
243
+ function applyEdit(edit) {
244
+ const column = columns[edit.col];
245
+ if (!column || edit.rowId < 0 || edit.rowId >= rowCount) return;
246
+ if (column.kind === "number") {
247
+ const blank = edit.value === null || edit.value === "";
248
+ column.valid[edit.rowId] = blank ? 0 : 1;
249
+ column.data[edit.rowId] = blank ? 0 : Number(edit.value);
250
+ return;
251
+ }
252
+ if (column.kind === "boolean") {
253
+ const blank = edit.value === null || edit.value === "";
254
+ column.valid[edit.rowId] = blank ? 0 : 1;
255
+ column.data[edit.rowId] = edit.value === true ? 1 : 0;
256
+ return;
257
+ }
258
+ if (edit.value === null || edit.value === "") {
259
+ column.data[edit.rowId] = -1;
260
+ return;
261
+ }
262
+ const text = String(edit.value);
263
+ if (!column.codeByValue) {
264
+ column.codeByValue = /* @__PURE__ */ new Map();
265
+ (column.dict ?? []).forEach((value, code2) => column.codeByValue.set(value, code2));
266
+ }
267
+ let code = column.codeByValue.get(text);
268
+ if (code === void 0) {
269
+ code = column.dict.length;
270
+ column.dict.push(text);
271
+ column.codeByValue.set(text, code);
272
+ column.rank = void 0;
273
+ column.lower = void 0;
274
+ }
275
+ column.data[edit.rowId] = code;
276
+ }
277
+ scope.onmessage = (event) => {
278
+ const message = event.data;
279
+ try {
280
+ if (message.type === "init") {
281
+ columns = message.columns.map((payload) => ({ ...payload }));
282
+ rowCount = message.rowCount;
283
+ activeSort = [];
284
+ activeFilter = [];
285
+ recomputeView();
286
+ scope.postMessage({ type: "ready", seq: message.seq });
287
+ return;
288
+ }
289
+ if (message.type === "setView") {
290
+ activeSort = message.sort;
291
+ activeFilter = message.filter;
292
+ recomputeView();
293
+ scope.postMessage({
294
+ type: "viewResult",
295
+ seq: message.seq,
296
+ viewRowCount: perm.length
297
+ });
298
+ return;
299
+ }
300
+ if (message.type === "rows") {
301
+ const start = Math.max(0, message.start);
302
+ const end = Math.min(perm.length, start + message.count);
303
+ const rowIds = [];
304
+ const cells = [];
305
+ for (let index = start; index < end; index++) {
306
+ const rowId = perm[index];
307
+ rowIds.push(rowId);
308
+ cells.push(columns.map((column) => cellValue(column, rowId)));
309
+ }
310
+ scope.postMessage({ type: "rowsResult", seq: message.seq, start, rowIds, cells });
311
+ return;
312
+ }
313
+ if (message.type === "applyEdits") {
314
+ const touched = /* @__PURE__ */ new Set();
315
+ for (const edit of message.edits) {
316
+ applyEdit(edit);
317
+ touched.add(edit.col);
318
+ }
319
+ const staleView = activeSort.some((term) => touched.has(term.col)) || activeFilter.some((clause) => touched.has(clause.col));
320
+ scope.postMessage({ type: "editResult", seq: message.seq, staleView });
321
+ return;
322
+ }
323
+ if (message.type === "distinct") {
324
+ const column = columns[message.col];
325
+ const limit = Math.max(1, message.limit);
326
+ let hasBlank = false;
327
+ let values = [];
328
+ let totalDistinct = 0;
329
+ if (column && (column.kind === "string" || column.kind === "date")) {
330
+ const dict = column.dict ?? [];
331
+ const used = new Uint8Array(dict.length);
332
+ const codes = column.data;
333
+ for (let row = 0; row < rowCount; row++) {
334
+ const code = codes[row];
335
+ if (code < 0) hasBlank = true;
336
+ else used[code] = 1;
337
+ }
338
+ const rank = ensureRank(column);
339
+ const present = [];
340
+ for (let code = 0; code < used.length; code++) {
341
+ if (used[code] === 1) present.push(code);
342
+ }
343
+ present.sort((a, b) => rank[a] - rank[b]);
344
+ totalDistinct = present.length;
345
+ values = present.slice(0, limit).map((code) => dict[code]);
346
+ } else if (column && column.kind === "number") {
347
+ const data = column.data;
348
+ const seen = /* @__PURE__ */ new Set();
349
+ for (let row = 0; row < rowCount; row++) {
350
+ if (!column.valid || column.valid[row] === 0) hasBlank = true;
351
+ else seen.add(data[row]);
352
+ }
353
+ const sorted = [...seen].sort((a, b) => a - b);
354
+ totalDistinct = sorted.length;
355
+ values = sorted.slice(0, limit).map((value) => String(value));
356
+ } else if (column) {
357
+ let sawTrue = false;
358
+ let sawFalse = false;
359
+ const data = column.data;
360
+ for (let row = 0; row < rowCount; row++) {
361
+ if (!column.valid || column.valid[row] === 0) hasBlank = true;
362
+ else if (data[row] === 1) sawTrue = true;
363
+ else sawFalse = true;
364
+ }
365
+ if (sawFalse) values.push("false");
366
+ if (sawTrue) values.push("true");
367
+ totalDistinct = values.length;
368
+ values = values.slice(0, limit);
369
+ }
370
+ scope.postMessage({
371
+ type: "distinctResult",
372
+ seq: message.seq,
373
+ values,
374
+ totalDistinct,
375
+ hasBlank
376
+ });
377
+ return;
378
+ }
379
+ if (message.type === "dispose") {
380
+ columns = [];
381
+ perm = [];
382
+ rowCount = 0;
383
+ }
384
+ } catch (err) {
385
+ const seq = "seq" in message ? message.seq : -1;
386
+ scope.postMessage({
387
+ type: "error",
388
+ seq,
389
+ message: err instanceof Error ? err.message : String(err)
390
+ });
391
+ }
392
+ };
393
+ }
394
+ function buildKernelSource() {
395
+ return `(${tableKernel.toString()})(self);`;
396
+ }
397
+
398
+ // src/store/client.ts
399
+ import { parseTableViewState, serializeTableViewState } from "@bendyline/squisq/table";
400
+ var LocalKernelHost = class {
401
+ constructor() {
402
+ this.onResponse = () => {
403
+ };
404
+ const scope = {
405
+ onmessage: null,
406
+ postMessage: (message) => this.onResponse(message)
407
+ };
408
+ tableKernel(scope);
409
+ this.scope = scope;
410
+ }
411
+ post(message) {
412
+ this.scope.onmessage?.({ data: message });
413
+ }
414
+ terminate() {
415
+ this.scope.onmessage = null;
416
+ }
417
+ };
418
+ var BlobWorkerHost = class {
419
+ constructor() {
420
+ this.onResponse = () => {
421
+ };
422
+ const url = URL.createObjectURL(new Blob([buildKernelSource()], { type: "text/javascript" }));
423
+ try {
424
+ this.worker = new Worker(url);
425
+ } finally {
426
+ URL.revokeObjectURL(url);
427
+ }
428
+ this.worker.onmessage = (event) => {
429
+ this.onResponse(event.data);
430
+ };
431
+ }
432
+ post(message, transfer) {
433
+ this.worker.postMessage(message, transfer ?? []);
434
+ }
435
+ terminate() {
436
+ this.worker.terminate();
437
+ }
438
+ };
439
+ function toPayload(column) {
440
+ if (column.kind === "number" || column.kind === "boolean") {
441
+ return {
442
+ payload: { name: column.name, kind: column.kind, data: column.data, valid: column.valid },
443
+ transfer: [column.data.buffer, column.valid.buffer]
444
+ };
445
+ }
446
+ return {
447
+ payload: { name: column.name, kind: column.kind, data: column.codes, dict: column.dict },
448
+ transfer: [column.codes.buffer]
449
+ };
450
+ }
451
+ var TableStoreClient = class {
452
+ constructor(table, options = {}) {
453
+ this.seq = 0;
454
+ this.pending = /* @__PURE__ */ new Map();
455
+ const columnar = buildColumnarTable(table);
456
+ this.schema = {
457
+ columns: columnar.columns.map((column) => ({ name: column.name, kind: column.kind })),
458
+ rowCount: columnar.rowCount
459
+ };
460
+ this.viewRowCount = columnar.rowCount;
461
+ this.host = options.forceLocal || typeof Worker === "undefined" ? new LocalKernelHost() : new BlobWorkerHost();
462
+ this.host.onResponse = (message) => {
463
+ const resolve = this.pending.get(message.seq);
464
+ if (resolve) {
465
+ this.pending.delete(message.seq);
466
+ resolve(message);
467
+ }
468
+ };
469
+ const payloads = columnar.columns.map(toPayload);
470
+ this.ready = this.request(
471
+ (seq) => ({
472
+ type: "init",
473
+ seq,
474
+ columns: payloads.map((entry) => entry.payload),
475
+ rowCount: columnar.rowCount
476
+ }),
477
+ payloads.flatMap((entry) => entry.transfer)
478
+ ).then(() => void 0);
479
+ }
480
+ request(build, transfer) {
481
+ const seq = ++this.seq;
482
+ return new Promise((resolve, reject) => {
483
+ this.pending.set(seq, (message) => {
484
+ if (message.type === "error") reject(new Error(message.message));
485
+ else resolve(message);
486
+ });
487
+ this.host.post(build(seq), transfer);
488
+ });
489
+ }
490
+ async describe() {
491
+ await this.ready;
492
+ return this.schema;
493
+ }
494
+ async setView(view) {
495
+ await this.ready;
496
+ const raw = serializeTableViewState(view);
497
+ const headers = this.schema.columns.map((column) => column.name);
498
+ const { view: resolved, issues } = parseTableViewState(raw.sort, raw.filter, headers);
499
+ const columnIndex = new Map(headers.map((name, index) => [name, index]));
500
+ const response = await this.request((seq) => ({
501
+ type: "setView",
502
+ seq,
503
+ sort: resolved.sort.map((term) => ({
504
+ col: columnIndex.get(term.column),
505
+ dir: term.dir
506
+ })),
507
+ filter: resolved.filter.map((clause) => ({
508
+ col: columnIndex.get(clause.column),
509
+ op: clause.op,
510
+ value: clause.value,
511
+ ...clause.caseSensitive ? { caseSensitive: true } : {}
512
+ }))
513
+ }));
514
+ if (response.type !== "viewResult") throw new Error("unexpected kernel response");
515
+ this.viewRowCount = response.viewRowCount;
516
+ return { viewRowCount: response.viewRowCount, issues };
517
+ }
518
+ async rows(start, count) {
519
+ await this.ready;
520
+ const response = await this.request((seq) => ({ type: "rows", seq, start, count }));
521
+ if (response.type !== "rowsResult") throw new Error("unexpected kernel response");
522
+ return { start: response.start, rowIds: response.rowIds, cells: response.cells };
523
+ }
524
+ async applyEdits(edits) {
525
+ await this.ready;
526
+ const response = await this.request((seq) => ({
527
+ type: "applyEdits",
528
+ seq,
529
+ edits: edits.map((edit) => ({ rowId: edit.rowId, col: edit.col, value: edit.value }))
530
+ }));
531
+ if (response.type !== "editResult") throw new Error("unexpected kernel response");
532
+ return { staleView: response.staleView };
533
+ }
534
+ async distinct(col, limit) {
535
+ await this.ready;
536
+ const response = await this.request((seq) => ({ type: "distinct", seq, col, limit }));
537
+ if (response.type !== "distinctResult") throw new Error("unexpected kernel response");
538
+ return {
539
+ values: response.values,
540
+ totalDistinct: response.totalDistinct,
541
+ hasBlank: response.hasBlank
542
+ };
543
+ }
544
+ /** Row count under the current view (post-filter). */
545
+ get currentViewRowCount() {
546
+ return this.viewRowCount;
547
+ }
548
+ dispose() {
549
+ this.host.post({ type: "dispose" });
550
+ this.host.terminate();
551
+ this.pending.clear();
552
+ }
553
+ };
554
+
555
+ // src/store/journal.ts
556
+ var EditJournal = class {
557
+ constructor() {
558
+ /** Latest value per cell, keyed `rowId:col`. */
559
+ this.latest = /* @__PURE__ */ new Map();
560
+ this.undoStack = [];
561
+ this.redoStack = [];
562
+ }
563
+ get dirtyCount() {
564
+ return this.latest.size;
565
+ }
566
+ get canUndo() {
567
+ return this.undoStack.length > 0;
568
+ }
569
+ get canRedo() {
570
+ return this.redoStack.length > 0;
571
+ }
572
+ isDirty(rowId, col) {
573
+ return this.latest.has(`${rowId}:${col}`);
574
+ }
575
+ /** Record one committed batch of edits. Clears the redo stack. */
576
+ commit(entries) {
577
+ if (entries.length === 0) return;
578
+ this.undoStack.push(entries);
579
+ this.redoStack.length = 0;
580
+ for (const entry of entries) this.applyLatest(entry);
581
+ }
582
+ /** Pop the latest batch; returns the INVERSE edits to apply to the store. */
583
+ undo() {
584
+ const batch = this.undoStack.pop();
585
+ if (!batch) return [];
586
+ this.redoStack.push(batch);
587
+ const inverse = batch.map((entry) => ({
588
+ rowId: entry.rowId,
589
+ col: entry.col,
590
+ value: entry.prev
591
+ }));
592
+ this.rebuildLatest();
593
+ return inverse;
594
+ }
595
+ /** Re-apply the most recently undone batch. */
596
+ redo() {
597
+ const batch = this.redoStack.pop();
598
+ if (!batch) return [];
599
+ this.undoStack.push(batch);
600
+ this.rebuildLatest();
601
+ return batch.map((entry) => ({ rowId: entry.rowId, col: entry.col, value: entry.next }));
602
+ }
603
+ /** Net outstanding edits (what a Save must persist). */
604
+ entries() {
605
+ return [...this.latest.values()];
606
+ }
607
+ clear() {
608
+ this.latest.clear();
609
+ this.undoStack.length = 0;
610
+ this.redoStack.length = 0;
611
+ }
612
+ applyLatest(entry) {
613
+ const key = `${entry.rowId}:${entry.col}`;
614
+ const existing = this.latest.get(key);
615
+ const merged = existing ? { ...entry, prev: existing.prev } : { ...entry };
616
+ if (merged.prev === merged.next) this.latest.delete(key);
617
+ else this.latest.set(key, merged);
618
+ }
619
+ rebuildLatest() {
620
+ this.latest.clear();
621
+ for (const batch of this.undoStack) {
622
+ for (const entry of batch) this.applyLatest(entry);
623
+ }
624
+ }
625
+ };
626
+ var journalCache = /* @__PURE__ */ new Map();
627
+ function journalFor(path, revision) {
628
+ const key = `${revision} ${path}`;
629
+ let journal = journalCache.get(key);
630
+ if (!journal) {
631
+ for (const existing of journalCache.keys()) {
632
+ if (existing.endsWith(` ${path}`) && existing !== key) journalCache.delete(existing);
633
+ }
634
+ journal = new EditJournal();
635
+ journalCache.set(key, journal);
636
+ }
637
+ return journal;
638
+ }
639
+ function discardJournal(path) {
640
+ for (const existing of [...journalCache.keys()]) {
641
+ if (existing.endsWith(` ${path}`)) journalCache.delete(existing);
642
+ }
643
+ }
644
+
645
+ // src/DataGrid.tsx
646
+ import {
647
+ useCallback,
648
+ useEffect,
649
+ useLayoutEffect,
650
+ useMemo,
651
+ useRef,
652
+ useState
653
+ } from "react";
654
+ import { useVirtualizer } from "@tanstack/react-virtual";
655
+ import { serializeTableViewState as serializeTableViewState2 } from "@bendyline/squisq/table";
656
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
657
+ var ROW_HEIGHT = 28;
658
+ var PAGE_SIZE = 200;
659
+ var OVERSCAN = 12;
660
+ var DEFAULT_HEIGHT = 420;
661
+ var COPY_CELL_CAP = 5e4;
662
+ var DEFAULT_COL_WIDTH = 140;
663
+ function cellDisplay(value) {
664
+ if (value === null || value === void 0) return "";
665
+ if (typeof value === "boolean") return value ? "true" : "false";
666
+ return String(value);
667
+ }
668
+ function coerceInput(text, kind) {
669
+ const trimmed = text.trim();
670
+ if (trimmed === "") return null;
671
+ if (kind === "number") {
672
+ const value = Number(trimmed);
673
+ if (!Number.isFinite(value)) return { error: "not a number" };
674
+ return value;
675
+ }
676
+ if (kind === "boolean") {
677
+ if (/^(?:true|1)$/i.test(trimmed)) return true;
678
+ if (/^(?:false|0)$/i.test(trimmed)) return false;
679
+ return { error: "true or false" };
680
+ }
681
+ return text;
682
+ }
683
+ var TEXT_OP_CHOICES = [
684
+ { op: "~", glyph: "~", label: "Contains" },
685
+ { op: "!~", glyph: "!~", label: "Doesn't contain" },
686
+ { op: "=", glyph: "=", label: "Equals" },
687
+ { op: "!=", glyph: "\u2260", label: "Not equal" },
688
+ { op: "^~", glyph: "^", label: "Starts with" },
689
+ { op: "$~", glyph: "$", label: "Ends with" }
690
+ ];
691
+ var COMPARE_OP_CHOICES = [
692
+ { op: "=", glyph: "=", label: "Equals" },
693
+ { op: "!=", glyph: "\u2260", label: "Not equal" },
694
+ { op: "<", glyph: "<", label: "Less than" },
695
+ { op: ">", glyph: ">", label: "Greater than" },
696
+ { op: "<=", glyph: "\u2264", label: "At most" },
697
+ { op: ">=", glyph: "\u2265", label: "At least" }
698
+ ];
699
+ var UNARY_OP_CHOICES = [
700
+ { op: "=", glyph: "\u2205", label: "Is empty", unary: true },
701
+ { op: "!=", glyph: "\u2260\u2205", label: "Is not empty", unary: true }
702
+ ];
703
+ function opChoicesFor(kind) {
704
+ if (kind === "number" || kind === "boolean") {
705
+ return [...COMPARE_OP_CHOICES, ...UNARY_OP_CHOICES];
706
+ }
707
+ return [
708
+ ...TEXT_OP_CHOICES,
709
+ ...COMPARE_OP_CHOICES.filter((choice) => choice.op !== "=" && choice.op !== "!="),
710
+ ...UNARY_OP_CHOICES
711
+ ];
712
+ }
713
+ function defaultOpFor(kind) {
714
+ return kind === "number" || kind === "boolean" ? "=" : "~";
715
+ }
716
+ function choiceMatches(choice, state) {
717
+ return choice.op === state.op && choice.unary === true === (state.unary === true);
718
+ }
719
+ function glyphFor(kind, state) {
720
+ return opChoicesFor(kind).find((choice) => choiceMatches(choice, state))?.glyph ?? TEXT_OP_CHOICES.find((choice) => choice.op === state.op)?.glyph ?? state.op;
721
+ }
722
+ var VALUE_MENU_LIMIT = 100;
723
+ var CASE_CAPABLE_OPS = ["=", "!=", "~", "!~", "^~", "$~"];
724
+ function normalizedRange(selection) {
725
+ return {
726
+ r0: Math.min(selection.anchor.row, selection.focus.row),
727
+ r1: Math.max(selection.anchor.row, selection.focus.row),
728
+ c0: Math.min(selection.anchor.col, selection.focus.col),
729
+ c1: Math.max(selection.anchor.col, selection.focus.col)
730
+ };
731
+ }
732
+ function DataGrid({
733
+ provider,
734
+ journal,
735
+ view,
736
+ onViewChange,
737
+ viewPersisted = true,
738
+ onSave,
739
+ saving = false,
740
+ height = DEFAULT_HEIGHT,
741
+ readOnlyReason,
742
+ isCellLocked,
743
+ lockedReason,
744
+ formulaSupport,
745
+ onCellEdited,
746
+ extraDirtyCount = 0,
747
+ onDiscardExtra,
748
+ className
749
+ }) {
750
+ const [schema, setSchema] = useState(null);
751
+ const [viewRowCount, setViewRowCount] = useState(0);
752
+ const [issueNote, setIssueNote] = useState(null);
753
+ const [staleView, setStaleView] = useState(false);
754
+ const [selection, setSelection] = useState(null);
755
+ const [editing, setEditing] = useState(
756
+ null
757
+ );
758
+ const [colWidths, setColWidths] = useState({});
759
+ const [filterOps, setFilterOps] = useState({});
760
+ const [opMenuCol, setOpMenuCol] = useState(null);
761
+ const [opMenuPos, setOpMenuPos] = useState({ left: 0, top: 0 });
762
+ const [valueMenuPos, setValueMenuPos] = useState({ left: 0, top: 0 });
763
+ const [valueMenuCol, setValueMenuCol] = useState(null);
764
+ const [valueMenuData, setValueMenuData] = useState(null);
765
+ const [dirtyTick, setDirtyTick] = useState(0);
766
+ const [announce, setAnnounce] = useState("");
767
+ const [fetchTick, setFetchTick] = useState(0);
768
+ const bodyRef = useRef(null);
769
+ const rootRef = useRef(null);
770
+ const opMenuRef = useRef(null);
771
+ const valueMenuRef = useRef(null);
772
+ const focusRef = useRef(null);
773
+ const wasEditingRef = useRef(false);
774
+ useEffect(() => {
775
+ if (wasEditingRef.current && !editing) focusRef.current?.focus();
776
+ wasEditingRef.current = editing !== null;
777
+ }, [editing]);
778
+ const cache = useRef({ version: 0, pages: /* @__PURE__ */ new Map() });
779
+ const inFlight = useRef(/* @__PURE__ */ new Set());
780
+ const selectionCells = useRef(/* @__PURE__ */ new Map());
781
+ const viewRef = useRef(view);
782
+ viewRef.current = view;
783
+ const editable = Boolean(journal && provider.applyEdits && !readOnlyReason);
784
+ const dirtyCount = journal?.dirtyCount ?? 0;
785
+ void dirtyTick;
786
+ void fetchTick;
787
+ useEffect(() => {
788
+ let cancelled = false;
789
+ provider.describe().then(
790
+ (described) => {
791
+ if (!cancelled) setSchema(described);
792
+ },
793
+ () => void 0
794
+ );
795
+ return () => {
796
+ cancelled = true;
797
+ };
798
+ }, [provider]);
799
+ const applyView = useCallback(
800
+ async (next) => {
801
+ const result = await provider.setView(next);
802
+ cache.current = { version: cache.current.version + 1, pages: /* @__PURE__ */ new Map() };
803
+ inFlight.current.clear();
804
+ setViewRowCount(result.viewRowCount);
805
+ setStaleView(false);
806
+ setIssueNote(
807
+ result.issues.length > 0 ? result.issues.map((i) => i.message).join("; ") : null
808
+ );
809
+ setFetchTick((t) => t + 1);
810
+ },
811
+ [provider]
812
+ );
813
+ useEffect(() => {
814
+ void applyView(view);
815
+ }, [applyView, JSON.stringify(serializeTableViewState2(view))]);
816
+ const virtualizer = useVirtualizer({
817
+ count: viewRowCount,
818
+ getScrollElement: () => bodyRef.current,
819
+ estimateSize: () => ROW_HEIGHT,
820
+ overscan: OVERSCAN,
821
+ // Correct first paint before the scroll element is measured — and the
822
+ // only measurement available in layout-less environments (jsdom).
823
+ initialRect: { width: 800, height }
824
+ });
825
+ const virtualItems = virtualizer.getVirtualItems();
826
+ useEffect(() => {
827
+ if (viewRowCount === 0) return;
828
+ const version = cache.current.version;
829
+ const first = virtualItems[0]?.index ?? 0;
830
+ const last = virtualItems[virtualItems.length - 1]?.index ?? 0;
831
+ const firstPage = Math.floor(first / PAGE_SIZE);
832
+ const lastPage = Math.floor(last / PAGE_SIZE);
833
+ for (let page = firstPage; page <= lastPage; page++) {
834
+ if (cache.current.pages.has(page) || inFlight.current.has(page)) continue;
835
+ inFlight.current.add(page);
836
+ provider.rows(page * PAGE_SIZE, PAGE_SIZE).then(
837
+ (result) => {
838
+ inFlight.current.delete(page);
839
+ if (cache.current.version !== version) return;
840
+ cache.current.pages.set(page, {
841
+ rowIds: Array.from(result.rowIds),
842
+ cells: result.cells
843
+ });
844
+ setFetchTick((t) => t + 1);
845
+ },
846
+ () => inFlight.current.delete(page)
847
+ );
848
+ }
849
+ }, [provider, virtualItems, viewRowCount, fetchTick]);
850
+ const cellAt = useCallback((row) => {
851
+ const page = cache.current.pages.get(Math.floor(row / PAGE_SIZE));
852
+ if (!page) return null;
853
+ const offset = row % PAGE_SIZE;
854
+ const rowId = page.rowIds[offset];
855
+ const cells = page.cells[offset];
856
+ if (rowId === void 0 || !cells) return null;
857
+ return { rowId, cells };
858
+ }, []);
859
+ useEffect(() => {
860
+ if (!selection || !schema) return;
861
+ const { r0, r1 } = normalizedRange(selection);
862
+ if ((r1 - r0 + 1) * schema.columns.length > COPY_CELL_CAP) return;
863
+ let cancelled = false;
864
+ void (async () => {
865
+ const rows = /* @__PURE__ */ new Map();
866
+ for (let start = r0; start <= r1; start += PAGE_SIZE) {
867
+ const page = await provider.rows(start, Math.min(PAGE_SIZE, r1 - start + 1));
868
+ if (cancelled) return;
869
+ page.cells.forEach((cells, index) => rows.set(page.start + index, cells));
870
+ }
871
+ selectionCells.current = rows;
872
+ })();
873
+ return () => {
874
+ cancelled = true;
875
+ };
876
+ }, [provider, schema, selection]);
877
+ const handleCopy = useCallback(
878
+ (event) => {
879
+ if (!selection || !schema || editing) return;
880
+ const target = event.target;
881
+ if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) return;
882
+ event.preventDefault();
883
+ const { r0, r1, c0, c1 } = normalizedRange(selection);
884
+ const capRows = Math.floor(COPY_CELL_CAP / Math.max(1, c1 - c0 + 1));
885
+ const lastRow = Math.min(r1, r0 + capRows - 1);
886
+ const lines = [];
887
+ const htmlRows = [];
888
+ for (let row = r0; row <= lastRow; row++) {
889
+ const cells = selectionCells.current.get(row) ?? cellAt(row)?.cells;
890
+ const values = [];
891
+ for (let col = c0; col <= c1; col++) values.push(cellDisplay(cells?.[col] ?? null));
892
+ lines.push(values.join(" "));
893
+ htmlRows.push(`<tr>${values.map((v) => `<td>${escapeHtml(v)}</td>`).join("")}</tr>`);
894
+ }
895
+ event.clipboardData.setData("text/plain", lines.join("\n"));
896
+ event.clipboardData.setData("text/html", `<table>${htmlRows.join("")}</table>`);
897
+ if (lastRow < r1) {
898
+ setAnnounce(`Copy truncated to ${lastRow - r0 + 1} rows (${COPY_CELL_CAP} cell cap).`);
899
+ } else {
900
+ setAnnounce(`Copied ${lastRow - r0 + 1}\xD7${c1 - c0 + 1} cells.`);
901
+ }
902
+ },
903
+ [cellAt, editing, schema, selection]
904
+ );
905
+ const beginEdit = useCallback(
906
+ (pos, seed) => {
907
+ if (!editable || !schema) return;
908
+ const current = cellAt(pos.row);
909
+ if (current && isCellLocked?.(current.rowId, pos.col)) {
910
+ setAnnounce(lockedReason ?? "This cell is locked");
911
+ return;
912
+ }
913
+ const formula = current ? formulaSupport?.getFormula(current.rowId, pos.col) : void 0;
914
+ setEditing({
915
+ ...pos,
916
+ draft: seed ?? (formula !== void 0 ? `=${formula}` : cellDisplay(current?.cells[pos.col] ?? null))
917
+ });
918
+ },
919
+ [cellAt, editable, schema, isCellLocked, lockedReason, formulaSupport]
920
+ );
921
+ const applyCacheUpdates = useCallback((edits) => {
922
+ if (edits.length === 0) return;
923
+ const byRowId = /* @__PURE__ */ new Map();
924
+ for (const edit of edits) {
925
+ const list = byRowId.get(edit.rowId) ?? [];
926
+ list.push(edit);
927
+ byRowId.set(edit.rowId, list);
928
+ }
929
+ for (const page of cache.current.pages.values()) {
930
+ for (let offset = 0; offset < page.rowIds.length; offset++) {
931
+ const rowEdits = byRowId.get(page.rowIds[offset]);
932
+ if (!rowEdits) continue;
933
+ for (const edit of rowEdits) {
934
+ page.cells[offset][edit.col] = edit.value;
935
+ }
936
+ }
937
+ }
938
+ setFetchTick((t) => t + 1);
939
+ }, []);
940
+ const commitEdit = useCallback(async () => {
941
+ if (!editing || !schema || !journal || !provider.applyEdits) return true;
942
+ if (formulaSupport && editing.draft.trim().startsWith("=")) {
943
+ const located2 = cellAt(editing.row);
944
+ if (!located2) {
945
+ setEditing(null);
946
+ return true;
947
+ }
948
+ const result2 = await formulaSupport.commitFormula(
949
+ located2.rowId,
950
+ editing.col,
951
+ editing.draft.trim().slice(1)
952
+ );
953
+ if (!result2.ok) {
954
+ setEditing({ ...editing, error: result2.error ?? "formula rejected" });
955
+ return false;
956
+ }
957
+ setEditing(null);
958
+ if (result2.updates && result2.updates.length > 0) {
959
+ const applied = await provider.applyEdits(result2.updates);
960
+ if (applied.staleView) setStaleView(true);
961
+ applyCacheUpdates(result2.updates);
962
+ }
963
+ setDirtyTick((t) => t + 1);
964
+ return true;
965
+ }
966
+ const kind = schema.columns[editing.col]?.kind ?? "string";
967
+ const coerced = coerceInput(editing.draft, kind);
968
+ if (typeof coerced === "object" && coerced !== null && "error" in coerced) {
969
+ setEditing({ ...editing, error: coerced.error });
970
+ return false;
971
+ }
972
+ const located = cellAt(editing.row);
973
+ if (!located) {
974
+ setEditing(null);
975
+ return true;
976
+ }
977
+ const prev = located.cells[editing.col] ?? null;
978
+ const next = coerced;
979
+ setEditing(null);
980
+ if (prev === next) return true;
981
+ journal.commit([{ rowId: located.rowId, col: editing.col, prev, next }]);
982
+ located.cells[editing.col] = next;
983
+ const result = await provider.applyEdits([
984
+ { rowId: located.rowId, col: editing.col, value: next }
985
+ ]);
986
+ if (result.staleView) setStaleView(true);
987
+ const dependents = await onCellEdited?.({
988
+ rowId: located.rowId,
989
+ col: editing.col,
990
+ value: next
991
+ });
992
+ if (dependents && dependents.length > 0) {
993
+ const applied = await provider.applyEdits(dependents);
994
+ if (applied.staleView) setStaleView(true);
995
+ applyCacheUpdates(dependents);
996
+ }
997
+ setDirtyTick((t) => t + 1);
998
+ return true;
999
+ }, [applyCacheUpdates, cellAt, editing, formulaSupport, journal, onCellEdited, provider, schema]);
1000
+ const handlePaste = useCallback(
1001
+ (event) => {
1002
+ if (!selection || !schema || !journal || !provider.applyEdits || editing) return;
1003
+ const target = event.target;
1004
+ if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) return;
1005
+ const text = event.clipboardData.getData("text/plain");
1006
+ if (!text) return;
1007
+ event.preventDefault();
1008
+ const lines = text.replace(/\r\n?/g, "\n").split("\n");
1009
+ if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
1010
+ const block = lines.map((line) => line.split(" "));
1011
+ const { r0, c0 } = normalizedRange(selection);
1012
+ const entries = [];
1013
+ let skipped = 0;
1014
+ for (let dr = 0; dr < block.length; dr++) {
1015
+ const row = r0 + dr;
1016
+ if (row >= viewRowCount) {
1017
+ skipped += block[dr].length;
1018
+ continue;
1019
+ }
1020
+ const located = cellAt(row);
1021
+ if (!located) {
1022
+ skipped += block[dr].length;
1023
+ continue;
1024
+ }
1025
+ for (let dc = 0; dc < block[dr].length; dc++) {
1026
+ const col = c0 + dc;
1027
+ const kind = schema.columns[col]?.kind;
1028
+ if (kind === void 0 || isCellLocked?.(located.rowId, col)) {
1029
+ skipped++;
1030
+ continue;
1031
+ }
1032
+ const coerced = coerceInput(block[dr][dc], kind);
1033
+ if (typeof coerced === "object" && coerced !== null && "error" in coerced) {
1034
+ skipped++;
1035
+ continue;
1036
+ }
1037
+ const prev = located.cells[col] ?? null;
1038
+ const next = coerced;
1039
+ if (prev === next) continue;
1040
+ entries.push({ rowId: located.rowId, col, prev, next });
1041
+ located.cells[col] = next;
1042
+ }
1043
+ }
1044
+ if (entries.length === 0) {
1045
+ if (skipped > 0) setAnnounce(`Nothing pasted (${skipped} cells skipped).`);
1046
+ return;
1047
+ }
1048
+ journal.commit(entries);
1049
+ void (async () => {
1050
+ const edits = entries.map(({ rowId, col, next }) => ({ rowId, col, value: next }));
1051
+ const result = await provider.applyEdits(edits);
1052
+ if (result.staleView) setStaleView(true);
1053
+ const dependents = [];
1054
+ for (const edit of edits) {
1055
+ const extra = await onCellEdited?.(edit);
1056
+ if (extra) dependents.push(...extra);
1057
+ }
1058
+ if (dependents.length > 0) {
1059
+ const applied = await provider.applyEdits(dependents);
1060
+ if (applied.staleView) setStaleView(true);
1061
+ applyCacheUpdates(dependents);
1062
+ }
1063
+ setDirtyTick((t) => t + 1);
1064
+ setFetchTick((t) => t + 1);
1065
+ setAnnounce(
1066
+ `Pasted ${entries.length} cell${entries.length === 1 ? "" : "s"}` + (skipped > 0 ? ` (${skipped} skipped)` : "") + "."
1067
+ );
1068
+ })();
1069
+ },
1070
+ [
1071
+ applyCacheUpdates,
1072
+ cellAt,
1073
+ editing,
1074
+ isCellLocked,
1075
+ journal,
1076
+ onCellEdited,
1077
+ provider,
1078
+ schema,
1079
+ selection,
1080
+ viewRowCount
1081
+ ]
1082
+ );
1083
+ const runJournal = useCallback(
1084
+ async (direction) => {
1085
+ if (!journal || !provider.applyEdits) return;
1086
+ const edits = direction === "undo" ? journal.undo() : journal.redo();
1087
+ if (edits.length === 0) return;
1088
+ const result = await provider.applyEdits(edits);
1089
+ if (result.staleView) setStaleView(true);
1090
+ cache.current.pages.clear();
1091
+ setDirtyTick((t) => t + 1);
1092
+ setFetchTick((t) => t + 1);
1093
+ },
1094
+ [journal, provider]
1095
+ );
1096
+ const cycleSort = useCallback(
1097
+ (columnName, additive) => {
1098
+ const current = viewRef.current;
1099
+ const existing = current.sort.find((term) => term.column === columnName);
1100
+ let nextTerms = additive ? [...current.sort] : [];
1101
+ if (!existing) {
1102
+ nextTerms = additive ? [...nextTerms, { column: columnName, dir: "asc" }] : [{ column: columnName, dir: "asc" }];
1103
+ } else if (existing.dir === "asc") {
1104
+ nextTerms = (additive ? current.sort : [existing]).map(
1105
+ (term) => term.column === columnName ? { ...term, dir: "desc" } : term
1106
+ );
1107
+ } else {
1108
+ nextTerms = (additive ? current.sort : []).filter((term) => term.column !== columnName);
1109
+ }
1110
+ onViewChange?.({ ...current, sort: nextTerms });
1111
+ },
1112
+ [onViewChange]
1113
+ );
1114
+ const setColumnFilter = useCallback(
1115
+ (columnName, text, op, caseSensitive, unary = false) => {
1116
+ const current = viewRef.current;
1117
+ const others = current.filter.filter((clause) => clause.column !== columnName);
1118
+ onViewChange?.({
1119
+ ...current,
1120
+ filter: unary ? [...others, { column: columnName, op, value: "" }] : text.trim() === "" ? others : [
1121
+ ...others,
1122
+ {
1123
+ column: columnName,
1124
+ op,
1125
+ value: text,
1126
+ ...caseSensitive && CASE_CAPABLE_OPS.includes(op) ? { caseSensitive: true } : {}
1127
+ }
1128
+ ]
1129
+ });
1130
+ },
1131
+ [onViewChange]
1132
+ );
1133
+ const opStateFor = useCallback(
1134
+ (col, columnName) => {
1135
+ const local = filterOps[col];
1136
+ if (local) return local;
1137
+ const clause = viewRef.current.filter.find((entry) => entry.column === columnName);
1138
+ if (clause) {
1139
+ return {
1140
+ op: clause.op,
1141
+ caseSensitive: clause.caseSensitive === true,
1142
+ ...clause.value === "" && (clause.op === "=" || clause.op === "!=") ? { unary: true } : {}
1143
+ };
1144
+ }
1145
+ return { op: defaultOpFor(schema?.columns[col]?.kind ?? "string"), caseSensitive: false };
1146
+ },
1147
+ [filterOps, schema]
1148
+ );
1149
+ const chooseFilterOp = useCallback(
1150
+ (col, columnName, next) => {
1151
+ setFilterOps((prev) => ({ ...prev, [col]: next }));
1152
+ setOpMenuCol(null);
1153
+ const clause = viewRef.current.filter.find((entry) => entry.column === columnName);
1154
+ if (next.unary) {
1155
+ setColumnFilter(columnName, "", next.op, false, true);
1156
+ } else if (clause && clause.value.trim() !== "") {
1157
+ setColumnFilter(columnName, clause.value, next.op, next.caseSensitive);
1158
+ } else if (clause) {
1159
+ setColumnFilter(columnName, "", next.op, next.caseSensitive);
1160
+ }
1161
+ },
1162
+ [setColumnFilter]
1163
+ );
1164
+ const clearColumnFilter = useCallback(
1165
+ (col, columnName) => {
1166
+ const prior = opStateFor(col, columnName);
1167
+ setFilterOps((prev) => ({
1168
+ ...prev,
1169
+ [col]: prior.unary ? { op: defaultOpFor(schema?.columns[col]?.kind ?? "string"), caseSensitive: false } : { op: prior.op, caseSensitive: prior.caseSensitive }
1170
+ }));
1171
+ setOpMenuCol(null);
1172
+ setColumnFilter(columnName, "", prior.op, prior.caseSensitive);
1173
+ },
1174
+ [opStateFor, schema, setColumnFilter]
1175
+ );
1176
+ const menuAnchorPos = useCallback((anchor) => {
1177
+ const rootRect = rootRef.current?.getBoundingClientRect();
1178
+ const rect = anchor.getBoundingClientRect();
1179
+ if (!rootRect) return { left: 0, top: 0 };
1180
+ return { left: rect.left - rootRect.left, top: rect.bottom - rootRect.top + 2 };
1181
+ }, []);
1182
+ const toggleOpMenu = useCallback(
1183
+ (col, anchor) => {
1184
+ setValueMenuCol(null);
1185
+ setOpMenuPos(menuAnchorPos(anchor));
1186
+ setOpMenuCol((open) => open === col ? null : col);
1187
+ },
1188
+ [menuAnchorPos]
1189
+ );
1190
+ const openValueMenu = useCallback(
1191
+ (col, anchor) => {
1192
+ setOpMenuCol(null);
1193
+ if (valueMenuCol === col) {
1194
+ setValueMenuCol(null);
1195
+ return;
1196
+ }
1197
+ setValueMenuPos(menuAnchorPos(anchor));
1198
+ setValueMenuCol(col);
1199
+ void provider.distinct?.(col, VALUE_MENU_LIMIT).then((result) => {
1200
+ setValueMenuData({ col, result });
1201
+ });
1202
+ },
1203
+ [menuAnchorPos, provider, valueMenuCol]
1204
+ );
1205
+ useLayoutEffect(() => {
1206
+ const root = rootRef.current;
1207
+ if (!root) return;
1208
+ for (const menu of [opMenuRef.current, valueMenuRef.current]) {
1209
+ if (!menu) continue;
1210
+ const maxLeft = root.clientWidth - menu.offsetWidth - 4;
1211
+ const maxTop = root.clientHeight - menu.offsetHeight - 4;
1212
+ menu.style.left = `${Math.max(4, Math.min(parseFloat(menu.style.left) || 0, maxLeft))}px`;
1213
+ menu.style.top = `${Math.max(4, Math.min(parseFloat(menu.style.top) || 0, maxTop))}px`;
1214
+ }
1215
+ }, [opMenuCol, valueMenuCol, valueMenuData]);
1216
+ const chooseFilterValue = useCallback(
1217
+ (col, columnName, value) => {
1218
+ setFilterOps((prev) => ({ ...prev, [col]: { op: "=", caseSensitive: false } }));
1219
+ setValueMenuCol(null);
1220
+ setColumnFilter(columnName, value, "=", false);
1221
+ },
1222
+ [setColumnFilter]
1223
+ );
1224
+ const moveFocus = useCallback(
1225
+ (rowDelta, colDelta, extend, absolute) => {
1226
+ if (!schema) return;
1227
+ setSelection((prev) => {
1228
+ const from = prev?.focus ?? { row: 0, col: 0 };
1229
+ const next = {
1230
+ row: Math.max(0, Math.min(viewRowCount - 1, absolute?.row ?? from.row + rowDelta)),
1231
+ col: Math.max(
1232
+ 0,
1233
+ Math.min(schema.columns.length - 1, absolute?.col ?? from.col + colDelta)
1234
+ )
1235
+ };
1236
+ virtualizer.scrollToIndex(next.row);
1237
+ return extend && prev ? { anchor: prev.anchor, focus: next } : { anchor: next, focus: next };
1238
+ });
1239
+ },
1240
+ [schema, viewRowCount, virtualizer]
1241
+ );
1242
+ const handleKeyDown = useCallback(
1243
+ (event) => {
1244
+ if (!schema) return;
1245
+ const target = event.target;
1246
+ const inTextEntry = target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target !== null && target.isContentEditable;
1247
+ if (inTextEntry && !editing) return;
1248
+ const meta = event.metaKey || event.ctrlKey;
1249
+ if (editing) {
1250
+ if (event.key === "Escape") {
1251
+ setEditing(null);
1252
+ event.preventDefault();
1253
+ } else if (event.key === "Enter") {
1254
+ void commitEdit().then((ok) => ok && moveFocus(1, 0, false));
1255
+ event.preventDefault();
1256
+ } else if (event.key === "Tab") {
1257
+ void commitEdit().then((ok) => ok && moveFocus(0, event.shiftKey ? -1 : 1, false));
1258
+ event.preventDefault();
1259
+ }
1260
+ return;
1261
+ }
1262
+ const pageRows = Math.max(1, Math.floor(height / ROW_HEIGHT) - 1);
1263
+ switch (event.key) {
1264
+ case "ArrowDown":
1265
+ moveFocus(
1266
+ meta ? Number.POSITIVE_INFINITY : 1,
1267
+ 0,
1268
+ event.shiftKey,
1269
+ meta ? { row: viewRowCount - 1 } : void 0
1270
+ );
1271
+ break;
1272
+ case "ArrowUp":
1273
+ moveFocus(meta ? 0 : -1, 0, event.shiftKey, meta ? { row: 0 } : void 0);
1274
+ break;
1275
+ case "ArrowRight":
1276
+ moveFocus(
1277
+ 0,
1278
+ meta ? 0 : 1,
1279
+ event.shiftKey,
1280
+ meta ? { col: schema.columns.length - 1 } : void 0
1281
+ );
1282
+ break;
1283
+ case "ArrowLeft":
1284
+ moveFocus(0, meta ? 0 : -1, event.shiftKey, meta ? { col: 0 } : void 0);
1285
+ break;
1286
+ case "PageDown":
1287
+ moveFocus(pageRows, 0, event.shiftKey);
1288
+ break;
1289
+ case "PageUp":
1290
+ moveFocus(-pageRows, 0, event.shiftKey);
1291
+ break;
1292
+ case "Home":
1293
+ moveFocus(0, 0, event.shiftKey, meta ? { row: 0, col: 0 } : { col: 0 });
1294
+ break;
1295
+ case "End":
1296
+ moveFocus(
1297
+ 0,
1298
+ 0,
1299
+ event.shiftKey,
1300
+ meta ? { row: viewRowCount - 1, col: schema.columns.length - 1 } : { col: schema.columns.length - 1 }
1301
+ );
1302
+ break;
1303
+ case "Enter":
1304
+ case "F2":
1305
+ if (selection) beginEdit(selection.focus);
1306
+ break;
1307
+ case "a":
1308
+ case "A":
1309
+ if (meta) {
1310
+ setSelection({
1311
+ anchor: { row: 0, col: 0 },
1312
+ focus: { row: viewRowCount - 1, col: schema.columns.length - 1 }
1313
+ });
1314
+ break;
1315
+ }
1316
+ if (selection && !meta) beginEdit(selection.focus, event.key);
1317
+ else return;
1318
+ break;
1319
+ case "z":
1320
+ case "Z":
1321
+ if (meta) {
1322
+ void runJournal(event.shiftKey ? "redo" : "undo");
1323
+ break;
1324
+ }
1325
+ if (selection) beginEdit(selection.focus, event.key);
1326
+ else return;
1327
+ break;
1328
+ default:
1329
+ if (selection && event.key.length === 1 && !meta && !event.altKey) {
1330
+ beginEdit(selection.focus, event.key);
1331
+ break;
1332
+ }
1333
+ return;
1334
+ }
1335
+ event.preventDefault();
1336
+ },
1337
+ [
1338
+ beginEdit,
1339
+ commitEdit,
1340
+ editing,
1341
+ height,
1342
+ moveFocus,
1343
+ runJournal,
1344
+ schema,
1345
+ selection,
1346
+ viewRowCount
1347
+ ]
1348
+ );
1349
+ const startResize = useCallback((col, startX, startWidth) => {
1350
+ const onMove = (event) => {
1351
+ setColWidths((widths) => ({
1352
+ ...widths,
1353
+ [col]: Math.max(60, startWidth + (event.clientX - startX))
1354
+ }));
1355
+ };
1356
+ const onUp = () => {
1357
+ window.removeEventListener("pointermove", onMove);
1358
+ window.removeEventListener("pointerup", onUp);
1359
+ };
1360
+ window.addEventListener("pointermove", onMove);
1361
+ window.addEventListener("pointerup", onUp);
1362
+ }, []);
1363
+ const gridTemplate = useMemo(() => {
1364
+ const count = schema?.columns.length ?? 0;
1365
+ return Array.from(
1366
+ { length: count },
1367
+ (_, col) => `${colWidths[col] ?? DEFAULT_COL_WIDTH}px`
1368
+ ).join(" ");
1369
+ }, [colWidths, schema]);
1370
+ const range = selection ? normalizedRange(selection) : null;
1371
+ const filterValueFor = (name) => view.filter.find((clause) => clause.column === name)?.value ?? "";
1372
+ return /* @__PURE__ */ jsxs(
1373
+ "div",
1374
+ {
1375
+ ref: rootRef,
1376
+ className: `squisq-grid${className ? ` ${className}` : ""}`,
1377
+ role: "grid",
1378
+ "aria-rowcount": viewRowCount + 1,
1379
+ "aria-colcount": schema?.columns.length ?? 0,
1380
+ "aria-multiselectable": "true",
1381
+ onKeyDown: handleKeyDown,
1382
+ onCopy: handleCopy,
1383
+ onPaste: handlePaste,
1384
+ onMouseDownCapture: (event) => {
1385
+ const target = event.target;
1386
+ if (opMenuCol !== null && !target?.closest(".squisq-grid-opmenu, .squisq-grid-opbutton")) {
1387
+ setOpMenuCol(null);
1388
+ }
1389
+ if (valueMenuCol !== null && !target?.closest(".squisq-grid-valuemenu, .squisq-grid-valuebutton")) {
1390
+ setValueMenuCol(null);
1391
+ }
1392
+ },
1393
+ onKeyDownCapture: (event) => {
1394
+ if (event.key !== "Escape") return;
1395
+ if (opMenuCol !== null) setOpMenuCol(null);
1396
+ if (valueMenuCol !== null) setValueMenuCol(null);
1397
+ },
1398
+ children: [
1399
+ /* @__PURE__ */ jsxs(
1400
+ "div",
1401
+ {
1402
+ className: "squisq-grid-scroller",
1403
+ ref: bodyRef,
1404
+ style: { height },
1405
+ onScroll: () => {
1406
+ if (opMenuCol !== null) setOpMenuCol(null);
1407
+ if (valueMenuCol !== null) setValueMenuCol(null);
1408
+ },
1409
+ children: [
1410
+ /* @__PURE__ */ jsx(
1411
+ "div",
1412
+ {
1413
+ className: "squisq-grid-header",
1414
+ role: "row",
1415
+ style: { gridTemplateColumns: gridTemplate },
1416
+ children: schema?.columns.map((column, col) => {
1417
+ const term = view.sort.find((entry) => entry.column === column.name);
1418
+ const ariaSort = term ? term.dir === "asc" ? "ascending" : "descending" : "none";
1419
+ return /* @__PURE__ */ jsxs(
1420
+ "div",
1421
+ {
1422
+ role: "columnheader",
1423
+ "aria-sort": ariaSort,
1424
+ className: "squisq-grid-headercell",
1425
+ children: [
1426
+ /* @__PURE__ */ jsxs(
1427
+ "button",
1428
+ {
1429
+ type: "button",
1430
+ className: "squisq-grid-sortbutton",
1431
+ onClick: (event) => cycleSort(column.name, event.shiftKey),
1432
+ title: `Sort by ${column.name}`,
1433
+ children: [
1434
+ /* @__PURE__ */ jsx("span", { className: "squisq-grid-colname", children: column.name }),
1435
+ term && /* @__PURE__ */ jsx("span", { className: "squisq-grid-sortmark", "aria-hidden": "true", children: term.dir === "asc" ? "\u25B2" : "\u25BC" })
1436
+ ]
1437
+ }
1438
+ ),
1439
+ /* @__PURE__ */ jsx("div", { className: "squisq-grid-filterrow", children: (() => {
1440
+ const opState = opStateFor(col, column.name);
1441
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
1442
+ /* @__PURE__ */ jsxs(
1443
+ "button",
1444
+ {
1445
+ type: "button",
1446
+ className: `squisq-grid-opbutton${opState.caseSensitive ? " squisq-grid-opbutton--cs" : ""}`,
1447
+ "aria-label": `Filter operator for ${column.name}`,
1448
+ "aria-expanded": opMenuCol === col,
1449
+ title: `${opChoicesFor(column.kind).find((c) => choiceMatches(c, opState))?.label ?? opState.op}${opState.caseSensitive ? " (case-sensitive)" : ""}`,
1450
+ onClick: (event) => toggleOpMenu(col, event.currentTarget),
1451
+ children: [
1452
+ /* @__PURE__ */ jsx("span", { className: "squisq-grid-opglyph", children: glyphFor(column.kind, opState) }),
1453
+ /* @__PURE__ */ jsx("span", { className: "squisq-grid-opcaret", "aria-hidden": "true", children: "\u25BE" })
1454
+ ]
1455
+ }
1456
+ ),
1457
+ /* @__PURE__ */ jsx(
1458
+ "input",
1459
+ {
1460
+ className: "squisq-grid-filterinput",
1461
+ "aria-label": `Filter ${column.name}`,
1462
+ placeholder: opState.unary ? opState.op === "=" ? "(empty)" : "(not empty)" : "filter",
1463
+ disabled: opState.unary === true,
1464
+ value: filterValueFor(column.name),
1465
+ onChange: (event) => setColumnFilter(
1466
+ column.name,
1467
+ event.target.value,
1468
+ opState.op,
1469
+ opState.caseSensitive
1470
+ )
1471
+ }
1472
+ ),
1473
+ provider.distinct && /* @__PURE__ */ jsx(
1474
+ "button",
1475
+ {
1476
+ type: "button",
1477
+ className: "squisq-grid-valuebutton",
1478
+ "aria-label": `Filter ${column.name} by value`,
1479
+ "aria-expanded": valueMenuCol === col,
1480
+ title: "Filter by value",
1481
+ onClick: (event) => openValueMenu(col, event.currentTarget),
1482
+ children: "\u25BE"
1483
+ }
1484
+ )
1485
+ ] });
1486
+ })() }),
1487
+ /* @__PURE__ */ jsx(
1488
+ "div",
1489
+ {
1490
+ className: "squisq-grid-resizer",
1491
+ onPointerDown: (event) => {
1492
+ event.preventDefault();
1493
+ startResize(col, event.clientX, colWidths[col] ?? DEFAULT_COL_WIDTH);
1494
+ }
1495
+ }
1496
+ )
1497
+ ]
1498
+ },
1499
+ col
1500
+ );
1501
+ })
1502
+ }
1503
+ ),
1504
+ /* @__PURE__ */ jsx("div", { className: "squisq-grid-body", style: { height: virtualizer.getTotalSize() }, children: virtualItems.map((item) => {
1505
+ const located = cellAt(item.index);
1506
+ return /* @__PURE__ */ jsx(
1507
+ "div",
1508
+ {
1509
+ role: "row",
1510
+ "aria-rowindex": item.index + 2,
1511
+ className: "squisq-grid-row",
1512
+ style: {
1513
+ transform: `translateY(${item.start}px)`,
1514
+ gridTemplateColumns: gridTemplate,
1515
+ height: ROW_HEIGHT
1516
+ },
1517
+ children: schema?.columns.map((column, col) => {
1518
+ const inRange = range && item.index >= range.r0 && item.index <= range.r1 && col >= range.c0 && col <= range.c1;
1519
+ const isFocus = selection && selection.focus.row === item.index && selection.focus.col === col;
1520
+ const isEditing = editing && editing.row === item.index && editing.col === col;
1521
+ const dirty = located && journal ? journal.isDirty(located.rowId, col) : false;
1522
+ const locked = editable && located ? isCellLocked?.(located.rowId, col) ?? false : false;
1523
+ const formula = located && formulaSupport ? formulaSupport.getFormula(located.rowId, col) : void 0;
1524
+ const classes = [
1525
+ "squisq-grid-cell",
1526
+ column.kind === "number" ? "squisq-grid-cell--num" : "",
1527
+ inRange ? "squisq-grid-cell--selected" : "",
1528
+ isFocus ? "squisq-grid-cell--focus" : "",
1529
+ dirty ? "squisq-grid-cell--dirty" : "",
1530
+ locked ? "squisq-grid-cell--locked" : "",
1531
+ formula !== void 0 ? "squisq-grid-cell--formula" : ""
1532
+ ].filter(Boolean).join(" ");
1533
+ return /* @__PURE__ */ jsx(
1534
+ "div",
1535
+ {
1536
+ role: "gridcell",
1537
+ "aria-colindex": col + 1,
1538
+ "aria-selected": inRange ? "true" : void 0,
1539
+ ...dirty ? { "aria-description": "edited, unsaved" } : {},
1540
+ ...locked && lockedReason ? { title: lockedReason } : formula !== void 0 ? { title: `=${formula}` } : {},
1541
+ ...locked ? { "aria-readonly": "true" } : {},
1542
+ className: classes,
1543
+ tabIndex: isFocus ? 0 : -1,
1544
+ ref: isFocus ? focusRef : void 0,
1545
+ onMouseDown: (event) => {
1546
+ const pos = { row: item.index, col };
1547
+ setSelection(
1548
+ (prev) => event.shiftKey && prev ? { anchor: prev.anchor, focus: pos } : { anchor: pos, focus: pos }
1549
+ );
1550
+ },
1551
+ onDoubleClick: () => beginEdit({ row: item.index, col }),
1552
+ children: isEditing ? /* @__PURE__ */ jsx(
1553
+ "input",
1554
+ {
1555
+ className: `squisq-grid-editor${editing.error ? " squisq-grid-editor--error" : ""}`,
1556
+ autoFocus: true,
1557
+ value: editing.draft,
1558
+ title: editing.error,
1559
+ onChange: (event) => setEditing({ ...editing, draft: event.target.value, error: void 0 }),
1560
+ onBlur: () => void commitEdit()
1561
+ }
1562
+ ) : cellDisplay(located?.cells[col] ?? null)
1563
+ },
1564
+ col
1565
+ );
1566
+ })
1567
+ },
1568
+ item.key
1569
+ );
1570
+ }) })
1571
+ ]
1572
+ }
1573
+ ),
1574
+ opMenuCol !== null && schema?.columns[opMenuCol] && (() => {
1575
+ const col = opMenuCol;
1576
+ const column = schema.columns[col];
1577
+ const opState = opStateFor(col, column.name);
1578
+ const caseCapable = column.kind !== "number" && column.kind !== "boolean";
1579
+ const filterActive = view.filter.some((clause) => clause.column === column.name);
1580
+ return /* @__PURE__ */ jsxs(
1581
+ "div",
1582
+ {
1583
+ ref: opMenuRef,
1584
+ className: "squisq-grid-opmenu",
1585
+ role: "menu",
1586
+ style: { left: opMenuPos.left, top: opMenuPos.top },
1587
+ children: [
1588
+ filterActive && /* @__PURE__ */ jsx(
1589
+ "button",
1590
+ {
1591
+ type: "button",
1592
+ role: "menuitem",
1593
+ className: "squisq-grid-opoption squisq-grid-opclear",
1594
+ onClick: () => clearColumnFilter(col, column.name),
1595
+ children: "(Clear filter)"
1596
+ }
1597
+ ),
1598
+ opChoicesFor(column.kind).map((choice) => /* @__PURE__ */ jsxs(
1599
+ "button",
1600
+ {
1601
+ type: "button",
1602
+ role: "menuitemradio",
1603
+ "aria-checked": choiceMatches(choice, opState),
1604
+ className: `squisq-grid-opoption${choiceMatches(choice, opState) ? " squisq-grid-opoption--active" : ""}`,
1605
+ onClick: () => chooseFilterOp(col, column.name, {
1606
+ op: choice.op,
1607
+ caseSensitive: opState.caseSensitive,
1608
+ ...choice.unary ? { unary: true } : {}
1609
+ }),
1610
+ children: [
1611
+ /* @__PURE__ */ jsx("span", { className: "squisq-grid-opglyph", children: choice.glyph }),
1612
+ choice.label
1613
+ ]
1614
+ },
1615
+ `${choice.op}${choice.unary ? "0" : ""}`
1616
+ )),
1617
+ caseCapable && /* @__PURE__ */ jsxs("label", { className: "squisq-grid-opcase", children: [
1618
+ /* @__PURE__ */ jsx(
1619
+ "input",
1620
+ {
1621
+ type: "checkbox",
1622
+ checked: opState.caseSensitive,
1623
+ onChange: (event) => chooseFilterOp(col, column.name, {
1624
+ op: opState.op,
1625
+ caseSensitive: event.target.checked,
1626
+ ...opState.unary ? { unary: true } : {}
1627
+ })
1628
+ }
1629
+ ),
1630
+ "Case sensitive"
1631
+ ] })
1632
+ ]
1633
+ }
1634
+ );
1635
+ })(),
1636
+ valueMenuCol !== null && schema?.columns[valueMenuCol] && (() => {
1637
+ const col = valueMenuCol;
1638
+ const column = schema.columns[col];
1639
+ const opState = opStateFor(col, column.name);
1640
+ const filterActive = view.filter.some((clause) => clause.column === column.name);
1641
+ return /* @__PURE__ */ jsx(
1642
+ "div",
1643
+ {
1644
+ ref: valueMenuRef,
1645
+ className: "squisq-grid-valuemenu",
1646
+ role: "menu",
1647
+ style: { left: valueMenuPos.left, top: valueMenuPos.top },
1648
+ children: valueMenuData?.col === col ? /* @__PURE__ */ jsxs(Fragment, { children: [
1649
+ filterActive && /* @__PURE__ */ jsx(
1650
+ "button",
1651
+ {
1652
+ type: "button",
1653
+ role: "menuitem",
1654
+ className: "squisq-grid-opoption squisq-grid-opclear",
1655
+ onClick: () => {
1656
+ setValueMenuCol(null);
1657
+ clearColumnFilter(col, column.name);
1658
+ },
1659
+ children: "(All)"
1660
+ }
1661
+ ),
1662
+ valueMenuData.result.hasBlank && /* @__PURE__ */ jsx(
1663
+ "button",
1664
+ {
1665
+ type: "button",
1666
+ role: "menuitemradio",
1667
+ "aria-checked": filterActive && opState.unary === true,
1668
+ className: `squisq-grid-opoption${filterActive && opState.unary === true ? " squisq-grid-opoption--active" : ""}`,
1669
+ onClick: () => {
1670
+ setValueMenuCol(null);
1671
+ chooseFilterOp(col, column.name, {
1672
+ op: "=",
1673
+ caseSensitive: false,
1674
+ unary: true
1675
+ });
1676
+ },
1677
+ children: "(Blanks)"
1678
+ }
1679
+ ),
1680
+ valueMenuData.result.values.map((value) => {
1681
+ const active = filterActive && !opState.unary && opState.op === "=" && filterValueFor(column.name) === value;
1682
+ return /* @__PURE__ */ jsx(
1683
+ "button",
1684
+ {
1685
+ type: "button",
1686
+ role: "menuitemradio",
1687
+ "aria-checked": active,
1688
+ className: `squisq-grid-opoption squisq-grid-valueoption${active ? " squisq-grid-opoption--active" : ""}`,
1689
+ title: value,
1690
+ onClick: () => chooseFilterValue(col, column.name, value),
1691
+ children: value
1692
+ },
1693
+ value
1694
+ );
1695
+ }),
1696
+ valueMenuData.result.totalDistinct > valueMenuData.result.values.length && /* @__PURE__ */ jsxs("div", { className: "squisq-grid-valuemenu-note", children: [
1697
+ "showing ",
1698
+ valueMenuData.result.values.length,
1699
+ " of",
1700
+ " ",
1701
+ valueMenuData.result.totalDistinct.toLocaleString(),
1702
+ " values"
1703
+ ] })
1704
+ ] }) : /* @__PURE__ */ jsx("div", { className: "squisq-grid-valuemenu-note", children: "loading\u2026" })
1705
+ }
1706
+ );
1707
+ })(),
1708
+ /* @__PURE__ */ jsxs("div", { className: "squisq-grid-footer", children: [
1709
+ /* @__PURE__ */ jsxs("span", { className: "squisq-grid-status", "aria-live": "polite", children: [
1710
+ announce || `${viewRowCount.toLocaleString()} row${viewRowCount === 1 ? "" : "s"}${schema && viewRowCount !== schema.rowCount ? ` (of ${schema.rowCount.toLocaleString()})` : ""}${schema ? `, ${schema.columns.length} column${schema.columns.length === 1 ? "" : "s"}` : ""}`,
1711
+ issueNote ? ` \xB7 ${issueNote}` : "",
1712
+ !viewPersisted && (view.sort.length > 0 || view.filter.length > 0) ? " \xB7 view not saved to document" : ""
1713
+ ] }),
1714
+ staleView && /* @__PURE__ */ jsx(
1715
+ "button",
1716
+ {
1717
+ type: "button",
1718
+ className: "squisq-grid-refresh",
1719
+ onClick: () => void applyView(viewRef.current),
1720
+ children: "order may be outdated \u2014 refresh"
1721
+ }
1722
+ ),
1723
+ readOnlyReason && /* @__PURE__ */ jsx("span", { className: "squisq-grid-readonly", children: readOnlyReason }),
1724
+ editable && dirtyCount + extraDirtyCount > 0 && /* @__PURE__ */ jsxs("span", { className: "squisq-grid-dirtybar", children: [
1725
+ (dirtyCount + extraDirtyCount).toLocaleString(),
1726
+ " unsaved edit",
1727
+ dirtyCount + extraDirtyCount === 1 ? "" : "s",
1728
+ /* @__PURE__ */ jsx(
1729
+ "button",
1730
+ {
1731
+ type: "button",
1732
+ className: "squisq-grid-save",
1733
+ disabled: saving,
1734
+ onClick: () => void onSave?.(),
1735
+ children: saving ? "Saving\u2026" : "Save"
1736
+ }
1737
+ ),
1738
+ /* @__PURE__ */ jsx(
1739
+ "button",
1740
+ {
1741
+ type: "button",
1742
+ className: "squisq-grid-discard",
1743
+ disabled: saving,
1744
+ onClick: () => {
1745
+ void (async () => {
1746
+ while (journal?.canUndo) await runJournal("undo");
1747
+ if (onDiscardExtra) {
1748
+ await onDiscardExtra();
1749
+ cache.current.pages.clear();
1750
+ setFetchTick((t) => t + 1);
1751
+ setDirtyTick((t) => t + 1);
1752
+ }
1753
+ })();
1754
+ },
1755
+ children: "Discard"
1756
+ }
1757
+ )
1758
+ ] })
1759
+ ] })
1760
+ ]
1761
+ }
1762
+ );
1763
+ }
1764
+ function escapeHtml(text) {
1765
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1766
+ }
1767
+ export {
1768
+ DataGrid,
1769
+ EditJournal,
1770
+ LocalKernelHost,
1771
+ TableStoreClient,
1772
+ buildColumnarTable,
1773
+ buildKernelSource,
1774
+ columnCellValue,
1775
+ discardJournal,
1776
+ journalFor,
1777
+ tableKernel
1778
+ };