@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/README.md CHANGED
@@ -8,8 +8,9 @@
8
8
  [![npm bundle size](https://img.shields.io/bundlephobia/minzip/@zakkster/lite-table?style=for-the-badge)](https://bundlephobia.com/result?p=@zakkster/lite-table)
9
9
  [![npm downloads](https://img.shields.io/npm/dm/@zakkster/lite-table?style=for-the-badge&color=blue)](https://www.npmjs.com/package/@zakkster/lite-table)
10
10
  [![npm total downloads](https://img.shields.io/npm/dt/@zakkster/lite-table?style=for-the-badge&color=blue)](https://www.npmjs.com/package/@zakkster/lite-table)
11
+ [![lite-signal peer](https://img.shields.io/npm/dependency-version/@zakkster/lite-table/peer/@zakkster/lite-signal?style=for-the-badge&color=blue)](https://github.com/PeshoVurtoleta/lite-signal)
11
12
  ![TypeScript](https://img.shields.io/badge/TypeScript-Types-informational)
12
- ![Dependencies](https://img.shields.io/badge/dependencies-3-brightgreen)
13
+ ![Dependencies](https://img.shields.io/badge/dependencies-0-brightgreen)
13
14
  [![license](https://img.shields.io/badge/license-MIT-blue?style=flat-square)](./LICENSE.txt)
14
15
 
15
16
  ```bash
@@ -48,6 +49,10 @@ Synchronous, virtual, allocation-free in the steady state. A 100,000-row scroll
48
49
  - [Architecture in one diagram](#architecture-in-one-diagram)
49
50
  - [How a scroll propagates](#how-a-scroll-propagates)
50
51
  - [API reference](#api-reference)
52
+ - [Export](#export)
53
+ - [Cell editing](#cell-editing)
54
+ - [Per-column filtering](#per-column-filtering)
55
+ - [Grouping and aggregation](#grouping-and-aggregation)
51
56
  - [Pinning and sticky offsets](#pinning-and-sticky-offsets)
52
57
  - [Flex columns](#flex-columns)
53
58
  - [Sorting](#sorting)
@@ -96,9 +101,9 @@ No microtask between `A` and `H`. No reconciliation. No diffing. Just version-st
96
101
 
97
102
  - **`createTable(config)`** -- headless `TableCore`. Reactive columns, visible rows, sort, selection, focused cell. Pure data, no DOM.
98
103
  - **`mountTable(host, table, options?)`** -- DOM mount. Builds a fixed slot pool, header bar, viewport, attaches all reactive bindings, returns `{ root, dispose }`.
99
- - **`createTable` accepts** rows (array or getter), columns, `getRowId`, `rowHeight`, `overscan`, `initialFocus`, `initialSort`.
100
- - **Reactive surface:** `visibleRows`, `rowCount`, `visibleColumns`, `displayIndexByKey`, `colTemplate`, `colPlacement`, `contentWidth`, `leftOffsets`, `rightOffsets`, `sortChain`, `focusedCell`, `selection`, `selectionAnchor`.
101
- - **Methods:** `setSort`, `addSort`, `toggleSort`, `clearSort`, `setColumnWidth`, `setColumnHidden`, `setColumnPin`, `setColumnFlex`, `setColumnOrder`, `moveColumn`, `selectRow`, `selectRowRange`, `selectAll`, `clearSelection`, `isSelected`, `moveFocus`, `cellId`, `dispose`.
104
+ - **`createTable` accepts** rows (array or getter), columns, `getRowId`, `rowHeight`, `overscan`, `initialFocus`, `initialSort`, `onCellEdit`, `groupBy`, `initialCollapsedGroups`, `showGrandTotal`.
105
+ - **Reactive surface:** `visibleRows`, `rowCount`, `filteredRows`, `visibleColumns`, `displayIndexByKey`, `colTemplate`, `colPlacement`, `contentWidth`, `leftOffsets`, `rightOffsets`, `sortChain`, `columnFilters`, `focusedCell`, `selection`, `selectionAnchor`, `editingCell`, `editingDraft`, `groupBy`, `collapsedGroups`, `groupedRows`, `visibleEntries`, `entryCount`.
106
+ - **Methods:** `setSort`, `addSort`, `toggleSort`, `clearSort`, `setColumnWidth`, `setColumnHidden`, `setColumnPin`, `setColumnFlex`, `setColumnOrder`, `moveColumn`, `setColumnFilter`, `clearColumnFilters`, `startEdit`, `commitEdit`, `cancelEdit`, `isEditing`, `exportCsv`, `exportJson`, `setGroupBy`, `toggleGroup`, `expandGroup`, `collapseGroup`, `expandAllGroups`, `collapseAllGroups`, `isGroupCollapsed`, `groupAncestryAt`, `selectRow`, `selectRowRange`, `selectAll`, `clearSelection`, `isSelected`, `moveFocus`, `cellId`, `dispose`.
102
107
  - **Per-column state:** every `ColumnState` exposes `width`, `hidden`, `pin`, `flex` as signals -- wire them directly to your own UI controls.
103
108
 
104
109
  Full type definitions ship in [`Table.d.ts`](./Table.d.ts) and are referenced from `package.json`. Every public symbol has JSDoc.
@@ -242,10 +247,18 @@ const table = createTable({
242
247
  rows: Row[] | (() => Row[]),
243
248
  columns: ColumnDef[],
244
249
  getRowId: (row: Row) => string | number,
245
- rowHeight?: number, // default 32
246
- overscan?: number, // default 4
250
+ rowHeight?: number, // default 32
251
+ overscan?: number, // default 4
247
252
  initialFocus?: CellId | null,
248
- initialSort?: SortEntry[]
253
+ initialSort?: SortEntry[],
254
+
255
+ // M2 -- editing
256
+ onCellEdit?: ({ row, columnKey, oldValue, newValue }) => void | Promise<any>,
257
+
258
+ // M3 -- grouping + aggregation
259
+ groupBy?: string | string[] | null, // default null (ungrouped fast path)
260
+ initialCollapsedGroups?: string[][], // e.g. [["Europe"], ["Asia","Books"]]
261
+ showGrandTotal?: boolean // default false
249
262
  });
250
263
  ```
251
264
 
@@ -285,6 +298,17 @@ interface ColumnDef {
285
298
  reorderable?: boolean; // default true
286
299
  accessor?: (row) => any; // default row[key]
287
300
  compare?: (a, b) => number; // default null-safe numeric/string
301
+
302
+ // M2
303
+ editable?: boolean; // opt-in for cell editing
304
+ filterable?: boolean; // opt-in for per-column filter
305
+ filter?: (value, query, row) => boolean; // custom filter predicate
306
+ filterPlaceholder?: string; // default "Filter…"
307
+
308
+ // M3
309
+ aggregate?: "sum" | "avg" | "min" | "max" | "count" // reducer for group + total cells
310
+ | ((rows, col) => any) | null;
311
+ aggregateFormat?: (value, col, count) => string; // display formatter (raw stays available)
288
312
  }
289
313
  ```
290
314
 
@@ -320,8 +344,9 @@ These are live signals. `column.width.set(160)` updates the CSS template, the st
320
344
  ```ts
321
345
  table.columns // ColumnState[]
322
346
  table.rowsGetter() // current row source
323
- table.visibleRows() // Computed<Row[]> -- sort applied
324
- table.rowCount() // Computed<number>
347
+ table.visibleRows() // Computed<Row[]> -- sort applied, data-only
348
+ table.rowCount() // Computed<number> -- data-only
349
+ table.filteredRows() // Computed<Row[]> -- post-filter, pre-sort (M2)
325
350
  table.columnOrder() // Signal<string[]>
326
351
  table.visibleColumns() // Computed<ColumnState[]>
327
352
  table.displayIndexByKey() // Computed<Map<key, 0-indexed display pos>>
@@ -331,9 +356,20 @@ table.contentWidth() // Computed<number> -- sum of widths
331
356
  table.leftOffsets() // Computed<Map<key, cumulative left px>>
332
357
  table.rightOffsets() // Computed<Map<key, cumulative right px>>
333
358
  table.sortChain() // Signal<SortEntry[]>
359
+ table.columnFilters() // Signal<ReadonlyMap<key, query>> (M2)
334
360
  table.focusedCell() // Signal<CellId | null>
335
- table.selection() // Signal<Set<RowId>>
361
+ table.selection() // Signal<{mode, set}> -- predicate, not a list
336
362
  table.selectionAnchor() // Signal<RowId | null>
363
+ table.selectedCount() // Computed<number> -- reactive O(1)
364
+ table.editingCell() // Signal<{rowId, columnKey} | null> (M2)
365
+ table.editingDraft() // Signal<string> (M2)
366
+
367
+ // M3: grouping + aggregation
368
+ table.groupBy() // Signal<string[]> -- current group keys ([] = ungrouped)
369
+ table.collapsedGroups() // Signal<Set<string>> -- path-strings (U+001F separator)
370
+ table.groupedRows() // Computed<GroupNode[] | null> -- null when ungrouped
371
+ table.visibleEntries() // Computed<Entry[]> -- data + group-headers + grand-total interleaved
372
+ table.entryCount() // Computed<number> -- length of visibleEntries; drives the axis
337
373
  ```
338
374
 
339
375
  All `()` calls are tracked reads -- `effect(() => log(table.rowCount()))` will re-run whenever the row source changes.
@@ -366,12 +402,451 @@ table.isSelected(rowId)
366
402
  table.moveFocus(direction) // "up" | "down" | "left" | "right" | "home" | "end" | "pageUp" | "pageDown"
367
403
  table.cellId(rowId, columnKey) // stable string id (matches DOM)
368
404
 
405
+ // Filters (M2)
406
+ table.setColumnFilter(key, value) // null/""/whitespace clears that column
407
+ table.clearColumnFilters() // clear all
408
+ table.columnFilters() // ReadonlyMap<string, string>
409
+ table.filteredRows() // computed: rows post-filter, pre-sort
410
+
411
+ // Editing (M2)
412
+ table.startEdit(rowId, columnKey) // no-op on non-editable columns
413
+ table.commitEdit() // reads editingDraft
414
+ table.commitEdit(explicitValue) // commit a specific value
415
+ table.cancelEdit() // discard; no onCellEdit call
416
+ table.isEditing(rowId, columnKey) // O(1) predicate
417
+ table.editingCell() // { rowId, columnKey } | null
418
+ table.editingDraft() // current in-progress string
419
+
420
+ // Export (M1.1)
421
+ table.exportCsv({ rows?, columns?, delimiter?, quote?, headers?, newline?, bom?, formatter? }) // → string
422
+ table.exportJson({ rows?, columns?, indent?, format?, formatter? }) // → string | object[]
423
+
424
+ // Grouping + aggregation (M3)
425
+ table.setGroupBy(v) // string | string[] | null; unknown keys dropped
426
+ table.toggleGroup(path) // flip collapsed for one group path
427
+ table.expandGroup(path) // no-op if already expanded
428
+ table.collapseGroup(path) // no-op if already collapsed
429
+ table.expandAllGroups() // clears collapsed set
430
+ table.collapseAllGroups() // walks groupedRows() once
431
+ table.isGroupCollapsed(path) // O(1) predicate
432
+ table.groupAncestryAt(entryIndex) // GroupHeaderEntry[] -- for sticky headers
433
+
369
434
  // Lifecycle
370
435
  table.dispose()
371
436
  ```
372
437
 
373
438
  ---
374
439
 
440
+ ## Export
441
+
442
+ `exportCsv` and `exportJson` materialize a row source into a string (or, for JSON, an array). Both methods take the same `rows` and `columns` selectors plus their own format-specific options.
443
+
444
+ ### `rows` source
445
+
446
+ | Value | Meaning |
447
+ | ------------- | -------------------------------------------------------------------------------- |
448
+ | `"visible"` | The current `visibleRows()`. Honors sort + the row source (post-pagination view). **Default.** |
449
+ | `"all"` | `rowsGetter()` -- the raw source you gave to `createTable`. If you gave a function (paginated source), this is the current page, NOT the master. See pitfall below. |
450
+ | `"selected"` | The current selection materialized against `rowsGetter()`. Same caveat as `"all"`. |
451
+ | `Array` | An explicit row array (e.g. a master array held externally). |
452
+
453
+ **Paginated-getter pitfall**: if `createTable({ rows: () => allRows.slice(...) })` is a paginated function, `"all"` and `"selected"` resolve against that function -- which returns only the current page. To export beyond the page, pass the master array explicitly:
454
+
455
+ ```js
456
+ table.exportCsv({ rows: allRows }); // entire master
457
+ table.exportCsv({ rows: table.selectedRows(allRows) }); // selected across master
458
+ ```
459
+
460
+ ### `columns` selector
461
+
462
+ | Value | Meaning |
463
+ | --------------- | ---------------------------------------------------------------- |
464
+ | `"visible"` | `visibleColumns()` -- honors hide state + current order. **Default.** |
465
+ | `"all"` | All declared columns in declaration order, including hidden. |
466
+ | `Array<string>` | Explicit projection by key. Order in the output matches array order. Unknown keys are silently dropped. |
467
+
468
+ ### `exportCsv` options
469
+
470
+ | Option | Type | Default | Notes |
471
+ | ------------ | -------------------------- | --------- | ---------------------------------------------- |
472
+ | `delimiter` | `string` | `","` | `"\t"` for TSV, `";"` for European regional |
473
+ | `quote` | `string` | `'"'` | Per RFC 4180; embedded quotes are doubled |
474
+ | `headers` | `boolean` | `true` | Emit header row |
475
+ | `newline` | `string` | `"\r\n"` | RFC 4180 says CRLF; `"\n"` works for most consumers |
476
+ | `bom` | `boolean` | `false` | Prepend UTF-8 BOM for Excel-on-Windows |
477
+ | `formatter` | `(row, col) => unknown` | - | Per-cell formatter, runs before CSV escaping |
478
+
479
+ CSV escaping follows RFC 4180: a field is quoted if it contains the delimiter, the quote character, CR, or LF. Embedded quotes are doubled. The column's `accessor` (if any) is honored.
480
+
481
+ ### `exportJson` options
482
+
483
+ | Option | Type | Default | Notes |
484
+ | ----------- | -------------------------- | ------------ | ---------------------------------------------------------------- |
485
+ | `indent` | `number` | `0` | `JSON.stringify` indent; 0 = single-line compact |
486
+ | `format` | `"string"` \| `"array"` | `"string"` | `"array"` skips JSON.stringify and returns the projected array |
487
+ | `formatter` | `(row, col) => unknown` | - | Per-cell formatter |
488
+
489
+ Fast path: `exportJson({ columns: "all", format: "array" })` with no formatter returns a shallow `rows.slice()` -- the row objects themselves, not copies. Use this when piping into IndexedDB / postMessage / structured clone.
490
+
491
+ ### Triggering a browser download
492
+
493
+ The methods return strings; the consumer handles the download:
494
+
495
+ ```js
496
+ function downloadFile(text, filename, mime) {
497
+ const blob = new Blob([text], { type: mime });
498
+ const url = URL.createObjectURL(blob);
499
+ const a = document.createElement("a");
500
+ a.href = url;
501
+ a.download = filename;
502
+ document.body.appendChild(a);
503
+ a.click();
504
+ document.body.removeChild(a);
505
+ URL.revokeObjectURL(url);
506
+ }
507
+
508
+ downloadFile(table.exportCsv({ bom: true }), "data.csv", "text/csv;charset=utf-8");
509
+ ```
510
+
511
+ The BOM (`bom: true`) is the difference between Excel opening your file as UTF-8 vs garbling non-ASCII characters on Windows.
512
+
513
+ ---
514
+
515
+ ## Cell editing
516
+
517
+ Opt-in per column via `editable: true`. When set, double-click (or `F2` / `Enter` on the focused cell) puts the cell into `contenteditable` mode with its current value pre-selected. Enter commits, Tab commits + moves to the next cell, Escape cancels.
518
+
519
+ ```js
520
+ const table = createTable({
521
+ rows,
522
+ columns: [
523
+ { key: "id", width: 60 },
524
+ { key: "name", editable: true },
525
+ { key: "email", editable: true },
526
+ { key: "joined" }, // not editable -- no double-click affordance
527
+ ],
528
+ getRowId: r => r.id,
529
+ onCellEdit: ({ row, columnKey, oldValue, newValue }) => {
530
+ // Mutate the row, send to backend, dispatch to a store, whatever.
531
+ // lite-table does NOT mutate the row for you.
532
+ row[columnKey] = newValue;
533
+ },
534
+ });
535
+
536
+ mountTable(host, table);
537
+ ```
538
+
539
+ ### Commit semantics
540
+
541
+ - **Enter** commits + moves focus to the row below (spreadsheet idiom)
542
+ - **Tab** / **Shift+Tab** commit + move focus right / left
543
+ - **Blur** (clicking outside, focusing another cell) commits
544
+ - **Escape** cancels: edit state cleared, no `onCellEdit` call
545
+ - **A second `startEdit` on a different cell** auto-commits the first
546
+ - **A `startEdit` on the same cell** is a no-op (does not re-seed the draft)
547
+ - `commitEdit` skips `onCellEdit` when the new value equals the old value (string-coerced: see below), so accidental Enter-without-typing is free even on numeric / typed columns
548
+
549
+ The `onCellEdit` handler is called with `{ row, columnKey, oldValue, newValue }`. The table does **not** mutate the row -- the handler is the consumer's hook to write somewhere (the row, a backend, a store). Throwing from the handler is caught + logged; subsequent edits work normally.
550
+
551
+ ### `newValue` is always a string
552
+
553
+ When the edit comes from the DOM (double-click → contenteditable → Enter / Tab / blur), `newValue` is whatever the user typed: **always a string**. lite-table doesn't try to guess that "100" should be a number or "true" should be a boolean -- that's the consumer's call.
554
+
555
+ For non-string columns, coerce inside your handler:
556
+
557
+ ```js
558
+ onCellEdit: ({ row, columnKey, newValue }) => {
559
+ if (columnKey === "count") row.count = Number(newValue);
560
+ else if (columnKey === "active") row.active = newValue === "true";
561
+ else if (columnKey === "due") row.due = new Date(newValue);
562
+ else row[columnKey] = newValue;
563
+ },
564
+ ```
565
+
566
+ The **unchanged-guard** compares `String(oldValue) !== newValue` for string `newValue`s (the common case from the DOM), so pressing Enter on a numeric column without typing doesn't fire your handler -- `100` and `"100"` aren't strict-equal but they're "unchanged" from the user's perspective. When you call `commitEdit(explicitValue)` with a non-string explicit value, the guard falls back to strict equality so you have predictable control.
567
+
568
+ ### Reactive surface
569
+
570
+ ```js
571
+ table.editingCell() // { rowId, columnKey } | null
572
+ table.editingDraft() // current in-progress string
573
+ table.isEditing(rowId, columnKey) // O(1) predicate
574
+
575
+ table.startEdit(rowId, columnKey)
576
+ table.commitEdit() // reads editingDraft
577
+ table.commitEdit("explicit value")
578
+ table.cancelEdit()
579
+ ```
580
+
581
+ ### Programmatic editing
582
+
583
+ Skip the double-click affordance entirely if you want -- `startEdit` is the only entry point you need. Use it for "edit on selection", per-row action menus, or hotkey-triggered batch edits:
584
+
585
+ ```js
586
+ // "Edit name on selected row" toolbar button:
587
+ editNameBtn.addEventListener("click", () => {
588
+ const f = table.focusedCell();
589
+ if (f) table.startEdit(f.rowId, "name");
590
+ });
591
+ ```
592
+
593
+ ### Editing + reactive row sources
594
+
595
+ If your `rows` is a function (paginated, filtered, etc.), the edited row may scroll out of view mid-edit. The edit state is keyed on `rowId`, so:
596
+
597
+ - The slot DOM gets recycled to show a different row; `contenteditable` is removed from the recycled cell automatically.
598
+ - The `editingCell` signal stays set, pointing at the row that's no longer visible.
599
+ - When that row scrolls back into view, the cell becomes `contenteditable` again with the draft preserved.
600
+
601
+ This is the spreadsheet idiom too -- you can scroll while typing without losing your input. To force a commit, call `table.commitEdit()` from your scroll handler if you want stricter semantics.
602
+
603
+ ### Performance
604
+
605
+ Editable columns use one extra reactive effect per cell (the contenteditable management) and add two event listeners (input, keydown) plus two more on dblclick and blur. The text effect on editable cells skips its `textContent` write while the cell is the active edit target, so user keystrokes don't fight a reactive paint. Non-editable columns pay nothing -- the editing machinery is gated on `col.editable` and never attaches.
606
+
607
+ ---
608
+
609
+ ## Per-column filtering
610
+
611
+ Opt-in per column via `filterable: true`. A filter row appears between the header and the viewport, with one `<input>` per filterable column. The default predicate is case-insensitive substring match on the stringified cell value (after the column's `accessor` runs); pass a custom `filter` for richer semantics.
612
+
613
+ ```js
614
+ const table = createTable({
615
+ rows,
616
+ columns: [
617
+ { key: "id", width: 70 },
618
+ { key: "name", filterable: true }, // default substring
619
+ { key: "email", filterable: true, filterPlaceholder: "name@domain" },
620
+ { key: "role", filterable: true, filterPlaceholder: "engineer / pm / …" },
621
+ { key: "salary", filterable: true,
622
+ // ">N" / "<N" / "N" exact / substring fallback
623
+ filter: (v, q) => {
624
+ if (q.startsWith(">")) {
625
+ const n = Number(q.slice(1));
626
+ return Number.isFinite(n) && v > n;
627
+ }
628
+ if (q.startsWith("<")) {
629
+ const n = Number(q.slice(1));
630
+ return Number.isFinite(n) && v < n;
631
+ }
632
+ const n = Number(q);
633
+ if (Number.isFinite(n)) return v === n;
634
+ return String(v).indexOf(q) >= 0;
635
+ },
636
+ filterPlaceholder: ">100k" },
637
+ ],
638
+ getRowId: r => r.id,
639
+ });
640
+ ```
641
+
642
+ ### Predicate contract
643
+
644
+ ```ts
645
+ (value: unknown, query: string, row: Row) => boolean
646
+ ```
647
+
648
+ - `value` is the column's value (post-`accessor`)
649
+ - `query` is the trimmed filter input. Empty / whitespace-only queries are treated as "no filter" and your predicate is not invoked for them
650
+ - `row` is the full row object -- handy for cross-field filters (e.g., "show rows where `firstName + lastName` matches")
651
+
652
+ Filters from multiple columns AND together. A row must pass every active filter to remain visible.
653
+
654
+ ### Filter order in the pipeline
655
+
656
+ ```
657
+ rowsGetter() → filteredRows → visibleRows → exports / mount / etc.
658
+ ▲ ▲
659
+ │ └─ sort applied here
660
+ └─ filters applied here
661
+ ```
662
+
663
+ This means **export of `rows: "visible"` is already filtered + sorted**, which is what you want for "export what the user sees". For "export the master regardless of filters/sort", use `rows: "all"` or pass the master array explicitly.
664
+
665
+ ### Reactive surface
666
+
667
+ ```js
668
+ table.columnFilters() // ReadonlyMap<string, string>
669
+ table.filteredRows() // computed: rows post-filter, pre-sort
670
+ table.setColumnFilter("role", "eng") // set
671
+ table.setColumnFilter("role", "") // clear that column
672
+ table.setColumnFilter("role", null) // also clears
673
+ table.clearColumnFilters() // clear all
674
+ ```
675
+
676
+ The filter row's inputs are bound two-way to `columnFilters`. If you mutate the signal programmatically (e.g., to restore filter state from a URL), the inputs update automatically.
677
+
678
+ ### Keyboard
679
+
680
+ - **Escape** on a filter input clears that column's filter (the input clears too)
681
+ - The filter row is part of the focusable tab order; Tab moves to the next filter input or to the next focusable element after the row
682
+
683
+ ### Hiding the filter row
684
+
685
+ The filter row is only mounted if at least one declared column has `filterable: true`. To temporarily hide it without un-mounting, hide all filterable columns (`setColumnHidden`); their filter cells go to `display: none` with the rest of the column.
686
+
687
+ ### Performance
688
+
689
+ Filtering is O(N) over the source rows on every filter change, executed inside a single computed. With 5000 rows + a handful of filterable columns, this runs in under a millisecond in our demo. The filtered array is a fresh allocation per change (Object.is inequality is required to notify the sort + selection downstream); for million-row data sources you typically want backend filtering anyway.
690
+
691
+ The fast path returns the source array identity when no filter is active, so just enabling `filterable: true` on columns costs nothing until the user types something.
692
+
693
+ ---
694
+
695
+ ## Grouping and aggregation
696
+
697
+ Group rows by one or more column keys and fold per-group aggregates. Group headers are pool slots like any other row -- same reactive rendering path, same zero-GC scroll math. There are no extra DOM primitives: a group header IS a `.lt-row` with a discriminator class.
698
+
699
+ ```js
700
+ import { createTable, mountTable } from "@zakkster/lite-table";
701
+
702
+ const table = createTable({
703
+ rows,
704
+ columns: [
705
+ { key: "id", width: 80 },
706
+ { key: "region", width: 120 },
707
+ { key: "status", width: 120 },
708
+ { key: "value", width: 140, compare: (a, b) => a - b,
709
+ aggregate: "sum",
710
+ aggregateFormat: (v) => "$" + v.toFixed(0) }
711
+ ],
712
+ getRowId: r => r.id,
713
+ groupBy: ["region", "status"], // multi-level
714
+ showGrandTotal: true // pinned "Total (N)" entry at tail
715
+ });
716
+ mountTable(host, table);
717
+ ```
718
+
719
+ Clicking a group header row toggles its collapse; a chevron in the first cell flips between `▼` and `▶`. Aggregates render in the columns that declared an `aggregate` -- other columns stay blank on header rows.
720
+
721
+ ### The pipeline
722
+
723
+ ```
724
+ rowsGetter -> filteredRows -> groupedRows -> visibleEntries -> visibleRows
725
+ │ │
726
+ │ └── pool slots + sticky headers
727
+ └── sort applies WITHIN each leaf group
728
+ ```
729
+
730
+ - Filter runs BEFORE grouping. Empty groups vanish -- no dead headers.
731
+ - Sort chain applies WITHIN leaves. Groups themselves are ordered by group key ascending; `null` values bucket last.
732
+ - Aggregates always fold over leaf rows at every depth. That's the safe default for reducers that don't compose (median, last-value-wins, `distinct-count`). Sum/avg/min/max/count don't care; you can supply a custom reducer if you have one that needs different semantics.
733
+
734
+ ### Column config
735
+
736
+ | Field | Type | Purpose |
737
+ | ----------------- | ------------------------------------------ | --------------------------------------------------------------------------- |
738
+ | `aggregate` | `"sum" \| "avg" \| "min" \| "max" \| "count"` or `(rows, col) => any` | The reducer. Nullish values are skipped for `sum`/`avg`/`min`/`max`; `count` counts rows unconditionally. Custom function receives the leaf-row array. |
739
+ | `aggregateFormat` | `(value, col, count) => string` | Display formatter for header + grand-total cells. `entry.aggregates.get(key)` still returns the raw so exports stay authoritative. Errors caught + logged. |
740
+
741
+ ### createTable config
742
+
743
+ | Field | Type | Default |
744
+ | ------------------------ | ----------------------------------- | ------- |
745
+ | `groupBy` | `string \| string[] \| null` | `null` |
746
+ | `initialCollapsedGroups` | `string[][]` | `[]` |
747
+ | `showGrandTotal` | `boolean` | `false` |
748
+
749
+ Unknown keys in `groupBy` are silently dropped, so persisted state that outlives a column config survives without crashes.
750
+
751
+ ### Reactive surface
752
+
753
+ ```js
754
+ table.groupBy() // Signal<string[]>
755
+ table.collapsedGroups() // Signal<Set<string>> — path strings
756
+ table.groupedRows() // Computed<GroupNode[] | null> — null when ungrouped
757
+ table.visibleEntries() // Computed<Entry[]> — interleaved for rendering
758
+ table.entryCount() // Computed<number> — drives the virtual axis
759
+ table.visibleRows() // Computed<Row[]> — data-only; unchanged 1.1.0 contract
760
+ table.rowCount() // Computed<number> — data-only; unchanged
761
+ ```
762
+
763
+ An `Entry` is one of:
764
+
765
+ ```
766
+ { type: "data", row }
767
+ { type: "group-header", depth, key, value, path, pathStr, count, aggregates, isCollapsed }
768
+ { type: "grand-total", aggregates, count }
769
+ ```
770
+
771
+ `path` is the array of ancestor group values (e.g., `["Europe", "Books"]`); `pathStr` is the U+001F-separated form used as the collapsed-set key. The mount layer dispatches per-slot rendering on `entry.type`; consumers driving their own renderer do the same.
772
+
773
+ ### Methods
774
+
775
+ ```js
776
+ table.setGroupBy(key | keys | null) // idempotent; unknown keys dropped
777
+ table.toggleGroup(path)
778
+ table.expandGroup(path)
779
+ table.collapseGroup(path)
780
+ table.expandAllGroups()
781
+ table.collapseAllGroups() // walks groupedRows() once
782
+ table.isGroupCollapsed(path) // O(1) predicate
783
+ table.groupAncestryAt(entryIndex) // for sticky group headers
784
+ ```
785
+
786
+ ### Ungrouped fast path
787
+
788
+ When `groupBy()` is empty, `groupedRows` short-circuits to `null` and `visibleRows` returns the sort-applied rows directly -- byte-identical to 1.1.0 behavior. Non-grouping tables pay one signal read and nothing else.
789
+
790
+ ### Sticky group headers + grand total
791
+
792
+ Both are built in. Sticky group headers glue below the column header and reactively track the active ancestor group at every depth level as the user scrolls -- click one to collapse its subtree. Sticky grand total glues to the bottom of the viewport whenever `showGrandTotal: true`.
793
+
794
+ Under the hood, both are `position: sticky` zero-height containers with absolute-positioned rows -- they don't reserve scroll space and don't interfere with pool math. They render only when needed (no sticky container when ungrouped; no footer when grand total isn't configured). Nothing to opt into and nothing to opt out of.
795
+
796
+ The sticky ancestor lookup uses `axis.firstIndex()` (not `axis.start()`) so sticky matches what's under the column-header line -- `start` includes overscan slots above the viewport and would show ancestors of a not-yet-visible entry.
797
+
798
+ If you need a custom sticky design (e.g. a breadcrumb variant, a different offset for an app-level top bar), hide the built-ins with `display: none` on `.lt-sticky-groups` / `.lt-sticky-grand-total` and rebuild the same in ~20 lines using the exposed primitive:
799
+
800
+ ```js
801
+ import { effect } from "@zakkster/lite-signal";
802
+
803
+ effect(() => {
804
+ const ancestors = table.groupAncestryAt(mount.axis.firstIndex());
805
+ // render `ancestors` however you like -- click each to toggleGroup(a.path)
806
+ });
807
+ ```
808
+
809
+ ### Rendered DOM
810
+
811
+ Group headers render as:
812
+
813
+ ```html
814
+ <div class="lt-row lt-row-group-header" data-depth="N" data-collapsed="true|false">
815
+ <div class="lt-cell" data-key="…">▼ Asia (100)</div>
816
+ <div class="lt-cell" data-key="…"></div>
817
+ <div class="lt-cell" data-key="…">$5,092</div>
818
+ ...
819
+ </div>
820
+ ```
821
+
822
+ One cell per column matching the data-row grid template. The first visible cell holds the chevron + value + count; other cells hold their column's aggregate (or empty if the column has no aggregate). Aggregate cells right-align with tabular-numerics for column alignment across headers.
823
+
824
+ Grand-total rows use `<div class="lt-row lt-row-grand-total">` with the same cell structure; the first cell reads `Total (N)`.
825
+
826
+ Sticky group headers live in `<div class="lt-sticky-groups">` -- a direct child of `.lt-viewport`, before `.lt-inner`. Each depth's row also carries `.lt-sticky-group` so you can restyle them independently of pool-rendered headers. Sticky grand total lives in `<div class="lt-sticky-grand-total">` (after `.lt-inner`) with a `.lt-sticky-grand-total-row` inside. Sticky rows do NOT carry the base `.lt-row` class so `querySelectorAll(".lt-row")` still counts only pool slots.
827
+
828
+ ### Interaction
829
+
830
+ - **Click on a group-header cell** (inline or sticky) toggles the group's collapse.
831
+ - **Click on a grand-total row** is ignored (no selection change, no side effect).
832
+ - **Selection / edit / focus** are gated on `entry.type === "data"`. Clicking a header doesn't clear the row selection, editable cells stay non-editing on headers, and arrow-key focus moves stay inside data rows.
833
+
834
+ ### Performance notes
835
+
836
+ - Grouping is O(N) per rebuild (walk + bucket + per-leaf sort). Rebuilds fire on `filteredRows()`, `sortChain()`, or `groupBy()` changes -- never on scroll.
837
+ - Aggregates fold once per rebuild. For deep trees this dominates; a 100k-row / 3-level table with 4 aggregates folds in a few ms.
838
+ - Scroll cost stays at 1 transform write per pool slot per boundary cross, unchanged from 1.1.0. `axis.setCount(entryCount())` bumps when collapse changes -- boundary math re-derives on the next frame.
839
+ - Signal-node overhead: adding `aggregate` to a column costs one entry in a Map. Adding `groupBy` costs one extra computed per slot (`slotEntry`) beyond the 1.1.0 per-cell effects. For the recommended 6-col / 24-slot table, expect ~950 nodes with aggregates + grouping enabled -- still under the default 1024 registry cap. Bump the cap explicitly for larger footprints:
840
+
841
+ ```js
842
+ import { createRegistry, setDefaultRegistry } from "@zakkster/lite-signal";
843
+ setDefaultRegistry(createRegistry({ initialNodes: 4096 }));
844
+ ```
845
+
846
+ Verified zero-GC: 10,000 boundary scrolls across a 100,000-row grouped view with sticky overlays active produces signal-node delta 0, pool delta 0. See `bench/03-heap-grouped.js`.
847
+
848
+ ---
849
+
375
850
  ## Pinning and sticky offsets
376
851
 
377
852
  Pinned columns use `position: sticky` with cumulative offsets. The math is:
@@ -542,13 +1017,14 @@ setDefaultRegistry(createRegistry({
542
1017
 
543
1018
  ## Benchmarks
544
1019
 
545
- Four benchmarks, all in `bench/`:
1020
+ Five benchmarks, all in `bench/`:
546
1021
 
547
1022
  | Bench | Measures |
548
1023
  | ---------------------------------- | ----------------------------------------------------------- |
549
1024
  | `01-scroll-writes.js` | DOM allocations vs in-place updates per scroll boundary cross, against clusterize.js + a naive virtual implementation |
550
1025
  | `02-mount.js` | Time to first paint at 1k / 10k / 100k / 1M rows |
551
- | `03-heap.js` | Steady-state heap delta + signal-node growth across 10k boundary scrolls |
1026
+ | `03-heap.js` | Steady-state heap delta + signal-node growth across 10k boundary scrolls (ungrouped) |
1027
+ | `03-heap-grouped.js` | Same shape as `03-heap.js` but with `groupBy: "status"` + `showGrandTotal: true` + sticky overlays active |
552
1028
  | `04-sort.js` | 100k-row sort + cached re-read + toggle cycle |
553
1029
 
554
1030
  Representative numbers on a 2016 MacBook (your hardware will be different; relative ordering is what matters):
@@ -594,7 +1070,7 @@ Two tiers, all reproducible.
594
1070
 
595
1071
  ### Tier 1 -- Behavior (unit tests, fast)
596
1072
 
597
- `npm test` runs the suite in `test/`, 110 tests across 9 files:
1073
+ `npm test` runs the suite in `test/`, **257 tests across 14 files**:
598
1074
 
599
1075
  - **`core.test.js`** -- `createTable` API surface, reactive `visibleColumns`, `colTemplate`, `colPlacement` consistency under hide/pin/reorder, sort chain semantics, dispose idempotency.
600
1076
  - **`dom.test.js`** -- `mountTable` produces correct DOM structure, header cells, slot pool sized to viewport, ARIA roles + indices, `aria-activedescendant` updates on focus moves, dispose tears down all bindings.
@@ -605,6 +1081,11 @@ Two tiers, all reproducible.
605
1081
  - **`columns.test.js`** -- `setColumnWidth` clamping, `setColumnHidden`, `setColumnPin`, `setColumnFlex` and the **pinning-suspends-flex** invariant (regression for the offset-vs-rendered-width mismatch), `moveColumn` cases, `colTemplate` segment count consistency with `colPlacement`.
606
1082
  - **`keyboard.test.js`** -- `moveFocus` arrow / Home / End / PageUp / PageDown, focus clamps at edges, focus survives row reorder.
607
1083
  - **`extras.test.js`** -- `scrollToIndex` × 3 align modes, pointer-driven column resize + reorder, `injectStyles:false`, mount-disposes-table lifecycle, null / zero / string-ID cells, `addSort(null)` removal, `moveFocus` from null focus, 50-column stress, steady-state graph stability under 1000 sort flips + 1000 selection toggles + 500 resize ops.
1084
+ - **`export.test.js`** -- RFC-4180 escaping, all option combinations, edge cases including 10k-row dataset finishing < 500ms (M1.1).
1085
+ - **`scope-dispose.test.js`** -- the M1.1 dispose-leak fix, dispose idempotency, 50-cycle round trip.
1086
+ - **`m2.test.js`** -- filtering basics, case insensitivity, multi-column AND, custom predicates, predicate receiving the full row, filter+sort interaction, filter+export, null/undefined handling, edit state transitions, all startEdit/commitEdit/cancelEdit semantics, onCellEdit handler error containment, edit+filter interaction, edit+dispose, numeric column unchanged-guard.
1087
+ - **`grouping.test.js`** (new in 1.2.0) -- config parsing, tree structure (single- and multi-level), aggregate types with null handling and custom-function support, leaf-fold correctness, `visibleEntries` interleave, backwards compat with the ungrouped fast path, sort within groups, filter-then-group interaction, collapse/expand/all, grand-total stability across collapse, `groupAncestryAt`, reactive propagation via signal-backed rows, edge cases.
1088
+ - **`grouping.dom.test.js`** (new in 1.2.0) -- row classes, first-cell chevron+count, aggregate cell rendering, click-to-toggle, click preserves selection, grand-total class + content, chevron flip via `data-collapsed`, `entryCount` drives virtual axis, data-row selection interleaved with headers, collapse hides data rows immediately, dispose cleanup, sticky containers in the correct viewport slots, sticky hidden when ungrouped, sticky grand-total hidden when unconfigured, hiding the first column shifts chevron to the next, column reorder keeps sticky + pool aligned, filter-row + grouping coexist.
608
1089
 
609
1090
  ```bash
610
1091
  npm test
@@ -612,19 +1093,20 @@ npm test
612
1093
 
613
1094
  ### Tier 2 -- Memory (zero-GC verification)
614
1095
 
615
- The `03-heap.js` bench is the production-style memory invariant: 10,000 boundary scrolls, 100,000 rows, must show **zero signal-node growth, zero link growth, zero pool growth**, and a heap delta inside V8's noise floor.
1096
+ The `03-heap.js` bench is the production-style memory invariant: 10,000 boundary scrolls, 100,000 rows, must show **zero signal-node growth, zero link growth, zero pool growth**, and a heap delta inside V8's noise floor. `03-heap-grouped.js` is the same invariant for a grouped view with sticky overlays active.
616
1097
 
617
1098
  ```bash
618
1099
  node --expose-gc bench/03-heap.js
1100
+ node --expose-gc bench/03-heap-grouped.js
619
1101
  ```
620
1102
 
621
- If this fails, something allocates in the hot scroll path and we want to find it before publish.
1103
+ If either fails, something allocates in the hot scroll path and we want to find it before publish.
622
1104
 
623
1105
  ---
624
1106
 
625
1107
  ## What this is not
626
1108
 
627
- - **A general-purpose grid component.** No cell editing yet, no row groups, no aggregations, no in-place pagination. The headless core makes those buildable on top, but they're not in the box.
1109
+ - **A general-purpose grid component.** No in-place pagination. The headless core makes it buildable on top, but it's not in the box.
628
1110
  - **A perfect fit for every workload.** For a 50-row, no-virtualization-needed table, the slot pool is over-engineering -- a plain `<table>` is simpler and just as fast. `lite-table` shines starting at ~1,000 rows or when the columns and rows are independently reactive.
629
1111
  - **A renderer.** It owns the DOM topology and the bindings between reactive sources and DOM properties. It does not own your row data, your filtering pipeline, or your data fetching.
630
1112
  - **A library for the server.** It works in Node under `happy-dom` for tests, but there's no SSR story. Use it on the client.
@@ -643,7 +1125,7 @@ Built on the `@zakkster/lite-*` zero-GC ESM family. All MIT.
643
1125
  **Helpers**
644
1126
  - [`@zakkster/lite-persist`](https://www.npmjs.com/package/@zakkster/lite-persist) -- drop-in localStorage / IndexedDB / file persistence for `Signal` and `Computed`. Use it for column-state, sort, and selection persistence across sessions.
645
1127
 
646
- The package tree is intentionally small. `lite-table` itself is a single file ESM module (~1300 lines including types-in-JSDoc), zero runtime dependencies beyond the three substrate packages.
1128
+ The package tree is intentionally small. `lite-table` itself is a single file ESM module (~3100 lines including types-in-JSDoc and inline design commentary), zero runtime dependencies beyond the three substrate packages.
647
1129
 
648
1130
  ---
649
1131
 
@@ -725,6 +1207,68 @@ async function loadPage(n) {
725
1207
 
726
1208
  </details>
727
1209
 
1210
+ <details>
1211
+ <summary>Client-side pagination via a reactive row source (M1.1 pattern).</summary>
1212
+
1213
+ The reactive `rows: () => ...` getter form makes pagination effectively free: the page index and page size are signals, and `visibleRows` derives from both. Changing either signal triggers `visibleRows` to recompute the slice -- the slot pool's text effects re-pull, and the DOM topology stays unchanged.
1214
+
1215
+ ```js
1216
+ import { signal, computed } from "@zakkster/lite-signal";
1217
+ import { createTable, mountTable } from "@zakkster/lite-table";
1218
+
1219
+ const allRows = await fetchEverything(); // your master array
1220
+
1221
+ const pageSize = signal(25);
1222
+ const pageIndex = signal(0); // 0-based
1223
+
1224
+ const pageCount = computed(() => {
1225
+ const sz = pageSize();
1226
+ return sz === 0 ? 1 : Math.max(1, Math.ceil(allRows.length / sz));
1227
+ });
1228
+
1229
+ const table = createTable({
1230
+ rows: () => {
1231
+ const sz = pageSize();
1232
+ if (sz === 0) return allRows;
1233
+ const start = pageIndex() * sz;
1234
+ return allRows.slice(start, start + sz);
1235
+ },
1236
+ columns: COLS,
1237
+ getRowId: r => r.id,
1238
+ });
1239
+
1240
+ mountTable(host, table);
1241
+
1242
+ // Page-size dropdown
1243
+ pageSizeSelectEl.addEventListener("change", (e) => {
1244
+ pageSize.set(Number(e.target.value));
1245
+ pageIndex.set(0); // reset to first page when size changes
1246
+ });
1247
+
1248
+ // First / prev / next / last
1249
+ firstBtn.addEventListener("click", () => pageIndex.set(0));
1250
+ prevBtn.addEventListener("click", () => pageIndex.set(Math.max(0, pageIndex() - 1)));
1251
+ nextBtn.addEventListener("click", () => pageIndex.set(Math.min(pageCount() - 1, pageIndex() + 1)));
1252
+ lastBtn.addEventListener("click", () => pageIndex.set(pageCount() - 1));
1253
+ ```
1254
+
1255
+ The slot pool isn't recreated when the page changes -- the same 24 row elements re-bind to the new slice. A `setPageSize(100)` after browsing to page 50 just sets two signals and the next paint is the new page.
1256
+
1257
+ **Exporting a paginated table**: because `rows: "all"` and `rows: "selected"` resolve against `rowsGetter()` (which IS your paginated function), those selectors give you the current page only. To export across the master, pass the master array explicitly:
1258
+
1259
+ ```js
1260
+ // Just the current page (what the user sees):
1261
+ table.exportCsv(); // rows: "visible"
1262
+
1263
+ // All 5000 rows (the master):
1264
+ table.exportCsv({ rows: allRows });
1265
+
1266
+ // All rows the user picked (Select-All across all pages):
1267
+ table.exportCsv({ rows: table.selectedRows(allRows) });
1268
+ ```
1269
+
1270
+ </details>
1271
+
728
1272
  ---
729
1273
 
730
1274
  ## FAQ
@@ -783,9 +1327,9 @@ Two patterns. Either render a checkbox character in the cell's `accessor` (`acce
783
1327
  ## npm scripts
784
1328
 
785
1329
  ```bash
786
- npm test # 110 tests across 9 files (node:test, --expose-gc)
1330
+ npm test # 257 tests across 14 files (node:test, --expose-gc)
787
1331
  npm run demo # zero-dep static server on http://localhost:8080
788
- npm run bench # all four benches sequentially (output: text or --md)
1332
+ npm run bench # all five benches sequentially (output: text or --md)
789
1333
  ```
790
1334
 
791
1335
  To run a single bench, invoke it directly:
@@ -794,6 +1338,7 @@ To run a single bench, invoke it directly:
794
1338
  node --expose-gc bench/01-scroll-writes.js
795
1339
  node --expose-gc bench/02-mount.js
796
1340
  node --expose-gc bench/03-heap.js
1341
+ node --expose-gc bench/03-heap-grouped.js
797
1342
  node --expose-gc bench/04-sort.js
798
1343
  ```
799
1344