@zakkster/lite-table 1.0.0 → 1.1.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/CHANGELOG.md +205 -0
- package/README.md +341 -1
- package/Table.d.ts +134 -1
- package/Table.js +737 -35
- package/llms.txt +121 -2
- package/package.json +11 -7
package/Table.js
CHANGED
|
@@ -90,49 +90,80 @@ function defaultCompare(a, b) {
|
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
/**
|
|
93
|
-
* Create a disposable scope. Tracks
|
|
94
|
-
* and arbitrary cleanups. `dispose()` runs
|
|
93
|
+
* Create a disposable scope. Tracks signals, computeds, effects, event
|
|
94
|
+
* listeners, and arbitrary user cleanups. `dispose()` runs them in reverse
|
|
95
|
+
* order, once.
|
|
96
|
+
*
|
|
97
|
+
* Implementation note: lite-signal returns CALLABLE handles for signals and
|
|
98
|
+
* computeds. To free them we must call the lite-signal `dispose()` (imported
|
|
99
|
+
* here as `disposeNode`) on the handle -- calling the handle as a function
|
|
100
|
+
* just reads it. Effect disposers are themselves the cleanup function; event
|
|
101
|
+
* remover thunks and user onCleanup callbacks are likewise just called.
|
|
102
|
+
*
|
|
103
|
+
* Cleanups are recorded as discriminated entries so the dispose loop knows
|
|
104
|
+
* which path to take per entry. The previous (v1.0.0) implementation pushed
|
|
105
|
+
* raw signal handles and tried to call them on cleanup -- which silently
|
|
106
|
+
* read them instead of disposing, leaving the nodes pinned in the lite-signal
|
|
107
|
+
* registry across createTable -> dispose() cycles (a real leak; visible only
|
|
108
|
+
* when many tables are created in one process, e.g. tests).
|
|
95
109
|
*/
|
|
96
110
|
function createScope() {
|
|
97
|
-
const
|
|
111
|
+
const KIND_NODE = 1; // lite-signal handle (signal or computed)
|
|
112
|
+
const KIND_THUNK = 2; // plain function: effect disposer, remover, user cleanup
|
|
113
|
+
// Two parallel arrays keep the per-entry shape allocation-free; one
|
|
114
|
+
// slot per cleanup is two integers' worth of memory, no per-entry obj.
|
|
115
|
+
const cleanupKinds = [];
|
|
116
|
+
const cleanupTargets = [];
|
|
98
117
|
let disposed = false;
|
|
118
|
+
|
|
119
|
+
function pushNode(handle) {
|
|
120
|
+
cleanupKinds.push(KIND_NODE);
|
|
121
|
+
cleanupTargets.push(handle);
|
|
122
|
+
}
|
|
123
|
+
function pushThunk(fn) {
|
|
124
|
+
cleanupKinds.push(KIND_THUNK);
|
|
125
|
+
cleanupTargets.push(fn);
|
|
126
|
+
}
|
|
127
|
+
|
|
99
128
|
const scope = {
|
|
100
129
|
signal(initial, opts) {
|
|
101
130
|
const s = signal(initial, opts);
|
|
102
|
-
|
|
131
|
+
pushNode(s);
|
|
103
132
|
return s;
|
|
104
133
|
},
|
|
105
134
|
computed(fn, opts) {
|
|
106
135
|
const c = computed(fn, opts);
|
|
107
|
-
|
|
136
|
+
pushNode(c);
|
|
108
137
|
return c;
|
|
109
138
|
},
|
|
110
139
|
effect(fn) {
|
|
111
140
|
const d = effect(fn);
|
|
112
|
-
|
|
141
|
+
pushThunk(d);
|
|
113
142
|
return d;
|
|
114
143
|
},
|
|
115
144
|
on(el, type, handler, opts) {
|
|
116
145
|
el.addEventListener(type, handler, opts);
|
|
117
146
|
const off = () => el.removeEventListener(type, handler, opts);
|
|
118
|
-
|
|
147
|
+
pushThunk(off);
|
|
119
148
|
return off;
|
|
120
149
|
},
|
|
121
150
|
onCleanup(fn) {
|
|
122
|
-
|
|
151
|
+
pushThunk(fn);
|
|
123
152
|
}
|
|
124
153
|
};
|
|
125
154
|
function dispose() {
|
|
126
155
|
if (disposed) return;
|
|
127
156
|
disposed = true;
|
|
128
|
-
for (let i =
|
|
129
|
-
const
|
|
157
|
+
for (let i = cleanupKinds.length - 1; i >= 0; i--) {
|
|
158
|
+
const k = cleanupKinds[i];
|
|
159
|
+
const t = cleanupTargets[i];
|
|
130
160
|
try {
|
|
131
|
-
if (
|
|
132
|
-
else
|
|
161
|
+
if (k === KIND_NODE) disposeNode(t);
|
|
162
|
+
else t();
|
|
133
163
|
} catch (_e) { /* per-handler */ }
|
|
134
164
|
}
|
|
135
|
-
|
|
165
|
+
cleanupKinds.length = 0;
|
|
166
|
+
cleanupTargets.length = 0;
|
|
136
167
|
}
|
|
137
168
|
return { scope, dispose };
|
|
138
169
|
}
|
|
@@ -169,6 +200,19 @@ function createColumnState(def, scope, defaults) {
|
|
|
169
200
|
pinnable: def.pinnable !== false,
|
|
170
201
|
hideable: def.hideable !== false,
|
|
171
202
|
reorderable: def.reorderable !== false,
|
|
203
|
+
// M2: cell editing opt-in. False by default; columns that drive
|
|
204
|
+
// computed values (accessor with no underlying field) should stay
|
|
205
|
+
// false because there's nowhere to write back to.
|
|
206
|
+
editable: def.editable === true,
|
|
207
|
+
// M2: per-column filter opt-in. False by default.
|
|
208
|
+
filterable: def.filterable === true,
|
|
209
|
+
// M2: optional custom filter predicate. (value, query, row) => boolean.
|
|
210
|
+
// value is the column's value (accessor-resolved). query is the trimmed
|
|
211
|
+
// current filter string. row is the full row (handy for cross-field
|
|
212
|
+
// filters or when the consumer wants more than the cell value).
|
|
213
|
+
// Default: case-insensitive substring match on the stringified value.
|
|
214
|
+
filter: typeof def.filter === "function" ? def.filter : null,
|
|
215
|
+
filterPlaceholder: typeof def.filterPlaceholder === "string" ? def.filterPlaceholder : null,
|
|
172
216
|
minWidth,
|
|
173
217
|
maxWidth,
|
|
174
218
|
width,
|
|
@@ -244,7 +288,11 @@ export function createTable(config) {
|
|
|
244
288
|
rowHeight = 32,
|
|
245
289
|
overscan = 4,
|
|
246
290
|
initialFocus = null,
|
|
247
|
-
initialSort = []
|
|
291
|
+
initialSort = [],
|
|
292
|
+
// M2: cell-edit hook. Fires on commit with the row + column key +
|
|
293
|
+
// new + old value. Consumer mutates their row data (or stores the
|
|
294
|
+
// change in a backend); the table itself does NOT mutate rows.
|
|
295
|
+
onCellEdit = null
|
|
248
296
|
} = config;
|
|
249
297
|
|
|
250
298
|
if (typeof getRowId !== "function") {
|
|
@@ -391,9 +439,59 @@ export function createTable(config) {
|
|
|
391
439
|
return m;
|
|
392
440
|
});
|
|
393
441
|
|
|
442
|
+
// --- Filters (M2) ---
|
|
443
|
+
// Reactive Map<columnKey, string>. We use a Map so we can clear and add
|
|
444
|
+
// without re-allocating an object literal per change, but we wrap mutations
|
|
445
|
+
// in a fresh-Map .set() call (Object.is inequality is what notifies
|
|
446
|
+
// downstream computeds; mutating in place would silently skip propagation).
|
|
447
|
+
const columnFilters = scope.signal(new Map());
|
|
448
|
+
|
|
449
|
+
function _filterMatchesAll(row) {
|
|
450
|
+
const filters = columnFilters();
|
|
451
|
+
if (filters.size === 0) return true;
|
|
452
|
+
for (const [key, raw] of filters) {
|
|
453
|
+
const q = typeof raw === "string" ? raw.trim() : "";
|
|
454
|
+
if (q.length === 0) continue;
|
|
455
|
+
const col = columnsByKey.get(key);
|
|
456
|
+
if (!col || !col.filterable) continue;
|
|
457
|
+
const v = readCell(col, row);
|
|
458
|
+
const pred = col.filter;
|
|
459
|
+
if (pred) {
|
|
460
|
+
if (!pred(v, q, row)) return false;
|
|
461
|
+
} else {
|
|
462
|
+
// Default: case-insensitive substring on stringified value.
|
|
463
|
+
// null/undefined become empty -- they fail any non-empty filter.
|
|
464
|
+
const s = (v === null || v === undefined) ? "" : String(v);
|
|
465
|
+
if (s.toLowerCase().indexOf(q.toLowerCase()) < 0) return false;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
return true;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Reactive: filtered source. visibleRows now sorts THIS, not raw rowsGetter.
|
|
472
|
+
// When no filter is active, returns the source array as-is (identity) to
|
|
473
|
+
// avoid an unnecessary O(N) allocation. When any filter has a non-empty
|
|
474
|
+
// query, walks the source once and produces a fresh filtered array.
|
|
475
|
+
const filteredRows = scope.computed(() => {
|
|
476
|
+
const src = rowsGetter();
|
|
477
|
+
const filters = columnFilters();
|
|
478
|
+
// Fast path: no filters at all OR every filter is empty.
|
|
479
|
+
if (filters.size === 0) return src;
|
|
480
|
+
let hasActive = false;
|
|
481
|
+
for (const v of filters.values()) {
|
|
482
|
+
if (typeof v === "string" && v.trim().length > 0) { hasActive = true; break; }
|
|
483
|
+
}
|
|
484
|
+
if (!hasActive) return src;
|
|
485
|
+
const out = [];
|
|
486
|
+
for (let i = 0; i < src.length; i++) {
|
|
487
|
+
if (_filterMatchesAll(src[i])) out.push(src[i]);
|
|
488
|
+
}
|
|
489
|
+
return out;
|
|
490
|
+
});
|
|
491
|
+
|
|
394
492
|
// --- Sort ---
|
|
395
493
|
// Sort chain: list of {key, dir}. visibleRows applies it as a stable
|
|
396
|
-
// multi-key sort to the source
|
|
494
|
+
// multi-key sort to the filtered source.
|
|
397
495
|
const sortChain = scope.signal(
|
|
398
496
|
Array.isArray(initialSort)
|
|
399
497
|
? initialSort.filter((s) => s && columnsByKey.has(s.key))
|
|
@@ -408,7 +506,7 @@ export function createTable(config) {
|
|
|
408
506
|
let _sortIdxBuf = null;
|
|
409
507
|
|
|
410
508
|
const visibleRows = scope.computed(() => {
|
|
411
|
-
const src =
|
|
509
|
+
const src = filteredRows();
|
|
412
510
|
const chain = sortChain();
|
|
413
511
|
if (!chain.length) return src;
|
|
414
512
|
const n = src.length;
|
|
@@ -452,22 +550,50 @@ export function createTable(config) {
|
|
|
452
550
|
const chain = sortChain();
|
|
453
551
|
const additive = opts && opts.additive === true;
|
|
454
552
|
const existing = chain.findIndex((e) => e.key === key);
|
|
455
|
-
|
|
456
|
-
const nextDir = existing < 0
|
|
457
|
-
? "asc"
|
|
458
|
-
: chain[existing].dir === "asc" ? "desc" : null;
|
|
553
|
+
|
|
459
554
|
if (additive) {
|
|
555
|
+
// Shift-click: 3-state cycle per chain entry.
|
|
556
|
+
// not in chain -> append at asc
|
|
557
|
+
// in chain asc -> flip to desc
|
|
558
|
+
// in chain desc -> remove from chain
|
|
559
|
+
// This is how chain membership is MANAGED -- it can both grow
|
|
560
|
+
// and shrink the chain.
|
|
460
561
|
const next = chain.slice();
|
|
461
562
|
if (existing < 0) {
|
|
462
|
-
next.push({ key, dir:
|
|
463
|
-
} else if (
|
|
464
|
-
next
|
|
563
|
+
next.push({ key, dir: "asc" });
|
|
564
|
+
} else if (chain[existing].dir === "asc") {
|
|
565
|
+
next[existing] = { key, dir: "desc" };
|
|
465
566
|
} else {
|
|
466
|
-
next
|
|
567
|
+
next.splice(existing, 1);
|
|
467
568
|
}
|
|
468
569
|
sortChain.set(next);
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// Plain click: drives the PRIMARY sort, never silently dismantles a
|
|
574
|
+
// user-built multi-column chain.
|
|
575
|
+
if (existing < 0) {
|
|
576
|
+
// Column not in chain: replace whole chain with single-col asc.
|
|
577
|
+
// Lets the user re-anchor primary sort with one click.
|
|
578
|
+
sortChain.set([{ key, dir: "asc" }]);
|
|
579
|
+
} else if (chain.length === 1) {
|
|
580
|
+
// Sole entry: legacy 3-state cycle (asc -> desc -> cleared).
|
|
581
|
+
// Some users rely on a third plain click to fully unsort.
|
|
582
|
+
sortChain.set(chain[0].dir === "asc"
|
|
583
|
+
? [{ key, dir: "desc" }]
|
|
584
|
+
: []);
|
|
469
585
|
} else {
|
|
470
|
-
|
|
586
|
+
// Column is already part of a MULTI-column chain: toggle just
|
|
587
|
+
// that entry's direction (asc <-> desc). No removal -- the user
|
|
588
|
+
// built this chain on purpose; one stray plain-click should not
|
|
589
|
+
// tear it down. Removal is intentional via shift-click (cycle to
|
|
590
|
+
// remove) or `clearSort()`.
|
|
591
|
+
const next = chain.slice();
|
|
592
|
+
next[existing] = {
|
|
593
|
+
key,
|
|
594
|
+
dir: chain[existing].dir === "asc" ? "desc" : "asc"
|
|
595
|
+
};
|
|
596
|
+
sortChain.set(next);
|
|
471
597
|
}
|
|
472
598
|
}
|
|
473
599
|
|
|
@@ -762,6 +888,309 @@ export function createTable(config) {
|
|
|
762
888
|
columnOrder.set(order);
|
|
763
889
|
}
|
|
764
890
|
|
|
891
|
+
// --- Filters (M2 mutators) ---
|
|
892
|
+
function setColumnFilter(key, value) {
|
|
893
|
+
const col = columnsByKey.get(key);
|
|
894
|
+
if (!col || !col.filterable) return;
|
|
895
|
+
const next = new Map(columnFilters());
|
|
896
|
+
if (value === null || value === undefined || value === "") {
|
|
897
|
+
next.delete(key);
|
|
898
|
+
} else {
|
|
899
|
+
next.set(key, String(value));
|
|
900
|
+
}
|
|
901
|
+
columnFilters.set(next);
|
|
902
|
+
}
|
|
903
|
+
function clearColumnFilters() {
|
|
904
|
+
if (columnFilters().size === 0) return;
|
|
905
|
+
columnFilters.set(new Map());
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
// --- Cell editing (M2) ---
|
|
909
|
+
// editingCell points at the (rowId, columnKey) currently being edited,
|
|
910
|
+
// or null. Like focusedCell, editing is keyed on row identity so it
|
|
911
|
+
// survives scroll, sort, filter, slot recycling -- a cell that scrolls
|
|
912
|
+
// out of the viewport stays "editing" if it scrolls back in.
|
|
913
|
+
const editingCell = scope.signal(null);
|
|
914
|
+
// editingDraft is the in-progress value being typed. mountTable writes to
|
|
915
|
+
// it from `input` events on the contenteditable cell; the consumer rarely
|
|
916
|
+
// needs to read this directly (commitEdit captures it for them).
|
|
917
|
+
const editingDraft = scope.signal("");
|
|
918
|
+
|
|
919
|
+
function isEditing(rowId, columnKey) {
|
|
920
|
+
const e = editingCell();
|
|
921
|
+
if (e === null) return false;
|
|
922
|
+
return e.rowId === rowId && e.columnKey === columnKey;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
function startEdit(rowId, columnKey) {
|
|
926
|
+
const col = columnsByKey.get(columnKey);
|
|
927
|
+
if (!col || !col.editable) return;
|
|
928
|
+
// Already editing this exact cell -- no-op so an idempotent double-
|
|
929
|
+
// click or programmatic call doesn't clobber the in-flight draft.
|
|
930
|
+
const e = editingCell();
|
|
931
|
+
if (e !== null && e.rowId === rowId && e.columnKey === columnKey) return;
|
|
932
|
+
// Editing a different cell -- commit before switching so the user's
|
|
933
|
+
// typed-in value isn't silently dropped on a tab-out / click-elsewhere.
|
|
934
|
+
if (e !== null) {
|
|
935
|
+
commitEdit();
|
|
936
|
+
}
|
|
937
|
+
// Seed the draft with the current cell value so the consumer's
|
|
938
|
+
// onCellEdit gets a clean oldValue/newValue diff even if they don't
|
|
939
|
+
// touch anything.
|
|
940
|
+
const row = _findRowById(rowId);
|
|
941
|
+
if (row !== undefined) {
|
|
942
|
+
const v = readCell(col, row);
|
|
943
|
+
editingDraft.set(v === null || v === undefined ? "" : String(v));
|
|
944
|
+
} else {
|
|
945
|
+
editingDraft.set("");
|
|
946
|
+
}
|
|
947
|
+
editingCell.set({ rowId, columnKey });
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
function commitEdit(explicitValue) {
|
|
951
|
+
const e = editingCell();
|
|
952
|
+
if (e === null) return;
|
|
953
|
+
const col = columnsByKey.get(e.columnKey);
|
|
954
|
+
const row = _findRowById(e.rowId);
|
|
955
|
+
const newValue = arguments.length > 0 ? explicitValue : editingDraft();
|
|
956
|
+
editingCell.set(null);
|
|
957
|
+
editingDraft.set("");
|
|
958
|
+
if (col && row !== undefined && onCellEdit) {
|
|
959
|
+
const oldValue = readCell(col, row);
|
|
960
|
+
|
|
961
|
+
// No-op guard: skip the hook when nothing actually changed.
|
|
962
|
+
// If newValue is a string (from contenteditable), coerce oldValue
|
|
963
|
+
// to a string to prevent false positives (e.g., 100 !== "100").
|
|
964
|
+
// If a non-string is explicitly passed, use standard strict equality.
|
|
965
|
+
const isChanged = typeof newValue === "string"
|
|
966
|
+
? String(oldValue) !== newValue
|
|
967
|
+
: oldValue !== newValue;
|
|
968
|
+
|
|
969
|
+
if (isChanged) {
|
|
970
|
+
try {
|
|
971
|
+
onCellEdit({
|
|
972
|
+
row,
|
|
973
|
+
columnKey: e.columnKey,
|
|
974
|
+
oldValue,
|
|
975
|
+
newValue,
|
|
976
|
+
});
|
|
977
|
+
} catch (err) {
|
|
978
|
+
try { console.error("lite-table: onCellEdit threw:", err); } catch (_) {}
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
function cancelEdit() {
|
|
985
|
+
if (editingCell() === null) return;
|
|
986
|
+
editingCell.set(null);
|
|
987
|
+
editingDraft.set("");
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
// Best-effort row lookup by id. Walks the master once -- O(N). The cell-
|
|
991
|
+
// editing happy path uses this once per commit, not per keystroke, so the
|
|
992
|
+
// linear scan is fine; if a consumer with millions of rows wants O(1)
|
|
993
|
+
// they can build their own id->row index and pass it in via onCellEdit
|
|
994
|
+
// by other means (or use selectedRows-style materializers).
|
|
995
|
+
function _findRowById(rowId) {
|
|
996
|
+
const src = rowsGetter();
|
|
997
|
+
for (let i = 0; i < src.length; i++) {
|
|
998
|
+
if (getRowId(src[i]) === rowId) return src[i];
|
|
999
|
+
}
|
|
1000
|
+
return undefined;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
// --- Export ---
|
|
1004
|
+
// Resolve the row source for an export call:
|
|
1005
|
+
// rows: "visible" => visibleRows() (post-sort/filter, post-pagination if you reactify it)
|
|
1006
|
+
// rows: "selected" => current selection materialized against the chosen source
|
|
1007
|
+
// rows: "all" => rowsGetter() (the original master)
|
|
1008
|
+
// rows: <array> => an explicit array (e.g. a snapshot)
|
|
1009
|
+
// Selection mode interacts naturally: passing rows: "selected" with
|
|
1010
|
+
// selectAll() active exports every row minus the blacklist, which is
|
|
1011
|
+
// typically what the user means when they Ctrl+A then "Export selected".
|
|
1012
|
+
function _resolveRows(rowsOpt) {
|
|
1013
|
+
if (Array.isArray(rowsOpt)) return rowsOpt;
|
|
1014
|
+
if (rowsOpt === "all") return rowsGetter();
|
|
1015
|
+
if (rowsOpt === "selected") return selectedRows(rowsGetter());
|
|
1016
|
+
// default "visible"
|
|
1017
|
+
return visibleRows();
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
// Resolve column list. "visible" honors hidden + order; "all" walks
|
|
1021
|
+
// declared columns in declaration order regardless of hide/order state.
|
|
1022
|
+
// Array of keys = explicit projection.
|
|
1023
|
+
function _resolveColumns(colsOpt) {
|
|
1024
|
+
if (Array.isArray(colsOpt)) {
|
|
1025
|
+
const out = [];
|
|
1026
|
+
for (const k of colsOpt) {
|
|
1027
|
+
const c = columnsByKey.get(k);
|
|
1028
|
+
if (c) out.push(c);
|
|
1029
|
+
}
|
|
1030
|
+
return out;
|
|
1031
|
+
}
|
|
1032
|
+
if (colsOpt === "all") return columns.slice();
|
|
1033
|
+
// default "visible"
|
|
1034
|
+
return visibleColumns();
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
// Extract the displayed value for a cell: accessor() if present, else
|
|
1038
|
+
// row[key]. Used by both exports + a documented helper if you ever
|
|
1039
|
+
// want the same projection elsewhere (currently inlined for speed).
|
|
1040
|
+
function _cellValue(row, col) {
|
|
1041
|
+
return col.accessor ? col.accessor(row) : row[col.key];
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
// CSV escape rule (RFC 4180): if a field contains the delimiter, a
|
|
1045
|
+
// quote, CR, or LF, it must be enclosed in quotes and any inner quote
|
|
1046
|
+
// doubled. We additionally accept a custom delimiter + quote char.
|
|
1047
|
+
function _csvEscape(value, delimiter, quote) {
|
|
1048
|
+
if (value === null || value === undefined) return "";
|
|
1049
|
+
const s = typeof value === "string" ? value : String(value);
|
|
1050
|
+
if (s.length === 0) return "";
|
|
1051
|
+
// The quote-doubling is a regex with the configured quote char.
|
|
1052
|
+
// Build the matcher lazily; for the default '"' delimiter ',' this
|
|
1053
|
+
// is the same hot path every call, so V8 caches the regex behind
|
|
1054
|
+
// the literal anyway.
|
|
1055
|
+
const needsQuote =
|
|
1056
|
+
s.indexOf(delimiter) !== -1 ||
|
|
1057
|
+
s.indexOf(quote) !== -1 ||
|
|
1058
|
+
s.indexOf("\n") !== -1 ||
|
|
1059
|
+
s.indexOf("\r") !== -1;
|
|
1060
|
+
if (!needsQuote) return s;
|
|
1061
|
+
// Double any embedded quote
|
|
1062
|
+
let escaped = "";
|
|
1063
|
+
for (let i = 0; i < s.length; i++) {
|
|
1064
|
+
const ch = s[i];
|
|
1065
|
+
escaped += ch === quote ? quote + quote : ch;
|
|
1066
|
+
}
|
|
1067
|
+
return quote + escaped + quote;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
/**
|
|
1071
|
+
* Export rows to a CSV string.
|
|
1072
|
+
*
|
|
1073
|
+
* @param {object} [opts]
|
|
1074
|
+
* @param {"visible"|"all"|"selected"|Array} [opts.rows="visible"]
|
|
1075
|
+
* Which rows to export.
|
|
1076
|
+
* @param {"visible"|"all"|Array<string>} [opts.columns="visible"]
|
|
1077
|
+
* Which columns to project. Array values are column keys.
|
|
1078
|
+
* @param {string} [opts.delimiter=","]
|
|
1079
|
+
* Field separator. Common alternatives: "\t" for TSV, ";" for
|
|
1080
|
+
* regional CSV.
|
|
1081
|
+
* @param {string} [opts.quote='"']
|
|
1082
|
+
* Quote character (per RFC 4180).
|
|
1083
|
+
* @param {boolean} [opts.headers=true]
|
|
1084
|
+
* Emit a header row.
|
|
1085
|
+
* @param {string} [opts.newline="\r\n"]
|
|
1086
|
+
* Line separator. RFC 4180 says CRLF; LF works fine for most
|
|
1087
|
+
* consumers and tends to be what spreadsheet imports prefer
|
|
1088
|
+
* when opened on macOS / Linux.
|
|
1089
|
+
* @param {boolean} [opts.bom=false]
|
|
1090
|
+
* Prepend a UTF-8 BOM. Excel on Windows uses this to detect
|
|
1091
|
+
* UTF-8 encoding for non-ASCII content.
|
|
1092
|
+
* @param {(row:any, col:ColumnState) => unknown} [opts.formatter]
|
|
1093
|
+
* Optional cell formatter run BEFORE the value is stringified
|
|
1094
|
+
* and CSV-escaped. Receives the raw row + the column state.
|
|
1095
|
+
* Use this for date formatting, number locales, etc.
|
|
1096
|
+
* @returns {string}
|
|
1097
|
+
*/
|
|
1098
|
+
function exportCsv(opts) {
|
|
1099
|
+
opts = opts || {};
|
|
1100
|
+
const rows = _resolveRows(opts.rows);
|
|
1101
|
+
const cols = _resolveColumns(opts.columns);
|
|
1102
|
+
const delimiter = typeof opts.delimiter === "string" && opts.delimiter.length > 0 ? opts.delimiter : ",";
|
|
1103
|
+
const quote = typeof opts.quote === "string" && opts.quote.length > 0 ? opts.quote : '"';
|
|
1104
|
+
const headers = opts.headers !== false;
|
|
1105
|
+
const newline = typeof opts.newline === "string" ? opts.newline : "\r\n";
|
|
1106
|
+
const bom = opts.bom === true;
|
|
1107
|
+
const fmt = typeof opts.formatter === "function" ? opts.formatter : null;
|
|
1108
|
+
|
|
1109
|
+
// Pre-size for one pass append; the final string is built by Array.join
|
|
1110
|
+
// to avoid the O(N^2) string concat trap.
|
|
1111
|
+
const lines = [];
|
|
1112
|
+
if (headers) {
|
|
1113
|
+
const head = new Array(cols.length);
|
|
1114
|
+
for (let i = 0; i < cols.length; i++) {
|
|
1115
|
+
head[i] = _csvEscape(cols[i].header, delimiter, quote);
|
|
1116
|
+
}
|
|
1117
|
+
lines.push(head.join(delimiter));
|
|
1118
|
+
}
|
|
1119
|
+
for (let r = 0; r < rows.length; r++) {
|
|
1120
|
+
const row = rows[r];
|
|
1121
|
+
const cells = new Array(cols.length);
|
|
1122
|
+
for (let c = 0; c < cols.length; c++) {
|
|
1123
|
+
const col = cols[c];
|
|
1124
|
+
const raw = fmt ? fmt(row, col) : _cellValue(row, col);
|
|
1125
|
+
cells[c] = _csvEscape(raw, delimiter, quote);
|
|
1126
|
+
}
|
|
1127
|
+
lines.push(cells.join(delimiter));
|
|
1128
|
+
}
|
|
1129
|
+
const body = lines.join(newline);
|
|
1130
|
+
return bom ? "\uFEFF" + body : body;
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
/**
|
|
1134
|
+
* Export rows to a JSON string (or array, if `format: "array"`).
|
|
1135
|
+
*
|
|
1136
|
+
* @param {object} [opts]
|
|
1137
|
+
* @param {"visible"|"all"|"selected"|Array} [opts.rows="visible"]
|
|
1138
|
+
* @param {"visible"|"all"|Array<string>} [opts.columns="visible"]
|
|
1139
|
+
* When provided, output objects contain only the projected keys.
|
|
1140
|
+
* With "all" + no formatter, this is just `rows` unchanged
|
|
1141
|
+
* (the raw row objects), so we avoid an unnecessary allocation.
|
|
1142
|
+
* @param {number} [opts.indent=0]
|
|
1143
|
+
* JSON.stringify indent. 0 = compact (single line).
|
|
1144
|
+
* @param {"string"|"array"} [opts.format="string"]
|
|
1145
|
+
* "string" (default) returns the serialized JSON text.
|
|
1146
|
+
* "array" returns the projected array directly (skips
|
|
1147
|
+
* JSON.stringify). Useful for chaining into IndexedDB,
|
|
1148
|
+
* postMessage, structured clone, or further transforms.
|
|
1149
|
+
* @param {(row:any, col:ColumnState) => unknown} [opts.formatter]
|
|
1150
|
+
* Optional cell formatter, same shape as exportCsv.
|
|
1151
|
+
* @returns {string|Array<object>}
|
|
1152
|
+
*/
|
|
1153
|
+
function exportJson(opts) {
|
|
1154
|
+
opts = opts || {};
|
|
1155
|
+
const rows = _resolveRows(opts.rows);
|
|
1156
|
+
const colsOpt = opts.columns;
|
|
1157
|
+
const fmt = typeof opts.formatter === "function" ? opts.formatter : null;
|
|
1158
|
+
const indent = typeof opts.indent === "number" && opts.indent > 0 ? opts.indent : 0;
|
|
1159
|
+
const asArray = opts.format === "array";
|
|
1160
|
+
|
|
1161
|
+
let out;
|
|
1162
|
+
|
|
1163
|
+
// Fast path: when the consumer wants the raw row objects (no column
|
|
1164
|
+
// projection, no formatter) we can return the array as-is for
|
|
1165
|
+
// format:"array" or stringify it directly for format:"string".
|
|
1166
|
+
if (colsOpt === undefined && !fmt) {
|
|
1167
|
+
// Default behaviour: project to visible columns (matches CSV
|
|
1168
|
+
// default). If you really want the raw rows, pass columns: "all".
|
|
1169
|
+
// We still go through the projection path below.
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
if (colsOpt === "all" && !fmt) {
|
|
1173
|
+
// Raw rows -- safe because we don't mutate, the consumer owns
|
|
1174
|
+
// the lifetime of the strings.
|
|
1175
|
+
out = rows.slice();
|
|
1176
|
+
} else {
|
|
1177
|
+
const cols = _resolveColumns(colsOpt);
|
|
1178
|
+
out = new Array(rows.length);
|
|
1179
|
+
for (let r = 0; r < rows.length; r++) {
|
|
1180
|
+
const row = rows[r];
|
|
1181
|
+
const obj = {};
|
|
1182
|
+
for (let c = 0; c < cols.length; c++) {
|
|
1183
|
+
const col = cols[c];
|
|
1184
|
+
obj[col.key] = fmt ? fmt(row, col) : _cellValue(row, col);
|
|
1185
|
+
}
|
|
1186
|
+
out[r] = obj;
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
if (asArray) return out;
|
|
1191
|
+
return indent > 0 ? JSON.stringify(out, null, indent) : JSON.stringify(out);
|
|
1192
|
+
}
|
|
1193
|
+
|
|
765
1194
|
return {
|
|
766
1195
|
// Static
|
|
767
1196
|
columns,
|
|
@@ -772,6 +1201,7 @@ export function createTable(config) {
|
|
|
772
1201
|
|
|
773
1202
|
// Reactive: data
|
|
774
1203
|
rowsGetter,
|
|
1204
|
+
filteredRows,
|
|
775
1205
|
visibleRows,
|
|
776
1206
|
rowCount,
|
|
777
1207
|
|
|
@@ -785,14 +1215,17 @@ export function createTable(config) {
|
|
|
785
1215
|
leftOffsets,
|
|
786
1216
|
rightOffsets,
|
|
787
1217
|
|
|
788
|
-
// Reactive: sort
|
|
1218
|
+
// Reactive: sort / filters
|
|
789
1219
|
sortChain,
|
|
1220
|
+
columnFilters,
|
|
790
1221
|
|
|
791
|
-
// Reactive: focus / selection
|
|
1222
|
+
// Reactive: focus / selection / editing
|
|
792
1223
|
focusedCell,
|
|
793
1224
|
selection,
|
|
794
1225
|
selectionAnchor,
|
|
795
1226
|
selectedCount,
|
|
1227
|
+
editingCell,
|
|
1228
|
+
editingDraft,
|
|
796
1229
|
|
|
797
1230
|
// Methods: sort
|
|
798
1231
|
setSort, addSort, toggleSort, clearSort,
|
|
@@ -805,6 +1238,15 @@ export function createTable(config) {
|
|
|
805
1238
|
setColumnWidth, setColumnHidden, setColumnPin, setColumnFlex,
|
|
806
1239
|
setColumnOrder, moveColumn,
|
|
807
1240
|
|
|
1241
|
+
// Methods: filters
|
|
1242
|
+
setColumnFilter, clearColumnFilters,
|
|
1243
|
+
|
|
1244
|
+
// Methods: editing
|
|
1245
|
+
startEdit, commitEdit, cancelEdit, isEditing,
|
|
1246
|
+
|
|
1247
|
+
// Methods: export
|
|
1248
|
+
exportCsv, exportJson,
|
|
1249
|
+
|
|
808
1250
|
// Methods: focus
|
|
809
1251
|
moveFocus,
|
|
810
1252
|
|
|
@@ -913,7 +1355,26 @@ const DEFAULT_STYLES =
|
|
|
913
1355
|
// cell to a new stacking context (which box-shadow + position:relative
|
|
914
1356
|
// could do, causing pixel-shift during compositor layer creation), and
|
|
915
1357
|
// doesn't conflict with sticky positioning on pinned cells.
|
|
916
|
-
".lt-cell.is-focused{outline:2px solid #3b82f6;outline-offset:-2px}"
|
|
1358
|
+
".lt-cell.is-focused{outline:2px solid #3b82f6;outline-offset:-2px}" +
|
|
1359
|
+
// M2: editable cell affordance (subtle cursor hint on hover) + active
|
|
1360
|
+
// editing state (filled background + caret-line outline).
|
|
1361
|
+
".lt-cell[data-editable=\"true\"]{cursor:text}" +
|
|
1362
|
+
".lt-cell.is-editing{outline:2px solid #f59e0b;outline-offset:-2px;" +
|
|
1363
|
+
"background:#fff;overflow:visible;white-space:normal;" +
|
|
1364
|
+
"text-overflow:clip}" +
|
|
1365
|
+
// M2: filter row sits between header and viewport, mirrors --lt-cols.
|
|
1366
|
+
".lt-filter-row{position:sticky;top:32px;z-index:4;display:grid;" +
|
|
1367
|
+
"grid-template-columns:var(--lt-cols);background:#f1f5f9;" +
|
|
1368
|
+
"border-bottom:1px solid #e2e8f0;width:max-content;min-width:100%;" +
|
|
1369
|
+
"grid-auto-flow:column;grid-template-rows:auto}" +
|
|
1370
|
+
".lt-filter-cell{padding:4px 6px;box-sizing:border-box;" +
|
|
1371
|
+
"border-right:1px solid #f1f5f9;grid-row:1;background:inherit}" +
|
|
1372
|
+
".lt-filter-cell[data-pin=\"left\"]{position:sticky;z-index:5;background:#f1f5f9}" +
|
|
1373
|
+
".lt-filter-cell[data-pin=\"right\"]{position:sticky;z-index:5;background:#f1f5f9}" +
|
|
1374
|
+
".lt-filter-input{width:100%;padding:3px 6px;font:inherit;font-size:12px;" +
|
|
1375
|
+
"background:#fff;color:#0f172a;border:1px solid #cbd5e1;border-radius:3px;" +
|
|
1376
|
+
"outline:none;box-sizing:border-box}" +
|
|
1377
|
+
".lt-filter-input:focus{border-color:#3b82f6;box-shadow:0 0 0 2px #dbeafe}";
|
|
917
1378
|
|
|
918
1379
|
let _stylesInjected = new WeakSet();
|
|
919
1380
|
function injectStyles(doc) {
|
|
@@ -991,7 +1452,10 @@ export function mountTable(host, table, options) {
|
|
|
991
1452
|
sortChain, focusedCell, selection,
|
|
992
1453
|
getRowId, cellId,
|
|
993
1454
|
toggleSort, selectRow, isSelected, moveFocus,
|
|
994
|
-
setColumnWidth, moveColumn
|
|
1455
|
+
setColumnWidth, moveColumn,
|
|
1456
|
+
// M2 surface
|
|
1457
|
+
columnFilters, setColumnFilter,
|
|
1458
|
+
editingCell, editingDraft, startEdit, commitEdit, cancelEdit
|
|
995
1459
|
} = table;
|
|
996
1460
|
|
|
997
1461
|
const { scope, dispose: disposeScope } = createScope();
|
|
@@ -1052,6 +1516,93 @@ export function mountTable(host, table, options) {
|
|
|
1052
1516
|
}
|
|
1053
1517
|
root.appendChild(header);
|
|
1054
1518
|
|
|
1519
|
+
// ----- Filter row (M2) --------------------------------------------------
|
|
1520
|
+
// Only mounted if at least one declared column has filterable: true.
|
|
1521
|
+
// Sits between the header and the viewport in the DOM, uses the same
|
|
1522
|
+
// CSS Grid template (via --lt-cols inherited from root), and recycles
|
|
1523
|
+
// never -- one input per filterable column, hidden when the column is
|
|
1524
|
+
// hidden.
|
|
1525
|
+
const filterCells = new Map(); // key -> input element
|
|
1526
|
+
const hasAnyFilterable = columns.some((c) => c.filterable);
|
|
1527
|
+
let filterRow = null;
|
|
1528
|
+
if (hasAnyFilterable) {
|
|
1529
|
+
filterRow = doc.createElement("div");
|
|
1530
|
+
filterRow.className = "lt-filter-row";
|
|
1531
|
+
filterRow.setAttribute("role", "row");
|
|
1532
|
+
filterRow.setAttribute("aria-rowindex", "2");
|
|
1533
|
+
|
|
1534
|
+
for (const col of columns) {
|
|
1535
|
+
const cell = doc.createElement("div");
|
|
1536
|
+
cell.className = "lt-filter-cell";
|
|
1537
|
+
cell.setAttribute("data-key", col.key);
|
|
1538
|
+
cell.setAttribute("data-pin", "none");
|
|
1539
|
+
|
|
1540
|
+
if (col.filterable) {
|
|
1541
|
+
const input = doc.createElement("input");
|
|
1542
|
+
input.type = "text";
|
|
1543
|
+
input.className = "lt-filter-input";
|
|
1544
|
+
input.setAttribute("aria-label", "Filter " + col.header);
|
|
1545
|
+
if (col.filterPlaceholder) input.placeholder = col.filterPlaceholder;
|
|
1546
|
+
else input.placeholder = "Filter…";
|
|
1547
|
+
|
|
1548
|
+
// Two-way binding. Outer source of truth is columnFilters; we
|
|
1549
|
+
// read it on every change and write into the input only if the
|
|
1550
|
+
// values diverged (so user typing doesn't get reset by their
|
|
1551
|
+
// own write echoing back). Likewise we write into the signal
|
|
1552
|
+
// from `input` events only when divergent.
|
|
1553
|
+
scope.effect(() => {
|
|
1554
|
+
const cur = columnFilters().get(col.key) || "";
|
|
1555
|
+
if (input.value !== cur) input.value = cur;
|
|
1556
|
+
});
|
|
1557
|
+
scope.on(input, "input", () => {
|
|
1558
|
+
setColumnFilter(col.key, input.value);
|
|
1559
|
+
});
|
|
1560
|
+
// Escape clears just this column's filter.
|
|
1561
|
+
scope.on(input, "keydown", (ev) => {
|
|
1562
|
+
if (ev.key === "Escape") {
|
|
1563
|
+
ev.preventDefault();
|
|
1564
|
+
setColumnFilter(col.key, "");
|
|
1565
|
+
}
|
|
1566
|
+
});
|
|
1567
|
+
|
|
1568
|
+
cell.appendChild(input);
|
|
1569
|
+
filterCells.set(col.key, input);
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
// Mirror grid placement, hide, and pin offsets from the column
|
|
1573
|
+
// state -- same logic as header cells. We reuse the column's
|
|
1574
|
+
// reactive placement so a hidden column drops its filter cell
|
|
1575
|
+
// too, and pinned columns keep their filter input sticky.
|
|
1576
|
+
scope.effect(() => {
|
|
1577
|
+
const placement = colPlacement().get(col.key);
|
|
1578
|
+
if (placement == null) {
|
|
1579
|
+
cell.style.display = "none";
|
|
1580
|
+
} else {
|
|
1581
|
+
cell.style.display = "";
|
|
1582
|
+
cell.style.gridColumn = placement + " / span 1";
|
|
1583
|
+
}
|
|
1584
|
+
});
|
|
1585
|
+
scope.effect(() => {
|
|
1586
|
+
const pinSide = col.pin();
|
|
1587
|
+
cell.setAttribute("data-pin", pinSide);
|
|
1588
|
+
if (pinSide === "left") {
|
|
1589
|
+
cell.style.left = (leftOffsets().get(col.key) || 0) + "px";
|
|
1590
|
+
cell.style.right = "";
|
|
1591
|
+
} else if (pinSide === "right") {
|
|
1592
|
+
cell.style.right = (rightOffsets().get(col.key) || 0) + "px";
|
|
1593
|
+
cell.style.left = "";
|
|
1594
|
+
} else {
|
|
1595
|
+
cell.style.left = "";
|
|
1596
|
+
cell.style.right = "";
|
|
1597
|
+
}
|
|
1598
|
+
});
|
|
1599
|
+
|
|
1600
|
+
filterRow.appendChild(cell);
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
root.appendChild(filterRow);
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1055
1606
|
// Per-header reactive bindings: gridColumn, display, sticky pin, aria-sort.
|
|
1056
1607
|
for (const col of columns) {
|
|
1057
1608
|
const hc = headerCells.get(col.key);
|
|
@@ -1254,14 +1805,32 @@ export function mountTable(host, table, options) {
|
|
|
1254
1805
|
}
|
|
1255
1806
|
});
|
|
1256
1807
|
|
|
1257
|
-
// Reactive text.
|
|
1258
|
-
|
|
1808
|
+
// Reactive text. We use a manual effect (not bindText) so we can
|
|
1809
|
+
// skip the write when this cell is currently being edited -- the
|
|
1810
|
+
// contenteditable cell IS the source of truth while editing, and
|
|
1811
|
+
// any textContent write would clobber the user's caret.
|
|
1812
|
+
scope.effect(() => {
|
|
1259
1813
|
const i = slotIndex();
|
|
1260
1814
|
const rs = visibleRows();
|
|
1261
|
-
if (i < 0 || i >= rs.length)
|
|
1262
|
-
const
|
|
1263
|
-
|
|
1264
|
-
|
|
1815
|
+
if (i < 0 || i >= rs.length) { cellEl.textContent = ""; return; }
|
|
1816
|
+
const row = rs[i];
|
|
1817
|
+
// Editing gate: suspend text writes for the cell currently in
|
|
1818
|
+
// edit mode. The effect still tracks `editingCell` so it
|
|
1819
|
+
// resumes the moment editing ends. Tracking visibleRows + col
|
|
1820
|
+
// here means scrolling-during-edit re-points the slot to a
|
|
1821
|
+
// different row; we commit the in-flight edit in that case
|
|
1822
|
+
// (see slot-row scroll effect below) so the suspended write
|
|
1823
|
+
// resumes with the right row's data.
|
|
1824
|
+
if (col.editable) {
|
|
1825
|
+
const e = editingCell();
|
|
1826
|
+
if (e !== null && row != null && e.rowId === getRowId(row) && e.columnKey === col.key) {
|
|
1827
|
+
return;
|
|
1828
|
+
}
|
|
1829
|
+
}
|
|
1830
|
+
const v = readCell(col, row);
|
|
1831
|
+
const text = v == null ? "" : String(v);
|
|
1832
|
+
if (cellEl.textContent !== text) cellEl.textContent = text;
|
|
1833
|
+
});
|
|
1265
1834
|
|
|
1266
1835
|
// Reactive id (the aria-activedescendant target).
|
|
1267
1836
|
scope.onCleanup(bindAttr(cellEl, "id", () => {
|
|
@@ -1288,6 +1857,123 @@ export function mountTable(host, table, options) {
|
|
|
1288
1857
|
return getRowId(row) === f.rowId && col.key === f.columnKey;
|
|
1289
1858
|
}));
|
|
1290
1859
|
|
|
1860
|
+
// Editable cells: manage contenteditable + the editing-flash class.
|
|
1861
|
+
// We only attach this machinery for columns that opted in -- non-
|
|
1862
|
+
// editable cells stay simple text nodes with no event listeners.
|
|
1863
|
+
if (col.editable) {
|
|
1864
|
+
cellEl.setAttribute("data-editable", "true");
|
|
1865
|
+
|
|
1866
|
+
// Editing state painted as data + class + contenteditable.
|
|
1867
|
+
scope.effect(() => {
|
|
1868
|
+
const i = slotIndex();
|
|
1869
|
+
const rs = visibleRows();
|
|
1870
|
+
if (i < 0 || i >= rs.length) {
|
|
1871
|
+
cellEl.removeAttribute("contenteditable");
|
|
1872
|
+
cellEl.classList.remove("is-editing");
|
|
1873
|
+
return;
|
|
1874
|
+
}
|
|
1875
|
+
const row = rs[i];
|
|
1876
|
+
if (row == null) {
|
|
1877
|
+
cellEl.removeAttribute("contenteditable");
|
|
1878
|
+
cellEl.classList.remove("is-editing");
|
|
1879
|
+
return;
|
|
1880
|
+
}
|
|
1881
|
+
const e = editingCell();
|
|
1882
|
+
const editingThis = e !== null && e.rowId === getRowId(row) && e.columnKey === col.key;
|
|
1883
|
+
if (editingThis) {
|
|
1884
|
+
if (cellEl.getAttribute("contenteditable") !== "true") {
|
|
1885
|
+
cellEl.setAttribute("contenteditable", "true");
|
|
1886
|
+
// Seed textContent with the draft so what the user
|
|
1887
|
+
// sees matches what commitEdit will read. Capture
|
|
1888
|
+
// the draft synchronously to avoid re-running this
|
|
1889
|
+
// effect on every keystroke (draft changes don't
|
|
1890
|
+
// need to re-paint anything else).
|
|
1891
|
+
const seed = editingDraft.peek();
|
|
1892
|
+
if (cellEl.textContent !== seed) cellEl.textContent = seed;
|
|
1893
|
+
// Defer focus to the next microtask so any
|
|
1894
|
+
// synchronous DOM rearrangement (slot recycle,
|
|
1895
|
+
// resize) doesn't pre-empt the focus call.
|
|
1896
|
+
queueMicrotask(() => {
|
|
1897
|
+
if (cellEl.getAttribute("contenteditable") === "true") {
|
|
1898
|
+
cellEl.focus();
|
|
1899
|
+
// Select all so a tab into the cell starts
|
|
1900
|
+
// with the whole value highlighted -- the
|
|
1901
|
+
// standard spreadsheet idiom.
|
|
1902
|
+
const sel = doc.getSelection ? doc.getSelection() : null;
|
|
1903
|
+
if (sel) {
|
|
1904
|
+
const range = doc.createRange();
|
|
1905
|
+
range.selectNodeContents(cellEl);
|
|
1906
|
+
sel.removeAllRanges();
|
|
1907
|
+
sel.addRange(range);
|
|
1908
|
+
}
|
|
1909
|
+
}
|
|
1910
|
+
});
|
|
1911
|
+
}
|
|
1912
|
+
cellEl.classList.add("is-editing");
|
|
1913
|
+
} else {
|
|
1914
|
+
if (cellEl.hasAttribute("contenteditable")) {
|
|
1915
|
+
cellEl.removeAttribute("contenteditable");
|
|
1916
|
+
}
|
|
1917
|
+
cellEl.classList.remove("is-editing");
|
|
1918
|
+
}
|
|
1919
|
+
});
|
|
1920
|
+
|
|
1921
|
+
// input event: keep the draft in sync with the visible content
|
|
1922
|
+
// so commitEdit (which reads editingDraft) gets the latest.
|
|
1923
|
+
scope.on(cellEl, "input", () => {
|
|
1924
|
+
// Only act when this cell is the one being edited. Slot
|
|
1925
|
+
// recycling could in theory fire input on a stale cell
|
|
1926
|
+
// (it shouldn't if contenteditable isn't set, but the
|
|
1927
|
+
// guard is cheap).
|
|
1928
|
+
if (cellEl.getAttribute("contenteditable") !== "true") return;
|
|
1929
|
+
editingDraft.set(cellEl.textContent || "");
|
|
1930
|
+
});
|
|
1931
|
+
|
|
1932
|
+
// keydown: Enter commits (and we move down), Escape cancels,
|
|
1933
|
+
// Tab commits (and the browser moves focus).
|
|
1934
|
+
scope.on(cellEl, "keydown", (ev) => {
|
|
1935
|
+
if (cellEl.getAttribute("contenteditable") !== "true") return;
|
|
1936
|
+
if (ev.key === "Escape") {
|
|
1937
|
+
ev.preventDefault();
|
|
1938
|
+
ev.stopPropagation();
|
|
1939
|
+
cancelEdit();
|
|
1940
|
+
// Restore focus to the root so keyboard nav continues.
|
|
1941
|
+
root.focus();
|
|
1942
|
+
} else if (ev.key === "Enter" && !ev.shiftKey) {
|
|
1943
|
+
ev.preventDefault();
|
|
1944
|
+
ev.stopPropagation();
|
|
1945
|
+
commitEdit();
|
|
1946
|
+
root.focus();
|
|
1947
|
+
// Move focus to the row below if there is one.
|
|
1948
|
+
moveFocus("down");
|
|
1949
|
+
} else if (ev.key === "Tab") {
|
|
1950
|
+
ev.preventDefault();
|
|
1951
|
+
ev.stopPropagation();
|
|
1952
|
+
commitEdit();
|
|
1953
|
+
root.focus();
|
|
1954
|
+
moveFocus(ev.shiftKey ? "left" : "right");
|
|
1955
|
+
}
|
|
1956
|
+
});
|
|
1957
|
+
|
|
1958
|
+
// blur: commit. Use focusout so it fires reliably across all
|
|
1959
|
+
// browsers and bubbles through the cell.
|
|
1960
|
+
scope.on(cellEl, "blur", () => {
|
|
1961
|
+
if (cellEl.getAttribute("contenteditable") !== "true") return;
|
|
1962
|
+
commitEdit();
|
|
1963
|
+
});
|
|
1964
|
+
|
|
1965
|
+
// dblclick: start editing this cell.
|
|
1966
|
+
scope.on(cellEl, "dblclick", (ev) => {
|
|
1967
|
+
const i = slotIndex.peek();
|
|
1968
|
+
const rs = visibleRows.peek();
|
|
1969
|
+
if (i < 0 || i >= rs.length) return;
|
|
1970
|
+
const row = rs[i];
|
|
1971
|
+
if (row == null) return;
|
|
1972
|
+
ev.preventDefault();
|
|
1973
|
+
startEdit(getRowId(row), col.key);
|
|
1974
|
+
});
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1291
1977
|
// No pointerdown listener here -- the root has one delegated
|
|
1292
1978
|
// listener that uses closest('.lt-cell') + data-key + slot index.
|
|
1293
1979
|
|
|
@@ -1426,6 +2112,22 @@ export function mountTable(host, table, options) {
|
|
|
1426
2112
|
if (ctrl) { table.selectAll(); }
|
|
1427
2113
|
else handled = false;
|
|
1428
2114
|
break;
|
|
2115
|
+
// M2: F2 and Enter (no modifiers) on the focused cell start
|
|
2116
|
+
// editing if the column is editable. Matches the spreadsheet
|
|
2117
|
+
// convention -- F2 universally, Enter as a convenience.
|
|
2118
|
+
case "F2":
|
|
2119
|
+
case "Enter": {
|
|
2120
|
+
if (ev.shiftKey || ctrl) { handled = false; break; }
|
|
2121
|
+
const f = focusedCell.peek();
|
|
2122
|
+
if (!f) { handled = false; break; }
|
|
2123
|
+
const col = columns.find((c) => c.key === f.columnKey);
|
|
2124
|
+
if (col && col.editable) {
|
|
2125
|
+
startEdit(f.rowId, f.columnKey);
|
|
2126
|
+
} else {
|
|
2127
|
+
handled = false;
|
|
2128
|
+
}
|
|
2129
|
+
break;
|
|
2130
|
+
}
|
|
1429
2131
|
default:
|
|
1430
2132
|
handled = false;
|
|
1431
2133
|
}
|