@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/README.md +55 -18
- package/dist/form.mjs +2 -2
- package/dist/form.mjs.map +1 -1
- package/dist/react-util.d.mts +14 -3
- package/dist/react-util.d.mts.map +1 -1
- package/dist/react-util.mjs +3 -24
- package/dist/react-util.mjs.map +1 -1
- package/dist/router.d.mts +9 -0
- package/dist/router.d.mts.map +1 -1
- package/dist/router.mjs +32 -7
- package/dist/router.mjs.map +1 -1
- package/dist/table.d.mts +619 -0
- package/dist/table.d.mts.map +1 -0
- package/dist/table.mjs +1318 -0
- package/dist/table.mjs.map +1 -0
- package/dist/useResize-BhJdvtef.mjs +38 -0
- package/dist/useResize-BhJdvtef.mjs.map +1 -0
- package/package.json +6 -2
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
|