@happyvertical/smrt-ui 0.42.3 → 0.42.5

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.
Files changed (52) hide show
  1. package/README.md +275 -0
  2. package/dist/components/data/DataTable.svelte +1444 -172
  3. package/dist/components/data/DataTable.svelte.d.ts +1 -1
  4. package/dist/components/data/DataTable.svelte.d.ts.map +1 -1
  5. package/dist/components/data/DataTableController.d.ts +235 -0
  6. package/dist/components/data/DataTableController.d.ts.map +1 -0
  7. package/dist/components/data/DataTableController.js +792 -0
  8. package/dist/components/data/DataTableIdentity.d.ts +21 -0
  9. package/dist/components/data/DataTableIdentity.d.ts.map +1 -0
  10. package/dist/components/data/DataTableIdentity.js +28 -0
  11. package/dist/components/data/DataTableLayout.d.ts +37 -0
  12. package/dist/components/data/DataTableLayout.d.ts.map +1 -0
  13. package/dist/components/data/DataTableLayout.js +135 -0
  14. package/dist/components/data/DataTablePerformance.d.ts +31 -0
  15. package/dist/components/data/DataTablePerformance.d.ts.map +1 -0
  16. package/dist/components/data/DataTablePerformance.js +46 -0
  17. package/dist/components/data/DataTableVirtualization.d.ts +71 -0
  18. package/dist/components/data/DataTableVirtualization.d.ts.map +1 -0
  19. package/dist/components/data/DataTableVirtualization.js +100 -0
  20. package/dist/components/data/__benchmarks__/DataTable.bench.d.ts +2 -0
  21. package/dist/components/data/__benchmarks__/DataTable.bench.d.ts.map +1 -0
  22. package/dist/components/data/__benchmarks__/DataTable.bench.js +53 -0
  23. package/dist/components/data/__fixtures__/DataTableConformanceFixture.d.ts +27 -0
  24. package/dist/components/data/__fixtures__/DataTableConformanceFixture.d.ts.map +1 -0
  25. package/dist/components/data/__fixtures__/DataTableConformanceFixture.js +141 -0
  26. package/dist/components/data/__fixtures__/DataTablePerformanceFixture.d.ts +10 -0
  27. package/dist/components/data/__fixtures__/DataTablePerformanceFixture.d.ts.map +1 -0
  28. package/dist/components/data/__fixtures__/DataTablePerformanceFixture.js +23 -0
  29. package/dist/components/data/__tests__/DataTable.test.js +644 -21
  30. package/dist/components/data/__tests__/DataTableConformance.test.js +212 -0
  31. package/dist/components/data/__tests__/DataTableController.test.js +336 -0
  32. package/dist/components/data/__tests__/DataTableIdentity.test.js +29 -0
  33. package/dist/components/data/__tests__/DataTableLayout.test.js +195 -0
  34. package/dist/components/data/__tests__/DataTablePerformance.test.js +21 -0
  35. package/dist/components/data/__tests__/DataTableVirtualization.test.js +80 -0
  36. package/dist/components/data/__tests__/DataTableVirtualizationComponent.test.js +255 -0
  37. package/dist/components/data/__tests__/data-surface.test.js +675 -0
  38. package/dist/components/data/data-surface.d.ts +246 -0
  39. package/dist/components/data/data-surface.d.ts.map +1 -0
  40. package/dist/components/data/data-surface.js +1102 -0
  41. package/dist/components/data/index.d.ts +5 -0
  42. package/dist/components/data/index.d.ts.map +1 -1
  43. package/dist/components/data/index.js +5 -0
  44. package/dist/components/data/types.d.ts +110 -2
  45. package/dist/components/data/types.d.ts.map +1 -1
  46. package/dist/i18n/strings.d.ts +26 -0
  47. package/dist/i18n/strings.d.ts.map +1 -1
  48. package/dist/i18n/strings.js +28 -2
  49. package/dist/svelte/playground/DataTablePreview.svelte +400 -10
  50. package/dist/svelte/playground/DataTablePreview.svelte.d.ts.map +1 -1
  51. package/dist/svelte/playground.js +2 -2
  52. package/package.json +3 -2
@@ -0,0 +1,792 @@
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
+ columnWidths: [],
21
+ columnPinning: [],
22
+ selection: { scope: 'explicit', rowIds: [] },
23
+ selectedRowIds: [],
24
+ expandedRowIds: [],
25
+ };
26
+ const FILTER_OPERATORS = new Set([
27
+ 'equals',
28
+ 'notEquals',
29
+ 'contains',
30
+ 'notContains',
31
+ 'startsWith',
32
+ 'endsWith',
33
+ 'in',
34
+ 'notIn',
35
+ 'gt',
36
+ 'gte',
37
+ 'lt',
38
+ 'lte',
39
+ 'isNull',
40
+ 'isNotNull',
41
+ ]);
42
+ function assertColumnId(value, label = 'column id') {
43
+ if (typeof value !== 'string' || value.length === 0) {
44
+ throw new TypeError(`DataTable ${label} must be a non-empty string`);
45
+ }
46
+ return value;
47
+ }
48
+ export function assertDataTableRowId(value) {
49
+ if (typeof value === 'string' && value.length > 0)
50
+ return value;
51
+ if (typeof value === 'number' && Number.isFinite(value))
52
+ return value === 0 ? 0 : value;
53
+ throw new TypeError('DataTable row ids must be non-empty strings or finite numbers');
54
+ }
55
+ function assertPage(value) {
56
+ if (typeof value !== 'number' ||
57
+ !Number.isFinite(value) ||
58
+ !Number.isInteger(value) ||
59
+ value <= 0) {
60
+ throw new TypeError('DataTable page must be a positive integer');
61
+ }
62
+ return value;
63
+ }
64
+ function assertPageSize(value) {
65
+ if (value === null || value === undefined)
66
+ return null;
67
+ if (typeof value !== 'number' ||
68
+ !Number.isFinite(value) ||
69
+ !Number.isInteger(value) ||
70
+ value <= 0) {
71
+ throw new TypeError('DataTable pageSize must be a positive integer or null');
72
+ }
73
+ return value;
74
+ }
75
+ function canonicalJson(value, ancestors = new Set()) {
76
+ if (value === null ||
77
+ typeof value === 'string' ||
78
+ typeof value === 'boolean') {
79
+ return value;
80
+ }
81
+ if (typeof value === 'number') {
82
+ if (!Number.isFinite(value)) {
83
+ throw new TypeError('DataTable values must not contain non-finite numbers');
84
+ }
85
+ return value === 0 ? 0 : value;
86
+ }
87
+ if (Array.isArray(value)) {
88
+ if (ancestors.has(value))
89
+ throw new TypeError('DataTable values must not be circular');
90
+ ancestors.add(value);
91
+ const result = value.map((entry) => canonicalJson(entry, ancestors));
92
+ ancestors.delete(value);
93
+ return result;
94
+ }
95
+ if (value &&
96
+ typeof value === 'object' &&
97
+ Object.getPrototypeOf(value) === Object.prototype) {
98
+ if (ancestors.has(value))
99
+ throw new TypeError('DataTable values must not be circular');
100
+ ancestors.add(value);
101
+ const result = {};
102
+ for (const key of Object.keys(value).sort()) {
103
+ result[key] = canonicalJson(value[key], ancestors);
104
+ }
105
+ ancestors.delete(value);
106
+ return result;
107
+ }
108
+ throw new TypeError('DataTable values must be JSON-safe plain data');
109
+ }
110
+ function jsonSignature(value) {
111
+ return JSON.stringify(canonicalJson(value));
112
+ }
113
+ export function compareDataTableRowIds(left, right) {
114
+ if (typeof left !== typeof right)
115
+ return typeof left === 'number' ? -1 : 1;
116
+ if (typeof left === 'number' && typeof right === 'number')
117
+ return left - right;
118
+ return left < right ? -1 : left > right ? 1 : 0;
119
+ }
120
+ export function dataTableRowIdKey(value) {
121
+ return `${typeof value}:${String(value)}`;
122
+ }
123
+ function normalizeRowIds(values) {
124
+ const ids = new Map();
125
+ for (const value of values) {
126
+ const id = assertDataTableRowId(value);
127
+ ids.set(dataTableRowIdKey(id), id);
128
+ }
129
+ return [...ids.values()].sort(compareDataTableRowIds);
130
+ }
131
+ function assertQueryRevisionValue(value, label) {
132
+ if (typeof value !== 'string' || value.length === 0) {
133
+ throw new TypeError(`DataTable ${label} must be a non-empty string`);
134
+ }
135
+ return value;
136
+ }
137
+ function normalizeQueryRevision(value) {
138
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
139
+ throw new TypeError('DataTable query binding must be a plain object');
140
+ }
141
+ const input = value;
142
+ return {
143
+ queryFingerprint: assertQueryRevisionValue(input.queryFingerprint, 'query fingerprint'),
144
+ queryRevision: assertQueryRevisionValue(input.queryRevision, 'query revision'),
145
+ };
146
+ }
147
+ function normalizeSelection(value, legacyRowIds) {
148
+ if (value === undefined) {
149
+ return { scope: 'explicit', rowIds: normalizeRowIds(legacyRowIds) };
150
+ }
151
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
152
+ throw new TypeError('DataTable selection must be a plain object');
153
+ }
154
+ const input = value;
155
+ if (input.scope === 'page' || input.scope === 'explicit') {
156
+ if (!Array.isArray(input.rowIds)) {
157
+ throw new TypeError(`DataTable ${input.scope} selection requires rowIds`);
158
+ }
159
+ return { scope: input.scope, rowIds: normalizeRowIds(input.rowIds) };
160
+ }
161
+ if (input.scope === 'allMatching') {
162
+ if (Object.hasOwn(input, 'rowIds')) {
163
+ throw new TypeError('DataTable allMatching selection must not contain rowIds');
164
+ }
165
+ if (typeof input.expectedCount !== 'number' ||
166
+ !Number.isFinite(input.expectedCount) ||
167
+ !Number.isInteger(input.expectedCount) ||
168
+ input.expectedCount < 0) {
169
+ throw new TypeError('DataTable allMatching expectedCount must be a non-negative integer');
170
+ }
171
+ return {
172
+ scope: 'allMatching',
173
+ ...normalizeQueryRevision(input),
174
+ expectedCount: input.expectedCount,
175
+ };
176
+ }
177
+ throw new TypeError('DataTable selection scope must be page, explicit, or allMatching');
178
+ }
179
+ function selectedRowIdsFor(selection) {
180
+ return selection.scope === 'allMatching' ? [] : selection.rowIds;
181
+ }
182
+ function withSelection(state, selection) {
183
+ const normalized = normalizeSelection(selection, []);
184
+ return {
185
+ ...state,
186
+ selection: normalized,
187
+ selectedRowIds: selectedRowIdsFor(normalized),
188
+ };
189
+ }
190
+ function clearSelectionForPageChange(state) {
191
+ return state.selection.scope === 'page'
192
+ ? withSelection(state, { scope: 'page', rowIds: [] })
193
+ : state;
194
+ }
195
+ function clearSelectionForQueryChange(state) {
196
+ if (state.selection.scope === 'page') {
197
+ return withSelection(state, { scope: 'page', rowIds: [] });
198
+ }
199
+ if (state.selection.scope === 'allMatching') {
200
+ return withSelection(state, { scope: 'explicit', rowIds: [] });
201
+ }
202
+ return state;
203
+ }
204
+ /**
205
+ * Refuse an all-matching selection when the action's query has changed since
206
+ * selection. Domain actions must call this before a destructive operation.
207
+ */
208
+ export function assertDataTableSelectionCurrent(selection, currentQuery) {
209
+ if (selection.scope !== 'allMatching')
210
+ return;
211
+ const current = normalizeQueryRevision(currentQuery);
212
+ if (selection.queryFingerprint !== current.queryFingerprint ||
213
+ selection.queryRevision !== current.queryRevision) {
214
+ throw new TypeError('DataTable allMatching selection is stale for the current query revision');
215
+ }
216
+ }
217
+ function normalizeUniqueColumnIds(values) {
218
+ const ids = new Set();
219
+ for (const value of values)
220
+ ids.add(assertColumnId(value));
221
+ return [...ids];
222
+ }
223
+ function normalizeFilters(values) {
224
+ const filters = values.map((filter) => {
225
+ const columnId = assertColumnId(filter?.columnId, 'filter column id');
226
+ if (!FILTER_OPERATORS.has(filter?.operator)) {
227
+ throw new TypeError(`Unsupported DataTable filter operator: ${String(filter?.operator)}`);
228
+ }
229
+ const needsValue = filter.operator !== 'isNull' && filter.operator !== 'isNotNull';
230
+ if (needsValue && !Object.hasOwn(filter, 'value')) {
231
+ throw new TypeError(`DataTable filter ${filter.operator} requires a value`);
232
+ }
233
+ const value = needsValue && Object.hasOwn(filter, 'value')
234
+ ? canonicalJson(filter.value)
235
+ : undefined;
236
+ return value === undefined
237
+ ? { columnId, operator: filter.operator }
238
+ : { columnId, operator: filter.operator, value };
239
+ });
240
+ return filters.sort((left, right) => {
241
+ const leftKey = `${left.columnId}\u0000${left.operator}\u0000${jsonSignature(Object.hasOwn(left, 'value') ? left.value : null)}`;
242
+ const rightKey = `${right.columnId}\u0000${right.operator}\u0000${jsonSignature(Object.hasOwn(right, 'value')
243
+ ? right.value
244
+ : null)}`;
245
+ return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0;
246
+ });
247
+ }
248
+ function normalizeSorting(values) {
249
+ const seen = new Set();
250
+ const result = [];
251
+ for (const rule of values) {
252
+ const columnId = assertColumnId(rule?.columnId, 'sort column id');
253
+ if (rule?.direction !== 'asc' && rule?.direction !== 'desc') {
254
+ throw new TypeError('DataTable sort directions must be asc or desc');
255
+ }
256
+ if (!seen.has(columnId)) {
257
+ seen.add(columnId);
258
+ result.push({ columnId, direction: rule.direction });
259
+ }
260
+ }
261
+ return result;
262
+ }
263
+ function normalizeVisibility(values) {
264
+ const entries = new Map();
265
+ for (const entry of values) {
266
+ const columnId = assertColumnId(entry?.columnId, 'visibility column id');
267
+ if (typeof entry?.visible !== 'boolean') {
268
+ throw new TypeError('DataTable column visibility must be boolean');
269
+ }
270
+ entries.set(columnId, entry.visible);
271
+ }
272
+ return [...entries.entries()]
273
+ .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
274
+ .map(([columnId, visible]) => ({ columnId, visible }));
275
+ }
276
+ function normalizeWidths(values) {
277
+ const entries = new Map();
278
+ for (const entry of values) {
279
+ const columnId = assertColumnId(entry?.columnId, 'width column id');
280
+ if (typeof entry?.width !== 'number' ||
281
+ !Number.isFinite(entry.width) ||
282
+ entry.width <= 0) {
283
+ throw new TypeError('DataTable column widths must be positive finite numbers');
284
+ }
285
+ entries.set(columnId, entry.width);
286
+ }
287
+ return [...entries.entries()]
288
+ .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
289
+ .map(([columnId, width]) => ({ columnId, width }));
290
+ }
291
+ function normalizePinning(values) {
292
+ const entries = new Map();
293
+ for (const entry of values) {
294
+ const columnId = assertColumnId(entry?.columnId, 'pin column id');
295
+ if (entry?.position !== 'start' && entry?.position !== 'end') {
296
+ throw new TypeError('DataTable column pins must be start or end');
297
+ }
298
+ entries.set(columnId, entry.position);
299
+ }
300
+ return [...entries.entries()]
301
+ .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
302
+ .map(([columnId, position]) => ({ columnId, position }));
303
+ }
304
+ function normalizeModes(modes) {
305
+ const result = { ...DEFAULT_MODES, ...modes };
306
+ for (const mode of Object.values(result)) {
307
+ if (mode !== 'local' && mode !== 'manual') {
308
+ throw new TypeError('DataTable modes must be local or manual');
309
+ }
310
+ }
311
+ return result;
312
+ }
313
+ function normalizeState(state, columnIds, hiddenColumnIds) {
314
+ const input = state ?? {};
315
+ const selection = normalizeSelection(input.selection, input.selectedRowIds ?? DEFAULT_STATE.selectedRowIds);
316
+ const knownColumns = columnIds ? normalizeUniqueColumnIds(columnIds) : null;
317
+ const allowed = knownColumns ? new Set(knownColumns) : null;
318
+ const keepKnown = (columnId) => !allowed || allowed.has(columnId);
319
+ const hidden = new Set((hiddenColumnIds ? normalizeUniqueColumnIds(hiddenColumnIds) : []).filter(keepKnown));
320
+ const visibility = normalizeVisibility(input.columnVisibility ?? DEFAULT_STATE.columnVisibility).filter((entry) => keepKnown(entry.columnId));
321
+ const knownVisibility = new Map(visibility.map((entry) => [entry.columnId, entry.visible]));
322
+ if (knownColumns) {
323
+ for (const columnId of knownColumns) {
324
+ if (!knownVisibility.has(columnId))
325
+ knownVisibility.set(columnId, true);
326
+ }
327
+ }
328
+ for (const columnId of hidden)
329
+ knownVisibility.set(columnId, false);
330
+ const columnOrder = normalizeUniqueColumnIds(input.columnOrder ?? DEFAULT_STATE.columnOrder).filter(keepKnown);
331
+ if (knownColumns) {
332
+ for (const columnId of knownColumns) {
333
+ if (!columnOrder.includes(columnId))
334
+ columnOrder.push(columnId);
335
+ }
336
+ }
337
+ const columnWidths = normalizeWidths(input.columnWidths ?? DEFAULT_STATE.columnWidths).filter((entry) => keepKnown(entry.columnId));
338
+ const columnPinning = normalizePinning(input.columnPinning ?? DEFAULT_STATE.columnPinning).filter((entry) => keepKnown(entry.columnId));
339
+ return {
340
+ search: typeof input.search === 'string' ? input.search : DEFAULT_STATE.search,
341
+ filters: normalizeFilters(input.filters ?? DEFAULT_STATE.filters).filter((filter) => keepKnown(filter.columnId)),
342
+ sorting: normalizeSorting(input.sorting ?? DEFAULT_STATE.sorting).filter((sort) => keepKnown(sort.columnId)),
343
+ page: assertPage(input.page ?? DEFAULT_STATE.page),
344
+ pageSize: assertPageSize(input.pageSize ?? DEFAULT_STATE.pageSize),
345
+ columnOrder,
346
+ columnVisibility: normalizeVisibility([...knownVisibility.entries()].map(([columnId, visible]) => ({
347
+ columnId,
348
+ visible,
349
+ }))),
350
+ columnWidths,
351
+ columnPinning,
352
+ selection,
353
+ selectedRowIds: selectedRowIdsFor(selection),
354
+ expandedRowIds: normalizeRowIds(input.expandedRowIds ?? DEFAULT_STATE.expandedRowIds),
355
+ };
356
+ }
357
+ function stateSignature(state) {
358
+ return jsonSignature(canonicalJson(state));
359
+ }
360
+ function snapshotSignature(snapshot) {
361
+ return jsonSignature(canonicalJson(snapshot));
362
+ }
363
+ function cloneState(state) {
364
+ return canonicalJson(state);
365
+ }
366
+ function cloneSnapshot(snapshot) {
367
+ return canonicalJson(snapshot);
368
+ }
369
+ function resetPage(state, changed) {
370
+ return changed && state.page !== 1 ? { ...state, page: 1 } : state;
371
+ }
372
+ /** Apply one command without mutating the supplied state. */
373
+ export function transitionDataTableState(state, command) {
374
+ const current = normalizeState(state);
375
+ let next;
376
+ switch (command.type) {
377
+ case 'setSearch': {
378
+ if (typeof command.search !== 'string')
379
+ throw new TypeError('DataTable search must be a string');
380
+ const changed = command.search !== current.search;
381
+ next = resetPage({ ...current, search: command.search }, changed);
382
+ if (changed)
383
+ next = clearSelectionForQueryChange(next);
384
+ break;
385
+ }
386
+ case 'setFilters': {
387
+ const filters = normalizeFilters(command.filters);
388
+ const changed = jsonSignature(filters) !== jsonSignature(current.filters);
389
+ next = resetPage({ ...current, filters }, changed);
390
+ if (changed)
391
+ next = clearSelectionForQueryChange(next);
392
+ break;
393
+ }
394
+ case 'setSorting': {
395
+ const sorting = normalizeSorting(command.sorting);
396
+ const changed = jsonSignature(sorting) !== jsonSignature(current.sorting);
397
+ next = resetPage({ ...current, sorting }, changed);
398
+ if (changed)
399
+ next = clearSelectionForQueryChange(next);
400
+ break;
401
+ }
402
+ case 'toggleSorting': {
403
+ const columnId = assertColumnId(command.columnId, 'sort column id');
404
+ const index = current.sorting.findIndex((rule) => rule.columnId === columnId);
405
+ const previous = index >= 0 ? current.sorting[index] : undefined;
406
+ const toggled = !previous
407
+ ? { columnId, direction: 'asc' }
408
+ : previous.direction === 'asc'
409
+ ? { columnId, direction: 'desc' }
410
+ : null;
411
+ const sorting = command.multi
412
+ ? previous
413
+ ? toggled
414
+ ? current.sorting.map((rule, ruleIndex) => ruleIndex === index ? toggled : rule)
415
+ : current.sorting.filter((rule) => rule.columnId !== columnId)
416
+ : [...current.sorting, toggled]
417
+ : toggled
418
+ ? [toggled]
419
+ : [];
420
+ const changed = jsonSignature(sorting) !== jsonSignature(current.sorting);
421
+ next = resetPage({ ...current, sorting }, changed);
422
+ if (changed)
423
+ next = clearSelectionForQueryChange(next);
424
+ break;
425
+ }
426
+ case 'setPage': {
427
+ const page = assertPage(command.page);
428
+ next = { ...current, page };
429
+ if (page !== current.page)
430
+ next = clearSelectionForPageChange(next);
431
+ break;
432
+ }
433
+ case 'setPageSize': {
434
+ const pageSize = assertPageSize(command.pageSize);
435
+ const changed = pageSize !== current.pageSize;
436
+ next = resetPage({ ...current, pageSize }, changed);
437
+ if (changed)
438
+ next = clearSelectionForPageChange(next);
439
+ break;
440
+ }
441
+ case 'setColumnOrder':
442
+ next = {
443
+ ...current,
444
+ columnOrder: normalizeUniqueColumnIds(command.columnIds),
445
+ };
446
+ break;
447
+ case 'setColumnVisibility':
448
+ next = {
449
+ ...current,
450
+ columnVisibility: normalizeVisibility(command.columns),
451
+ };
452
+ break;
453
+ case 'setColumnWidths':
454
+ next = {
455
+ ...current,
456
+ columnWidths: normalizeWidths(command.columns),
457
+ };
458
+ break;
459
+ case 'setColumnWidth': {
460
+ const columnId = assertColumnId(command.columnId, 'width column id');
461
+ const widths = new Map(current.columnWidths.map((entry) => [entry.columnId, entry.width]));
462
+ if (command.width === null) {
463
+ widths.delete(columnId);
464
+ }
465
+ else {
466
+ const normalized = normalizeWidths([
467
+ { columnId, width: command.width },
468
+ ]);
469
+ widths.set(columnId, normalized[0].width);
470
+ }
471
+ next = {
472
+ ...current,
473
+ columnWidths: normalizeWidths([...widths.entries()].map(([id, width]) => ({ columnId: id, width }))),
474
+ };
475
+ break;
476
+ }
477
+ case 'setColumnPinning':
478
+ next = {
479
+ ...current,
480
+ columnPinning: normalizePinning(command.columns),
481
+ };
482
+ break;
483
+ case 'setColumnPin': {
484
+ const columnId = assertColumnId(command.columnId, 'pin column id');
485
+ const pins = new Map(current.columnPinning.map((entry) => [entry.columnId, entry.position]));
486
+ if (command.position === null) {
487
+ pins.delete(columnId);
488
+ }
489
+ else {
490
+ const normalized = normalizePinning([
491
+ { columnId, position: command.position },
492
+ ]);
493
+ pins.set(columnId, normalized[0].position);
494
+ }
495
+ next = {
496
+ ...current,
497
+ columnPinning: normalizePinning([...pins.entries()].map(([id, position]) => ({
498
+ columnId: id,
499
+ position,
500
+ }))),
501
+ };
502
+ break;
503
+ }
504
+ case 'setSelection':
505
+ next = withSelection(current, command.selection);
506
+ break;
507
+ case 'setPageSelection':
508
+ next = withSelection(current, {
509
+ scope: 'page',
510
+ rowIds: command.rowIds,
511
+ });
512
+ break;
513
+ case 'selectAllMatching':
514
+ next = withSelection(current, {
515
+ scope: 'allMatching',
516
+ ...normalizeQueryRevision(command),
517
+ expectedCount: command.expectedCount,
518
+ });
519
+ break;
520
+ case 'setSelectedRows':
521
+ next = withSelection(current, {
522
+ scope: 'explicit',
523
+ rowIds: command.rowIds,
524
+ });
525
+ break;
526
+ case 'toggleRowSelection': {
527
+ if (current.selection.scope === 'allMatching') {
528
+ throw new TypeError('DataTable cannot toggle an individual row while allMatching is active');
529
+ }
530
+ const rowId = assertDataTableRowId(command.rowId);
531
+ const ids = new Map(current.selection.rowIds.map((id) => [dataTableRowIdKey(id), id]));
532
+ ids.has(dataTableRowIdKey(rowId))
533
+ ? ids.delete(dataTableRowIdKey(rowId))
534
+ : ids.set(dataTableRowIdKey(rowId), rowId);
535
+ next = withSelection(current, {
536
+ scope: current.selection.scope,
537
+ rowIds: [...ids.values()],
538
+ });
539
+ break;
540
+ }
541
+ case 'setExpandedRows':
542
+ next = { ...current, expandedRowIds: normalizeRowIds(command.rowIds) };
543
+ break;
544
+ case 'toggleRowExpansion': {
545
+ const rowId = assertDataTableRowId(command.rowId);
546
+ const ids = new Map(current.expandedRowIds.map((id) => [dataTableRowIdKey(id), id]));
547
+ ids.has(dataTableRowIdKey(rowId))
548
+ ? ids.delete(dataTableRowIdKey(rowId))
549
+ : ids.set(dataTableRowIdKey(rowId), rowId);
550
+ next = { ...current, expandedRowIds: normalizeRowIds([...ids.values()]) };
551
+ break;
552
+ }
553
+ case 'reset':
554
+ next = { ...DEFAULT_STATE };
555
+ break;
556
+ default:
557
+ throw new TypeError(`Unsupported DataTable command: ${String(command.type)}`);
558
+ }
559
+ return normalizeState(next);
560
+ }
561
+ /** Parse persisted state defensively before an external adapter restores it. */
562
+ export function hydrateDataTableSnapshot(value) {
563
+ if (!value ||
564
+ typeof value !== 'object' ||
565
+ Object.getPrototypeOf(value) !== Object.prototype) {
566
+ throw new TypeError('DataTable snapshot must be a plain object');
567
+ }
568
+ const input = value;
569
+ if (input.version !== 1 && input.version !== 2 && input.version !== 3)
570
+ throw new TypeError('Unsupported DataTable snapshot version');
571
+ return {
572
+ version: 3,
573
+ modes: normalizeModes(input.modes),
574
+ state: normalizeState(input.state),
575
+ };
576
+ }
577
+ /** A headless state owner used by both rendered controls and programmatic commands. */
578
+ export class DataTableController {
579
+ state;
580
+ modes;
581
+ columnIds;
582
+ hiddenColumnIds;
583
+ /**
584
+ * Static schema visibility is a rendering constraint, not a saved-view
585
+ * preference. Keep the latter while a column is constrained so removing the
586
+ * constraint restores the caller's prior intent.
587
+ */
588
+ visibilityBeforeStaticHide = new Map();
589
+ controlled;
590
+ listeners = new Set();
591
+ onStateChange;
592
+ pendingControlledState;
593
+ constructor(options = {}) {
594
+ this.columnIds = options.columnIds
595
+ ? normalizeUniqueColumnIds(options.columnIds)
596
+ : undefined;
597
+ this.hiddenColumnIds = options.hiddenColumnIds
598
+ ? normalizeUniqueColumnIds(options.hiddenColumnIds)
599
+ : undefined;
600
+ this.rememberStaticVisibility(options.state ?? options.initialState ?? {}, this.hiddenColumnIds ?? []);
601
+ this.controlled = options.state !== undefined;
602
+ this.state = normalizeState(options.state ?? options.initialState, this.columnIds, this.hiddenColumnIds);
603
+ this.modes = normalizeModes(options.modes);
604
+ this.onStateChange = options.onStateChange;
605
+ }
606
+ getState() {
607
+ return cloneState(this.state);
608
+ }
609
+ getModes() {
610
+ return { ...this.modes };
611
+ }
612
+ snapshot() {
613
+ return { version: 3, modes: this.getModes(), state: this.getState() };
614
+ }
615
+ subscribe(listener) {
616
+ this.listeners.add(listener);
617
+ return () => this.listeners.delete(listener);
618
+ }
619
+ /** Dispatches a serializable command. Controlled controllers emit a proposal only. */
620
+ dispatch(command) {
621
+ const previous = this.snapshot();
622
+ const candidate = normalizeState(transitionDataTableState(this.state, command), this.columnIds, this.hiddenColumnIds);
623
+ const next = {
624
+ version: 3,
625
+ modes: this.getModes(),
626
+ state: candidate,
627
+ };
628
+ const changed = snapshotSignature(previous) !== snapshotSignature(next);
629
+ const transition = {
630
+ command,
631
+ previous,
632
+ next: cloneSnapshot(next),
633
+ changed,
634
+ };
635
+ if (!changed)
636
+ return transition;
637
+ if (this.controlled) {
638
+ const signature = stateSignature(candidate);
639
+ if (this.pendingControlledState === signature)
640
+ return transition;
641
+ this.pendingControlledState = signature;
642
+ this.onStateChange?.(cloneState(candidate), command);
643
+ return transition;
644
+ }
645
+ this.state = candidate;
646
+ this.pendingControlledState = undefined;
647
+ this.onStateChange?.(cloneState(candidate), command);
648
+ this.emit(transition);
649
+ return transition;
650
+ }
651
+ /** Supplies state from a controlled host or an external persistence adapter. */
652
+ replaceState(state) {
653
+ const previous = this.snapshot();
654
+ if (this.controlled) {
655
+ this.rememberStaticVisibility(state, this.hiddenColumnIds ?? [], true);
656
+ }
657
+ const nextState = normalizeState(state, this.columnIds, this.hiddenColumnIds);
658
+ const next = {
659
+ version: 3,
660
+ modes: this.getModes(),
661
+ state: nextState,
662
+ };
663
+ const changed = snapshotSignature(previous) !== snapshotSignature(next);
664
+ const transition = {
665
+ command: null,
666
+ previous,
667
+ next: cloneSnapshot(next),
668
+ changed,
669
+ };
670
+ this.state = nextState;
671
+ this.pendingControlledState = undefined;
672
+ if (changed)
673
+ this.emit(transition);
674
+ return transition;
675
+ }
676
+ /** Changes ownership without treating it as a user command. */
677
+ setControlled(controlled) {
678
+ this.controlled = controlled;
679
+ if (!controlled)
680
+ this.pendingControlledState = undefined;
681
+ }
682
+ /** Configures transformation ownership; this remains outside persisted state. */
683
+ setModes(modes) {
684
+ const previous = this.snapshot();
685
+ this.modes = normalizeModes(modes);
686
+ const next = this.snapshot();
687
+ const changed = snapshotSignature(previous) !== snapshotSignature(next);
688
+ const transition = { command: null, previous, next, changed };
689
+ if (changed)
690
+ this.emit(transition);
691
+ return transition;
692
+ }
693
+ /** Reconciles stale saved-view column IDs with the renderer's current columns. */
694
+ setColumnIds(columnIds, hiddenColumnIds = []) {
695
+ const previous = this.snapshot();
696
+ const previousHidden = new Set(this.hiddenColumnIds ?? []);
697
+ this.columnIds = normalizeUniqueColumnIds(columnIds);
698
+ this.hiddenColumnIds = normalizeUniqueColumnIds(hiddenColumnIds);
699
+ this.rememberStaticVisibility(this.state, this.hiddenColumnIds.filter((columnId) => !previousHidden.has(columnId)));
700
+ const restoredVisibility = new Map(this.state.columnVisibility.map((entry) => [
701
+ entry.columnId,
702
+ entry.visible,
703
+ ]));
704
+ for (const columnId of previousHidden) {
705
+ if (this.hiddenColumnIds.includes(columnId))
706
+ continue;
707
+ const visible = this.visibilityBeforeStaticHide.get(columnId);
708
+ if (visible !== undefined)
709
+ restoredVisibility.set(columnId, visible);
710
+ this.visibilityBeforeStaticHide.delete(columnId);
711
+ }
712
+ const knownColumns = new Set(this.columnIds);
713
+ for (const columnId of this.visibilityBeforeStaticHide.keys()) {
714
+ if (!knownColumns.has(columnId)) {
715
+ this.visibilityBeforeStaticHide.delete(columnId);
716
+ }
717
+ }
718
+ this.state = normalizeState({
719
+ ...this.state,
720
+ columnVisibility: [...restoredVisibility.entries()].map(([columnId, visible]) => ({ columnId, visible })),
721
+ }, this.columnIds, this.hiddenColumnIds);
722
+ const next = this.snapshot();
723
+ const changed = snapshotSignature(previous) !== snapshotSignature(next);
724
+ const transition = { command: null, previous, next, changed };
725
+ if (changed)
726
+ this.emit(transition);
727
+ return transition;
728
+ }
729
+ rememberStaticVisibility(state, hiddenColumnIds, overwrite = false) {
730
+ const visibility = new Map(normalizeVisibility(state.columnVisibility ?? []).map((entry) => [
731
+ entry.columnId,
732
+ entry.visible,
733
+ ]));
734
+ for (const columnId of hiddenColumnIds) {
735
+ const visible = visibility.get(columnId);
736
+ if (visible !== undefined &&
737
+ (overwrite || !this.visibilityBeforeStaticHide.has(columnId))) {
738
+ this.visibilityBeforeStaticHide.set(columnId, visible);
739
+ }
740
+ else if (!this.visibilityBeforeStaticHide.has(columnId)) {
741
+ this.visibilityBeforeStaticHide.set(columnId, true);
742
+ }
743
+ }
744
+ }
745
+ /** Clamp against a reliable total. A missing total intentionally does not guess. */
746
+ clampPage(totalRows) {
747
+ if (totalRows === null || totalRows === undefined) {
748
+ const snapshot = this.snapshot();
749
+ return {
750
+ command: null,
751
+ previous: snapshot,
752
+ next: snapshot,
753
+ changed: false,
754
+ };
755
+ }
756
+ if (!Number.isFinite(totalRows) ||
757
+ !Number.isInteger(totalRows) ||
758
+ totalRows < 0) {
759
+ throw new TypeError('DataTable totalRows must be a non-negative integer');
760
+ }
761
+ const pageCount = this.state.pageSize
762
+ ? Math.max(1, Math.ceil(totalRows / this.state.pageSize))
763
+ : 1;
764
+ if (this.state.page <= pageCount) {
765
+ const snapshot = this.snapshot();
766
+ return {
767
+ command: null,
768
+ previous: snapshot,
769
+ next: snapshot,
770
+ changed: false,
771
+ };
772
+ }
773
+ return this.dispatch({ type: 'setPage', page: pageCount });
774
+ }
775
+ emit(transition) {
776
+ for (const listener of [...this.listeners])
777
+ listener(cloneTransition(transition));
778
+ }
779
+ }
780
+ function cloneTransition(transition) {
781
+ return {
782
+ command: transition.command
783
+ ? canonicalJson(transition.command)
784
+ : null,
785
+ previous: cloneSnapshot(transition.previous),
786
+ next: cloneSnapshot(transition.next),
787
+ changed: transition.changed,
788
+ };
789
+ }
790
+ export function createDataTableController(options = {}) {
791
+ return new DataTableController(options);
792
+ }