@jayalfredprufrock/mobx-toolbox 0.7.0 → 0.8.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/dist/table.mjs ADDED
@@ -0,0 +1,1318 @@
1
+ import { t as useResize } from "./useResize-BhJdvtef.mjs";
2
+ import { observer } from "mobx-react-lite";
3
+ import { Fragment, createContext, memo, useContext, useEffect, useRef, useState } from "react";
4
+ import { action, comparer, computed, makeObservable, observable, reaction } from "mobx";
5
+ import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
6
+
7
+ //#region src/table/util.ts
8
+ const titleCase = (str) => {
9
+ return str.trim().replace(/([a-z])([A-Z]+)/g, "$1 $2").split(/[-_\s]+/).filter((s) => s.trim()).map((s) => s.charAt(0).toUpperCase() + s.slice(1).toLowerCase()).join(" ");
10
+ };
11
+ /**
12
+ * Resolve a column key against a row: a direct property hit wins (so a literal "a.b" property
13
+ * still works), otherwise the key is walked as a dot-path ("owner.name").
14
+ */
15
+ const getPath = (obj, path) => {
16
+ if (obj == null) return void 0;
17
+ const direct = obj[path];
18
+ if (direct !== void 0 || !path.includes(".")) return direct;
19
+ let current = obj;
20
+ for (const segment of path.split(".")) {
21
+ if (current == null) return void 0;
22
+ current = current[segment];
23
+ }
24
+ return current;
25
+ };
26
+ const asString = (value) => String(value);
27
+ /**
28
+ * Default sort comparator over extracted cell values — nullish first, numbers numerically, Dates
29
+ * chronologically, everything else by locale string.
30
+ */
31
+ const compareValues = (a, b) => {
32
+ if (a == null && b == null) return 0;
33
+ if (a == null) return -1;
34
+ if (b == null) return 1;
35
+ if (typeof a === "number" && typeof b === "number") return a - b;
36
+ if (a instanceof Date && b instanceof Date) return a.getTime() - b.getTime();
37
+ return asString(a).localeCompare(asString(b));
38
+ };
39
+
40
+ //#endregion
41
+ //#region src/table/column.model.ts
42
+ const DEFAULT_MIN_WIDTH = 120;
43
+ /** Key assigned to a selection column when its def doesn't supply one. */
44
+ const SELECTION_COLUMN_KEY = "__selection__";
45
+ var ColumnModel = class ColumnModel {
46
+ table;
47
+ config;
48
+ pinned = false;
49
+ hidden = false;
50
+ manualWidth = void 0;
51
+ /** Resolved pixel width — distributed across the viewport by the table (see `columnWidths`). */
52
+ get width() {
53
+ return this.table.columnWidths.get(this) ?? 0;
54
+ }
55
+ /** Fixed pixel width when the column isn't flexible (manual override or an explicit number). */
56
+ get fixedWidth() {
57
+ if (this.manualWidth !== void 0) return this.manualWidth;
58
+ return typeof this.config.width === "number" ? this.config.width : void 0;
59
+ }
60
+ /** Flex weight (the `N` in `"Nfr"`); `0` when fixed. An unspecified width means `1fr`. */
61
+ get grow() {
62
+ if (this.fixedWidth !== void 0) return 0;
63
+ if (typeof this.config.width === "string") {
64
+ const n = Number.parseFloat(this.config.width);
65
+ return Number.isFinite(n) && n > 0 ? n : 1;
66
+ }
67
+ return 1;
68
+ }
69
+ get minWidth() {
70
+ return this.config.minWidth ?? DEFAULT_MIN_WIDTH;
71
+ }
72
+ get maxWidth() {
73
+ return this.config.maxWidth ?? Number.POSITIVE_INFINITY;
74
+ }
75
+ get resizable() {
76
+ return this.config.resizable !== false;
77
+ }
78
+ /** Whether header UIs should offer sorting on this column (selection columns never do). */
79
+ get sortable() {
80
+ return this.config.sortable !== false && !this.selection;
81
+ }
82
+ /** Whether this is the built-in row-selection column. */
83
+ get selection() {
84
+ return this.config.selection === true;
85
+ }
86
+ /**
87
+ * True for the innermost pinned column on its side (the one bordering the scrollable area).
88
+ * Consumers hang the pinned boundary shadow off `[data-pinned-edge]` so a group of pinned
89
+ * columns shows a single shadow at the seam.
90
+ */
91
+ get isPinnedEdge() {
92
+ const siblings = this.pinnedSiblings;
93
+ return siblings ? siblings[siblings.length - 1] === this : false;
94
+ }
95
+ /**
96
+ * True for the outermost pinned column on its side (the one at the viewport edge). Used by the
97
+ * header to round its outer corners so a pinned column doesn't paint a square over the rounded
98
+ * header background. Both rendered pinned arrays are ordered outer-edge-first.
99
+ */
100
+ get isPinnedOuterEdge() {
101
+ const siblings = this.pinnedSiblings;
102
+ return siblings ? siblings[0] === this : false;
103
+ }
104
+ get offset() {
105
+ const cols = {
106
+ left: this.table.leftPinnedRenderedColumns,
107
+ right: this.table.rightPinnedRenderedColumns,
108
+ unpinned: this.table.unpinnedColumns
109
+ }[this.pinned || "unpinned"];
110
+ return cols.slice(0, cols.indexOf(this)).reduce((sum, c) => sum + c.width, 0);
111
+ }
112
+ get title() {
113
+ return this.config.title ?? titleCase(this.config.key);
114
+ }
115
+ /** 1-based visual column position (pinned blocks at the edges) — the aria-colindex value. */
116
+ get ariaColIndex() {
117
+ return this.table.visualColumns.indexOf(this) + 1;
118
+ }
119
+ get key() {
120
+ return this.config.key;
121
+ }
122
+ /** Active sort direction for this column, or undefined when it doesn't participate in the sort. */
123
+ get sortDirection() {
124
+ return this.table.sorts.find((s) => s.key === this.key)?.direction;
125
+ }
126
+ /** 1-based position in the sort priority (the "1"/"2" badge in multi-sort UIs); undefined when unsorted. */
127
+ get sortIndex() {
128
+ const index = this.table.sorts.findIndex((s) => s.key === this.key);
129
+ return index >= 0 ? index + 1 : void 0;
130
+ }
131
+ get pinnedSiblings() {
132
+ if (this.pinned === "left") return this.table.leftPinnedRenderedColumns;
133
+ if (this.pinned === "right") return this.table.rightPinnedRenderedColumns;
134
+ }
135
+ constructor(table, config) {
136
+ this.table = table;
137
+ this.config = config;
138
+ makeObservable(this, {
139
+ pinned: observable,
140
+ hidden: observable,
141
+ manualWidth: observable,
142
+ width: computed,
143
+ fixedWidth: computed,
144
+ grow: computed,
145
+ isPinnedEdge: computed,
146
+ isPinnedOuterEdge: computed,
147
+ offset: computed,
148
+ title: computed,
149
+ ariaColIndex: computed,
150
+ sortDirection: computed,
151
+ sortIndex: computed,
152
+ setPinned: action,
153
+ setManualWidth: action,
154
+ setHidden: action
155
+ });
156
+ this.setPinned(config.pinned);
157
+ }
158
+ setPinned(pinned) {
159
+ this.pinned = pinned;
160
+ }
161
+ setManualWidth(width) {
162
+ this.manualWidth = width;
163
+ }
164
+ setHidden(hidden) {
165
+ this.hidden = hidden;
166
+ }
167
+ /** Sort by this column — replaces the sort list unless `preserve: true` (see TableModel.setSort). */
168
+ sortBy(direction, opts) {
169
+ this.table.setSort(this.key, direction, opts);
170
+ }
171
+ /** Remove this column from the sort; other columns' sorts are untouched. */
172
+ clearSort() {
173
+ this.table.clearSort(this.key);
174
+ }
175
+ /** Raw cell value for a row — what sorting compares and the default render displays. */
176
+ getValue(row) {
177
+ return this.config.value(row);
178
+ }
179
+ /** Ascending comparison of two rows by this column's extracted values (`compare` def or the default). */
180
+ compareRows(a, b) {
181
+ return (this.config.compare ?? compareValues)(this.getValue(a), this.getValue(b));
182
+ }
183
+ static fromDef(table, def) {
184
+ const { render, ...config } = typeof def === "string" ? { key: def } : def;
185
+ const key = config.key ?? (config.selection ? "__selection__" : "");
186
+ const value = config.value ?? (config.selection ? () => null : (row) => getPath(row, key));
187
+ return new ColumnModel(table, {
188
+ ...config,
189
+ key,
190
+ value,
191
+ render: render ?? value
192
+ });
193
+ }
194
+ };
195
+
196
+ //#endregion
197
+ //#region src/table/components/cell-slot.tsx
198
+ /**
199
+ * Per-cell reactive boundary. `Table.Header`/`Table.Row` iterate the rendered columns and hand each
200
+ * off to a `CellSlot` rather than calling the consumer's render function inline — so the render runs
201
+ * inside *this* component's MobX reaction. The upshot: a cell re-renders only when the observables
202
+ * *it* reads change (e.g. one field of one row), never because a sibling cell or the row did.
203
+ *
204
+ * It renders a transparent fragment, so whatever the consumer returns (a `<Table.Cell>`) lands
205
+ * directly in the parent grid with no wrapper element.
206
+ */
207
+ const CellSlot = observer(({ column, render }) => {
208
+ return /* @__PURE__ */ jsx(Fragment$1, { children: render(column) });
209
+ });
210
+
211
+ //#endregion
212
+ //#region src/table/components/cell-style.ts
213
+ /**
214
+ * Structural style shared by header and body cells: pinned cells stick to their edge at the
215
+ * column's offset and must be opaque (they overlap scrolling cells). The opaque fill is a CSS var
216
+ * so the consumer owns the color — set `--table-pinned-bg` once (and override it inside the header
217
+ * to match a header background). `Canvas` is a theme-aware system default for zero-config use.
218
+ *
219
+ * These are the *only* styles the library forces on a cell; everything cosmetic (padding, font,
220
+ * borders, hover) is left to the consumer's `className`/`style`.
221
+ */
222
+ const pinnedCellStyle = (column) => {
223
+ if (!column.pinned) return { position: "relative" };
224
+ return {
225
+ position: "sticky",
226
+ [column.pinned]: column.offset,
227
+ background: "var(--table-pinned-bg, Canvas)",
228
+ zIndex: 1
229
+ };
230
+ };
231
+
232
+ //#endregion
233
+ //#region src/table/components/checkbox.tsx
234
+ /**
235
+ * Zero-config fallback used by `<Table.SelectionCell>` / `<Table.SelectAll>` when the consumer
236
+ * neither registers a `checkbox` on `<Table.Root>` nor passes a render-prop. `indeterminate` is a
237
+ * DOM-only property, so it's applied via ref rather than an attribute.
238
+ */
239
+ const NativeCheckbox = ({ checked, indeterminate = false, onChange, ...rest }) => {
240
+ const ref = useRef(null);
241
+ useEffect(() => {
242
+ if (ref.current) ref.current.indeterminate = indeterminate;
243
+ }, [indeterminate]);
244
+ return /* @__PURE__ */ jsx("input", {
245
+ ref,
246
+ type: "checkbox",
247
+ checked,
248
+ onChange,
249
+ ...rest
250
+ });
251
+ };
252
+
253
+ //#endregion
254
+ //#region src/table/components/column-resizer.tsx
255
+ /**
256
+ * Drag handle on a header cell's edge that resizes its column. Dragging sets the column's
257
+ * `manualWidth` (treated as a fixed width in the distribution, so the remaining flex columns reflow
258
+ * to fill); double-click resets it to auto. Right-pinned columns are anchored to the right, so their
259
+ * handle sits on the left edge and the drag delta is inverted.
260
+ *
261
+ * Move/up listeners live on the window for the duration of the drag, so the resize tracks and ends
262
+ * wherever the pointer is released — not only over the handle.
263
+ */
264
+ const TableResizer = observer(({ column }) => {
265
+ const [resizing, setResizing] = useState(false);
266
+ const drag = useRef(null);
267
+ const raf = useRef(void 0);
268
+ const teardown = useRef(void 0);
269
+ const onLeftEdge = column.pinned === "right";
270
+ useEffect(() => () => teardown.current?.(), []);
271
+ const beginResize = (e) => {
272
+ e.preventDefault();
273
+ e.stopPropagation();
274
+ drag.current = {
275
+ startX: e.clientX,
276
+ startWidth: column.width
277
+ };
278
+ setResizing(true);
279
+ let latestX = e.clientX;
280
+ const applyFrame = () => {
281
+ raf.current = void 0;
282
+ if (!drag.current) return;
283
+ const delta = latestX - drag.current.startX;
284
+ const next = drag.current.startWidth + (onLeftEdge ? -delta : delta);
285
+ column.setManualWidth(Math.max(column.minWidth, next));
286
+ };
287
+ const onMove = (ev) => {
288
+ latestX = ev.clientX;
289
+ if (raf.current === void 0) raf.current = requestAnimationFrame(applyFrame);
290
+ };
291
+ const stop = () => {
292
+ drag.current = null;
293
+ setResizing(false);
294
+ teardown.current?.();
295
+ };
296
+ teardown.current = () => {
297
+ window.removeEventListener("pointermove", onMove);
298
+ window.removeEventListener("pointerup", stop);
299
+ window.removeEventListener("pointercancel", stop);
300
+ if (raf.current !== void 0) {
301
+ cancelAnimationFrame(raf.current);
302
+ raf.current = void 0;
303
+ }
304
+ document.body.style.userSelect = "";
305
+ document.body.style.cursor = "";
306
+ teardown.current = void 0;
307
+ };
308
+ window.addEventListener("pointermove", onMove);
309
+ window.addEventListener("pointerup", stop);
310
+ window.addEventListener("pointercancel", stop);
311
+ document.body.style.userSelect = "none";
312
+ document.body.style.cursor = "col-resize";
313
+ };
314
+ const resetWidth = (e) => {
315
+ e.stopPropagation();
316
+ column.setManualWidth(void 0);
317
+ };
318
+ const style = {
319
+ position: "absolute",
320
+ top: 0,
321
+ ...onLeftEdge ? { left: 0 } : { right: 0 },
322
+ width: "9px",
323
+ height: "100%",
324
+ cursor: "col-resize",
325
+ touchAction: "none",
326
+ userSelect: "none",
327
+ zIndex: 1
328
+ };
329
+ return /* @__PURE__ */ jsx("div", {
330
+ role: "separator",
331
+ "aria-orientation": "vertical",
332
+ className: "column-resizer",
333
+ "data-resizing": resizing || void 0,
334
+ onPointerDown: beginResize,
335
+ onDoubleClick: resetWidth,
336
+ style
337
+ });
338
+ });
339
+
340
+ //#endregion
341
+ //#region src/table/table.context.ts
342
+ const tableContext = createContext(void 0);
343
+ const useTableContext = () => {
344
+ const context = useContext(tableContext);
345
+ if (!context) throw new Error("Table context not available. Are you within the <Table.Root /> component?");
346
+ return context;
347
+ };
348
+ const TableProvider = tableContext.Provider;
349
+ const defaultSlots = { checkbox: NativeCheckbox };
350
+ const slotsContext = createContext(defaultSlots);
351
+ const TableSlotsProvider = slotsContext.Provider;
352
+ const useTableSlots = () => useContext(slotsContext);
353
+
354
+ //#endregion
355
+ //#region src/table/components/table-body.tsx
356
+ /**
357
+ * The virtualized body. Owns the scroll-sized spacer and the `translate3d` window offset, then maps
358
+ * the rendered slice of rows through the `children` render-prop. Rows are keyed by their row id
359
+ * (see `rowIds`): by default the original index in the source array — stable under sort/filter/
360
+ * scroll and across `appendRows` — or the consumer's `getRowId`, which stays stable even when a
361
+ * refetch replaces the row objects.
362
+ */
363
+ const TableBody = observer(({ className, style, children }) => {
364
+ const table = useTableContext();
365
+ return /* @__PURE__ */ jsx("div", {
366
+ style: {
367
+ width: `${table.virtualWidth}px`,
368
+ height: `${table.virtualHeight}px`
369
+ },
370
+ children: /* @__PURE__ */ jsx("div", {
371
+ style: {
372
+ position: "absolute",
373
+ transform: `translate3d(0px, ${table.virtualOffsetY}px, 0px)`
374
+ },
375
+ children: /* @__PURE__ */ jsx("div", {
376
+ role: "rowgroup",
377
+ className,
378
+ style: {
379
+ display: "grid",
380
+ gridTemplateColumns: table.gridTemplateColumns,
381
+ ...style
382
+ },
383
+ children: table.renderedRows.map((row) => /* @__PURE__ */ jsx(Fragment, { children: children(row) }, table.rowIds.get(row)))
384
+ })
385
+ })
386
+ });
387
+ });
388
+ /**
389
+ * A single body row. Mirrors the header's layout ownership (grid subgrid, pinned/spacer ordering)
390
+ * and defers each cell to `children` via a per-cell `CellSlot`. Exposes `data-selected` so the
391
+ * consumer can highlight selected rows in CSS. Reading selection here (not row field data) keeps
392
+ * single-cell content updates from re-rendering the whole row — only a selection toggle does.
393
+ */
394
+ const TableRowInner = observer(({ row, className, style, children, ...rest }) => {
395
+ const table = useTableContext();
396
+ const displayIndex = table.displayRowIndexMap.get(row);
397
+ return /* @__PURE__ */ jsxs("div", {
398
+ ...rest,
399
+ role: "row",
400
+ "aria-rowindex": displayIndex !== void 0 ? displayIndex + 2 : void 0,
401
+ "aria-selected": table.selectable ? table.isRowSelected(row) : void 0,
402
+ "data-selected": table.isRowSelected(row) || void 0,
403
+ "data-expanded": table.isRowExpanded(row) || void 0,
404
+ className,
405
+ style: {
406
+ height: `${table.rowHeight}px`,
407
+ display: "grid",
408
+ gridColumn: "1 / -1",
409
+ gridTemplateColumns: "subgrid",
410
+ alignItems: "stretch",
411
+ textAlign: "left",
412
+ ...style
413
+ },
414
+ children: [
415
+ table.leftPinnedRenderedColumns.map((col) => /* @__PURE__ */ jsx(CellSlot, {
416
+ column: col,
417
+ render: children
418
+ }, col.key)),
419
+ /* @__PURE__ */ jsx("div", { role: "presentation" }),
420
+ table.unpinnedRenderedColumns.map((col) => /* @__PURE__ */ jsx(CellSlot, {
421
+ column: col,
422
+ render: children
423
+ }, col.key)),
424
+ table.rightPinnedRenderedColumns.map((col) => /* @__PURE__ */ jsx(CellSlot, {
425
+ column: col,
426
+ render: children
427
+ }, col.key))
428
+ ]
429
+ });
430
+ });
431
+ /**
432
+ * Memoized on identity so scrolling (and filtering) only renders rows that actually entered/changed.
433
+ * `children` (the per-column render-prop) gets a fresh closure on every `TableBody` render, so it is
434
+ * *deliberately excluded* from the comparison — for a row still in the window that closure is
435
+ * equivalent (it closes over the same `row` and reads row/column state live). Every other prop —
436
+ * including pass-through DOM props like `onClick` — is compared shallowly. Layout changes still
437
+ * flow through because the inner `observer` re-renders on the column/width observables it reads, and
438
+ * per-cell data changes flow through each `CellSlot`'s own observer — neither is gated by this memo.
439
+ * (Pass stable `className`/`style`/handlers, not fresh inline values, or the row re-renders every frame.)
440
+ */
441
+ const TableRow = memo(TableRowInner, (prev, next) => {
442
+ const keys = new Set([...Object.keys(prev), ...Object.keys(next)]);
443
+ for (const key of keys) {
444
+ if (key === "children") continue;
445
+ if (prev[key] !== next[key]) return false;
446
+ }
447
+ return true;
448
+ });
449
+ /**
450
+ * A single body cell. Owns pinning/offset/`data-pinned*`; cosmetics are the consumer's via
451
+ * `className`/`style`, other DOM props pass through.
452
+ */
453
+ const TableCell = observer(({ column, children, className, style, ...rest }) => {
454
+ return /* @__PURE__ */ jsx("div", {
455
+ ...rest,
456
+ role: "cell",
457
+ "aria-colindex": column.ariaColIndex,
458
+ "data-pinned": column.pinned || void 0,
459
+ "data-pinned-corner": column.pinned && column.isPinnedOuterEdge && column.pinned || void 0,
460
+ "data-pinned-edge": column.isPinnedEdge || void 0,
461
+ className,
462
+ style: {
463
+ ...pinnedCellStyle(column),
464
+ ...style
465
+ },
466
+ children
467
+ });
468
+ });
469
+
470
+ //#endregion
471
+ //#region src/table/components/table-header.tsx
472
+ /**
473
+ * The sticky header row group. Owns layout — the grid track template, the left-pinned / spacer /
474
+ * unpinned / right-pinned ordering — and defers each cell's content to the `children` render-prop
475
+ * via a per-cell `CellSlot`.
476
+ */
477
+ const TableHeader = observer(({ className, style, children }) => {
478
+ const table = useTableContext();
479
+ return /* @__PURE__ */ jsx("div", {
480
+ role: "rowgroup",
481
+ className: ["table-header", className].filter(Boolean).join(" "),
482
+ style: {
483
+ position: "sticky",
484
+ top: 0,
485
+ zIndex: 20,
486
+ width: `${table.virtualWidth}px`,
487
+ display: "grid",
488
+ gridTemplateColumns: table.gridTemplateColumns,
489
+ ...style
490
+ },
491
+ children: /* @__PURE__ */ jsxs("div", {
492
+ role: "row",
493
+ "aria-rowindex": 1,
494
+ style: {
495
+ gridColumn: "1 / -1",
496
+ gridRow: "1",
497
+ height: `${table.rowHeight}px`,
498
+ display: "grid",
499
+ gridTemplateColumns: "subgrid",
500
+ alignItems: "stretch",
501
+ textAlign: "left"
502
+ },
503
+ children: [
504
+ table.leftPinnedRenderedColumns.map((col) => /* @__PURE__ */ jsx(CellSlot, {
505
+ column: col,
506
+ render: children
507
+ }, col.key)),
508
+ /* @__PURE__ */ jsx("div", { role: "presentation" }),
509
+ table.unpinnedRenderedColumns.map((col) => /* @__PURE__ */ jsx(CellSlot, {
510
+ column: col,
511
+ render: children
512
+ }, col.key)),
513
+ table.rightPinnedRenderedColumns.map((col) => /* @__PURE__ */ jsx(CellSlot, {
514
+ column: col,
515
+ render: children
516
+ }, col.key))
517
+ ]
518
+ })
519
+ });
520
+ });
521
+ /**
522
+ * A single header cell. Owns the structural bits (sticky pinning, offset, `data-pinned*`) and stays
523
+ * cosmetically open — the consumer's `className`/`style` add padding, font, borders, etc.; other
524
+ * DOM props pass through.
525
+ */
526
+ const TableColumnHeader = observer(({ column, children, className, style, ...rest }) => {
527
+ return /* @__PURE__ */ jsx("div", {
528
+ ...rest,
529
+ role: "columnheader",
530
+ "aria-colindex": column.ariaColIndex,
531
+ "aria-sort": column.sortDirection ? column.sortDirection === "asc" ? "ascending" : "descending" : void 0,
532
+ "data-pinned": column.pinned || void 0,
533
+ "data-pinned-edge": column.isPinnedEdge || void 0,
534
+ "data-pinned-corner": column.pinned && column.isPinnedOuterEdge && column.pinned || void 0,
535
+ className,
536
+ style: {
537
+ ...pinnedCellStyle(column),
538
+ scrollSnapAlign: column.pinned ? void 0 : "start",
539
+ ...style
540
+ },
541
+ children
542
+ });
543
+ });
544
+
545
+ //#endregion
546
+ //#region src/table/components/selection.tsx
547
+ const centerStyle = {
548
+ display: "flex",
549
+ alignItems: "center",
550
+ justifyContent: "center"
551
+ };
552
+ /** A body cell wired to per-row selection. Renders the registered checkbox unless given a render-prop. */
553
+ const SelectionCell = observer(({ column, row, children }) => {
554
+ const table = useTableContext();
555
+ const { checkbox: Checkbox } = useTableSlots();
556
+ const state = {
557
+ checked: table.isRowSelected(row),
558
+ onChange: () => table.toggleRow(row)
559
+ };
560
+ return /* @__PURE__ */ jsx(TableCell, {
561
+ column,
562
+ style: centerStyle,
563
+ children: children ? children(state) : /* @__PURE__ */ jsx(Checkbox, {
564
+ ...state,
565
+ "aria-label": "Select row"
566
+ })
567
+ });
568
+ });
569
+ /** The select-all control (checked / indeterminate / none). Place inside a header cell, or use `<Table.SelectionHeaderCell>`. */
570
+ const SelectAll = observer(({ children }) => {
571
+ const table = useTableContext();
572
+ const { checkbox: Checkbox } = useTableSlots();
573
+ const state = {
574
+ checked: table.allRowsSelected,
575
+ indeterminate: table.someRowsSelected,
576
+ onChange: () => table.toggleAllRows()
577
+ };
578
+ return children ? /* @__PURE__ */ jsx(Fragment$1, { children: children(state) }) : /* @__PURE__ */ jsx(Checkbox, {
579
+ ...state,
580
+ "aria-label": "Select all rows"
581
+ });
582
+ });
583
+ /** A header cell holding the centered select-all control — the header twin of `<Table.SelectionCell>`. */
584
+ const SelectionHeaderCell = observer(({ column, children }) => {
585
+ return /* @__PURE__ */ jsx(TableColumnHeader, {
586
+ column,
587
+ style: centerStyle,
588
+ children: /* @__PURE__ */ jsx(SelectAll, { children })
589
+ });
590
+ });
591
+
592
+ //#endregion
593
+ //#region src/table/components/table-empty.tsx
594
+ /**
595
+ * The empty-state surface. Render it after `<Table.Body>`, gated by the consumer — e.g.
596
+ * `table.displayRows.length === 0 && <Table.Empty>…</Table.Empty>` — the library never decides
597
+ * what "empty" means or what to say about it (no rows vs. filtered-out are different stories,
598
+ * and only the consumer knows the words and recovery actions).
599
+ *
600
+ * Owns placement only: fills the viewport below the sticky header (subtracting the theme-owned
601
+ * header vars, falling back to the row height) and pins horizontally like the header pill, so
602
+ * children center in the visible area at any horizontal scroll offset. Cosmetics are the
603
+ * consumer's; `data-empty` is the styling hook.
604
+ */
605
+ const TableEmpty = observer(({ children, className, style, ...rest }) => {
606
+ const table = useTableContext();
607
+ return /* @__PURE__ */ jsx("div", {
608
+ ...rest,
609
+ "data-empty": "",
610
+ className,
611
+ style: {
612
+ position: "sticky",
613
+ left: 0,
614
+ width: "var(--table-viewport-width)",
615
+ height: `calc(${table.height}px - var(--table-header-height, ${table.rowHeight}px) - var(--table-header-gap, 0px))`,
616
+ display: "flex",
617
+ alignItems: "center",
618
+ justifyContent: "center",
619
+ ...style
620
+ },
621
+ children
622
+ });
623
+ });
624
+
625
+ //#endregion
626
+ //#region src/table/components/table-expansion.tsx
627
+ /**
628
+ * The detail panel below an expanded row. Render it as a sibling immediately after the row's
629
+ * `<Table.Row>` inside `<Table.Body>`'s render-prop, gated on `table.isRowExpanded(row)`.
630
+ *
631
+ * Owns the geometry contract: the block is exactly `expansionHeight` tall (taller content scrolls
632
+ * internally), and the cell pins to the viewport (`sticky` + explicit width — the same trick as
633
+ * the header background) so horizontal scrolling moves the columns underneath the panel, not the
634
+ * panel itself. Cosmetics are the consumer's via `className`/`style`.
635
+ */
636
+ const TableExpansionInner = observer(({ className, style, children }) => {
637
+ return /* @__PURE__ */ jsx("div", {
638
+ role: "row",
639
+ "data-expansion": "",
640
+ style: {
641
+ gridColumn: "1 / -1",
642
+ height: `${useTableContext().expansionHeight}px`,
643
+ minWidth: 0
644
+ },
645
+ children: /* @__PURE__ */ jsx("div", {
646
+ role: "cell",
647
+ "data-expansion": "",
648
+ className,
649
+ style: {
650
+ position: "sticky",
651
+ left: 0,
652
+ width: "var(--table-viewport-width)",
653
+ height: "100%",
654
+ overflowY: "auto",
655
+ ...style
656
+ },
657
+ children
658
+ })
659
+ });
660
+ });
661
+ /**
662
+ * Memoized on row identity like `TableRow`, with `children` deliberately excluded: the panel's
663
+ * element tree is rebuilt by the body render-prop every window shift, but for the same row it is
664
+ * equivalent. Panel content must derive from `row` (or be an observer reading live state) — not
665
+ * from other values captured in the render-prop closure.
666
+ */
667
+ const TableExpansion = memo(TableExpansionInner, (prev, next) => prev.row === next.row && prev.className === next.className && prev.style === next.style);
668
+
669
+ //#endregion
670
+ //#region src/table/use-scroll.ts
671
+ /**
672
+ * Reports a scroll container's offsets on every scroll event.
673
+ *
674
+ * `onScroll` is read through a ref so an inline arrow doesn't re-subscribe on every render — the
675
+ * table's root re-renders as the window shifts, and re-attaching the listener each time would be
676
+ * pure waste.
677
+ */
678
+ const useScroll = (ref, onScroll) => {
679
+ const onScrollRef = useRef(onScroll);
680
+ onScrollRef.current = onScroll;
681
+ useEffect(() => {
682
+ const scrollContainer = ref.current;
683
+ if (!scrollContainer) return;
684
+ const handleScroll = () => onScrollRef.current(scrollContainer.scrollLeft, scrollContainer.scrollTop);
685
+ scrollContainer.addEventListener("scroll", handleScroll, { passive: true });
686
+ return () => scrollContainer.removeEventListener("scroll", handleScroll);
687
+ }, [ref]);
688
+ };
689
+
690
+ //#endregion
691
+ //#region src/table/components/table-root.tsx
692
+ /**
693
+ * The table skeleton: a non-scrolling viewport wrapper (owns measured size + the shared
694
+ * `--table-row-height` var) around the scroll container that everything else renders into. This is
695
+ * the only structural piece consumers mount directly; header/body compose inside it.
696
+ */
697
+ const TableRoot = observer(({ table, children, style, className, checkbox }) => {
698
+ const viewportRef = useRef(null);
699
+ const scrollContainerRef = useRef(null);
700
+ useScroll(scrollContainerRef, (x, y) => table.setScroll(x, y));
701
+ useResize(viewportRef, (_width, height) => table.setHeight(height));
702
+ useResize(scrollContainerRef, (width) => table.setWidth(width));
703
+ useEffect(() => reaction(() => table.scrollRequest, (request) => {
704
+ const container = scrollContainerRef.current;
705
+ if (!request || !container) return;
706
+ container.scrollTo({ top: request.y === "end" ? container.scrollHeight : request.y });
707
+ table.clearScrollRequest();
708
+ }), [table, scrollContainerRef]);
709
+ return /* @__PURE__ */ jsx(TableProvider, {
710
+ value: table,
711
+ children: /* @__PURE__ */ jsx(TableSlotsProvider, {
712
+ value: { checkbox: checkbox ?? NativeCheckbox },
713
+ children: /* @__PURE__ */ jsx("div", {
714
+ ref: viewportRef,
715
+ className: "table-viewport",
716
+ style: {
717
+ width: "100%",
718
+ height: "100%",
719
+ position: "relative",
720
+ "--table-row-height": `${table.rowHeight}px`
721
+ },
722
+ children: /* @__PURE__ */ jsx("div", {
723
+ ref: scrollContainerRef,
724
+ role: "table",
725
+ "aria-rowcount": table.displayRows.length + 1,
726
+ "aria-colcount": table.orderedColumns.length,
727
+ "aria-multiselectable": table.selectable || void 0,
728
+ className,
729
+ style: {
730
+ position: "relative",
731
+ overflow: "auto",
732
+ containerType: "scroll-state",
733
+ scrollSnapType: "x proximity",
734
+ scrollPaddingLeft: `${table.leftPinnedRenderedColumns.reduce((sum, c) => sum + c.width, 0)}px`,
735
+ width: "100%",
736
+ maxHeight: `${table.height}px`,
737
+ "--table-viewport-width": `${table.width}px`,
738
+ ...style
739
+ },
740
+ children: table.width > 0 && table.height > 0 && children
741
+ })
742
+ })
743
+ })
744
+ });
745
+ });
746
+
747
+ //#endregion
748
+ //#region src/table/components/namespace.tsx
749
+ /**
750
+ * Compound namespace for the table skeleton. Consumers compose these into their own closed
751
+ * component (styles + defaults captured once), e.g. `<Table.Root><Table.Header>…`.
752
+ */
753
+ const Table = {
754
+ Root: TableRoot,
755
+ Header: TableHeader,
756
+ ColumnHeader: TableColumnHeader,
757
+ Body: TableBody,
758
+ Row: TableRow,
759
+ Cell: TableCell,
760
+ Empty: TableEmpty,
761
+ Expansion: TableExpansion,
762
+ Resizer: TableResizer,
763
+ SelectionCell,
764
+ SelectionHeaderCell,
765
+ SelectAll
766
+ };
767
+
768
+ //#endregion
769
+ //#region src/table/table.model.ts
770
+ var TableModel = class {
771
+ config;
772
+ rows = [];
773
+ columns = /* @__PURE__ */ new Map();
774
+ columnOrder = [];
775
+ filterSources = [];
776
+ scrollX = 0;
777
+ scrollY = 0;
778
+ height = 0;
779
+ width = 0;
780
+ sorts = [];
781
+ selectedIds = /* @__PURE__ */ new Set();
782
+ expandedIds = /* @__PURE__ */ new Set();
783
+ scrollRequest = void 0;
784
+ appliedState;
785
+ stateReactionDisposer;
786
+ get rowHeight() {
787
+ return this.config?.rowHeight ?? 40;
788
+ }
789
+ get rowOverscan() {
790
+ return this.config?.rowOverscan ?? 3;
791
+ }
792
+ get expansionHeight() {
793
+ return this.config?.expansionHeight ?? 320;
794
+ }
795
+ get columnOverscan() {
796
+ return this.config?.columnOverscan ?? 1;
797
+ }
798
+ get rowIds() {
799
+ const getRowId = this.config?.getRowId;
800
+ return new Map(this.rows.map((row, i) => [row, getRowId ? getRowId(row, i) : i]));
801
+ }
802
+ get allColumns() {
803
+ return this.columnOrder.flatMap((key) => {
804
+ const col = this.columns.get(key);
805
+ return col ? [col] : [];
806
+ });
807
+ }
808
+ get orderedColumns() {
809
+ return this.allColumns.filter((c) => !c.hidden);
810
+ }
811
+ /**
812
+ * Resolved pixel width for every column, distributed across the viewport (`width`).
813
+ * Fixed columns (explicit px or a manual override) claim their width; the rest are flex
814
+ * (`"Nfr"`, default `1fr`) and share the remaining space by weight, clamped to
815
+ * [minWidth, maxWidth] via a freeze-redistribute pass (a column that hits a clamp is frozen
816
+ * and its share is re-split among the others). Any leftover slack — every flex column capped
817
+ * at its max — is absorbed by the last column so the columns always fill the viewport (this
818
+ * also soaks up sub-pixel rounding). When the minimums don't fit, the total exceeds the
819
+ * viewport and the table scrolls horizontally.
820
+ */
821
+ get columnWidths() {
822
+ const cols = this.orderedColumns;
823
+ const result = /* @__PURE__ */ new Map();
824
+ if (cols.length === 0) return result;
825
+ const flex = [];
826
+ let fixedTotal = 0;
827
+ for (const col of cols) if (col.fixedWidth !== void 0) {
828
+ result.set(col, col.fixedWidth);
829
+ fixedTotal += col.fixedWidth;
830
+ } else flex.push(col);
831
+ const free = this.width - fixedTotal;
832
+ const frozen = /* @__PURE__ */ new Set();
833
+ while (frozen.size < flex.length) {
834
+ const active = flex.filter((c) => !frozen.has(c));
835
+ const remaining = free - flex.reduce((sum, c) => sum + (frozen.has(c) ? result.get(c) ?? 0 : 0), 0);
836
+ const totalGrow = active.reduce((sum, c) => sum + c.grow, 0);
837
+ if (totalGrow <= 0) {
838
+ for (const c of active) result.set(c, c.minWidth);
839
+ break;
840
+ }
841
+ let clamped = false;
842
+ for (const c of active) {
843
+ const share = remaining * c.grow / totalGrow;
844
+ if (share < c.minWidth) {
845
+ result.set(c, c.minWidth);
846
+ frozen.add(c);
847
+ clamped = true;
848
+ } else if (share > c.maxWidth) {
849
+ result.set(c, c.maxWidth);
850
+ frozen.add(c);
851
+ clamped = true;
852
+ }
853
+ }
854
+ if (!clamped) {
855
+ for (const c of active) result.set(c, remaining * c.grow / totalGrow);
856
+ break;
857
+ }
858
+ }
859
+ const used = cols.reduce((sum, c) => sum + (result.get(c) ?? 0), 0);
860
+ const slack = this.width - used;
861
+ if (slack > 0) {
862
+ const sink = cols[cols.length - 1];
863
+ result.set(sink, (result.get(sink) ?? 0) + slack);
864
+ }
865
+ return result;
866
+ }
867
+ get virtualWidth() {
868
+ return this.orderedColumns.reduce((sum, col) => sum + col.width, 0);
869
+ }
870
+ get virtualHeight() {
871
+ return this.filteredRows.length * this.rowHeight + this.expandedDisplayIndices.length * this.expansionHeight;
872
+ }
873
+ get expandedDisplayIndices() {
874
+ if (!this.expandedIds.size) return [];
875
+ const indices = [];
876
+ this.displayRows.forEach((row, i) => {
877
+ const id = this.rowIds.get(row);
878
+ if (id !== void 0 && this.expandedIds.has(id)) indices.push(i);
879
+ });
880
+ return indices;
881
+ }
882
+ get unpinnedColumns() {
883
+ return this.orderedColumns.filter((c) => !c.pinned);
884
+ }
885
+ get firstUnpinnedRenderedIndex() {
886
+ const firstVisibleIndex = this.unpinnedColumns.findIndex((col) => col.offset >= this.scrollX);
887
+ return Math.max(0, firstVisibleIndex - this.columnOverscan);
888
+ }
889
+ get lastUnpinnedRenderedIndex() {
890
+ const first = this.firstUnpinnedRenderedIndex;
891
+ const maxOffset = this.scrollX + this.width;
892
+ let lastVisibleIndex = this.unpinnedColumns.length - 1;
893
+ for (let i = first; i < this.unpinnedColumns.length; i++) if ((this.unpinnedColumns[i]?.offset ?? 0) >= maxOffset) {
894
+ lastVisibleIndex = i;
895
+ break;
896
+ }
897
+ return Math.min(this.unpinnedColumns.length - 1, lastVisibleIndex + this.columnOverscan);
898
+ }
899
+ get unpinnedRenderedColumns() {
900
+ return this.unpinnedColumns.slice(this.firstUnpinnedRenderedIndex, this.lastUnpinnedRenderedIndex + 1);
901
+ }
902
+ get leftPinnedRenderedColumns() {
903
+ return this.orderedColumns.filter((c) => c.pinned === "left");
904
+ }
905
+ get rightPinnedRenderedColumns() {
906
+ return this.orderedColumns.filter((c) => c.pinned === "right").reverse();
907
+ }
908
+ get filteredRows() {
909
+ const predicates = this.filterSources.flatMap((s) => s.predicate ? [s.predicate] : []);
910
+ if (!predicates.length) return this.rows;
911
+ return this.rows.filter((r) => predicates.every((p) => p(r)));
912
+ }
913
+ get displayRows() {
914
+ const rows = this.filteredRows;
915
+ if (this.config?.sortMode === "manual") return rows;
916
+ const active = this.sorts.flatMap(({ key, direction }) => {
917
+ const col = this.columns.get(key);
918
+ return col ? [{
919
+ col,
920
+ dir: direction === "desc" ? -1 : 1
921
+ }] : [];
922
+ });
923
+ if (!active.length) return rows;
924
+ return [...rows].sort((a, b) => {
925
+ for (const { col, dir } of active) {
926
+ const result = col.compareRows(a, b) * dir;
927
+ if (result !== 0) return result;
928
+ }
929
+ return 0;
930
+ });
931
+ }
932
+ get firstRenderedIndex() {
933
+ return Math.max(0, this.indexAtOffset(this.scrollY) - this.rowOverscan);
934
+ }
935
+ get lastRenderedIndex() {
936
+ const lastVisibleIndex = this.indexAtOffset(this.scrollY + this.height);
937
+ return Math.min(this.displayRows.length - 1, lastVisibleIndex + this.rowOverscan);
938
+ }
939
+ get renderedRows() {
940
+ return this.displayRows.slice(this.firstRenderedIndex, this.lastRenderedIndex + 1);
941
+ }
942
+ get virtualOffsetX() {
943
+ return this.unpinnedRenderedColumns.at(0)?.offset ?? 0;
944
+ }
945
+ get virtualOffsetY() {
946
+ return this.blockOffset(this.firstRenderedIndex);
947
+ }
948
+ get renderedColumns() {
949
+ return [
950
+ ...this.leftPinnedRenderedColumns,
951
+ ...this.unpinnedRenderedColumns,
952
+ ...this.rightPinnedRenderedColumns
953
+ ];
954
+ }
955
+ get visualColumns() {
956
+ return [
957
+ ...this.leftPinnedRenderedColumns,
958
+ ...this.unpinnedColumns,
959
+ ...this.rightPinnedRenderedColumns.slice().reverse()
960
+ ];
961
+ }
962
+ get displayRowIndexMap() {
963
+ return new Map(this.displayRows.map((row, i) => [row, i]));
964
+ }
965
+ /** Whether the table has a selection column (drives aria-multiselectable / aria-selected). */
966
+ get selectable() {
967
+ return this.allColumns.some((c) => c.selection);
968
+ }
969
+ get gridTemplateColumns() {
970
+ const cols = [];
971
+ cols.push(...this.leftPinnedRenderedColumns.map((c) => `${c.width}px`));
972
+ cols.push(`${this.virtualOffsetX}px`);
973
+ cols.push(...this.unpinnedRenderedColumns.map((c) => `${c.width}px`));
974
+ cols.push(...this.rightPinnedRenderedColumns.map((c) => `${c.width}px`));
975
+ return cols.join(" ");
976
+ }
977
+ /** Whether the viewport is scrolled to (within one row of) the end of the content. */
978
+ get atEnd() {
979
+ return this.scrollY + this.height >= this.virtualHeight - this.rowHeight;
980
+ }
981
+ /** The selected row objects, in source order. Derived from `selectedIds`, so ids without a
982
+ * matching row (possible only if a consumer mutates `selectedIds` directly) drop out. */
983
+ get selectedRows() {
984
+ const selected = [];
985
+ for (const [row, id] of this.rowIds) if (this.selectedIds.has(id)) selected.push(row);
986
+ return selected;
987
+ }
988
+ get allRowsSelected() {
989
+ return this.filteredRows.length > 0 && this.selectedRows.length >= this.filteredRows.length;
990
+ }
991
+ get someRowsSelected() {
992
+ return this.selectedRows.length > 0 && !this.allRowsSelected;
993
+ }
994
+ constructor(config) {
995
+ this.config = config;
996
+ makeObservable(this, {
997
+ rows: observable.ref,
998
+ columns: observable,
999
+ columnOrder: observable.ref,
1000
+ filterSources: observable.ref,
1001
+ scrollX: observable,
1002
+ scrollY: observable,
1003
+ height: observable,
1004
+ width: observable,
1005
+ sorts: observable.ref,
1006
+ selectedIds: observable.shallow,
1007
+ expandedIds: observable.shallow,
1008
+ scrollRequest: observable.ref,
1009
+ rowIds: computed,
1010
+ allColumns: computed,
1011
+ orderedColumns: computed,
1012
+ columnWidths: computed,
1013
+ virtualWidth: computed,
1014
+ virtualHeight: computed,
1015
+ expandedDisplayIndices: computed,
1016
+ unpinnedColumns: computed,
1017
+ firstUnpinnedRenderedIndex: computed,
1018
+ lastUnpinnedRenderedIndex: computed,
1019
+ unpinnedRenderedColumns: computed,
1020
+ leftPinnedRenderedColumns: computed,
1021
+ rightPinnedRenderedColumns: computed,
1022
+ filteredRows: computed,
1023
+ displayRows: computed,
1024
+ firstRenderedIndex: computed,
1025
+ lastRenderedIndex: computed,
1026
+ renderedRows: computed,
1027
+ virtualOffsetX: computed,
1028
+ virtualOffsetY: computed,
1029
+ renderedColumns: computed,
1030
+ visualColumns: computed,
1031
+ displayRowIndexMap: computed,
1032
+ selectable: computed,
1033
+ gridTemplateColumns: computed,
1034
+ atEnd: computed,
1035
+ selectedRows: computed,
1036
+ allRowsSelected: computed,
1037
+ someRowsSelected: computed,
1038
+ applyState: action.bound,
1039
+ setFilter: action.bound,
1040
+ syncColumns: action,
1041
+ moveColumn: action.bound,
1042
+ setRows: action,
1043
+ appendRows: action.bound,
1044
+ setScroll: action.bound,
1045
+ scrollToRow: action.bound,
1046
+ scrollToEnd: action.bound,
1047
+ clearScrollRequest: action.bound,
1048
+ setWidth: action.bound,
1049
+ setHeight: action.bound,
1050
+ setSort: action.bound,
1051
+ setSorts: action.bound,
1052
+ clearSort: action.bound,
1053
+ toggleRow: action.bound,
1054
+ selectAllRows: action.bound,
1055
+ clearSelection: action.bound,
1056
+ toggleRowExpanded: action.bound,
1057
+ collapseAllRows: action.bound,
1058
+ toggleAllRows: action.bound
1059
+ });
1060
+ if (config?.filter) this.setFilter(config.filter);
1061
+ if (config?.rows) this.setRows(config.rows);
1062
+ this.activate();
1063
+ }
1064
+ /**
1065
+ * (Re)start the `onStateChange` reaction. Pairs with `dispose` — `useTable` calls both across
1066
+ * effect cycles, so a StrictMode dev remount (mount → cleanup → mount against the same model)
1067
+ * re-arms the reaction instead of leaving the surviving model deaf. No-op when already active
1068
+ * or when the config has no `onStateChange`.
1069
+ */
1070
+ activate() {
1071
+ const onStateChange = this.config?.onStateChange;
1072
+ if (onStateChange && !this.stateReactionDisposer) this.stateReactionDisposer = reaction(() => this.getState(), onStateChange, { equals: comparer.structural });
1073
+ }
1074
+ /** Drop the `onStateChange` reaction. Pairs with `activate`. */
1075
+ dispose() {
1076
+ this.stateReactionDisposer?.();
1077
+ this.stateReactionDisposer = void 0;
1078
+ }
1079
+ rowId(row) {
1080
+ return this.rowIds.get(row);
1081
+ }
1082
+ /** Snapshot of the user-curated arrangement (see `TableState`). JSON-serializable. */
1083
+ getState() {
1084
+ const columns = {};
1085
+ for (const col of this.allColumns) {
1086
+ const entry = {
1087
+ hidden: col.hidden,
1088
+ pinned: col.pinned || false
1089
+ };
1090
+ if (col.manualWidth !== void 0) entry.width = col.manualWidth;
1091
+ columns[col.key] = entry;
1092
+ }
1093
+ return {
1094
+ columnOrder: this.columnOrder.slice(),
1095
+ columns,
1096
+ sorts: this.sorts.map((s) => ({ ...s }))
1097
+ };
1098
+ }
1099
+ /**
1100
+ * Restore a (possibly partial) snapshot. Keys with no matching column are kept aside and land
1101
+ * when a matching column appears (see `appliedState`); columns the snapshot doesn't mention are
1102
+ * left as they are, ordered after the snapshot's columns.
1103
+ */
1104
+ applyState(state) {
1105
+ this.appliedState = {
1106
+ ...this.appliedState,
1107
+ ...state
1108
+ };
1109
+ if (state.columns) for (const col of this.columns.values()) this.applyColumnState(col);
1110
+ if (state.columnOrder) this.columnOrder = this.mergedOrder(state.columnOrder);
1111
+ if (state.sorts) this.sorts = state.sorts.map((s) => ({ ...s }));
1112
+ }
1113
+ /** Replace the client-side filter source(s). Pass `undefined` to clear. */
1114
+ setFilter(filter) {
1115
+ this.filterSources = filter ? [filter].flat() : [];
1116
+ }
1117
+ /** Move a column to a new index in the display order. */
1118
+ moveColumn(key, toIndex) {
1119
+ const from = this.columnOrder.indexOf(key);
1120
+ if (from < 0) return;
1121
+ const order = this.columnOrder.slice();
1122
+ order.splice(from, 1);
1123
+ order.splice(Math.max(0, Math.min(order.length, toIndex)), 0, key);
1124
+ this.columnOrder = order;
1125
+ }
1126
+ /** Replace the dataset. Row-keyed state (selection, expansion) is reset — ids from the old world
1127
+ * (indices by default) must not silently attach to new rows. Use `appendRows` to add without resetting. */
1128
+ setRows(rows) {
1129
+ this.rows = rows;
1130
+ this.syncColumns();
1131
+ this.selectedIds.clear();
1132
+ this.expandedIds.clear();
1133
+ }
1134
+ /** Append rows without resetting row-keyed state — the "load more" path. Existing rows keep
1135
+ * their positions, so default (index) ids stay stable and selection survives. */
1136
+ appendRows(rows) {
1137
+ this.rows = [...this.rows, ...rows];
1138
+ this.syncColumns();
1139
+ }
1140
+ setScroll(x, y) {
1141
+ this.scrollX = x;
1142
+ this.scrollY = y;
1143
+ }
1144
+ /** Content offset of a display index's block top (row plus any expansion panels above it). */
1145
+ blockOffset(index) {
1146
+ return index * this.rowHeight + this.expandedAbove(index) * this.expansionHeight;
1147
+ }
1148
+ /** Scroll so the row's block top lands at the viewport top, or its block end at the bottom. */
1149
+ scrollToRow(row, align = "top") {
1150
+ const index = this.displayRowIndexMap.get(row);
1151
+ if (index === void 0) return;
1152
+ if (align === "top") {
1153
+ this.scrollRequest = { y: this.blockOffset(index) };
1154
+ return;
1155
+ }
1156
+ const id = this.rowIds.get(row);
1157
+ const expanded = id !== void 0 && this.expandedIds.has(id);
1158
+ const blockEnd = this.blockOffset(index) + this.rowHeight + (expanded ? this.expansionHeight : 0);
1159
+ this.scrollRequest = { y: Math.max(0, blockEnd - this.height) };
1160
+ }
1161
+ /** Scroll to the very end of the content. */
1162
+ scrollToEnd() {
1163
+ this.scrollRequest = { y: "end" };
1164
+ }
1165
+ clearScrollRequest() {
1166
+ this.scrollRequest = void 0;
1167
+ }
1168
+ setWidth(width) {
1169
+ this.width = width;
1170
+ }
1171
+ setHeight(height) {
1172
+ this.height = height;
1173
+ }
1174
+ /**
1175
+ * Set a column's sort. By default the whole sort list is replaced (single-sort behavior).
1176
+ * With `preserve: true` existing sorts are kept: a column already in the list changes
1177
+ * direction in place (keeping its priority), a new column is appended at the lowest priority.
1178
+ */
1179
+ setSort(key, direction, opts) {
1180
+ if (!opts?.preserve) {
1181
+ this.sorts = [{
1182
+ key,
1183
+ direction
1184
+ }];
1185
+ return;
1186
+ }
1187
+ const sorts = this.sorts.slice();
1188
+ const existing = sorts.findIndex((s) => s.key === key);
1189
+ if (existing >= 0) sorts[existing] = {
1190
+ key,
1191
+ direction
1192
+ };
1193
+ else sorts.push({
1194
+ key,
1195
+ direction
1196
+ });
1197
+ this.sorts = sorts;
1198
+ }
1199
+ /** Replace the whole sort list at once (restoring a saved view); `setSort` covers per-column interactions. */
1200
+ setSorts(sorts) {
1201
+ this.sorts = sorts.map((s) => ({ ...s }));
1202
+ }
1203
+ /** Remove one column from the sort (later entries move up in priority), or all sorts when no key is given. */
1204
+ clearSort(key) {
1205
+ this.sorts = key === void 0 ? [] : this.sorts.filter((s) => s.key !== key);
1206
+ }
1207
+ isRowSelected(row) {
1208
+ const id = this.rowIds.get(row);
1209
+ return id !== void 0 && this.selectedIds.has(id);
1210
+ }
1211
+ toggleRow(row) {
1212
+ const id = this.rowIds.get(row);
1213
+ if (id === void 0) return;
1214
+ if (this.selectedIds.has(id)) this.selectedIds.delete(id);
1215
+ else this.selectedIds.add(id);
1216
+ }
1217
+ selectAllRows() {
1218
+ this.selectedIds.clear();
1219
+ for (const row of this.filteredRows) {
1220
+ const id = this.rowIds.get(row);
1221
+ if (id !== void 0) this.selectedIds.add(id);
1222
+ }
1223
+ }
1224
+ clearSelection() {
1225
+ this.selectedIds.clear();
1226
+ }
1227
+ toggleAllRows() {
1228
+ if (this.allRowsSelected) this.clearSelection();
1229
+ else this.selectAllRows();
1230
+ }
1231
+ isRowExpanded(row) {
1232
+ const id = this.rowIds.get(row);
1233
+ return id !== void 0 && this.expandedIds.has(id);
1234
+ }
1235
+ toggleRowExpanded(row) {
1236
+ const id = this.rowIds.get(row);
1237
+ if (id === void 0) return;
1238
+ if (this.expandedIds.has(id)) this.expandedIds.delete(id);
1239
+ else {
1240
+ if (this.config?.expandMode === "single") this.expandedIds.clear();
1241
+ this.expandedIds.add(id);
1242
+ }
1243
+ }
1244
+ collapseAllRows() {
1245
+ this.expandedIds.clear();
1246
+ }
1247
+ syncColumns() {
1248
+ const firstRow = this.rows?.at(0);
1249
+ const syncedColumns = (this.config?.columns ?? Object.keys(firstRow ?? {})).flatMap((defOrFactory) => {
1250
+ if (typeof defOrFactory === "function") {
1251
+ if (!firstRow) return [];
1252
+ return [defOrFactory(firstRow)].flat().map((def) => ColumnModel.fromDef(this, def));
1253
+ }
1254
+ return ColumnModel.fromDef(this, defOrFactory);
1255
+ });
1256
+ const syncedKeys = new Set(syncedColumns.map((c) => c.config.key));
1257
+ for (const key of this.columns.keys()) if (!syncedKeys.has(key)) this.columns.delete(key);
1258
+ const firstSync = this.columns.size === 0;
1259
+ for (const column of syncedColumns) if (!this.columns.has(column.config.key)) {
1260
+ this.columns.set(column.config.key, column);
1261
+ this.applyColumnState(column);
1262
+ }
1263
+ const keys = syncedColumns.map((c) => c.config.key);
1264
+ this.columnOrder = [...this.columnOrder.filter((k) => keys.includes(k)), ...keys.filter((k) => !this.columnOrder.includes(k))];
1265
+ if (firstSync && this.appliedState?.columnOrder) this.columnOrder = this.mergedOrder(this.appliedState.columnOrder);
1266
+ }
1267
+ applyColumnState(col) {
1268
+ const state = this.appliedState?.columns?.[col.key];
1269
+ if (!state) return;
1270
+ col.setHidden(state.hidden);
1271
+ col.setPinned(state.pinned);
1272
+ col.setManualWidth(state.width);
1273
+ }
1274
+ mergedOrder(order) {
1275
+ const known = order.filter((k) => this.columns.has(k));
1276
+ const rest = this.columnOrder.filter((k) => !known.includes(k));
1277
+ return [...known, ...rest];
1278
+ }
1279
+ expandedAbove(index) {
1280
+ let count = 0;
1281
+ for (const i of this.expandedDisplayIndices) if (i < index) count++;
1282
+ else break;
1283
+ return count;
1284
+ }
1285
+ /**
1286
+ * The display index of the row whose block (row + its expansion panel, if any) contains the
1287
+ * vertical content offset `y`. Walks the expanded indices accumulating their extra height —
1288
+ * a row scrolled past its own top stays "at" `y` while its panel is in view, so expanded rows
1289
+ * render as long as any part of their block does.
1290
+ */
1291
+ indexAtOffset(y) {
1292
+ const { rowHeight, expansionHeight } = this;
1293
+ let extra = 0;
1294
+ for (const i of this.expandedDisplayIndices) {
1295
+ const panelTop = (i + 1) * rowHeight + extra;
1296
+ if (panelTop > y) break;
1297
+ if (panelTop + expansionHeight > y) return i;
1298
+ extra += expansionHeight;
1299
+ }
1300
+ return Math.floor((y - extra) / rowHeight);
1301
+ }
1302
+ };
1303
+
1304
+ //#endregion
1305
+ //#region src/table/use-table.ts
1306
+ const useTable = (config) => {
1307
+ const tableRef = useRef(void 0);
1308
+ if (!tableRef.current) tableRef.current = new TableModel(config);
1309
+ useEffect(() => {
1310
+ tableRef.current?.activate();
1311
+ return () => tableRef.current?.dispose();
1312
+ }, []);
1313
+ return tableRef.current;
1314
+ };
1315
+
1316
+ //#endregion
1317
+ export { CellSlot, ColumnModel, NativeCheckbox, SELECTION_COLUMN_KEY, SelectAll, SelectionCell, SelectionHeaderCell, Table, TableBody, TableCell, TableColumnHeader, TableEmpty, TableExpansion, TableHeader, TableModel, TableProvider, TableResizer, TableRoot, TableRow, TableSlotsProvider, compareValues, getPath, pinnedCellStyle, slotsContext, tableContext, titleCase, useScroll, useTable, useTableContext, useTableSlots };
1318
+ //# sourceMappingURL=table.mjs.map