@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/CHANGELOG.md +93 -0
- package/LICENSE.txt +21 -0
- package/README.md +802 -0
- package/Table.d.ts +227 -0
- package/Table.js +1586 -0
- package/llms.txt +262 -0
- package/package.json +71 -0
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
|
+
*/
|