@zakkster/lite-table 1.0.0 → 1.2.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.
Files changed (6) hide show
  1. package/CHANGELOG.md +344 -0
  2. package/README.md +564 -19
  3. package/Table.d.ts +258 -1
  4. package/Table.js +1644 -116
  5. package/llms.txt +291 -6
  6. package/package.json +15 -9
package/Table.d.ts CHANGED
@@ -36,12 +36,38 @@ export interface ColumnDef<Row = any, Value = unknown> {
36
36
  pinnable?: boolean;
37
37
  hideable?: boolean;
38
38
  reorderable?: boolean;
39
+ /** M2: enable double-click / F2 cell editing for this column. Default false. */
40
+ editable?: boolean;
41
+ /** M2: enable the per-column filter input above this column. Default false. */
42
+ filterable?: boolean;
43
+ /** M2: custom filter predicate. Receives the column's value (post-accessor),
44
+ * the current (trimmed) filter query string, and the full row. Return
45
+ * true to keep the row in the result. Defaults to case-insensitive
46
+ * substring match on the stringified value. */
47
+ filter?: (value: Value, query: string, row: Row) => boolean;
48
+ /** M2: placeholder text for the filter input. Default "Filter…". */
49
+ filterPlaceholder?: string;
50
+ /** M3: aggregate reducer for group-header + grand-total cells. One of the
51
+ * built-in strings, or a custom `(rows, col) => value` function. `null`
52
+ * or omitted means "no aggregate" -- the column shows blank in header
53
+ * rows. Aggregates always fold over LEAF rows at every depth (safe
54
+ * for non-associative reducers like median or last-value-wins). */
55
+ aggregate?: AggregateSpec<Row> | null;
56
+ /** M3: format the aggregate for display. Only affects rendered text --
57
+ * `entry.aggregates.get(key)` still returns the raw value so consumers
58
+ * can format independently for export etc. Errors are caught + logged. */
59
+ aggregateFormat?: (value: unknown, col: ColumnState<Row>, count: number) => string;
39
60
  /** Computed value override; if absent, `row[key]` is used. */
40
61
  accessor?: (row: Row) => Value;
41
62
  /** Sort comparator; default is null-safe number-or-string compare. */
42
63
  compare?: (a: Value, b: Value) => number;
43
64
  }
44
65
 
66
+ /** M3: aggregate spec on a column. */
67
+ export type AggregateSpec<Row = any> =
68
+ | "sum" | "avg" | "min" | "max" | "count"
69
+ | ((rows: readonly Row[], col: ColumnState<Row>) => unknown);
70
+
45
71
  /** Live reactive state for a column, exposed on `table.columns[i]`. */
46
72
  export interface ColumnState<Row = any> {
47
73
  readonly key: string;
@@ -53,6 +79,11 @@ export interface ColumnState<Row = any> {
53
79
  readonly pinnable: boolean;
54
80
  readonly hideable: boolean;
55
81
  readonly reorderable: boolean;
82
+ /** M2 */
83
+ readonly editable: boolean;
84
+ readonly filterable: boolean;
85
+ readonly filter: ((value: unknown, query: string, row: Row) => boolean) | null;
86
+ readonly filterPlaceholder: string | null;
56
87
  readonly minWidth: number;
57
88
  readonly maxWidth: number;
58
89
  readonly width: Signal<number>;
@@ -68,6 +99,34 @@ export interface FocusedCell {
68
99
  columnKey: string;
69
100
  }
70
101
 
102
+ /** M2: cell currently being edited. Keyed on row identity so the edit
103
+ * state survives slot recycling / scroll / sort / filter -- same model
104
+ * as `FocusedCell`. */
105
+ export interface EditingCell {
106
+ rowId: string | number;
107
+ columnKey: string;
108
+ }
109
+
110
+ /** M2: payload passed to `onCellEdit` on commit.
111
+ *
112
+ * `newValue` is `string` when the commit came from the DOM editing path
113
+ * (double-click / F2 → contenteditable → Enter / Tab / blur). The
114
+ * consumer is responsible for coercion when editing typed columns. When
115
+ * the consumer calls `commitEdit(explicitValue)` with a non-string,
116
+ * `newValue` carries that value through unchanged (typed as unknown).
117
+ * `oldValue` is whatever the cell holds pre-edit (post-accessor).
118
+ *
119
+ * The unchanged-guard compares `String(oldValue) !== newValue` for string
120
+ * `newValue`s -- so a no-op Enter on a numeric column doesn't fire this
121
+ * handler. For non-string explicit values it falls back to strict
122
+ * equality. */
123
+ export interface CellEditPayload<Row = any> {
124
+ row: Row;
125
+ columnKey: string;
126
+ oldValue: unknown;
127
+ newValue: string | unknown;
128
+ }
129
+
71
130
  export interface SortEntry {
72
131
  key: string;
73
132
  dir: SortDir;
@@ -102,6 +161,117 @@ export interface CreateTableConfig<Row = any> {
102
161
  overscan?: number;
103
162
  initialFocus?: FocusedCell | null;
104
163
  initialSort?: readonly SortEntry[];
164
+ /** M2: commit hook for cell edits. Receives the row + column key + old +
165
+ * new value. The table does NOT mutate rows itself; consumer is
166
+ * responsible for the data write (in-memory mutation, backend POST,
167
+ * optimistic update, etc.). Skipped when newValue === oldValue.
168
+ * Errors thrown by the handler are caught + logged. */
169
+ onCellEdit?: (payload: CellEditPayload<Row>) => void | Promise<unknown>;
170
+
171
+ /** M3: group rows by one or more column keys. `null` (default) or an
172
+ * empty array = no grouping. A bare string is normalized to `[string]`.
173
+ * Unknown column keys are silently dropped so persisted state that
174
+ * outlives a column config survives without crashes. */
175
+ groupBy?: string | readonly string[] | null;
176
+ /** M3: pre-seed the collapsed set. Each entry is a group path -- e.g.
177
+ * `[["Europe"], ["Asia", "Books"]]`. Unknown paths become no-ops when
178
+ * their groups don't exist. */
179
+ initialCollapsedGroups?: readonly (readonly string[])[];
180
+ /** M3: append a grand-total entry at the tail of `visibleEntries` when
181
+ * any column has an `aggregate` spec. Grand-total aggregates fold
182
+ * over `filteredRows()` and are stable across collapse operations. */
183
+ showGrandTotal?: boolean;
184
+ }
185
+
186
+ // --- M3: grouping + aggregation types ---------------------------------------
187
+
188
+ /** One node in the grouped-rows tree. Leaf nodes have `rows` populated and
189
+ * `subGroups: null`; internal nodes have `subGroups` populated and
190
+ * `rows: null`. Aggregates always fold over leaf rows recursively. */
191
+ export interface GroupNode<Row = any> {
192
+ readonly depth: number;
193
+ /** The column key this depth groups on. */
194
+ readonly key: string;
195
+ /** The group's identifying value (post-`accessor`). `null` for the
196
+ * null / undefined bucket. */
197
+ readonly value: unknown;
198
+ /** Path of ancestor values including this node. e.g. `["Europe", "Books"]`. */
199
+ readonly path: readonly string[];
200
+ /** Stringified path (U+001F separator) -- the storage key for the
201
+ * collapsed-groups Set. */
202
+ readonly pathStr: string;
203
+ /** Recursive leaf-row count. */
204
+ readonly count: number;
205
+ readonly aggregates: ReadonlyMap<string, unknown>;
206
+ readonly subGroups: readonly GroupNode<Row>[] | null;
207
+ readonly rows: readonly Row[] | null;
208
+ }
209
+
210
+ /** A row in `visibleEntries`. The mount layer dispatches per-slot rendering
211
+ * on `type`. */
212
+ export type Entry<Row = any> = DataEntry<Row> | GroupHeaderEntry | GrandTotalEntry;
213
+
214
+ export interface DataEntry<Row = any> {
215
+ readonly type: "data";
216
+ readonly row: Row;
217
+ }
218
+ export interface GroupHeaderEntry {
219
+ readonly type: "group-header";
220
+ readonly depth: number;
221
+ readonly key: string;
222
+ readonly value: unknown;
223
+ readonly path: readonly string[];
224
+ readonly pathStr: string;
225
+ readonly count: number;
226
+ readonly aggregates: ReadonlyMap<string, unknown>;
227
+ readonly isCollapsed: boolean;
228
+ }
229
+ export interface GrandTotalEntry {
230
+ readonly type: "grand-total";
231
+ readonly aggregates: ReadonlyMap<string, unknown>;
232
+ readonly count: number;
233
+ }
234
+
235
+ // --- Export option types ----------------------------------------------------
236
+
237
+ /** Row source for export.
238
+ *
239
+ * - `"visible"` (default): the current post-sort view that the user sees.
240
+ * - `"all"`: the original master `rows` array, ignoring sort.
241
+ * - `"selected"`: the current selection materialized against the master
242
+ * array (so the export order matches insertion order, not the sorted
243
+ * order the user is looking at). Pass an explicit array if you want
244
+ * a different source.
245
+ * - Array: an explicit row array (e.g. a paginated page snapshot).
246
+ */
247
+ export type ExportRowsSource<Row = any> = "visible" | "all" | "selected" | readonly Row[];
248
+
249
+ /** Column projection for export.
250
+ *
251
+ * - `"visible"` (default): column order matches the current visible order
252
+ * and skips hidden columns.
253
+ * - `"all"`: all declared columns in declaration order, including hidden.
254
+ * - Array: explicit list of column keys (projection + ordering).
255
+ */
256
+ export type ExportColumnsSelector = "visible" | "all" | readonly string[];
257
+
258
+ export interface ExportCsvOptions<Row = any> {
259
+ rows?: ExportRowsSource<Row>;
260
+ columns?: ExportColumnsSelector;
261
+ delimiter?: string;
262
+ quote?: string;
263
+ headers?: boolean;
264
+ newline?: string;
265
+ bom?: boolean;
266
+ formatter?: (row: Row, col: ColumnState<Row>) => unknown;
267
+ }
268
+
269
+ export interface ExportJsonOptions<Row = any> {
270
+ rows?: ExportRowsSource<Row>;
271
+ columns?: ExportColumnsSelector;
272
+ indent?: number;
273
+ format?: "string" | "array";
274
+ formatter?: (row: Row, col: ColumnState<Row>) => unknown;
105
275
  }
106
276
 
107
277
  // --- Headless core ----------------------------------------------------------
@@ -116,9 +286,35 @@ export interface TableCore<Row = any> {
116
286
 
117
287
  // ---- Reactive: data ----
118
288
  rowsGetter(): readonly Row[];
289
+ /** M2: filtered rows BEFORE sort. `visibleRows` reads from this; consumers
290
+ * can read it directly when they want filter-aware row materializers
291
+ * (e.g. count of pre-sort matches, export of filtered-but-not-sorted). */
292
+ readonly filteredRows: Computed<readonly Row[]>;
119
293
  readonly visibleRows: Computed<readonly Row[]>;
120
294
  readonly rowCount: Computed<number>;
121
295
 
296
+ // ---- Reactive: grouping + aggregation (M3) ----
297
+ /** Current grouping keys. Empty array = no grouping (fast path).
298
+ * Assign via `setGroupBy`; the raw signal is exposed for read-only
299
+ * access + persistence with `@zakkster/lite-persist`. */
300
+ readonly groupBy: Signal<readonly string[]>;
301
+ /** Set of collapsed group path-strings (U+001F separator). Mutated via
302
+ * `toggleGroup`, `collapseGroup`, `expandGroup`, `collapseAllGroups`,
303
+ * `expandAllGroups`. `isGroupCollapsed(path)` is the O(1) predicate. */
304
+ readonly collapsedGroups: Signal<ReadonlySet<string>>;
305
+ /** Grouped tree of `filteredRows`, sorted within leaves per `sortChain`.
306
+ * `null` when `groupBy` is empty -- consumers can branch on this to
307
+ * render a flat list without walking the tree. */
308
+ readonly groupedRows: Computed<readonly GroupNode<Row>[] | null>;
309
+ /** Flat interleaved list of `{type: "data" | "group-header" | "grand-total"}`
310
+ * entries. Drives the mount layer's slot rendering. Length matches
311
+ * `entryCount()`. */
312
+ readonly visibleEntries: Computed<readonly Entry<Row>[]>;
313
+ /** Total entry count including group headers + grand total. Drives the
314
+ * virtual axis in the mount layer -- `rowCount` (data-only) is kept
315
+ * as a separate consumer stat. */
316
+ readonly entryCount: Computed<number>;
317
+
122
318
  // ---- Reactive: columns ----
123
319
  readonly columnOrder: Signal<readonly string[]>;
124
320
  readonly visibleColumns: Computed<readonly ColumnState<Row>[]>;
@@ -133,8 +329,12 @@ export interface TableCore<Row = any> {
133
329
  readonly leftOffsets: Computed<Map<string, number>>;
134
330
  readonly rightOffsets: Computed<Map<string, number>>;
135
331
 
136
- // ---- Reactive: sort / focus / selection ----
332
+ // ---- Reactive: sort / filter / focus / selection / editing ----
137
333
  readonly sortChain: Signal<readonly SortEntry[]>;
334
+ /** M2: per-column filter state. Map<columnKey, queryString>. Mutated
335
+ * via `setColumnFilter` -- direct .set on the signal works but always
336
+ * pass a fresh Map (Object.is-equal mutations would not notify). */
337
+ readonly columnFilters: Signal<ReadonlyMap<string, string>>;
138
338
  readonly focusedCell: Signal<FocusedCell | null>;
139
339
  /** Predicate-based selection state. Reading `.set.has(id)` directly is
140
340
  * wrong in all-mode -- use `isSelected(id)` for membership. */
@@ -143,6 +343,14 @@ export interface TableCore<Row = any> {
143
343
  /** Reactive O(1) selected count. In all-mode this is
144
344
  * `rowCount() - blacklist.size`; otherwise `whitelist.size`. */
145
345
  readonly selectedCount: Computed<number>;
346
+ /** M2: current cell being edited, or null. Keyed on row identity so it
347
+ * survives scroll / sort / filter / slot recycling. */
348
+ readonly editingCell: Signal<EditingCell | null>;
349
+ /** M2: in-progress edit value (the contenteditable's textContent).
350
+ * mountTable writes to this from `input` events; consumers can also
351
+ * set it programmatically to drive an external input. `commitEdit`
352
+ * without an explicit value reads this. */
353
+ readonly editingDraft: Signal<string>;
146
354
 
147
355
  // ---- Methods: sort ----
148
356
  setSort(key: string, dir: SortDir | null): void;
@@ -178,6 +386,55 @@ export interface TableCore<Row = any> {
178
386
  setColumnOrder(keys: readonly string[]): void;
179
387
  moveColumn(fromKey: string, toKey: string, opts?: { before?: boolean }): void;
180
388
 
389
+ // ---- Methods: filters (M2) ----
390
+ /** Set or clear a column's filter. Pass `null`/`undefined`/`""` to clear.
391
+ * No-op on non-filterable or unknown columns. */
392
+ setColumnFilter(key: string, value: string | null | undefined): void;
393
+ /** Clear all filters. No-op + no notify if the filter map is already empty. */
394
+ clearColumnFilters(): void;
395
+
396
+ // ---- Methods: grouping (M3) ----
397
+ /** Set the grouping keys. Accepts a bare string, a string[], or
398
+ * null/empty (no grouping). Unknown column keys are silently dropped.
399
+ * Idempotent -- setting the same effective value is a no-op that
400
+ * doesn't churn the signal. */
401
+ setGroupBy(v: string | readonly string[] | null): void;
402
+ /** Flip the collapsed state for the given group path. */
403
+ toggleGroup(path: readonly string[]): void;
404
+ /** Expand a specific group (no-op if already expanded). */
405
+ expandGroup(path: readonly string[]): void;
406
+ /** Collapse a specific group (no-op if already collapsed). */
407
+ collapseGroup(path: readonly string[]): void;
408
+ /** Expand every group -- clears the collapsed set. */
409
+ expandAllGroups(): void;
410
+ /** Collapse every group in the current tree. Walks `groupedRows()` once. */
411
+ collapseAllGroups(): void;
412
+ /** O(1) predicate: is this exact group path currently collapsed? */
413
+ isGroupCollapsed(path: readonly string[]): boolean;
414
+ /** Given an entry index (typically `axis.start()`), return the group
415
+ * headers that CONTAIN it -- one per depth level, deepest last.
416
+ * Used by consumers implementing sticky group headers. Returns [] for
417
+ * the ungrouped fast path or when the target is above the first header. */
418
+ groupAncestryAt(entryIndex: number): readonly GroupHeaderEntry[];
419
+
420
+ // ---- Methods: editing (M2) ----
421
+ /** Start editing the (rowId, columnKey) cell. No-op on non-editable
422
+ * columns. Auto-commits any in-flight edit of a different cell. Idempotent
423
+ * on the same cell (does not re-seed the draft). */
424
+ startEdit(rowId: string | number, columnKey: string): void;
425
+ /** Commit the in-flight edit. With no argument, reads from `editingDraft`.
426
+ * Fires `onCellEdit` only when `newValue !== oldValue`. Clears edit state. */
427
+ commitEdit(value?: string): void;
428
+ /** Discard the in-flight edit without firing `onCellEdit`. */
429
+ cancelEdit(): void;
430
+ /** O(1) predicate. */
431
+ isEditing(rowId: string | number, columnKey: string): boolean;
432
+
433
+ // ---- Methods: export ----
434
+ exportCsv(opts?: ExportCsvOptions<Row>): string;
435
+ exportJson(opts?: ExportJsonOptions<Row> & { format?: "string" }): string;
436
+ exportJson<R = Row>(opts: ExportJsonOptions<Row> & { format: "array" }): object[];
437
+
181
438
  // ---- Methods: focus ----
182
439
  moveFocus(direction: FocusDirection, opts?: { pageSize?: number }): void;
183
440