@zakkster/lite-table 1.0.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 ADDED
@@ -0,0 +1,1586 @@
1
+ /**
2
+ * @zakkster/lite-table
3
+ * -------------------
4
+ * Headless reactive data tables on @zakkster/lite-signal.
5
+ *
6
+ * Architecture:
7
+ *
8
+ * - No <table>. CSS Grid layout. ARIA roles: grid / row / columnheader /
9
+ * gridcell. The root container is the only focusable element; logical
10
+ * focus is the `focusedCell` signal, and aria-activedescendant tracks it.
11
+ *
12
+ * - Position-keyed pool of slot rows. DOM topology never changes during
13
+ * scroll, sort, filter, hide, or reorder. The same slot DOM nodes carry
14
+ * different rows over time; their cells' bindings (text, id, gridColumn,
15
+ * sticky position) mutate. Sub-row scroll = zero DOM writes (Object.is
16
+ * cutoff on the integer-truncated start index).
17
+ *
18
+ * - Identity follows the row, not the slot. Every cell's id is reactively
19
+ * bound to `lt_<rowId>__<columnKey>`. Focus, selection, edit state, and
20
+ * aria-activedescendant are all keyed on row identity, which means they
21
+ * survive scroll, sort, filter, recycling -- everything except actually
22
+ * removing the row from the dataset (and even then, logical state is
23
+ * preserved and rehydrates on re-add).
24
+ *
25
+ * - Columns are reactive. Each column has signals for width, hidden, and
26
+ * pin side (left/none/right). The visible-columns ordering is a computed
27
+ * over a column-order signal. Reorder mutates the order array; resize
28
+ * mutates one width signal; hide flips one boolean; pin moves between
29
+ * buckets. All of these update visually without recreating any DOM.
30
+ *
31
+ * - Pinned columns are placed in the same single CSS Grid as the row, but
32
+ * get `position: sticky` plus a reactive `left` or `right` offset that
33
+ * equals the cumulative width of their bucket up to that column. No
34
+ * three-grid split, no nested containers -- one row, one grid.
35
+ *
36
+ * - Sort is a chain of `{key, dir}` entries. visibleRows applies a stable
37
+ * multi-key sort to the source. Headers click-to-sort, shift-click to
38
+ * chain.
39
+ *
40
+ * - Selection is a Set<rowId>. Range selection uses an anchor and the
41
+ * current visible row order. Selection mutates by row id; rendering
42
+ * uses bindClass to highlight the slot whose current row id is in the
43
+ * set.
44
+ *
45
+ * - Keyboard nav is a `keydown` listener on root. Arrow keys, Home/End,
46
+ * PgUp/PgDn move focus; Space toggles selection of focused row; Shift+
47
+ * click extends; Ctrl/Cmd+click toggles.
48
+ *
49
+ * Public surface (see Table.d.ts for canonical types):
50
+ *
51
+ * createTable(config) -> TableCore
52
+ * Headless reactive state. Renderer-agnostic. SSR-safe.
53
+ *
54
+ * mountTable(host, table, options?) -> TableMount
55
+ * Attaches the CSS Grid DOM and wires reactivity.
56
+ *
57
+ * @module @zakkster/lite-table
58
+ */
59
+
60
+ import {
61
+ signal, computed, effect, untrack,
62
+ dispose as disposeNode
63
+ } from "@zakkster/lite-signal";
64
+ import { virtualAxis } from "@zakkster/lite-virtual";
65
+ import { bindText, bindAttr, bindOn, bindClass } from "@zakkster/lite-signal-dom";
66
+
67
+ // =============================================================================
68
+ // 1. INTERNAL HELPERS
69
+ // =============================================================================
70
+
71
+ function asGetter(x) {
72
+ if (typeof x === "function") return x;
73
+ const v = x;
74
+ return () => v;
75
+ }
76
+
77
+ function makeCellId(rowId, columnKey) {
78
+ return "lt_" + rowId + "__" + columnKey;
79
+ }
80
+
81
+ function defaultCompare(a, b) {
82
+ if (a == null && b == null) return 0;
83
+ if (a == null) return -1;
84
+ if (b == null) return 1;
85
+ if (typeof a === "number" && typeof b === "number") return a - b;
86
+ const as = String(a), bs = String(b);
87
+ if (as < bs) return -1;
88
+ if (as > bs) return 1;
89
+ return 0;
90
+ }
91
+
92
+ /**
93
+ * Create a disposable scope. Tracks effects, listeners, signals, computeds,
94
+ * and arbitrary cleanups. `dispose()` runs everything in reverse order, once.
95
+ */
96
+ function createScope() {
97
+ const cleanups = [];
98
+ let disposed = false;
99
+ const scope = {
100
+ signal(initial, opts) {
101
+ const s = signal(initial, opts);
102
+ cleanups.push(s);
103
+ return s;
104
+ },
105
+ computed(fn, opts) {
106
+ const c = computed(fn, opts);
107
+ cleanups.push(c);
108
+ return c;
109
+ },
110
+ effect(fn) {
111
+ const d = effect(fn);
112
+ cleanups.push(d);
113
+ return d;
114
+ },
115
+ on(el, type, handler, opts) {
116
+ el.addEventListener(type, handler, opts);
117
+ const off = () => el.removeEventListener(type, handler, opts);
118
+ cleanups.push(off);
119
+ return off;
120
+ },
121
+ onCleanup(fn) {
122
+ cleanups.push(fn);
123
+ }
124
+ };
125
+ function dispose() {
126
+ if (disposed) return;
127
+ disposed = true;
128
+ for (let i = cleanups.length - 1; i >= 0; i--) {
129
+ const c = cleanups[i];
130
+ try {
131
+ if (typeof c === "function") c();
132
+ else disposeNode(c);
133
+ } catch (_e) { /* per-handler */ }
134
+ }
135
+ cleanups.length = 0;
136
+ }
137
+ return { scope, dispose };
138
+ }
139
+
140
+ // =============================================================================
141
+ // 2. COLUMN STATE
142
+ // =============================================================================
143
+
144
+ /**
145
+ * One ColumnState per user-declared column. Static fields (key, header,
146
+ * accessor, compare) come from config; the rest are signals so the user (or
147
+ * the resize/reorder/pin/hide interactions) can mutate them.
148
+ */
149
+ function createColumnState(def, scope, defaults) {
150
+ const width = scope.signal(def.width || defaults.columnWidth);
151
+ const hidden = scope.signal(def.hidden === true);
152
+ const pin = scope.signal(def.pin === "left" || def.pin === "right" ? def.pin : "none");
153
+ // flex = 0 (default): column has the exact width given by `width()`. The
154
+ // trailing 1fr filler in colTemplate absorbs any leftover viewport space.
155
+ // flex > 0: column gets `minmax(<minWidth>px, <flex>fr)` -- it can grow
156
+ // to share leftover space proportionally with other flex columns, with
157
+ // minWidth as a floor. When ANY column has flex > 0, the trailing 1fr is
158
+ // dropped (flex columns absorb the space instead).
159
+ const flex = scope.signal(typeof def.flex === "number" && def.flex > 0 ? def.flex : 0);
160
+ const minWidth = def.minWidth || 40;
161
+ const maxWidth = def.maxWidth || 1600;
162
+ return {
163
+ key: def.key,
164
+ header: def.header != null ? def.header : def.key,
165
+ accessor: typeof def.accessor === "function" ? def.accessor : null,
166
+ compare: typeof def.compare === "function" ? def.compare : defaultCompare,
167
+ sortable: def.sortable !== false,
168
+ resizable: def.resizable !== false,
169
+ pinnable: def.pinnable !== false,
170
+ hideable: def.hideable !== false,
171
+ reorderable: def.reorderable !== false,
172
+ minWidth,
173
+ maxWidth,
174
+ width,
175
+ hidden,
176
+ pin,
177
+ flex
178
+ };
179
+ }
180
+
181
+ /**
182
+ * Read a column's value for a row.
183
+ */
184
+ function readCell(col, row) {
185
+ if (row == null) return undefined;
186
+ return col.accessor ? col.accessor(row) : row[col.key];
187
+ }
188
+
189
+ // =============================================================================
190
+ // 3. HEADLESS CORE
191
+ // =============================================================================
192
+
193
+ /**
194
+ * Create the headless reactive state for a table. Renderer-agnostic and
195
+ * SSR-safe; mount it with {@link mountTable} or render it yourself.
196
+ *
197
+ * Everything reactive lives in lite-signal nodes -- `visibleRows`,
198
+ * `visibleColumns`, `colTemplate`, `sortChain`, `focusedCell`, `selection`,
199
+ * `selectedCount` are all live and observable. Mutations go through the
200
+ * imperative methods (`setSort`, `selectRow`, `setColumnWidth`, ...) which
201
+ * write into those signals. See `Table.d.ts` for the full type contract.
202
+ *
203
+ * @template Row
204
+ * @param {object} config
205
+ * @param {readonly Row[] | (() => readonly Row[])} config.rows
206
+ * Row source. A plain array is read once at mount; a signal getter or
207
+ * function is re-read on each `visibleRows` recompute -- when wrapping
208
+ * a signal, just pass the signal itself.
209
+ * @param {readonly object[]} config.columns
210
+ * Column definitions. Non-empty; see {@link ColumnDef}. The first
211
+ * column with `flex > 0` becomes the space absorber on resize.
212
+ * @param {(row: Row) => string|number} config.getRowId
213
+ * REQUIRED. Stable identity per row -- selection, focus, and cell IDs
214
+ * key off this. Returning the same id twice across the dataset breaks
215
+ * slot reconciliation and ARIA `aria-activedescendant`.
216
+ * @param {number} [config.rowHeight=32]
217
+ * @param {number} [config.overscan=4]
218
+ * Extra rows rendered above / below the viewport for smoother scroll.
219
+ * @param {{rowId: string|number, columnKey: string} | null} [config.initialFocus=null]
220
+ * @param {readonly {key: string, dir: "asc"|"desc"}[]} [config.initialSort=[]]
221
+ *
222
+ * @returns {object} TableCore -- the reactive state surface. See `Table.d.ts`.
223
+ * @throws {TypeError} If `getRowId` is missing, `columns` is empty, or
224
+ * `rowHeight <= 0`.
225
+ *
226
+ * @example
227
+ * const table = createTable({
228
+ * rows: [{ id: 1, name: "Ada" }, { id: 2, name: "Linus" }],
229
+ * columns: [
230
+ * { key: "id", header: "ID", width: 60 },
231
+ * { key: "name", header: "Name", width: 200, flex: 1 }
232
+ * ],
233
+ * getRowId: (r) => r.id
234
+ * });
235
+ * table.setSort("name", "asc");
236
+ * table.selectRow(1);
237
+ */
238
+ export function createTable(config) {
239
+ if (!config) throw new TypeError("lite-table: config required");
240
+ const {
241
+ rows,
242
+ columns: columnDefs,
243
+ getRowId,
244
+ rowHeight = 32,
245
+ overscan = 4,
246
+ initialFocus = null,
247
+ initialSort = []
248
+ } = config;
249
+
250
+ if (typeof getRowId !== "function") {
251
+ throw new TypeError("lite-table: getRowId is required and must be a function");
252
+ }
253
+ if (!Array.isArray(columnDefs) || columnDefs.length === 0) {
254
+ throw new TypeError("lite-table: columns must be a non-empty array");
255
+ }
256
+ if (!(rowHeight > 0)) {
257
+ throw new TypeError("lite-table: rowHeight must be > 0");
258
+ }
259
+
260
+ const rowsGetter = asGetter(rows);
261
+
262
+ const { scope, dispose } = createScope();
263
+
264
+ // --- Columns ---
265
+ const defaults = { columnWidth: 120 };
266
+ const columns = columnDefs.map((d) => createColumnState(d, scope, defaults));
267
+ const columnsByKey = new Map(columns.map((c) => [c.key, c]));
268
+
269
+ // Column display order is a Signal<string[]> of column keys. Reorder
270
+ // mutates this array; hidden columns stay in the order (just skip on
271
+ // render). Pin doesn't move them in this list -- pin is applied as a
272
+ // bucket pass below.
273
+ const columnOrder = scope.signal(columns.map((c) => c.key));
274
+
275
+ // Bucketed + filtered: returned in render order [left, none, right].
276
+ const visibleColumns = scope.computed(() => {
277
+ const order = columnOrder();
278
+ const left = [], none = [], right = [];
279
+ for (let i = 0; i < order.length; i++) {
280
+ const c = columnsByKey.get(order[i]);
281
+ if (!c || c.hidden()) continue;
282
+ const pinSide = c.pin();
283
+ if (pinSide === "left") left.push(c);
284
+ else if (pinSide === "right") right.push(c);
285
+ else none.push(c);
286
+ }
287
+ return left.concat(none, right);
288
+ });
289
+
290
+ // displayIndexByKey: 0-indexed position within visibleColumns (left+unpinned+right).
291
+ // Used externally to identify a column's logical visible position.
292
+ const displayIndexByKey = scope.computed(() => {
293
+ const m = new Map();
294
+ const cols = visibleColumns();
295
+ for (let i = 0; i < cols.length; i++) m.set(cols[i].key, i);
296
+ return m;
297
+ });
298
+
299
+ // colLayout: a single source of truth for grid-template-columns and the
300
+ // per-column grid-column placement map. Computing both from the same loop
301
+ // guarantees that placement[key] is consistent with the segment index in
302
+ // the template -- there's no way for a column to think it's at position 4
303
+ // while the template only has 3 tracks.
304
+ //
305
+ // Three regimes per column:
306
+ // - flex = 0 OR pinned: "<width>px" -- exact width.
307
+ // - flex > 0 AND unpinned: "minmax(<minWidth>px, <flex>fr)" -- shares
308
+ // leftover space proportionally.
309
+ //
310
+ // Pinned columns MUST render at exactly c.width() because leftOffsets /
311
+ // rightOffsets are pre-computed cumulative sums of c.width(). If a pinned
312
+ // column instead got an fr-distributed track, the rendered box width
313
+ // would no longer match the offset arithmetic and sticky-positioned
314
+ // cells would overlap each other (a 180px-wide-on-paper name pinned
315
+ // right at offset 450 would render as 357px in a 1500px viewport, but
316
+ // its sticky offset was still calculated as if it were 180px, so it
317
+ // would slide over the adjacent right-pinned email). Flex silently
318
+ // applies to unpinned columns only; pinning effectively suspends it,
319
+ // and unpinning brings it back.
320
+ //
321
+ // The trailing "1fr" filler is appended only when NO unpinned column
322
+ // has flex>0. With an unpinned flex column present, that column
323
+ // absorbs leftover space directly. The 1fr is inserted BEFORE the
324
+ // first right-pinned column so right-pinned cells sit flush against
325
+ // the right edge instead of leaving a gap.
326
+ const colLayout = scope.computed(() => {
327
+ const visible = visibleColumns();
328
+ let anyFlex = false;
329
+ for (const c of visible) {
330
+ // Only unpinned flex counts -- a pinned column's flex is
331
+ // suspended (see comment above).
332
+ if (c.flex() > 0 && c.pin() === "none") { anyFlex = true; break; }
333
+ }
334
+ const parts = [];
335
+ const placement = new Map();
336
+ let pos = 1;
337
+ let fillerInserted = false;
338
+ for (const c of visible) {
339
+ if (c.pin() === "right" && !fillerInserted && !anyFlex) {
340
+ parts.push("1fr");
341
+ pos++;
342
+ fillerInserted = true;
343
+ }
344
+ const useFlex = c.flex() > 0 && c.pin() === "none";
345
+ if (useFlex) {
346
+ parts.push("minmax(" + c.minWidth + "px, " + c.flex() + "fr)");
347
+ } else {
348
+ parts.push(c.width() + "px");
349
+ }
350
+ placement.set(c.key, pos);
351
+ pos++;
352
+ }
353
+ if (!fillerInserted && !anyFlex) parts.push("1fr");
354
+ return { template: parts.join(" "), placement };
355
+ });
356
+ const colTemplate = scope.computed(() => colLayout().template);
357
+ const colPlacement = scope.computed(() => colLayout().placement);
358
+
359
+ // Sum of visible column widths. Exposed as part of the public reactive
360
+ // API for consumers that need to know the natural width (e.g. for an
361
+ // off-screen ghost during drag). The layout no longer applies this as a
362
+ // min-width -- inner uses `width: max-content; min-width: 100%` instead,
363
+ // which lets the grid template size itself and only stretches to viewport
364
+ // when the viewport is wider than the natural content.
365
+ const contentWidth = scope.computed(() => {
366
+ let sum = 0;
367
+ for (const c of visibleColumns()) sum += c.width();
368
+ return sum;
369
+ });
370
+
371
+ // Cumulative left offsets for left-pinned columns (relative to their bucket).
372
+ const leftOffsets = scope.computed(() => {
373
+ const m = new Map();
374
+ let acc = 0;
375
+ for (const c of visibleColumns()) {
376
+ if (c.pin() !== "left") continue;
377
+ m.set(c.key, acc);
378
+ acc += c.width();
379
+ }
380
+ return m;
381
+ });
382
+ // Cumulative right offsets, walking right-to-left.
383
+ const rightOffsets = scope.computed(() => {
384
+ const m = new Map();
385
+ const right = visibleColumns().filter((c) => c.pin() === "right");
386
+ let acc = 0;
387
+ for (let i = right.length - 1; i >= 0; i--) {
388
+ m.set(right[i].key, acc);
389
+ acc += right[i].width();
390
+ }
391
+ return m;
392
+ });
393
+
394
+ // --- Sort ---
395
+ // Sort chain: list of {key, dir}. visibleRows applies it as a stable
396
+ // multi-key sort to the source rows.
397
+ const sortChain = scope.signal(
398
+ Array.isArray(initialSort)
399
+ ? initialSort.filter((s) => s && columnsByKey.has(s.key))
400
+ : []
401
+ );
402
+
403
+ // Sort buffer: a Uint32Array of indices, reused across sorts so a 100k-row
404
+ // sort allocates exactly one array (the output), not 100k tuple arrays. V8's
405
+ // Array.sort and TypedArray.sort are both stable per spec, but we also fall
406
+ // back to comparing original indices as a tie-breaker to guarantee
407
+ // stability across the multi-key chain regardless of engine.
408
+ let _sortIdxBuf = null;
409
+
410
+ const visibleRows = scope.computed(() => {
411
+ const src = rowsGetter();
412
+ const chain = sortChain();
413
+ if (!chain.length) return src;
414
+ const n = src.length;
415
+
416
+ if (!_sortIdxBuf || _sortIdxBuf.length < n) {
417
+ // Grow factor of 2 to amortize reallocation.
418
+ const cap = Math.max(n, _sortIdxBuf ? _sortIdxBuf.length * 2 : 1024);
419
+ _sortIdxBuf = new Uint32Array(cap);
420
+ }
421
+ const view = _sortIdxBuf.subarray(0, n);
422
+ for (let i = 0; i < n; i++) view[i] = i;
423
+
424
+ view.sort((iA, iB) => {
425
+ const rowA = src[iA], rowB = src[iB];
426
+ for (let k = 0; k < chain.length; k++) {
427
+ const entry = chain[k];
428
+ const col = columnsByKey.get(entry.key);
429
+ if (!col) continue;
430
+ const av = readCell(col, rowA);
431
+ const bv = readCell(col, rowB);
432
+ const c = col.compare(av, bv);
433
+ if (c !== 0) return entry.dir === "desc" ? -c : c;
434
+ }
435
+ return iA - iB;
436
+ });
437
+
438
+ // Single output allocation: an array of N row references (not row copies).
439
+ // Returning a fresh array each time preserves Object.is inequality so
440
+ // downstream computeds re-evaluate. Reusing this array across calls
441
+ // would silently break consumers that hold the prior reference.
442
+ const out = new Array(n);
443
+ for (let i = 0; i < n; i++) out[i] = src[view[i]];
444
+ return out;
445
+ });
446
+
447
+ const rowCount = scope.computed(() => visibleRows().length);
448
+
449
+ function toggleSort(key, opts) {
450
+ const col = columnsByKey.get(key);
451
+ if (!col || !col.sortable) return;
452
+ const chain = sortChain();
453
+ const additive = opts && opts.additive === true;
454
+ 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;
459
+ if (additive) {
460
+ const next = chain.slice();
461
+ if (existing < 0) {
462
+ next.push({ key, dir: nextDir });
463
+ } else if (nextDir == null) {
464
+ next.splice(existing, 1);
465
+ } else {
466
+ next[existing] = { key, dir: nextDir };
467
+ }
468
+ sortChain.set(next);
469
+ } else {
470
+ sortChain.set(nextDir == null ? [] : [{ key, dir: nextDir }]);
471
+ }
472
+ }
473
+
474
+ function setSort(key, dir) {
475
+ if (!columnsByKey.has(key)) return;
476
+ if (dir == null) { sortChain.set([]); return; }
477
+ sortChain.set([{ key, dir }]);
478
+ }
479
+ function addSort(key, dir) {
480
+ if (!columnsByKey.has(key)) return;
481
+ const chain = sortChain().slice();
482
+ const idx = chain.findIndex((e) => e.key === key);
483
+ if (dir == null) {
484
+ if (idx >= 0) chain.splice(idx, 1);
485
+ } else if (idx < 0) {
486
+ chain.push({ key, dir });
487
+ } else {
488
+ chain[idx] = { key, dir };
489
+ }
490
+ sortChain.set(chain);
491
+ }
492
+ function clearSort() { sortChain.set([]); }
493
+
494
+ // --- Selection ---
495
+ // The selection state is a PREDICATE, not a list of IDs. Two modes:
496
+ //
497
+ // mode: "whitelist" -> set contains the selected IDs (the classic case).
498
+ // mode: "all" -> set is a BLACKLIST. Every row is selected EXCEPT
499
+ // those in the set. This makes Ctrl+A across 1M rows
500
+ // an O(1) operation -- no Set construction, no walk
501
+ // of the row source, no per-ID allocation.
502
+ //
503
+ // The cost is paid only at materialization: `selectedIds(source)` and
504
+ // `selectedRows(source)` walk the source once and run the predicate per
505
+ // row. Callers that want to stream (CSV export, server upload) use
506
+ // `forEachSelected` instead and never materialize the full list at all.
507
+ //
508
+ // Practical wins of the inversion:
509
+ // 1. New rows arriving from a paged backend after Ctrl+A are auto-
510
+ // included -- "all" is a predicate evaluated at read time, not a
511
+ // snapshot of IDs at click time.
512
+ // 2. Partial materialization (export top 1000 of a select-all set) is
513
+ // possible. With a 1M-entry whitelist it wasn't -- you had no way
514
+ // to know what was selected without enumerating.
515
+ // 3. `selectedCount()` is O(1) (rowCount - blacklist.size or
516
+ // whitelist.size), suitable for a reactive "X selected" badge.
517
+ //
518
+ // Range-select (shift-click) switches the selection BACK to whitelist
519
+ // mode, scoped to the range. This matches AG Grid's convention and what
520
+ // most users expect.
521
+ const selection = scope.signal({ mode: "whitelist", set: new Set() });
522
+ const selectionAnchor = scope.signal(null);
523
+
524
+ function isSelected(rowId) {
525
+ const sel = selection();
526
+ return sel.mode === "all" ? !sel.set.has(rowId) : sel.set.has(rowId);
527
+ }
528
+
529
+ // Reactive count derived from the predicate state. O(1).
530
+ const selectedCount = scope.computed(() => {
531
+ const sel = selection();
532
+ return sel.mode === "all" ? rowCount() - sel.set.size : sel.set.size;
533
+ });
534
+
535
+ function selectRow(rowId, mode) {
536
+ mode = mode || "set";
537
+ const cur = selection.peek();
538
+ if (mode === "set") {
539
+ // Single-click: replace selection with just this row. Always
540
+ // collapses to a single-entry whitelist regardless of prior mode.
541
+ selection.set({ mode: "whitelist", set: new Set([rowId]) });
542
+ selectionAnchor.set(rowId);
543
+ } else if (mode === "add") {
544
+ // Ensure rowId is selected. In whitelist mode, add to set; in
545
+ // all-mode, remove from blacklist.
546
+ const next = new Set(cur.set);
547
+ if (cur.mode === "all") next.delete(rowId);
548
+ else next.add(rowId);
549
+ selection.set({ mode: cur.mode, set: next });
550
+ if (!selectionAnchor.peek()) selectionAnchor.set(rowId);
551
+ } else if (mode === "toggle") {
552
+ // Flip rowId's membership. In whitelist mode, toggle in set; in
553
+ // all-mode, toggle in blacklist (which inverts membership).
554
+ const next = new Set(cur.set);
555
+ if (next.has(rowId)) next.delete(rowId);
556
+ else next.add(rowId);
557
+ selection.set({ mode: cur.mode, set: next });
558
+ selectionAnchor.set(rowId);
559
+ } else if (mode === "range") {
560
+ selectRowRange(selectionAnchor.peek(), rowId);
561
+ return;
562
+ }
563
+ }
564
+
565
+ function selectRowRange(anchorId, targetId) {
566
+ const list = visibleRows();
567
+ if (anchorId == null) {
568
+ // No anchor yet -- behave as a single-row set.
569
+ selection.set({ mode: "whitelist", set: new Set([targetId]) });
570
+ selectionAnchor.set(targetId);
571
+ return;
572
+ }
573
+ let aIdx = -1, tIdx = -1;
574
+ for (let i = 0; i < list.length; i++) {
575
+ const id = getRowId(list[i]);
576
+ if (id === anchorId) aIdx = i;
577
+ if (id === targetId) tIdx = i;
578
+ if (aIdx >= 0 && tIdx >= 0) break;
579
+ }
580
+ if (tIdx < 0) return;
581
+ if (aIdx < 0) aIdx = tIdx;
582
+ const [from, to] = aIdx < tIdx ? [aIdx, tIdx] : [tIdx, aIdx];
583
+ const next = new Set();
584
+ for (let i = from; i <= to; i++) next.add(getRowId(list[i]));
585
+ // Range select always collapses to a whitelist of the range. If the
586
+ // caller was previously in select-all mode, this is the documented
587
+ // "shift-click switches back to whitelist of range" convention.
588
+ selection.set({ mode: "whitelist", set: next });
589
+ }
590
+
591
+ function selectAll() {
592
+ // O(1). Flip to all-mode with an empty blacklist. No walk of rows,
593
+ // no Set construction with N entries. The predicate `isSelected`
594
+ // immediately returns true for every row.
595
+ selection.set({ mode: "all", set: new Set() });
596
+ const first = visibleRows()[0];
597
+ if (first && selectionAnchor.peek() == null) {
598
+ selectionAnchor.set(getRowId(first));
599
+ }
600
+ }
601
+
602
+ function clearSelection() {
603
+ selection.set({ mode: "whitelist", set: new Set() });
604
+ selectionAnchor.set(null);
605
+ }
606
+
607
+ // Materializers. These are the only O(N) operations -- you only pay the
608
+ // walk cost when something explicitly asks for the list. Source defaults
609
+ // to current visibleRows() but callers can pass a different source (e.g.
610
+ // unsorted master list for export).
611
+ function selectedIds(source) {
612
+ const rows = source != null ? source : visibleRows();
613
+ const out = [];
614
+ const sel = selection.peek();
615
+ if (sel.mode === "all") {
616
+ for (let i = 0; i < rows.length; i++) {
617
+ const id = getRowId(rows[i]);
618
+ if (!sel.set.has(id)) out.push(id);
619
+ }
620
+ } else {
621
+ for (let i = 0; i < rows.length; i++) {
622
+ const id = getRowId(rows[i]);
623
+ if (sel.set.has(id)) out.push(id);
624
+ }
625
+ }
626
+ return out;
627
+ }
628
+ function selectedRows(source) {
629
+ const rows = source != null ? source : visibleRows();
630
+ const out = [];
631
+ const sel = selection.peek();
632
+ if (sel.mode === "all") {
633
+ for (let i = 0; i < rows.length; i++) {
634
+ if (!sel.set.has(getRowId(rows[i]))) out.push(rows[i]);
635
+ }
636
+ } else {
637
+ for (let i = 0; i < rows.length; i++) {
638
+ if (sel.set.has(getRowId(rows[i]))) out.push(rows[i]);
639
+ }
640
+ }
641
+ return out;
642
+ }
643
+ // Iterator form. fn receives (row, id, index). Return false to stop early.
644
+ // This is the recommended path for CSV export, server upload, or any
645
+ // per-row processing -- it never allocates the materialized list.
646
+ function forEachSelected(fn, source) {
647
+ const rows = source != null ? source : visibleRows();
648
+ const sel = selection.peek();
649
+ if (sel.mode === "all") {
650
+ for (let i = 0; i < rows.length; i++) {
651
+ const id = getRowId(rows[i]);
652
+ if (!sel.set.has(id)) {
653
+ if (fn(rows[i], id, i) === false) return;
654
+ }
655
+ }
656
+ } else {
657
+ for (let i = 0; i < rows.length; i++) {
658
+ const id = getRowId(rows[i]);
659
+ if (sel.set.has(id)) {
660
+ if (fn(rows[i], id, i) === false) return;
661
+ }
662
+ }
663
+ }
664
+ }
665
+
666
+ // --- Focus ---
667
+ const focusedCell = scope.signal(initialFocus);
668
+
669
+ /**
670
+ * Move focused cell by direction. Side-effecting: scrolls focused row
671
+ * into view in the mount (mount subscribes to focusedCell movement).
672
+ */
673
+ function moveFocus(direction, opts) {
674
+ opts = opts || {};
675
+ const list = visibleRows();
676
+ const cols = visibleColumns();
677
+ if (!list.length || !cols.length) return;
678
+ const cur = focusedCell();
679
+ // No focus yet -> seed at the first cell. Without this, ArrowDown
680
+ // from a null focus lands on row 1 (impl treats null as row -1 then
681
+ // moves down to 0+1), which surprises keyboard users who expect the
682
+ // first arrow to PUT them in the grid, not move them past row 0.
683
+ if (!cur) {
684
+ focusedCell.set({
685
+ rowId: getRowId(list[0]),
686
+ columnKey: cols[0].key
687
+ });
688
+ return;
689
+ }
690
+ let rIdx = -1, cIdx = -1;
691
+ for (let i = 0; i < list.length; i++) {
692
+ if (getRowId(list[i]) === cur.rowId) { rIdx = i; break; }
693
+ }
694
+ for (let i = 0; i < cols.length; i++) {
695
+ if (cols[i].key === cur.columnKey) { cIdx = i; break; }
696
+ }
697
+ if (rIdx < 0) rIdx = 0;
698
+ if (cIdx < 0) cIdx = 0;
699
+
700
+ const pageSize = Math.max(1, opts.pageSize || 10);
701
+ switch (direction) {
702
+ case "up": rIdx = Math.max(0, rIdx - 1); break;
703
+ case "down": rIdx = Math.min(list.length - 1, rIdx + 1); break;
704
+ case "left": cIdx = Math.max(0, cIdx - 1); break;
705
+ case "right": cIdx = Math.min(cols.length - 1, cIdx + 1); break;
706
+ case "home": cIdx = 0; break;
707
+ case "end": cIdx = cols.length - 1; break;
708
+ case "rowStart": rIdx = 0; cIdx = 0; break;
709
+ case "rowEnd": rIdx = list.length - 1; cIdx = cols.length - 1; break;
710
+ case "pageUp": rIdx = Math.max(0, rIdx - pageSize); break;
711
+ case "pageDown": rIdx = Math.min(list.length - 1, rIdx + pageSize); break;
712
+ default: return;
713
+ }
714
+ focusedCell.set({
715
+ rowId: getRowId(list[rIdx]),
716
+ columnKey: cols[cIdx].key
717
+ });
718
+ }
719
+
720
+ // --- Column mutations ---
721
+ function setColumnWidth(key, w) {
722
+ const c = columnsByKey.get(key);
723
+ if (!c) return;
724
+ const clamped = Math.max(c.minWidth, Math.min(c.maxWidth, Math.round(w)));
725
+ c.width.set(clamped);
726
+ }
727
+ function setColumnHidden(key, hidden) {
728
+ const c = columnsByKey.get(key);
729
+ if (!c) return;
730
+ c.hidden.set(hidden !== false);
731
+ }
732
+ function setColumnPin(key, side) {
733
+ const c = columnsByKey.get(key);
734
+ if (!c) return;
735
+ const s = side === "left" || side === "right" ? side : "none";
736
+ c.pin.set(s);
737
+ }
738
+ function setColumnFlex(key, flex) {
739
+ const c = columnsByKey.get(key);
740
+ if (!c) return;
741
+ c.flex.set(typeof flex === "number" && flex > 0 ? flex : 0);
742
+ }
743
+ function setColumnOrder(keys) {
744
+ if (!Array.isArray(keys)) return;
745
+ // Validate: must be a permutation of declared keys.
746
+ if (keys.length !== columns.length) return;
747
+ for (const k of keys) if (!columnsByKey.has(k)) return;
748
+ const seen = new Set(keys);
749
+ if (seen.size !== keys.length) return;
750
+ columnOrder.set(keys.slice());
751
+ }
752
+ function moveColumn(fromKey, toKey, opts) {
753
+ opts = opts || {};
754
+ const before = opts.before !== false;
755
+ const order = columnOrder().slice();
756
+ const fromIdx = order.indexOf(fromKey);
757
+ const toIdx = order.indexOf(toKey);
758
+ if (fromIdx < 0 || toIdx < 0 || fromIdx === toIdx) return;
759
+ const [moved] = order.splice(fromIdx, 1);
760
+ const target = order.indexOf(toKey);
761
+ order.splice(before ? target : target + 1, 0, moved);
762
+ columnOrder.set(order);
763
+ }
764
+
765
+ return {
766
+ // Static
767
+ columns,
768
+ getRowId,
769
+ rowHeight,
770
+ overscan,
771
+ cellId: makeCellId,
772
+
773
+ // Reactive: data
774
+ rowsGetter,
775
+ visibleRows,
776
+ rowCount,
777
+
778
+ // Reactive: columns
779
+ columnOrder,
780
+ visibleColumns,
781
+ displayIndexByKey,
782
+ colPlacement,
783
+ colTemplate,
784
+ contentWidth,
785
+ leftOffsets,
786
+ rightOffsets,
787
+
788
+ // Reactive: sort
789
+ sortChain,
790
+
791
+ // Reactive: focus / selection
792
+ focusedCell,
793
+ selection,
794
+ selectionAnchor,
795
+ selectedCount,
796
+
797
+ // Methods: sort
798
+ setSort, addSort, toggleSort, clearSort,
799
+
800
+ // Methods: selection
801
+ selectRow, selectRowRange, selectAll, clearSelection, isSelected,
802
+ selectedIds, selectedRows, forEachSelected,
803
+
804
+ // Methods: columns
805
+ setColumnWidth, setColumnHidden, setColumnPin, setColumnFlex,
806
+ setColumnOrder, moveColumn,
807
+
808
+ // Methods: focus
809
+ moveFocus,
810
+
811
+ // Lifecycle
812
+ dispose,
813
+ _scope: scope
814
+ };
815
+ }
816
+
817
+ // =============================================================================
818
+ // 4. DEFAULT STYLES
819
+ // =============================================================================
820
+
821
+ const DEFAULT_STYLES =
822
+ // Root container
823
+ ".lt-root{display:flex;flex-direction:column;position:relative;outline:none;" +
824
+ "width:100%;height:100%;min-height:0;font-family:system-ui,sans-serif;" +
825
+ "font-size:14px;color:#0f172a;border:1px solid #e2e8f0;border-radius:6px;" +
826
+ "overflow:hidden;box-sizing:border-box;--lt-pin-bg:#fff;--lt-pin-alt-bg:#fafafa}" +
827
+ ".lt-root:focus-visible{box-shadow:0 0 0 2px #3b82f6}" +
828
+
829
+ // Header. width: max-content lets the grid container be sized by the
830
+ // sum of its column tracks (their natural widths). min-width: 100% then
831
+ // stretches it to the viewport when the viewport is wider so flex
832
+ // columns can absorb leftover space. This pair is more robust than the
833
+ // earlier `width: 100%; min-width: <computed contentWidth>px` approach,
834
+ // which forced a calculated width that could disagree with what the
835
+ // grid template actually distributed -- especially when flex columns
836
+ // had a minWidth floor that bumped above the share-of-fr they would
837
+ // have received in the available space.
838
+ ".lt-header{display:grid;grid-template-columns:var(--lt-cols);position:sticky;" +
839
+ "top:0;z-index:3;background:#f8fafc;border-bottom:1px solid #e2e8f0;" +
840
+ "user-select:none;width:max-content;min-width:100%}" +
841
+ ".lt-header-cell{padding:8px 12px;font-weight:600;color:#334155;" +
842
+ "border-right:1px solid #e2e8f0;overflow:hidden;text-overflow:ellipsis;" +
843
+ "white-space:nowrap;background:#f8fafc;position:relative;" +
844
+ "display:flex;align-items:center;gap:6px;cursor:default;" +
845
+ // grid-row: 1 -- LOCK every header cell to row 1. CSS Grid's sparse
846
+ // auto-placement cursor never moves backwards: if DOM-order N has
847
+ // grid-column:5 and DOM-order N+1 (a column reordered to the front)
848
+ // has grid-column:1, the placement cursor is past col 5 in row 1 and
849
+ // cannot backtrack -- the second item gets put on row 2. With
850
+ // grid-row:1 explicit on every cell, the auto-placement walk is
851
+ // bypassed entirely and the row can never overflow. This is the fix
852
+ // for the header-wraps-after-reorder bug.
853
+ "grid-row:1;" +
854
+ // touch-action: none -- on touch devices, pointer events default to
855
+ // touch-action: auto which lets the browser claim pointermove for
856
+ // native scroll BEFORE our 4px drag threshold triggers capture. None
857
+ // means drag-to-sort and drag-to-reorder actually receive pointermove.
858
+ // Horizontal scroll of the table is initiated from the body, not the
859
+ // header, so this doesn't reduce scrolling affordance.
860
+ "touch-action:none}" +
861
+ ".lt-header-cell.is-sortable{cursor:pointer}" +
862
+ // Dragged cell: dim + outlined ghost so the user can clearly see what
863
+ // they're carrying. opacity alone (the old behavior) was easy to miss
864
+ // against a low-contrast row background, especially in dark themes.
865
+ ".lt-header-cell.is-dragging{opacity:0.45;outline:2px dashed #3b82f6;" +
866
+ "outline-offset:-3px;cursor:grabbing}" +
867
+ // Drop indicators: 4px solid blue bar with a soft pulse animation so the
868
+ // user can see where the column will land. The earlier 2px inset shadow
869
+ // was too thin to read against a dense header.
870
+ ".lt-header-cell.is-drop-before{box-shadow:4px 0 0 #2563eb inset;" +
871
+ "animation:lt-drop-pulse 0.9s ease-in-out infinite}" +
872
+ ".lt-header-cell.is-drop-after{box-shadow:-4px 0 0 #2563eb inset;" +
873
+ "animation:lt-drop-pulse 0.9s ease-in-out infinite}" +
874
+ "@keyframes lt-drop-pulse{0%,100%{filter:none}50%{filter:brightness(1.15)}}" +
875
+ ".lt-header-cell[data-pin=\"left\"]{position:sticky;z-index:4;background:#f8fafc}" +
876
+ ".lt-header-cell[data-pin=\"right\"]{position:sticky;z-index:4;background:#f8fafc}" +
877
+ ".lt-header-cell-label{flex:1;overflow:hidden;text-overflow:ellipsis;" +
878
+ "pointer-events:none}" +
879
+ ".lt-header-sort{font-size:10px;color:#64748b;font-weight:600;min-width:14px;" +
880
+ "text-align:right;pointer-events:none}" +
881
+ ".lt-header-resize{position:absolute;top:0;right:0;width:6px;height:100%;" +
882
+ "cursor:col-resize;user-select:none;touch-action:none}" +
883
+ ".lt-header-resize:hover,.lt-header-resize.is-active{background:#3b82f6}" +
884
+ // While ANY column is being dragged, every cursor in the table becomes
885
+ // grabbing. The class is applied to the root by attachHeaderInteraction.
886
+ ".lt-root.is-dragging-column,.lt-root.is-dragging-column *{cursor:grabbing !important}" +
887
+
888
+ // Viewport. The viewport itself is the scroll container; inner uses the
889
+ // same width pair as header so they scroll together as one wide surface
890
+ // when columns exceed viewport.
891
+ ".lt-viewport{flex:1;overflow:auto;position:relative;contain:strict}" +
892
+ ".lt-inner{position:relative;width:max-content;min-width:100%}" +
893
+
894
+ // Rows
895
+ ".lt-row{display:grid;grid-template-columns:var(--lt-cols);" +
896
+ "position:absolute;left:0;right:0;top:0;border-bottom:1px solid #f1f5f9;" +
897
+ "background:var(--lt-pin-bg);contain:layout style}" +
898
+ ".lt-row.lt-row-alt{background:var(--lt-pin-alt-bg)}" +
899
+ ".lt-row.is-selected{background:#dbeafe}" +
900
+ ".lt-row.is-selected.lt-row-alt{background:#bfdbfe}" +
901
+
902
+ // Cells. grid-row: 1 -- LOCK to row 1 to bypass CSS Grid sparse
903
+ // auto-placement (see the .lt-header-cell rule above for the full
904
+ // explanation). Without this lock, a reordered column whose new
905
+ // grid-column lies "before" the placement cursor of the previous
906
+ // sibling wraps to row 2, taking its row's data with it.
907
+ ".lt-cell{padding:6px 12px;overflow:hidden;text-overflow:ellipsis;" +
908
+ "white-space:nowrap;border-right:1px solid #f1f5f9;line-height:20px;" +
909
+ "background:inherit;box-sizing:border-box;grid-row:1}" +
910
+ ".lt-cell[data-pin=\"left\"]{position:sticky;z-index:2}" +
911
+ ".lt-cell[data-pin=\"right\"]{position:sticky;z-index:2}" +
912
+ // Focus ring via outline: doesn't affect layout, doesn't promote the
913
+ // cell to a new stacking context (which box-shadow + position:relative
914
+ // could do, causing pixel-shift during compositor layer creation), and
915
+ // doesn't conflict with sticky positioning on pinned cells.
916
+ ".lt-cell.is-focused{outline:2px solid #3b82f6;outline-offset:-2px}";
917
+
918
+ let _stylesInjected = new WeakSet();
919
+ function injectStyles(doc) {
920
+ if (_stylesInjected.has(doc)) return;
921
+ _stylesInjected.add(doc);
922
+ const style = doc.createElement("style");
923
+ style.setAttribute("data-lt", "core");
924
+ style.textContent = DEFAULT_STYLES;
925
+ doc.head.appendChild(style);
926
+ }
927
+
928
+ // Test hook.
929
+ /**
930
+ * @internal
931
+ * Reset the styles-injected WeakSet so tests can re-assert injection
932
+ * behaviour from a clean slate. Not part of the public API.
933
+ */
934
+ export function _resetStylesForTest() {
935
+ _stylesInjected = new WeakSet();
936
+ }
937
+
938
+ // =============================================================================
939
+ // 5. DOM MOUNT
940
+ // =============================================================================
941
+
942
+ const DRAG_THRESHOLD_PX = 4;
943
+
944
+ /**
945
+ * Render a {@link createTable} core into a host element. Wires up CSS Grid
946
+ * layout, slot-pooled virtualization (constant DOM cost regardless of dataset
947
+ * size), reactive cell bindings, header sort / drag-reorder / drag-resize,
948
+ * pointer + keyboard handlers, and ARIA wiring (`role=grid`, `aria-rowcount`,
949
+ * `aria-activedescendant`).
950
+ *
951
+ * **Lifecycle coupling**: `mount.dispose()` also calls `table.dispose()`.
952
+ * One mount owns one table. To render the same data in two places, create
953
+ * two tables sharing the same row source.
954
+ *
955
+ * @template Row
956
+ * @param {HTMLElement} host
957
+ * Container element. The mount takes one child and lives there until
958
+ * `dispose()` removes it. Should have a non-zero `clientHeight` for
959
+ * virtualization to size the pool; defaults to `initialViewportHeight`
960
+ * while layout settles.
961
+ * @param {object} table TableCore from {@link createTable}.
962
+ * @param {object} [options]
963
+ * @param {boolean} [options.injectStyles=true]
964
+ * When false, the consumer is responsible for `.lt-*` CSS. The mount
965
+ * still applies inline `style.--lt-cols` and per-cell `grid-column`
966
+ * placements (which are required for the grid to lay out at all).
967
+ * @param {number} [options.initialViewportHeight=480]
968
+ * Used to size the slot pool before the ResizeObserver fires.
969
+ *
970
+ * @returns {object} TableMount -- `{ root, viewport, axis, scrollToIndex, poolSize, dispose }`.
971
+ * @throws {TypeError} If `host` or `table` is missing.
972
+ *
973
+ * @example
974
+ * const table = createTable({ ... });
975
+ * const m = mountTable(document.getElementById("grid"), table);
976
+ * m.scrollToIndex(500, "center");
977
+ * // ... later
978
+ * m.dispose();
979
+ */
980
+ export function mountTable(host, table, options) {
981
+ if (!host) throw new TypeError("lite-table: host element required");
982
+ if (!table) throw new TypeError("lite-table: table required");
983
+ options = options || {};
984
+ const doc = host.ownerDocument;
985
+ if (options.injectStyles !== false) injectStyles(doc);
986
+
987
+ const {
988
+ columns, rowHeight, overscan,
989
+ visibleRows, rowCount, visibleColumns, displayIndexByKey, colPlacement,
990
+ colTemplate, contentWidth, leftOffsets, rightOffsets,
991
+ sortChain, focusedCell, selection,
992
+ getRowId, cellId,
993
+ toggleSort, selectRow, isSelected, moveFocus,
994
+ setColumnWidth, moveColumn
995
+ } = table;
996
+
997
+ const { scope, dispose: disposeScope } = createScope();
998
+
999
+ // ----- Root container (the focusable element) ---------------------------
1000
+ const root = doc.createElement("div");
1001
+ root.className = "lt-root";
1002
+ root.setAttribute("role", "grid");
1003
+ root.setAttribute("tabindex", "0");
1004
+ root.setAttribute("aria-multiselectable", "true");
1005
+ root.setAttribute("aria-colcount", String(columns.length));
1006
+
1007
+ scope.effect(() => {
1008
+ root.setAttribute("aria-rowcount", String(rowCount() + 1));
1009
+ });
1010
+
1011
+ // Reactive grid template (CSS var consumed by both header and rows).
1012
+ scope.effect(() => {
1013
+ root.style.setProperty("--lt-cols", colTemplate());
1014
+ });
1015
+
1016
+ // ----- Header row -------------------------------------------------------
1017
+ const header = doc.createElement("div");
1018
+ header.className = "lt-header";
1019
+ header.setAttribute("role", "row");
1020
+ header.setAttribute("aria-rowindex", "1");
1021
+
1022
+ // One header cell per declared column. Reorder + hide + pin update their
1023
+ // gridColumn / display / position reactively -- DOM nodes never change.
1024
+ const headerCells = new Map(); // key -> { el, labelEl, sortEl, resizeEl }
1025
+ for (let i = 0; i < columns.length; i++) {
1026
+ const col = columns[i];
1027
+ const cell = doc.createElement("div");
1028
+ cell.className = "lt-header-cell";
1029
+ cell.setAttribute("role", "columnheader");
1030
+ cell.setAttribute("aria-colindex", String(i + 1));
1031
+ cell.setAttribute("data-key", col.key);
1032
+ if (col.sortable) cell.classList.add("is-sortable");
1033
+
1034
+ const labelEl = doc.createElement("span");
1035
+ labelEl.className = "lt-header-cell-label";
1036
+ labelEl.textContent = col.header;
1037
+ cell.appendChild(labelEl);
1038
+
1039
+ const sortEl = doc.createElement("span");
1040
+ sortEl.className = "lt-header-sort";
1041
+ sortEl.setAttribute("aria-hidden", "true");
1042
+ cell.appendChild(sortEl);
1043
+
1044
+ // Resize handle on the right edge.
1045
+ const resizeEl = doc.createElement("span");
1046
+ resizeEl.className = "lt-header-resize";
1047
+ resizeEl.setAttribute("aria-hidden", "true");
1048
+ if (col.resizable) cell.appendChild(resizeEl);
1049
+
1050
+ header.appendChild(cell);
1051
+ headerCells.set(col.key, { el: cell, labelEl, sortEl, resizeEl, col });
1052
+ }
1053
+ root.appendChild(header);
1054
+
1055
+ // Per-header reactive bindings: gridColumn, display, sticky pin, aria-sort.
1056
+ for (const col of columns) {
1057
+ const hc = headerCells.get(col.key);
1058
+
1059
+ // gridColumn / display. colPlacement gives the 1-indexed
1060
+ // grid-column accounting for the 1fr filler between unpinned and
1061
+ // right-pinned columns.
1062
+ scope.effect(() => {
1063
+ const placement = colPlacement().get(col.key);
1064
+ if (placement == null) {
1065
+ hc.el.style.display = "none";
1066
+ } else {
1067
+ hc.el.style.display = "";
1068
+ hc.el.style.gridColumn = placement + " / span 1";
1069
+ }
1070
+ });
1071
+
1072
+ // Pin: data-pin + left/right offsets.
1073
+ scope.effect(() => {
1074
+ const pinSide = col.pin();
1075
+ hc.el.setAttribute("data-pin", pinSide);
1076
+ if (pinSide === "left") {
1077
+ hc.el.style.left = (leftOffsets().get(col.key) || 0) + "px";
1078
+ hc.el.style.right = "";
1079
+ } else if (pinSide === "right") {
1080
+ hc.el.style.right = (rightOffsets().get(col.key) || 0) + "px";
1081
+ hc.el.style.left = "";
1082
+ } else {
1083
+ hc.el.style.left = "";
1084
+ hc.el.style.right = "";
1085
+ }
1086
+ });
1087
+
1088
+ // aria-sort + visible sort indicator.
1089
+ scope.effect(() => {
1090
+ const chain = sortChain();
1091
+ const idx = chain.findIndex((e) => e.key === col.key);
1092
+ const entry = idx >= 0 ? chain[idx] : null;
1093
+ if (entry) {
1094
+ hc.el.setAttribute("aria-sort", entry.dir === "asc" ? "ascending" : "descending");
1095
+ // Show arrow + position in chain (e.g., "1\u25b2", "2\u25bc").
1096
+ const arrow = entry.dir === "asc" ? "\u25b2" : "\u25bc";
1097
+ hc.sortEl.textContent = (chain.length > 1 ? (idx + 1) + arrow : arrow);
1098
+ } else {
1099
+ hc.el.setAttribute("aria-sort", "none");
1100
+ hc.sortEl.textContent = "";
1101
+ }
1102
+ });
1103
+
1104
+ // Click: sort. Shift-click: additive sort. Drag: reorder.
1105
+ // pointerdown -> capture potential drag; on pointerup we decide.
1106
+ if (col.sortable || col.reorderable) {
1107
+ attachHeaderInteraction(hc, col, scope, doc, {
1108
+ root, header, columnsByKey: new Map(columns.map(c => [c.key, c])),
1109
+ toggleSort, moveColumn, headerCells
1110
+ });
1111
+ }
1112
+
1113
+ // Resize: pointerdown on resize handle starts a drag-to-resize.
1114
+ if (col.resizable) {
1115
+ attachResizeInteraction(hc, col, scope, doc, setColumnWidth);
1116
+ }
1117
+ }
1118
+
1119
+ // ----- Viewport (scroll container) --------------------------------------
1120
+ // Header is INSIDE the viewport so horizontal scroll moves header + body
1121
+ // together (they share the same scroll context). Header still sticks to
1122
+ // the top via position:sticky.
1123
+ const viewport = doc.createElement("div");
1124
+ viewport.className = "lt-viewport";
1125
+ viewport.setAttribute("role", "presentation");
1126
+
1127
+ // Move the header from root into viewport.
1128
+ root.removeChild(header);
1129
+ viewport.appendChild(header);
1130
+
1131
+ const inner = doc.createElement("div");
1132
+ inner.className = "lt-inner";
1133
+ viewport.appendChild(inner);
1134
+ root.appendChild(viewport);
1135
+
1136
+ host.appendChild(root);
1137
+
1138
+ // Sizing of the scroll surface is now done entirely in CSS. Both `.lt-header`
1139
+ // and `.lt-inner` use `width: max-content; min-width: 100%` so the grid
1140
+ // container's intrinsic width is the natural width of its tracks, and it
1141
+ // stretches to viewport when the viewport is wider so flex columns can
1142
+ // absorb leftover space. The earlier JS effect that set
1143
+ // `style.minWidth = contentWidth() + "px"` was fragile: it could force a
1144
+ // width that disagreed with what the grid template actually distributed
1145
+ // (e.g. when a flex column's minWidth floor exceeded its share of fr),
1146
+ // which in Chrome could push cells into a second implicit row.
1147
+
1148
+ // ----- Virtual axis -----------------------------------------------------
1149
+ const initialVH = viewport.clientHeight ||
1150
+ options.initialViewportHeight || 480;
1151
+
1152
+ const axis = virtualAxis({
1153
+ count: rowCount.peek(),
1154
+ itemSize: rowHeight,
1155
+ viewport: initialVH,
1156
+ overscan
1157
+ });
1158
+
1159
+ scope.effect(() => { axis.setCount(rowCount()); });
1160
+ scope.effect(() => { inner.style.height = axis.totalSize() + "px"; });
1161
+
1162
+ // ----- Slot pool --------------------------------------------------------
1163
+ const poolSize = scope.signal(
1164
+ Math.ceil(initialVH / rowHeight) + overscan * 2 + 1
1165
+ );
1166
+
1167
+ const slots = [];
1168
+
1169
+ function buildSlot(poolIdx) {
1170
+ const rowEl = doc.createElement("div");
1171
+ rowEl.className = "lt-row";
1172
+ rowEl.setAttribute("role", "row");
1173
+
1174
+ const slotIndex = scope.computed(() => axis.start() + poolIdx);
1175
+
1176
+ // Position (translateY) -- single transform write per boundary cross.
1177
+ scope.effect(() => {
1178
+ const i = slotIndex();
1179
+ rowEl.style.transform = "translateY(" + (i * rowHeight) + "px)";
1180
+ });
1181
+
1182
+ // Visibility & aria-rowindex.
1183
+ scope.effect(() => {
1184
+ const i = slotIndex();
1185
+ const n = rowCount();
1186
+ if (i < 0 || i >= n) {
1187
+ rowEl.style.display = "none";
1188
+ rowEl.removeAttribute("aria-rowindex");
1189
+ } else {
1190
+ rowEl.style.display = "";
1191
+ rowEl.setAttribute("aria-rowindex", String(i + 2));
1192
+ }
1193
+ });
1194
+
1195
+ // Alt striping.
1196
+ scope.effect(() => {
1197
+ const i = slotIndex();
1198
+ if (i & 1) rowEl.classList.add("lt-row-alt");
1199
+ else rowEl.classList.remove("lt-row-alt");
1200
+ });
1201
+
1202
+ // Selection highlight: bindClass calls isSelected (the predicate),
1203
+ // which transparently handles both whitelist and all-mode selection.
1204
+ scope.onCleanup(bindClass(rowEl, "is-selected", () => {
1205
+ const i = slotIndex();
1206
+ const rs = visibleRows();
1207
+ if (i < 0 || i >= rs.length) return false;
1208
+ return isSelected(getRowId(rs[i]));
1209
+ }));
1210
+
1211
+ // aria-selected on the row.
1212
+ scope.onCleanup(bindAttr(rowEl, "aria-selected", () => {
1213
+ const i = slotIndex();
1214
+ const rs = visibleRows();
1215
+ if (i < 0 || i >= rs.length) return null;
1216
+ return isSelected(getRowId(rs[i])) ? "true" : "false";
1217
+ }));
1218
+
1219
+ // ----- Cells (one per declared column, in DOM config order) ---------
1220
+ for (let c = 0; c < columns.length; c++) {
1221
+ const col = columns[c];
1222
+ const cellEl = doc.createElement("div");
1223
+ cellEl.className = "lt-cell";
1224
+ cellEl.setAttribute("role", "gridcell");
1225
+ cellEl.setAttribute("aria-colindex", String(c + 1));
1226
+ // Static -- never changes for this cell. Read by the delegated
1227
+ // pointerdown handler on root to identify which column was tapped.
1228
+ cellEl.setAttribute("data-key", col.key);
1229
+
1230
+ // Reactive grid placement / hide.
1231
+ scope.effect(() => {
1232
+ const placement = colPlacement().get(col.key);
1233
+ if (placement == null) {
1234
+ cellEl.style.display = "none";
1235
+ } else {
1236
+ cellEl.style.display = "";
1237
+ cellEl.style.gridColumn = placement + " / span 1";
1238
+ }
1239
+ });
1240
+
1241
+ // Reactive pin (sticky + offset).
1242
+ scope.effect(() => {
1243
+ const pinSide = col.pin();
1244
+ cellEl.setAttribute("data-pin", pinSide);
1245
+ if (pinSide === "left") {
1246
+ cellEl.style.left = (leftOffsets().get(col.key) || 0) + "px";
1247
+ cellEl.style.right = "";
1248
+ } else if (pinSide === "right") {
1249
+ cellEl.style.right = (rightOffsets().get(col.key) || 0) + "px";
1250
+ cellEl.style.left = "";
1251
+ } else {
1252
+ cellEl.style.left = "";
1253
+ cellEl.style.right = "";
1254
+ }
1255
+ });
1256
+
1257
+ // Reactive text.
1258
+ scope.onCleanup(bindText(cellEl, () => {
1259
+ const i = slotIndex();
1260
+ const rs = visibleRows();
1261
+ if (i < 0 || i >= rs.length) return "";
1262
+ const v = readCell(col, rs[i]);
1263
+ return v == null ? "" : v;
1264
+ }));
1265
+
1266
+ // Reactive id (the aria-activedescendant target).
1267
+ scope.onCleanup(bindAttr(cellEl, "id", () => {
1268
+ const i = slotIndex();
1269
+ const rs = visibleRows();
1270
+ if (i < 0 || i >= rs.length) return null;
1271
+ const row = rs[i];
1272
+ if (row == null) return null;
1273
+ return cellId(getRowId(row), col.key);
1274
+ }));
1275
+
1276
+ // Focus indicator. Cheaper than rewriting a <style> element's
1277
+ // textContent because that invalidates CSSOM globally. Here each
1278
+ // cell flips one class; only the 1-2 cells that gain/lose focus
1279
+ // produce classList writes.
1280
+ scope.onCleanup(bindClass(cellEl, "is-focused", () => {
1281
+ const i = slotIndex();
1282
+ const rs = visibleRows();
1283
+ if (i < 0 || i >= rs.length) return false;
1284
+ const row = rs[i];
1285
+ if (row == null) return false;
1286
+ const f = focusedCell();
1287
+ if (!f) return false;
1288
+ return getRowId(row) === f.rowId && col.key === f.columnKey;
1289
+ }));
1290
+
1291
+ // No pointerdown listener here -- the root has one delegated
1292
+ // listener that uses closest('.lt-cell') + data-key + slot index.
1293
+
1294
+ rowEl.appendChild(cellEl);
1295
+ }
1296
+
1297
+ inner.appendChild(rowEl);
1298
+ return rowEl;
1299
+ }
1300
+
1301
+ for (let i = 0; i < poolSize.peek(); i++) slots.push(buildSlot(i));
1302
+
1303
+ scope.effect(() => {
1304
+ const want = poolSize();
1305
+ while (slots.length < want) slots.push(buildSlot(slots.length));
1306
+ });
1307
+
1308
+ // ----- Delegated pointerdown on root ------------------------------------
1309
+ // One listener instead of pool-size x columns. We use closest('.lt-cell')
1310
+ // to find the tapped cell, read its data-key for the column, and find the
1311
+ // row's slot position via slots.indexOf -- O(poolSize) lookup, which is
1312
+ // bounded by viewport.
1313
+ scope.on(root, "pointerdown", (ev) => {
1314
+ if (!ev.isPrimary || ev.button !== 0) return;
1315
+ const cell = ev.target.closest && ev.target.closest(".lt-cell");
1316
+ if (!cell) return;
1317
+ const colKey = cell.getAttribute("data-key");
1318
+ if (!colKey) return;
1319
+ const rowEl = cell.closest(".lt-row");
1320
+ if (!rowEl) return;
1321
+ const poolIdx = slots.indexOf(rowEl);
1322
+ if (poolIdx < 0) return;
1323
+ const slotIdx = untrack(() => axis.start()) + poolIdx;
1324
+ const rs = untrack(() => visibleRows());
1325
+ if (slotIdx < 0 || slotIdx >= rs.length) return;
1326
+ const row = rs[slotIdx];
1327
+ if (row == null) return;
1328
+ const rowId = getRowId(row);
1329
+ if (ev.shiftKey) selectRow(rowId, "range");
1330
+ else if (ev.ctrlKey || ev.metaKey) selectRow(rowId, "toggle");
1331
+ else selectRow(rowId, "set");
1332
+ focusedCell.set({ rowId, columnKey: colKey });
1333
+ // No preventDefault -- preserves native text selection on mouse and
1334
+ // scroll initiation on touch.
1335
+ });
1336
+
1337
+ // ----- aria-activedescendant --------------------------------------------
1338
+ scope.effect(() => {
1339
+ const f = focusedCell();
1340
+ if (!f) { root.removeAttribute("aria-activedescendant"); return; }
1341
+ root.setAttribute("aria-activedescendant", cellId(f.rowId, f.columnKey));
1342
+ });
1343
+
1344
+ // ----- Scroll wiring ----------------------------------------------------
1345
+ scope.on(viewport, "scroll", () => {
1346
+ axis.setScroll(viewport.scrollTop);
1347
+ }, { passive: true });
1348
+
1349
+ // ----- ResizeObserver ---------------------------------------------------
1350
+ if (typeof ResizeObserver !== "undefined") {
1351
+ const ro = new ResizeObserver(() => {
1352
+ const h = viewport.clientHeight;
1353
+ if (h > 0) {
1354
+ axis.setViewport(h);
1355
+ const want = Math.ceil(h / rowHeight) + overscan * 2 + 1;
1356
+ if (want > poolSize.peek()) poolSize.set(want);
1357
+ }
1358
+ });
1359
+ ro.observe(viewport);
1360
+ scope.onCleanup(() => ro.disconnect());
1361
+ }
1362
+
1363
+ // ----- Auto-scroll focused row into view --------------------------------
1364
+ // When focus moves via keyboard, scroll the focused row into view.
1365
+ scope.effect(() => {
1366
+ const f = focusedCell();
1367
+ if (!f) return;
1368
+ const list = untrack(() => visibleRows());
1369
+ let rIdx = -1;
1370
+ for (let i = 0; i < list.length; i++) {
1371
+ if (getRowId(list[i]) === f.rowId) { rIdx = i; break; }
1372
+ }
1373
+ if (rIdx < 0) return;
1374
+ const top = rIdx * rowHeight;
1375
+ const bottom = top + rowHeight;
1376
+ const sTop = viewport.scrollTop;
1377
+ const sBot = sTop + viewport.clientHeight;
1378
+ if (top < sTop) {
1379
+ viewport.scrollTop = top;
1380
+ axis.setScroll(top);
1381
+ } else if (bottom > sBot) {
1382
+ const next = bottom - viewport.clientHeight;
1383
+ viewport.scrollTop = next;
1384
+ axis.setScroll(next);
1385
+ }
1386
+ });
1387
+
1388
+ // ----- Keyboard navigation ---------------------------------------------
1389
+ const viewportPageSize = () =>
1390
+ Math.max(1, Math.floor((viewport.clientHeight || initialVH) / rowHeight));
1391
+
1392
+ scope.on(root, "keydown", (ev) => {
1393
+ // Only handle when focus is actually on root (not delegated to a child
1394
+ // editable element, etc.).
1395
+ if (ev.target !== root) return;
1396
+ let handled = true;
1397
+ const opts = { pageSize: viewportPageSize() };
1398
+ const ctrl = ev.ctrlKey || ev.metaKey;
1399
+ switch (ev.key) {
1400
+ case "ArrowUp": moveFocus("up", opts); break;
1401
+ case "ArrowDown": moveFocus("down", opts); break;
1402
+ case "ArrowLeft": moveFocus("left", opts); break;
1403
+ case "ArrowRight": moveFocus("right", opts); break;
1404
+ case "Home":
1405
+ if (ctrl) moveFocus("rowStart", opts);
1406
+ else moveFocus("home", opts);
1407
+ break;
1408
+ case "End":
1409
+ if (ctrl) moveFocus("rowEnd", opts);
1410
+ else moveFocus("end", opts);
1411
+ break;
1412
+ case "PageUp": moveFocus("pageUp", opts); break;
1413
+ case "PageDown": moveFocus("pageDown", opts); break;
1414
+ case " ": {
1415
+ const f = focusedCell.peek();
1416
+ if (f) {
1417
+ selectRow(f.rowId, ev.ctrlKey || ev.metaKey ? "toggle" : "set");
1418
+ }
1419
+ break;
1420
+ }
1421
+ case "Escape":
1422
+ table.clearSelection();
1423
+ break;
1424
+ case "a":
1425
+ case "A":
1426
+ if (ctrl) { table.selectAll(); }
1427
+ else handled = false;
1428
+ break;
1429
+ default:
1430
+ handled = false;
1431
+ }
1432
+ if (handled) ev.preventDefault();
1433
+ });
1434
+
1435
+ return {
1436
+ root,
1437
+ viewport,
1438
+ axis,
1439
+ scrollToIndex(index, align) {
1440
+ const px = axis.offsetForIndex(index, align);
1441
+ viewport.scrollTop = px;
1442
+ axis.setScroll(px);
1443
+ },
1444
+ poolSize() { return slots.length; },
1445
+ dispose() {
1446
+ disposeScope();
1447
+ if (table.dispose) table.dispose();
1448
+ if (root.parentNode) root.parentNode.removeChild(root);
1449
+ }
1450
+ };
1451
+ }
1452
+
1453
+ // =============================================================================
1454
+ // 6. HEADER INTERACTIONS (sort click vs reorder drag)
1455
+ // =============================================================================
1456
+
1457
+ /**
1458
+ * Attach pointerdown on a header cell that distinguishes click-to-sort from
1459
+ * drag-to-reorder by movement threshold.
1460
+ */
1461
+ function attachHeaderInteraction(hc, col, scope, doc, ctx) {
1462
+ const { root, header, toggleSort, moveColumn, headerCells } = ctx;
1463
+
1464
+ scope.onCleanup(bindOn(hc.el, "pointerdown", (ev) => {
1465
+ if (!ev.isPrimary || ev.button !== 0) return;
1466
+ // Resize handle takes precedence: skip if the target is the handle.
1467
+ if (ev.target === hc.resizeEl) return;
1468
+
1469
+ const startX = ev.clientX;
1470
+ const startY = ev.clientY;
1471
+ let dragging = false;
1472
+ let lastDropTarget = null;
1473
+ let lastDropBefore = true;
1474
+
1475
+ const onMove = (e) => {
1476
+ const dx = e.clientX - startX;
1477
+ const dy = e.clientY - startY;
1478
+ if (!dragging) {
1479
+ if (Math.abs(dx) < DRAG_THRESHOLD_PX && Math.abs(dy) < DRAG_THRESHOLD_PX) return;
1480
+ if (!col.reorderable) return;
1481
+ dragging = true;
1482
+ hc.el.classList.add("is-dragging");
1483
+ // Mark the whole root so every cursor (including the body
1484
+ // cells the user might pass over) shows grabbing. Without
1485
+ // this the cursor goes back to default the moment the
1486
+ // pointer leaves the dragged header cell.
1487
+ root.classList.add("is-dragging-column");
1488
+ try { hc.el.setPointerCapture(e.pointerId); } catch (_) {}
1489
+ }
1490
+ // Find the header cell under the pointer (excluding self).
1491
+ const hover = doc.elementFromPoint(e.clientX, e.clientY);
1492
+ let candidate = hover;
1493
+ while (candidate && !(candidate.classList && candidate.classList.contains("lt-header-cell"))) {
1494
+ candidate = candidate.parentElement;
1495
+ }
1496
+ clearDropIndicators(headerCells);
1497
+ if (!candidate || candidate === hc.el) {
1498
+ lastDropTarget = null;
1499
+ return;
1500
+ }
1501
+ const rect = candidate.getBoundingClientRect();
1502
+ const before = e.clientX < rect.left + rect.width / 2;
1503
+ candidate.classList.add(before ? "is-drop-before" : "is-drop-after");
1504
+ lastDropTarget = candidate.getAttribute("data-key");
1505
+ lastDropBefore = before;
1506
+ };
1507
+ const onUp = (e) => {
1508
+ doc.removeEventListener("pointermove", onMove);
1509
+ doc.removeEventListener("pointerup", onUp);
1510
+ doc.removeEventListener("pointercancel", onUp);
1511
+ try { hc.el.releasePointerCapture(e.pointerId); } catch (_) {}
1512
+ if (dragging) {
1513
+ hc.el.classList.remove("is-dragging");
1514
+ root.classList.remove("is-dragging-column");
1515
+ clearDropIndicators(headerCells);
1516
+ if (lastDropTarget) {
1517
+ moveColumn(col.key, lastDropTarget, { before: lastDropBefore });
1518
+ }
1519
+ } else {
1520
+ // Treat as click: sort.
1521
+ if (col.sortable) toggleSort(col.key, { additive: ev.shiftKey });
1522
+ }
1523
+ };
1524
+ doc.addEventListener("pointermove", onMove);
1525
+ doc.addEventListener("pointerup", onUp);
1526
+ doc.addEventListener("pointercancel", onUp);
1527
+ }));
1528
+ }
1529
+
1530
+ function clearDropIndicators(headerCells) {
1531
+ for (const { el } of headerCells.values()) {
1532
+ el.classList.remove("is-drop-before");
1533
+ el.classList.remove("is-drop-after");
1534
+ }
1535
+ }
1536
+
1537
+ // =============================================================================
1538
+ // 7. RESIZE INTERACTION
1539
+ // =============================================================================
1540
+
1541
+ function attachResizeInteraction(hc, col, scope, doc, setColumnWidth) {
1542
+ scope.onCleanup(bindOn(hc.resizeEl, "pointerdown", (ev) => {
1543
+ if (!ev.isPrimary || ev.button !== 0) return;
1544
+ ev.stopPropagation();
1545
+ ev.preventDefault();
1546
+ const startX = ev.clientX;
1547
+ const startW = col.width.peek();
1548
+ hc.resizeEl.classList.add("is-active");
1549
+ try { hc.resizeEl.setPointerCapture(ev.pointerId); } catch (_) {}
1550
+ const onMove = (e) => {
1551
+ setColumnWidth(col.key, startW + (e.clientX - startX));
1552
+ };
1553
+ const onUp = (e) => {
1554
+ doc.removeEventListener("pointermove", onMove);
1555
+ doc.removeEventListener("pointerup", onUp);
1556
+ doc.removeEventListener("pointercancel", onUp);
1557
+ try { hc.resizeEl.releasePointerCapture(e.pointerId); } catch (_) {}
1558
+ hc.resizeEl.classList.remove("is-active");
1559
+ };
1560
+ doc.addEventListener("pointermove", onMove);
1561
+ doc.addEventListener("pointerup", onUp);
1562
+ doc.addEventListener("pointercancel", onUp);
1563
+ }));
1564
+ }
1565
+
1566
+ // =============================================================================
1567
+ // 8. TYPEDEFS (JSDoc; canonical types in Table.d.ts)
1568
+ // =============================================================================
1569
+
1570
+ /**
1571
+ * @typedef {object} ColumnDef
1572
+ * @property {string} key
1573
+ * @property {string} [header]
1574
+ * @property {number} [width]
1575
+ * @property {number} [minWidth]
1576
+ * @property {number} [maxWidth]
1577
+ * @property {boolean} [hidden]
1578
+ * @property {"left"|"none"|"right"} [pin]
1579
+ * @property {boolean} [sortable]
1580
+ * @property {boolean} [resizable]
1581
+ * @property {boolean} [pinnable]
1582
+ * @property {boolean} [hideable]
1583
+ * @property {boolean} [reorderable]
1584
+ * @property {(row: any) => any} [accessor]
1585
+ * @property {(a: any, b: any) => number} [compare]
1586
+ */