@happyvertical/smrt-ui 0.42.3 → 0.42.4
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 +121 -0
- package/dist/components/data/DataTable.svelte +391 -105
- package/dist/components/data/DataTable.svelte.d.ts.map +1 -1
- package/dist/components/data/DataTableController.d.ts +195 -0
- package/dist/components/data/DataTableController.d.ts.map +1 -0
- package/dist/components/data/DataTableController.js +650 -0
- package/dist/components/data/DataTableIdentity.d.ts +21 -0
- package/dist/components/data/DataTableIdentity.d.ts.map +1 -0
- package/dist/components/data/DataTableIdentity.js +28 -0
- package/dist/components/data/__tests__/DataTable.test.js +257 -19
- package/dist/components/data/__tests__/DataTableController.test.js +214 -0
- package/dist/components/data/__tests__/DataTableIdentity.test.js +29 -0
- package/dist/components/data/index.d.ts +2 -0
- package/dist/components/data/index.d.ts.map +1 -1
- package/dist/components/data/index.js +2 -0
- package/dist/components/data/types.d.ts +27 -1
- package/dist/components/data/types.d.ts.map +1 -1
- package/dist/i18n/strings.d.ts +1 -0
- package/dist/i18n/strings.d.ts.map +1 -1
- package/dist/i18n/strings.js +1 -0
- package/dist/svelte/playground/DataTablePreview.svelte +34 -13
- package/dist/svelte/playground/DataTablePreview.svelte.d.ts.map +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,650 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headless, transport-neutral state for DataTable.
|
|
3
|
+
*
|
|
4
|
+
* The controller intentionally stores only view preferences. It never stores
|
|
5
|
+
* rows, callbacks, query objects, principals, or persistence adapters.
|
|
6
|
+
*/
|
|
7
|
+
const DEFAULT_MODES = {
|
|
8
|
+
filtering: 'local',
|
|
9
|
+
sorting: 'local',
|
|
10
|
+
pagination: 'local',
|
|
11
|
+
};
|
|
12
|
+
const DEFAULT_STATE = {
|
|
13
|
+
search: '',
|
|
14
|
+
filters: [],
|
|
15
|
+
sorting: [],
|
|
16
|
+
page: 1,
|
|
17
|
+
pageSize: null,
|
|
18
|
+
columnOrder: [],
|
|
19
|
+
columnVisibility: [],
|
|
20
|
+
selection: { scope: 'explicit', rowIds: [] },
|
|
21
|
+
selectedRowIds: [],
|
|
22
|
+
expandedRowIds: [],
|
|
23
|
+
};
|
|
24
|
+
const FILTER_OPERATORS = new Set([
|
|
25
|
+
'equals',
|
|
26
|
+
'notEquals',
|
|
27
|
+
'contains',
|
|
28
|
+
'notContains',
|
|
29
|
+
'startsWith',
|
|
30
|
+
'endsWith',
|
|
31
|
+
'in',
|
|
32
|
+
'notIn',
|
|
33
|
+
'gt',
|
|
34
|
+
'gte',
|
|
35
|
+
'lt',
|
|
36
|
+
'lte',
|
|
37
|
+
'isNull',
|
|
38
|
+
'isNotNull',
|
|
39
|
+
]);
|
|
40
|
+
function assertColumnId(value, label = 'column id') {
|
|
41
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
42
|
+
throw new TypeError(`DataTable ${label} must be a non-empty string`);
|
|
43
|
+
}
|
|
44
|
+
return value;
|
|
45
|
+
}
|
|
46
|
+
export function assertDataTableRowId(value) {
|
|
47
|
+
if (typeof value === 'string' && value.length > 0)
|
|
48
|
+
return value;
|
|
49
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
50
|
+
return value === 0 ? 0 : value;
|
|
51
|
+
throw new TypeError('DataTable row ids must be non-empty strings or finite numbers');
|
|
52
|
+
}
|
|
53
|
+
function assertPage(value) {
|
|
54
|
+
if (typeof value !== 'number' ||
|
|
55
|
+
!Number.isFinite(value) ||
|
|
56
|
+
!Number.isInteger(value) ||
|
|
57
|
+
value <= 0) {
|
|
58
|
+
throw new TypeError('DataTable page must be a positive integer');
|
|
59
|
+
}
|
|
60
|
+
return value;
|
|
61
|
+
}
|
|
62
|
+
function assertPageSize(value) {
|
|
63
|
+
if (value === null || value === undefined)
|
|
64
|
+
return null;
|
|
65
|
+
if (typeof value !== 'number' ||
|
|
66
|
+
!Number.isFinite(value) ||
|
|
67
|
+
!Number.isInteger(value) ||
|
|
68
|
+
value <= 0) {
|
|
69
|
+
throw new TypeError('DataTable pageSize must be a positive integer or null');
|
|
70
|
+
}
|
|
71
|
+
return value;
|
|
72
|
+
}
|
|
73
|
+
function canonicalJson(value, ancestors = new Set()) {
|
|
74
|
+
if (value === null ||
|
|
75
|
+
typeof value === 'string' ||
|
|
76
|
+
typeof value === 'boolean') {
|
|
77
|
+
return value;
|
|
78
|
+
}
|
|
79
|
+
if (typeof value === 'number') {
|
|
80
|
+
if (!Number.isFinite(value)) {
|
|
81
|
+
throw new TypeError('DataTable values must not contain non-finite numbers');
|
|
82
|
+
}
|
|
83
|
+
return value === 0 ? 0 : value;
|
|
84
|
+
}
|
|
85
|
+
if (Array.isArray(value)) {
|
|
86
|
+
if (ancestors.has(value))
|
|
87
|
+
throw new TypeError('DataTable values must not be circular');
|
|
88
|
+
ancestors.add(value);
|
|
89
|
+
const result = value.map((entry) => canonicalJson(entry, ancestors));
|
|
90
|
+
ancestors.delete(value);
|
|
91
|
+
return result;
|
|
92
|
+
}
|
|
93
|
+
if (value &&
|
|
94
|
+
typeof value === 'object' &&
|
|
95
|
+
Object.getPrototypeOf(value) === Object.prototype) {
|
|
96
|
+
if (ancestors.has(value))
|
|
97
|
+
throw new TypeError('DataTable values must not be circular');
|
|
98
|
+
ancestors.add(value);
|
|
99
|
+
const result = {};
|
|
100
|
+
for (const key of Object.keys(value).sort()) {
|
|
101
|
+
result[key] = canonicalJson(value[key], ancestors);
|
|
102
|
+
}
|
|
103
|
+
ancestors.delete(value);
|
|
104
|
+
return result;
|
|
105
|
+
}
|
|
106
|
+
throw new TypeError('DataTable values must be JSON-safe plain data');
|
|
107
|
+
}
|
|
108
|
+
function jsonSignature(value) {
|
|
109
|
+
return JSON.stringify(canonicalJson(value));
|
|
110
|
+
}
|
|
111
|
+
export function compareDataTableRowIds(left, right) {
|
|
112
|
+
if (typeof left !== typeof right)
|
|
113
|
+
return typeof left === 'number' ? -1 : 1;
|
|
114
|
+
if (typeof left === 'number' && typeof right === 'number')
|
|
115
|
+
return left - right;
|
|
116
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
117
|
+
}
|
|
118
|
+
export function dataTableRowIdKey(value) {
|
|
119
|
+
return `${typeof value}:${String(value)}`;
|
|
120
|
+
}
|
|
121
|
+
function normalizeRowIds(values) {
|
|
122
|
+
const ids = new Map();
|
|
123
|
+
for (const value of values) {
|
|
124
|
+
const id = assertDataTableRowId(value);
|
|
125
|
+
ids.set(dataTableRowIdKey(id), id);
|
|
126
|
+
}
|
|
127
|
+
return [...ids.values()].sort(compareDataTableRowIds);
|
|
128
|
+
}
|
|
129
|
+
function assertQueryRevisionValue(value, label) {
|
|
130
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
131
|
+
throw new TypeError(`DataTable ${label} must be a non-empty string`);
|
|
132
|
+
}
|
|
133
|
+
return value;
|
|
134
|
+
}
|
|
135
|
+
function normalizeQueryRevision(value) {
|
|
136
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
137
|
+
throw new TypeError('DataTable query binding must be a plain object');
|
|
138
|
+
}
|
|
139
|
+
const input = value;
|
|
140
|
+
return {
|
|
141
|
+
queryFingerprint: assertQueryRevisionValue(input.queryFingerprint, 'query fingerprint'),
|
|
142
|
+
queryRevision: assertQueryRevisionValue(input.queryRevision, 'query revision'),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
function normalizeSelection(value, legacyRowIds) {
|
|
146
|
+
if (value === undefined) {
|
|
147
|
+
return { scope: 'explicit', rowIds: normalizeRowIds(legacyRowIds) };
|
|
148
|
+
}
|
|
149
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
150
|
+
throw new TypeError('DataTable selection must be a plain object');
|
|
151
|
+
}
|
|
152
|
+
const input = value;
|
|
153
|
+
if (input.scope === 'page' || input.scope === 'explicit') {
|
|
154
|
+
if (!Array.isArray(input.rowIds)) {
|
|
155
|
+
throw new TypeError(`DataTable ${input.scope} selection requires rowIds`);
|
|
156
|
+
}
|
|
157
|
+
return { scope: input.scope, rowIds: normalizeRowIds(input.rowIds) };
|
|
158
|
+
}
|
|
159
|
+
if (input.scope === 'allMatching') {
|
|
160
|
+
if (Object.hasOwn(input, 'rowIds')) {
|
|
161
|
+
throw new TypeError('DataTable allMatching selection must not contain rowIds');
|
|
162
|
+
}
|
|
163
|
+
if (typeof input.expectedCount !== 'number' ||
|
|
164
|
+
!Number.isFinite(input.expectedCount) ||
|
|
165
|
+
!Number.isInteger(input.expectedCount) ||
|
|
166
|
+
input.expectedCount < 0) {
|
|
167
|
+
throw new TypeError('DataTable allMatching expectedCount must be a non-negative integer');
|
|
168
|
+
}
|
|
169
|
+
return {
|
|
170
|
+
scope: 'allMatching',
|
|
171
|
+
...normalizeQueryRevision(input),
|
|
172
|
+
expectedCount: input.expectedCount,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
throw new TypeError('DataTable selection scope must be page, explicit, or allMatching');
|
|
176
|
+
}
|
|
177
|
+
function selectedRowIdsFor(selection) {
|
|
178
|
+
return selection.scope === 'allMatching' ? [] : selection.rowIds;
|
|
179
|
+
}
|
|
180
|
+
function withSelection(state, selection) {
|
|
181
|
+
const normalized = normalizeSelection(selection, []);
|
|
182
|
+
return {
|
|
183
|
+
...state,
|
|
184
|
+
selection: normalized,
|
|
185
|
+
selectedRowIds: selectedRowIdsFor(normalized),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
function clearSelectionForPageChange(state) {
|
|
189
|
+
return state.selection.scope === 'page'
|
|
190
|
+
? withSelection(state, { scope: 'page', rowIds: [] })
|
|
191
|
+
: state;
|
|
192
|
+
}
|
|
193
|
+
function clearSelectionForQueryChange(state) {
|
|
194
|
+
if (state.selection.scope === 'page') {
|
|
195
|
+
return withSelection(state, { scope: 'page', rowIds: [] });
|
|
196
|
+
}
|
|
197
|
+
if (state.selection.scope === 'allMatching') {
|
|
198
|
+
return withSelection(state, { scope: 'explicit', rowIds: [] });
|
|
199
|
+
}
|
|
200
|
+
return state;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Refuse an all-matching selection when the action's query has changed since
|
|
204
|
+
* selection. Domain actions must call this before a destructive operation.
|
|
205
|
+
*/
|
|
206
|
+
export function assertDataTableSelectionCurrent(selection, currentQuery) {
|
|
207
|
+
if (selection.scope !== 'allMatching')
|
|
208
|
+
return;
|
|
209
|
+
const current = normalizeQueryRevision(currentQuery);
|
|
210
|
+
if (selection.queryFingerprint !== current.queryFingerprint ||
|
|
211
|
+
selection.queryRevision !== current.queryRevision) {
|
|
212
|
+
throw new TypeError('DataTable allMatching selection is stale for the current query revision');
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
function normalizeUniqueColumnIds(values) {
|
|
216
|
+
const ids = new Set();
|
|
217
|
+
for (const value of values)
|
|
218
|
+
ids.add(assertColumnId(value));
|
|
219
|
+
return [...ids];
|
|
220
|
+
}
|
|
221
|
+
function normalizeFilters(values) {
|
|
222
|
+
const filters = values.map((filter) => {
|
|
223
|
+
const columnId = assertColumnId(filter?.columnId, 'filter column id');
|
|
224
|
+
if (!FILTER_OPERATORS.has(filter?.operator)) {
|
|
225
|
+
throw new TypeError(`Unsupported DataTable filter operator: ${String(filter?.operator)}`);
|
|
226
|
+
}
|
|
227
|
+
const needsValue = filter.operator !== 'isNull' && filter.operator !== 'isNotNull';
|
|
228
|
+
if (needsValue && !Object.hasOwn(filter, 'value')) {
|
|
229
|
+
throw new TypeError(`DataTable filter ${filter.operator} requires a value`);
|
|
230
|
+
}
|
|
231
|
+
const value = needsValue && Object.hasOwn(filter, 'value')
|
|
232
|
+
? canonicalJson(filter.value)
|
|
233
|
+
: undefined;
|
|
234
|
+
return value === undefined
|
|
235
|
+
? { columnId, operator: filter.operator }
|
|
236
|
+
: { columnId, operator: filter.operator, value };
|
|
237
|
+
});
|
|
238
|
+
return filters.sort((left, right) => {
|
|
239
|
+
const leftKey = `${left.columnId}\u0000${left.operator}\u0000${jsonSignature(Object.hasOwn(left, 'value') ? left.value : null)}`;
|
|
240
|
+
const rightKey = `${right.columnId}\u0000${right.operator}\u0000${jsonSignature(Object.hasOwn(right, 'value')
|
|
241
|
+
? right.value
|
|
242
|
+
: null)}`;
|
|
243
|
+
return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0;
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
function normalizeSorting(values) {
|
|
247
|
+
const seen = new Set();
|
|
248
|
+
const result = [];
|
|
249
|
+
for (const rule of values) {
|
|
250
|
+
const columnId = assertColumnId(rule?.columnId, 'sort column id');
|
|
251
|
+
if (rule?.direction !== 'asc' && rule?.direction !== 'desc') {
|
|
252
|
+
throw new TypeError('DataTable sort directions must be asc or desc');
|
|
253
|
+
}
|
|
254
|
+
if (!seen.has(columnId)) {
|
|
255
|
+
seen.add(columnId);
|
|
256
|
+
result.push({ columnId, direction: rule.direction });
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return result;
|
|
260
|
+
}
|
|
261
|
+
function normalizeVisibility(values) {
|
|
262
|
+
const entries = new Map();
|
|
263
|
+
for (const entry of values) {
|
|
264
|
+
const columnId = assertColumnId(entry?.columnId, 'visibility column id');
|
|
265
|
+
if (typeof entry?.visible !== 'boolean') {
|
|
266
|
+
throw new TypeError('DataTable column visibility must be boolean');
|
|
267
|
+
}
|
|
268
|
+
entries.set(columnId, entry.visible);
|
|
269
|
+
}
|
|
270
|
+
return [...entries.entries()]
|
|
271
|
+
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
|
272
|
+
.map(([columnId, visible]) => ({ columnId, visible }));
|
|
273
|
+
}
|
|
274
|
+
function normalizeModes(modes) {
|
|
275
|
+
const result = { ...DEFAULT_MODES, ...modes };
|
|
276
|
+
for (const mode of Object.values(result)) {
|
|
277
|
+
if (mode !== 'local' && mode !== 'manual') {
|
|
278
|
+
throw new TypeError('DataTable modes must be local or manual');
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return result;
|
|
282
|
+
}
|
|
283
|
+
function normalizeState(state, columnIds) {
|
|
284
|
+
const input = state ?? {};
|
|
285
|
+
const selection = normalizeSelection(input.selection, input.selectedRowIds ?? DEFAULT_STATE.selectedRowIds);
|
|
286
|
+
const knownColumns = columnIds ? normalizeUniqueColumnIds(columnIds) : null;
|
|
287
|
+
const allowed = knownColumns ? new Set(knownColumns) : null;
|
|
288
|
+
const keepKnown = (columnId) => !allowed || allowed.has(columnId);
|
|
289
|
+
const visibility = normalizeVisibility(input.columnVisibility ?? DEFAULT_STATE.columnVisibility).filter((entry) => keepKnown(entry.columnId));
|
|
290
|
+
const knownVisibility = new Map(visibility.map((entry) => [entry.columnId, entry.visible]));
|
|
291
|
+
if (knownColumns) {
|
|
292
|
+
for (const columnId of knownColumns) {
|
|
293
|
+
if (!knownVisibility.has(columnId))
|
|
294
|
+
knownVisibility.set(columnId, true);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
const columnOrder = normalizeUniqueColumnIds(input.columnOrder ?? DEFAULT_STATE.columnOrder).filter(keepKnown);
|
|
298
|
+
if (knownColumns) {
|
|
299
|
+
for (const columnId of knownColumns) {
|
|
300
|
+
if (!columnOrder.includes(columnId))
|
|
301
|
+
columnOrder.push(columnId);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return {
|
|
305
|
+
search: typeof input.search === 'string' ? input.search : DEFAULT_STATE.search,
|
|
306
|
+
filters: normalizeFilters(input.filters ?? DEFAULT_STATE.filters).filter((filter) => keepKnown(filter.columnId)),
|
|
307
|
+
sorting: normalizeSorting(input.sorting ?? DEFAULT_STATE.sorting).filter((sort) => keepKnown(sort.columnId)),
|
|
308
|
+
page: assertPage(input.page ?? DEFAULT_STATE.page),
|
|
309
|
+
pageSize: assertPageSize(input.pageSize ?? DEFAULT_STATE.pageSize),
|
|
310
|
+
columnOrder,
|
|
311
|
+
columnVisibility: normalizeVisibility([...knownVisibility.entries()].map(([columnId, visible]) => ({
|
|
312
|
+
columnId,
|
|
313
|
+
visible,
|
|
314
|
+
}))),
|
|
315
|
+
selection,
|
|
316
|
+
selectedRowIds: selectedRowIdsFor(selection),
|
|
317
|
+
expandedRowIds: normalizeRowIds(input.expandedRowIds ?? DEFAULT_STATE.expandedRowIds),
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
function stateSignature(state) {
|
|
321
|
+
return jsonSignature(canonicalJson(state));
|
|
322
|
+
}
|
|
323
|
+
function snapshotSignature(snapshot) {
|
|
324
|
+
return jsonSignature(canonicalJson(snapshot));
|
|
325
|
+
}
|
|
326
|
+
function cloneState(state) {
|
|
327
|
+
return canonicalJson(state);
|
|
328
|
+
}
|
|
329
|
+
function cloneSnapshot(snapshot) {
|
|
330
|
+
return canonicalJson(snapshot);
|
|
331
|
+
}
|
|
332
|
+
function resetPage(state, changed) {
|
|
333
|
+
return changed && state.page !== 1 ? { ...state, page: 1 } : state;
|
|
334
|
+
}
|
|
335
|
+
/** Apply one command without mutating the supplied state. */
|
|
336
|
+
export function transitionDataTableState(state, command) {
|
|
337
|
+
const current = normalizeState(state);
|
|
338
|
+
let next;
|
|
339
|
+
switch (command.type) {
|
|
340
|
+
case 'setSearch': {
|
|
341
|
+
if (typeof command.search !== 'string')
|
|
342
|
+
throw new TypeError('DataTable search must be a string');
|
|
343
|
+
const changed = command.search !== current.search;
|
|
344
|
+
next = resetPage({ ...current, search: command.search }, changed);
|
|
345
|
+
if (changed)
|
|
346
|
+
next = clearSelectionForQueryChange(next);
|
|
347
|
+
break;
|
|
348
|
+
}
|
|
349
|
+
case 'setFilters': {
|
|
350
|
+
const filters = normalizeFilters(command.filters);
|
|
351
|
+
const changed = jsonSignature(filters) !== jsonSignature(current.filters);
|
|
352
|
+
next = resetPage({ ...current, filters }, changed);
|
|
353
|
+
if (changed)
|
|
354
|
+
next = clearSelectionForQueryChange(next);
|
|
355
|
+
break;
|
|
356
|
+
}
|
|
357
|
+
case 'setSorting': {
|
|
358
|
+
const sorting = normalizeSorting(command.sorting);
|
|
359
|
+
const changed = jsonSignature(sorting) !== jsonSignature(current.sorting);
|
|
360
|
+
next = resetPage({ ...current, sorting }, changed);
|
|
361
|
+
if (changed)
|
|
362
|
+
next = clearSelectionForQueryChange(next);
|
|
363
|
+
break;
|
|
364
|
+
}
|
|
365
|
+
case 'toggleSorting': {
|
|
366
|
+
const columnId = assertColumnId(command.columnId, 'sort column id');
|
|
367
|
+
const index = current.sorting.findIndex((rule) => rule.columnId === columnId);
|
|
368
|
+
const previous = index >= 0 ? current.sorting[index] : undefined;
|
|
369
|
+
const toggled = !previous
|
|
370
|
+
? { columnId, direction: 'asc' }
|
|
371
|
+
: previous.direction === 'asc'
|
|
372
|
+
? { columnId, direction: 'desc' }
|
|
373
|
+
: null;
|
|
374
|
+
const sorting = command.multi
|
|
375
|
+
? previous
|
|
376
|
+
? toggled
|
|
377
|
+
? current.sorting.map((rule, ruleIndex) => ruleIndex === index ? toggled : rule)
|
|
378
|
+
: current.sorting.filter((rule) => rule.columnId !== columnId)
|
|
379
|
+
: [...current.sorting, toggled]
|
|
380
|
+
: toggled
|
|
381
|
+
? [toggled]
|
|
382
|
+
: [];
|
|
383
|
+
const changed = jsonSignature(sorting) !== jsonSignature(current.sorting);
|
|
384
|
+
next = resetPage({ ...current, sorting }, changed);
|
|
385
|
+
if (changed)
|
|
386
|
+
next = clearSelectionForQueryChange(next);
|
|
387
|
+
break;
|
|
388
|
+
}
|
|
389
|
+
case 'setPage': {
|
|
390
|
+
const page = assertPage(command.page);
|
|
391
|
+
next = { ...current, page };
|
|
392
|
+
if (page !== current.page)
|
|
393
|
+
next = clearSelectionForPageChange(next);
|
|
394
|
+
break;
|
|
395
|
+
}
|
|
396
|
+
case 'setPageSize': {
|
|
397
|
+
const pageSize = assertPageSize(command.pageSize);
|
|
398
|
+
const changed = pageSize !== current.pageSize;
|
|
399
|
+
next = resetPage({ ...current, pageSize }, changed);
|
|
400
|
+
if (changed)
|
|
401
|
+
next = clearSelectionForPageChange(next);
|
|
402
|
+
break;
|
|
403
|
+
}
|
|
404
|
+
case 'setColumnOrder':
|
|
405
|
+
next = {
|
|
406
|
+
...current,
|
|
407
|
+
columnOrder: normalizeUniqueColumnIds(command.columnIds),
|
|
408
|
+
};
|
|
409
|
+
break;
|
|
410
|
+
case 'setColumnVisibility':
|
|
411
|
+
next = {
|
|
412
|
+
...current,
|
|
413
|
+
columnVisibility: normalizeVisibility(command.columns),
|
|
414
|
+
};
|
|
415
|
+
break;
|
|
416
|
+
case 'setSelection':
|
|
417
|
+
next = withSelection(current, command.selection);
|
|
418
|
+
break;
|
|
419
|
+
case 'setPageSelection':
|
|
420
|
+
next = withSelection(current, {
|
|
421
|
+
scope: 'page',
|
|
422
|
+
rowIds: command.rowIds,
|
|
423
|
+
});
|
|
424
|
+
break;
|
|
425
|
+
case 'selectAllMatching':
|
|
426
|
+
next = withSelection(current, {
|
|
427
|
+
scope: 'allMatching',
|
|
428
|
+
...normalizeQueryRevision(command),
|
|
429
|
+
expectedCount: command.expectedCount,
|
|
430
|
+
});
|
|
431
|
+
break;
|
|
432
|
+
case 'setSelectedRows':
|
|
433
|
+
next = withSelection(current, {
|
|
434
|
+
scope: 'explicit',
|
|
435
|
+
rowIds: command.rowIds,
|
|
436
|
+
});
|
|
437
|
+
break;
|
|
438
|
+
case 'toggleRowSelection': {
|
|
439
|
+
if (current.selection.scope === 'allMatching') {
|
|
440
|
+
throw new TypeError('DataTable cannot toggle an individual row while allMatching is active');
|
|
441
|
+
}
|
|
442
|
+
const rowId = assertDataTableRowId(command.rowId);
|
|
443
|
+
const ids = new Map(current.selection.rowIds.map((id) => [dataTableRowIdKey(id), id]));
|
|
444
|
+
ids.has(dataTableRowIdKey(rowId))
|
|
445
|
+
? ids.delete(dataTableRowIdKey(rowId))
|
|
446
|
+
: ids.set(dataTableRowIdKey(rowId), rowId);
|
|
447
|
+
next = withSelection(current, {
|
|
448
|
+
scope: current.selection.scope,
|
|
449
|
+
rowIds: [...ids.values()],
|
|
450
|
+
});
|
|
451
|
+
break;
|
|
452
|
+
}
|
|
453
|
+
case 'setExpandedRows':
|
|
454
|
+
next = { ...current, expandedRowIds: normalizeRowIds(command.rowIds) };
|
|
455
|
+
break;
|
|
456
|
+
case 'toggleRowExpansion': {
|
|
457
|
+
const rowId = assertDataTableRowId(command.rowId);
|
|
458
|
+
const ids = new Map(current.expandedRowIds.map((id) => [dataTableRowIdKey(id), id]));
|
|
459
|
+
ids.has(dataTableRowIdKey(rowId))
|
|
460
|
+
? ids.delete(dataTableRowIdKey(rowId))
|
|
461
|
+
: ids.set(dataTableRowIdKey(rowId), rowId);
|
|
462
|
+
next = { ...current, expandedRowIds: normalizeRowIds([...ids.values()]) };
|
|
463
|
+
break;
|
|
464
|
+
}
|
|
465
|
+
case 'reset':
|
|
466
|
+
next = { ...DEFAULT_STATE };
|
|
467
|
+
break;
|
|
468
|
+
default:
|
|
469
|
+
throw new TypeError(`Unsupported DataTable command: ${String(command.type)}`);
|
|
470
|
+
}
|
|
471
|
+
return normalizeState(next);
|
|
472
|
+
}
|
|
473
|
+
/** Parse persisted state defensively before an external adapter restores it. */
|
|
474
|
+
export function hydrateDataTableSnapshot(value) {
|
|
475
|
+
if (!value ||
|
|
476
|
+
typeof value !== 'object' ||
|
|
477
|
+
Object.getPrototypeOf(value) !== Object.prototype) {
|
|
478
|
+
throw new TypeError('DataTable snapshot must be a plain object');
|
|
479
|
+
}
|
|
480
|
+
const input = value;
|
|
481
|
+
if (input.version !== 1 && input.version !== 2)
|
|
482
|
+
throw new TypeError('Unsupported DataTable snapshot version');
|
|
483
|
+
return {
|
|
484
|
+
version: 2,
|
|
485
|
+
modes: normalizeModes(input.modes),
|
|
486
|
+
state: normalizeState(input.state),
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
/** A headless state owner used by both rendered controls and programmatic commands. */
|
|
490
|
+
export class DataTableController {
|
|
491
|
+
state;
|
|
492
|
+
modes;
|
|
493
|
+
columnIds;
|
|
494
|
+
controlled;
|
|
495
|
+
listeners = new Set();
|
|
496
|
+
onStateChange;
|
|
497
|
+
pendingControlledState;
|
|
498
|
+
constructor(options = {}) {
|
|
499
|
+
this.columnIds = options.columnIds
|
|
500
|
+
? normalizeUniqueColumnIds(options.columnIds)
|
|
501
|
+
: undefined;
|
|
502
|
+
this.controlled = options.state !== undefined;
|
|
503
|
+
this.state = normalizeState(options.state ?? options.initialState, this.columnIds);
|
|
504
|
+
this.modes = normalizeModes(options.modes);
|
|
505
|
+
this.onStateChange = options.onStateChange;
|
|
506
|
+
}
|
|
507
|
+
getState() {
|
|
508
|
+
return cloneState(this.state);
|
|
509
|
+
}
|
|
510
|
+
getModes() {
|
|
511
|
+
return { ...this.modes };
|
|
512
|
+
}
|
|
513
|
+
snapshot() {
|
|
514
|
+
return { version: 2, modes: this.getModes(), state: this.getState() };
|
|
515
|
+
}
|
|
516
|
+
subscribe(listener) {
|
|
517
|
+
this.listeners.add(listener);
|
|
518
|
+
return () => this.listeners.delete(listener);
|
|
519
|
+
}
|
|
520
|
+
/** Dispatches a serializable command. Controlled controllers emit a proposal only. */
|
|
521
|
+
dispatch(command) {
|
|
522
|
+
const previous = this.snapshot();
|
|
523
|
+
const candidate = normalizeState(transitionDataTableState(this.state, command), this.columnIds);
|
|
524
|
+
const next = {
|
|
525
|
+
version: 2,
|
|
526
|
+
modes: this.getModes(),
|
|
527
|
+
state: candidate,
|
|
528
|
+
};
|
|
529
|
+
const changed = snapshotSignature(previous) !== snapshotSignature(next);
|
|
530
|
+
const transition = {
|
|
531
|
+
command,
|
|
532
|
+
previous,
|
|
533
|
+
next: cloneSnapshot(next),
|
|
534
|
+
changed,
|
|
535
|
+
};
|
|
536
|
+
if (!changed)
|
|
537
|
+
return transition;
|
|
538
|
+
if (this.controlled) {
|
|
539
|
+
const signature = stateSignature(candidate);
|
|
540
|
+
if (this.pendingControlledState === signature)
|
|
541
|
+
return transition;
|
|
542
|
+
this.pendingControlledState = signature;
|
|
543
|
+
this.onStateChange?.(cloneState(candidate), command);
|
|
544
|
+
return transition;
|
|
545
|
+
}
|
|
546
|
+
this.state = candidate;
|
|
547
|
+
this.pendingControlledState = undefined;
|
|
548
|
+
this.onStateChange?.(cloneState(candidate), command);
|
|
549
|
+
this.emit(transition);
|
|
550
|
+
return transition;
|
|
551
|
+
}
|
|
552
|
+
/** Supplies state from a controlled host or an external persistence adapter. */
|
|
553
|
+
replaceState(state) {
|
|
554
|
+
const previous = this.snapshot();
|
|
555
|
+
const nextState = normalizeState(state, this.columnIds);
|
|
556
|
+
const next = {
|
|
557
|
+
version: 2,
|
|
558
|
+
modes: this.getModes(),
|
|
559
|
+
state: nextState,
|
|
560
|
+
};
|
|
561
|
+
const changed = snapshotSignature(previous) !== snapshotSignature(next);
|
|
562
|
+
const transition = {
|
|
563
|
+
command: null,
|
|
564
|
+
previous,
|
|
565
|
+
next: cloneSnapshot(next),
|
|
566
|
+
changed,
|
|
567
|
+
};
|
|
568
|
+
this.state = nextState;
|
|
569
|
+
this.pendingControlledState = undefined;
|
|
570
|
+
if (changed)
|
|
571
|
+
this.emit(transition);
|
|
572
|
+
return transition;
|
|
573
|
+
}
|
|
574
|
+
/** Changes ownership without treating it as a user command. */
|
|
575
|
+
setControlled(controlled) {
|
|
576
|
+
this.controlled = controlled;
|
|
577
|
+
if (!controlled)
|
|
578
|
+
this.pendingControlledState = undefined;
|
|
579
|
+
}
|
|
580
|
+
/** Configures transformation ownership; this remains outside persisted state. */
|
|
581
|
+
setModes(modes) {
|
|
582
|
+
const previous = this.snapshot();
|
|
583
|
+
this.modes = normalizeModes(modes);
|
|
584
|
+
const next = this.snapshot();
|
|
585
|
+
const changed = snapshotSignature(previous) !== snapshotSignature(next);
|
|
586
|
+
const transition = { command: null, previous, next, changed };
|
|
587
|
+
if (changed)
|
|
588
|
+
this.emit(transition);
|
|
589
|
+
return transition;
|
|
590
|
+
}
|
|
591
|
+
/** Reconciles stale saved-view column IDs with the renderer's current columns. */
|
|
592
|
+
setColumnIds(columnIds) {
|
|
593
|
+
const previous = this.snapshot();
|
|
594
|
+
this.columnIds = normalizeUniqueColumnIds(columnIds);
|
|
595
|
+
this.state = normalizeState(this.state, this.columnIds);
|
|
596
|
+
const next = this.snapshot();
|
|
597
|
+
const changed = snapshotSignature(previous) !== snapshotSignature(next);
|
|
598
|
+
const transition = { command: null, previous, next, changed };
|
|
599
|
+
if (changed)
|
|
600
|
+
this.emit(transition);
|
|
601
|
+
return transition;
|
|
602
|
+
}
|
|
603
|
+
/** Clamp against a reliable total. A missing total intentionally does not guess. */
|
|
604
|
+
clampPage(totalRows) {
|
|
605
|
+
if (totalRows === null || totalRows === undefined) {
|
|
606
|
+
const snapshot = this.snapshot();
|
|
607
|
+
return {
|
|
608
|
+
command: null,
|
|
609
|
+
previous: snapshot,
|
|
610
|
+
next: snapshot,
|
|
611
|
+
changed: false,
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
if (!Number.isFinite(totalRows) ||
|
|
615
|
+
!Number.isInteger(totalRows) ||
|
|
616
|
+
totalRows < 0) {
|
|
617
|
+
throw new TypeError('DataTable totalRows must be a non-negative integer');
|
|
618
|
+
}
|
|
619
|
+
const pageCount = this.state.pageSize
|
|
620
|
+
? Math.max(1, Math.ceil(totalRows / this.state.pageSize))
|
|
621
|
+
: 1;
|
|
622
|
+
if (this.state.page <= pageCount) {
|
|
623
|
+
const snapshot = this.snapshot();
|
|
624
|
+
return {
|
|
625
|
+
command: null,
|
|
626
|
+
previous: snapshot,
|
|
627
|
+
next: snapshot,
|
|
628
|
+
changed: false,
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
return this.dispatch({ type: 'setPage', page: pageCount });
|
|
632
|
+
}
|
|
633
|
+
emit(transition) {
|
|
634
|
+
for (const listener of [...this.listeners])
|
|
635
|
+
listener(cloneTransition(transition));
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
function cloneTransition(transition) {
|
|
639
|
+
return {
|
|
640
|
+
command: transition.command
|
|
641
|
+
? canonicalJson(transition.command)
|
|
642
|
+
: null,
|
|
643
|
+
previous: cloneSnapshot(transition.previous),
|
|
644
|
+
next: cloneSnapshot(transition.next),
|
|
645
|
+
changed: transition.changed,
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
export function createDataTableController(options = {}) {
|
|
649
|
+
return new DataTableController(options);
|
|
650
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type DataTableRowId } from './DataTableController.js';
|
|
2
|
+
/** A stable row identity accessor. It must not depend on display position. */
|
|
3
|
+
export type DataTableRowKey<T> = keyof T | ((row: T) => DataTableRowId);
|
|
4
|
+
export interface DataTableResolvedRow<T> {
|
|
5
|
+
row: T;
|
|
6
|
+
/** Index in the `data` array supplied to DataTable before local transforms. */
|
|
7
|
+
sourceIndex: number;
|
|
8
|
+
/** Canonical key used by Svelte, selection, and expansion. */
|
|
9
|
+
rowId: DataTableRowId;
|
|
10
|
+
}
|
|
11
|
+
export interface ResolveDataTableRowsOptions {
|
|
12
|
+
/** Durable table features may not use the historical source-index fallback. */
|
|
13
|
+
requireStableIdentity?: boolean;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Resolve the renderer's source rows once and fail closed for missing or
|
|
17
|
+
* duplicate durable identities. The index fallback only exists for
|
|
18
|
+
* presentational local tables that have no durable row state.
|
|
19
|
+
*/
|
|
20
|
+
export declare function resolveDataTableRows<T>(rows: readonly T[], rowKey: DataTableRowKey<T> | undefined, options?: ResolveDataTableRowsOptions): DataTableResolvedRow<T>[];
|
|
21
|
+
//# sourceMappingURL=DataTableIdentity.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"DataTableIdentity.d.ts","sourceRoot":"","sources":["../../../src/components/data/DataTableIdentity.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,cAAc,EAEpB,MAAM,0BAA0B,CAAC;AAElC,8EAA8E;AAC9E,MAAM,MAAM,eAAe,CAAC,CAAC,IAAI,MAAM,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,CAAC,KAAK,cAAc,CAAC,CAAC;AAExE,MAAM,WAAW,oBAAoB,CAAC,CAAC;IACrC,GAAG,EAAE,CAAC,CAAC;IACP,+EAA+E;IAC/E,WAAW,EAAE,MAAM,CAAC;IACpB,8DAA8D;IAC9D,KAAK,EAAE,cAAc,CAAC;CACvB;AAED,MAAM,WAAW,2BAA2B;IAC1C,+EAA+E;IAC/E,qBAAqB,CAAC,EAAE,OAAO,CAAC;CACjC;AAQD;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,CAAC,EACpC,IAAI,EAAE,SAAS,CAAC,EAAE,EAClB,MAAM,EAAE,eAAe,CAAC,CAAC,CAAC,GAAG,SAAS,EACtC,OAAO,GAAE,2BAAgC,GACxC,oBAAoB,CAAC,CAAC,CAAC,EAAE,CAqB3B"}
|