@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/Table.js CHANGED
@@ -90,49 +90,80 @@ function defaultCompare(a, b) {
90
90
  }
91
91
 
92
92
  /**
93
- * Create a disposable scope. Tracks effects, listeners, signals, computeds,
94
- * and arbitrary cleanups. `dispose()` runs everything in reverse order, once.
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 cleanups = [];
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
- cleanups.push(s);
131
+ pushNode(s);
103
132
  return s;
104
133
  },
105
134
  computed(fn, opts) {
106
135
  const c = computed(fn, opts);
107
- cleanups.push(c);
136
+ pushNode(c);
108
137
  return c;
109
138
  },
110
139
  effect(fn) {
111
140
  const d = effect(fn);
112
- cleanups.push(d);
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
- cleanups.push(off);
147
+ pushThunk(off);
119
148
  return off;
120
149
  },
121
150
  onCleanup(fn) {
122
- cleanups.push(fn);
151
+ pushThunk(fn);
123
152
  }
124
153
  };
125
154
  function dispose() {
126
155
  if (disposed) return;
127
156
  disposed = true;
128
- for (let i = cleanups.length - 1; i >= 0; i--) {
129
- const c = cleanups[i];
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 (typeof c === "function") c();
132
- else disposeNode(c);
161
+ if (k === KIND_NODE) disposeNode(t);
162
+ else t();
133
163
  } catch (_e) { /* per-handler */ }
134
164
  }
135
- cleanups.length = 0;
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 rows.
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 = rowsGetter();
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
- // Cycle: none -> asc -> desc -> none.
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: nextDir });
463
- } else if (nextDir == null) {
464
- next.splice(existing, 1);
563
+ next.push({ key, dir: "asc" });
564
+ } else if (chain[existing].dir === "asc") {
565
+ next[existing] = { key, dir: "desc" };
465
566
  } else {
466
- next[existing] = { key, dir: nextDir };
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
- sortChain.set(nextDir == null ? [] : [{ key, dir: nextDir }]);
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
- scope.onCleanup(bindText(cellEl, () => {
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) return "";
1262
- const v = readCell(col, rs[i]);
1263
- return v == null ? "" : v;
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
  }