aplance-ui 1.5.2 → 1.6.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 +1 -1
- package/dist/api.d.ts +2 -1
- package/dist/api.js +103 -68
- package/dist/api.js.map +1 -1
- package/dist/frameworks/vite-react/vite.config.ts.template +1 -1
- package/dist/index.js +103 -68
- package/dist/index.js.map +1 -1
- package/package.json +12 -6
- package/registry/components/primitives/modal/modal.module.css +9 -3
- package/registry/components/primitives/popover/popover-context.ts +2 -0
- package/registry/components/primitives/popover/popover.tsx +47 -2
- package/registry/components/ui/chart/chart.tsx +51 -30
- package/registry/components/ui/chart/types.ts +5 -1
- package/registry/components/ui/data-table/checkbox-column.tsx +12 -8
- package/registry/components/ui/data-table/data-table.module.css +41 -19
- package/registry/components/ui/data-table/data-table.tsx +1285 -1238
- package/registry/components/ui/data-table/filter-bar.tsx +56 -13
- package/registry/components/ui/data-table/filter-modal.tsx +16 -82
- package/registry/components/ui/data-table/filter-sheet.tsx +97 -0
- package/registry/components/ui/data-table/filters.tsx +21 -21
- package/registry/components/ui/data-table/pagination-bar.tsx +4 -6
- package/registry/components/ui/data-table/types.ts +149 -80
- package/registry/components/ui/data-table/useDataTable.tsx +35 -16
- package/registry/components/ui/data-table/utils.ts +691 -471
- package/registry/components/ui/input-fields/date-picker/calendar.tsx +7 -15
- package/registry/components/ui/input-fields/date-picker/date-picker.tsx +1 -1
- package/registry/components/ui/input-fields/date-picker/types.ts +3 -3
- package/registry/components/ui/input-fields/emoji-picker/emoji-palette.tsx +1 -1
- package/registry/registry.json +16 -11
|
@@ -1,471 +1,691 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
:
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
)
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
if (
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
*
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
if (
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
*
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
const
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
//
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
if (
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
)
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
state
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
const
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
const
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
}
|
|
1
|
+
import {
|
|
2
|
+
aggregationFn_count,
|
|
3
|
+
aggregationFn_extent,
|
|
4
|
+
aggregationFn_first,
|
|
5
|
+
aggregationFn_last,
|
|
6
|
+
aggregationFn_max,
|
|
7
|
+
aggregationFn_mean,
|
|
8
|
+
aggregationFn_median,
|
|
9
|
+
aggregationFn_min,
|
|
10
|
+
aggregationFn_sum,
|
|
11
|
+
aggregationFn_unique,
|
|
12
|
+
aggregationFn_uniqueCount,
|
|
13
|
+
columnGroupingFeature,
|
|
14
|
+
columnOrderingFeature,
|
|
15
|
+
columnPinningFeature,
|
|
16
|
+
columnResizingFeature,
|
|
17
|
+
columnSizingFeature,
|
|
18
|
+
columnVisibilityFeature,
|
|
19
|
+
createExpandedRowModel,
|
|
20
|
+
createGroupedRowModel,
|
|
21
|
+
createPaginatedRowModel,
|
|
22
|
+
createSortedRowModel,
|
|
23
|
+
metaHelper,
|
|
24
|
+
rowAggregationFeature,
|
|
25
|
+
rowExpandingFeature,
|
|
26
|
+
rowPaginationFeature,
|
|
27
|
+
rowSelectionFeature,
|
|
28
|
+
rowSortingFeature,
|
|
29
|
+
sortFn_alphanumeric,
|
|
30
|
+
sortFn_alphanumericCaseSensitive,
|
|
31
|
+
sortFn_basic,
|
|
32
|
+
sortFn_datetime,
|
|
33
|
+
sortFn_text,
|
|
34
|
+
sortFn_textCaseSensitive,
|
|
35
|
+
tableFeatures,
|
|
36
|
+
type Column,
|
|
37
|
+
type ColumnOrderState,
|
|
38
|
+
type ColumnPinningState,
|
|
39
|
+
type ColumnSizingState,
|
|
40
|
+
type ColumnVisibilityState,
|
|
41
|
+
type ExpandedState,
|
|
42
|
+
type RowData,
|
|
43
|
+
type Updater,
|
|
44
|
+
} from "@tanstack/react-table";
|
|
45
|
+
import type { CSSProperties, ReactNode } from "react";
|
|
46
|
+
import type {
|
|
47
|
+
ApplyPersistedTarget,
|
|
48
|
+
ColumnIdSource,
|
|
49
|
+
DataTableColumnMeta,
|
|
50
|
+
DataTableFeatures,
|
|
51
|
+
DataTableMeta,
|
|
52
|
+
FilterField,
|
|
53
|
+
FilterInputField,
|
|
54
|
+
FilterPlacement,
|
|
55
|
+
PersistedState,
|
|
56
|
+
SnapshotInput,
|
|
57
|
+
} from "./types";
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The TanStack Table features every DataTable registers, so only their code
|
|
61
|
+
* ships. Filtering is not among them: rows arrive already filtered (see
|
|
62
|
+
* `useDataTable`'s `filterFn`), so no filtered row model is needed. Rows run
|
|
63
|
+
* through sorting, then grouping and expansion, then pagination, so a
|
|
64
|
+
* client-side table sorts and groups every row before it pages them.
|
|
65
|
+
*
|
|
66
|
+
* The sort and aggregation functions are every built-in one, registered by
|
|
67
|
+
* name so a column's `sortFn: 'text'` or `aggregationFn: 'mean'` (and the
|
|
68
|
+
* default `'auto'` of both) resolves. The meta slots type `columnDef.meta` and
|
|
69
|
+
* `table.options.meta` for DataTable columns only, leaving other tables in the
|
|
70
|
+
* app untouched.
|
|
71
|
+
*/
|
|
72
|
+
export const dataTableFeatures = tableFeatures({
|
|
73
|
+
rowSortingFeature,
|
|
74
|
+
rowPaginationFeature,
|
|
75
|
+
rowSelectionFeature,
|
|
76
|
+
rowExpandingFeature,
|
|
77
|
+
columnGroupingFeature,
|
|
78
|
+
rowAggregationFeature,
|
|
79
|
+
columnOrderingFeature,
|
|
80
|
+
columnPinningFeature,
|
|
81
|
+
columnSizingFeature,
|
|
82
|
+
columnResizingFeature,
|
|
83
|
+
columnVisibilityFeature,
|
|
84
|
+
sortedRowModel: createSortedRowModel(),
|
|
85
|
+
groupedRowModel: createGroupedRowModel(),
|
|
86
|
+
expandedRowModel: createExpandedRowModel(),
|
|
87
|
+
paginatedRowModel: createPaginatedRowModel(),
|
|
88
|
+
sortFns: {
|
|
89
|
+
alphanumeric: sortFn_alphanumeric,
|
|
90
|
+
alphanumericCaseSensitive: sortFn_alphanumericCaseSensitive,
|
|
91
|
+
basic: sortFn_basic,
|
|
92
|
+
datetime: sortFn_datetime,
|
|
93
|
+
text: sortFn_text,
|
|
94
|
+
textCaseSensitive: sortFn_textCaseSensitive,
|
|
95
|
+
},
|
|
96
|
+
aggregationFns: {
|
|
97
|
+
count: aggregationFn_count,
|
|
98
|
+
extent: aggregationFn_extent,
|
|
99
|
+
first: aggregationFn_first,
|
|
100
|
+
last: aggregationFn_last,
|
|
101
|
+
max: aggregationFn_max,
|
|
102
|
+
mean: aggregationFn_mean,
|
|
103
|
+
median: aggregationFn_median,
|
|
104
|
+
min: aggregationFn_min,
|
|
105
|
+
sum: aggregationFn_sum,
|
|
106
|
+
unique: aggregationFn_unique,
|
|
107
|
+
uniqueCount: aggregationFn_uniqueCount,
|
|
108
|
+
},
|
|
109
|
+
columnMeta: metaHelper<DataTableColumnMeta>(),
|
|
110
|
+
tableMeta: metaHelper<DataTableMeta>(),
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Per-cell style map for one column. Returns the fixed width plus the
|
|
115
|
+
* sticky offset when the column is pinned. Reading offsets straight from
|
|
116
|
+
* tanstack (`getStart`/`getAfter`) keeps header and body cells in sync
|
|
117
|
+
* even after resizing or reordering - no manual bookkeeping needed.
|
|
118
|
+
*/
|
|
119
|
+
export function getColumnStyle<TData extends RowData>(
|
|
120
|
+
column: Column<DataTableFeatures, TData, unknown>,
|
|
121
|
+
size: number,
|
|
122
|
+
extraWidth = 0,
|
|
123
|
+
): CSSProperties {
|
|
124
|
+
const isPinned = column.getIsPinned();
|
|
125
|
+
const computedWidth = size + extraWidth;
|
|
126
|
+
const style: CSSProperties = {
|
|
127
|
+
width: `${computedWidth}px`,
|
|
128
|
+
minWidth: `${computedWidth}px`,
|
|
129
|
+
maxWidth: `${computedWidth}px`,
|
|
130
|
+
};
|
|
131
|
+
if (isPinned === "start") {
|
|
132
|
+
style.insetInlineStart = `${column.getStart("start")}px`;
|
|
133
|
+
} else if (isPinned === "end") {
|
|
134
|
+
style.insetInlineEnd = `${column.getAfter("end")}px`;
|
|
135
|
+
}
|
|
136
|
+
return style;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const NO_EXTRA_WIDTHS: Record<string, number> = {};
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Spread the body's spare width (`extraSpace`, px) across the columns that may
|
|
143
|
+
* grow, keyed by column id; the leftover pixels go to the first ones. Pinned
|
|
144
|
+
* columns never grow: tanstack computes sticky offsets from base sizes, so a
|
|
145
|
+
* wider pinned column would desync the offsets and overlap its neighbours.
|
|
146
|
+
* Neither does the select column, which keeps its compact width.
|
|
147
|
+
*/
|
|
148
|
+
export function growColumnWidths<TData extends RowData>(
|
|
149
|
+
columns: Column<DataTableFeatures, TData, unknown>[],
|
|
150
|
+
extraSpace: number,
|
|
151
|
+
): Record<string, number> {
|
|
152
|
+
if (extraSpace <= 0) return NO_EXTRA_WIDTHS;
|
|
153
|
+
const growColumns = columns.filter(
|
|
154
|
+
(column) => !column.getIsPinned() && column.id !== SELECT_COLUMN_ID,
|
|
155
|
+
);
|
|
156
|
+
if (growColumns.length === 0) return NO_EXTRA_WIDTHS;
|
|
157
|
+
|
|
158
|
+
const baseExtra = Math.floor(extraSpace / growColumns.length);
|
|
159
|
+
let remainder = extraSpace - baseExtra * growColumns.length;
|
|
160
|
+
const widths: Record<string, number> = {};
|
|
161
|
+
for (const column of growColumns) {
|
|
162
|
+
const grow = baseExtra + (remainder > 0 ? 1 : 0);
|
|
163
|
+
if (grow > 0) widths[column.id] = grow;
|
|
164
|
+
if (remainder > 0) remainder -= 1;
|
|
165
|
+
}
|
|
166
|
+
return widths;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Move `sourceId` to the position currently held by `targetId`. */
|
|
170
|
+
export function reorderColumnOrder(
|
|
171
|
+
order: ColumnOrderState,
|
|
172
|
+
sourceId: string,
|
|
173
|
+
targetId: string,
|
|
174
|
+
): ColumnOrderState {
|
|
175
|
+
const next = order.slice();
|
|
176
|
+
const fromIdx = next.indexOf(sourceId);
|
|
177
|
+
const toIdx = next.indexOf(targetId);
|
|
178
|
+
if (fromIdx === -1 || toIdx === -1) return order;
|
|
179
|
+
next.splice(fromIdx, 1);
|
|
180
|
+
next.splice(toIdx, 0, sourceId);
|
|
181
|
+
return next;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The pinned column that meets the scrolling center: the last one of the
|
|
186
|
+
* start group, the first one of the end group (each the last counted inward
|
|
187
|
+
* from its own edge). Only it draws the divider, so adjacent pinned columns
|
|
188
|
+
* never get a line between them.
|
|
189
|
+
*/
|
|
190
|
+
export function isLastPinned<TData extends RowData>(
|
|
191
|
+
column: Column<DataTableFeatures, TData, unknown>,
|
|
192
|
+
): boolean {
|
|
193
|
+
const pinned = column.getIsPinned();
|
|
194
|
+
if (pinned === "start") return column.getIsLastColumn("start");
|
|
195
|
+
if (pinned === "end") return column.getIsFirstColumn("end");
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const formatAggregateValue = (value: unknown): string =>
|
|
200
|
+
typeof value === "number"
|
|
201
|
+
? value.toLocaleString()
|
|
202
|
+
: value instanceof Date
|
|
203
|
+
? value.toLocaleDateString()
|
|
204
|
+
: String(value);
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* How a group row shows a column's aggregate when the column has no
|
|
208
|
+
* `aggregatedCell` of its own. Numbers and dates read in the viewer's locale.
|
|
209
|
+
* An `extent` (also what `auto` picks for a date column) reads as a
|
|
210
|
+
* `min - max` range, `unique` as a list, and a column with several
|
|
211
|
+
* aggregations as `name: value` pairs. Null when there is nothing to show,
|
|
212
|
+
* e.g. `auto` on a text column.
|
|
213
|
+
*/
|
|
214
|
+
export function formatAggregated(
|
|
215
|
+
value: unknown,
|
|
216
|
+
aggregationFn: unknown,
|
|
217
|
+
): string | null {
|
|
218
|
+
if (value == null) return null;
|
|
219
|
+
if (Array.isArray(value)) {
|
|
220
|
+
const isRange = aggregationFn === "extent" || aggregationFn === "auto";
|
|
221
|
+
return value.map(formatAggregateValue).join(isRange ? " - " : ", ");
|
|
222
|
+
}
|
|
223
|
+
if (typeof value === "object" && !(value instanceof Date)) {
|
|
224
|
+
return Object.entries(value)
|
|
225
|
+
.map(([name, entry]) => `${name}: ${formatAggregated(entry, name) ?? ""}`)
|
|
226
|
+
.join(", ");
|
|
227
|
+
}
|
|
228
|
+
return formatAggregateValue(value);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Resolve the runtime column id from a raw `ColumnDef`. Mirrors how
|
|
233
|
+
* tanstack derives ids: explicit `id` first, then `accessorKey` for
|
|
234
|
+
* accessor columns. Useful when consumers need to build menus (visibility,
|
|
235
|
+
* grouping, etc.) from the same column array they hand to the table -
|
|
236
|
+
* tanstack only assigns the resolved id once the table is built, so
|
|
237
|
+
* reading `c.id` directly off the def returns `undefined` for accessor
|
|
238
|
+
* columns and breaks state maps keyed by id.
|
|
239
|
+
*/
|
|
240
|
+
export function columnId(def: ColumnIdSource): string {
|
|
241
|
+
if (def.id) return def.id;
|
|
242
|
+
if (def.accessorKey != null) return String(def.accessorKey);
|
|
243
|
+
return "";
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* TanStack `Updater<T>` is `T | ((old: T) => T)`. When `T` itself extends
|
|
248
|
+
* `Function`, TypeScript widens the function branch to `((old: T) => T) |
|
|
249
|
+
* (T & Function)` after the `typeof` narrowing - and `T & Function` has no
|
|
250
|
+
* call signatures, so a cast is required to keep the compiler happy.
|
|
251
|
+
*/
|
|
252
|
+
export function resolveUpdater<T>(updater: Updater<T>, prev: T): T {
|
|
253
|
+
return typeof updater === "function"
|
|
254
|
+
? (updater as (old: T) => T)(prev)
|
|
255
|
+
: updater;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export const SELECT_COLUMN_ID = "select";
|
|
259
|
+
|
|
260
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
261
|
+
// State persistence helpers (URL search params / localStorage / manual).
|
|
262
|
+
// Pure functions - no React. The hook-level orchestration in `useDataTable`
|
|
263
|
+
// supplies the current state + setters and these functions take care of the
|
|
264
|
+
// snapshot/apply/read/write mechanics.
|
|
265
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Single source-of-truth for every URL param key used by the persistence
|
|
269
|
+
* layer. Both `readSearchParams` and `writeSearchParams` reference these
|
|
270
|
+
* values - adding or renaming a key is one edit here, not scattered string
|
|
271
|
+
* literals across multiple functions.
|
|
272
|
+
*/
|
|
273
|
+
export const PARAM_KEYS = {
|
|
274
|
+
pageIndex: "pageIndex",
|
|
275
|
+
pageSize: "pageSize",
|
|
276
|
+
sortBy: "sortBy",
|
|
277
|
+
sortDir: "sortDir",
|
|
278
|
+
columnOrder: "columnOrder",
|
|
279
|
+
columnVisibility: "columnVisibility",
|
|
280
|
+
columnPinning: "columnPinning",
|
|
281
|
+
columnSizing: "columnSizing",
|
|
282
|
+
grouping: "grouping",
|
|
283
|
+
expanded: "expanded",
|
|
284
|
+
} as const;
|
|
285
|
+
|
|
286
|
+
export type ParamKey = (typeof PARAM_KEYS)[keyof typeof PARAM_KEYS];
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Reserved param keys used by the snapshot encoding itself. Filter keys must
|
|
290
|
+
* not collide with these - when iterating filter keys we skip any reserved
|
|
291
|
+
* names so a filter named `pageIndex` cannot stomp on the pagination param.
|
|
292
|
+
* Derived from `PARAM_KEYS` so the two never drift apart.
|
|
293
|
+
*/
|
|
294
|
+
export const RESERVED_PARAM_KEYS = new Set<string>(Object.values(PARAM_KEYS));
|
|
295
|
+
|
|
296
|
+
const isNonEmptyExpanded = (e: ExpandedState | undefined): boolean =>
|
|
297
|
+
e !== undefined && (e === true || Object.keys(e).length > 0);
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Structural equality for two `ExpandedState`s (each a boolean or an
|
|
301
|
+
* id -> boolean record). Lets the snapshot skip persisting `expanded` while it
|
|
302
|
+
* still matches the table's default.
|
|
303
|
+
*/
|
|
304
|
+
const expandedEquals = (
|
|
305
|
+
a: ExpandedState | undefined,
|
|
306
|
+
b: ExpandedState | undefined,
|
|
307
|
+
): boolean => {
|
|
308
|
+
if (a === b) return true;
|
|
309
|
+
if (typeof a === "boolean" || typeof b === "boolean") return false;
|
|
310
|
+
if (!a || !b) return false;
|
|
311
|
+
const aKeys = Object.keys(a);
|
|
312
|
+
if (aKeys.length !== Object.keys(b).length) return false;
|
|
313
|
+
return aKeys.every((k) => a[k] === b[k]);
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Build a `PersistedState` snapshot from current state, omitting empty/default
|
|
318
|
+
* slices so the persisted blob (URL or storage) stays minimal.
|
|
319
|
+
* `defaultPageSize` lives on `SnapshotInput` so callers don't need to thread
|
|
320
|
+
* it as a separate argument - it's part of the snapshot's own context.
|
|
321
|
+
*/
|
|
322
|
+
export function snapshotPersistedState(input: SnapshotInput): PersistedState {
|
|
323
|
+
const out: PersistedState = {};
|
|
324
|
+
|
|
325
|
+
const activeFilters: Record<string, string> = {};
|
|
326
|
+
for (const [k, v] of Object.entries(input.filterValues)) {
|
|
327
|
+
if (v) activeFilters[k] = v;
|
|
328
|
+
}
|
|
329
|
+
if (Object.keys(activeFilters).length > 0) out.filterValues = activeFilters;
|
|
330
|
+
|
|
331
|
+
// Only persist pagination when it actually differs from defaults -
|
|
332
|
+
// pageSize is always a truthy number so `if (pageSize)` would always fire.
|
|
333
|
+
if (
|
|
334
|
+
input.pagination.pageIndex > 0 ||
|
|
335
|
+
input.pagination.pageSize !== input.defaultPageSize
|
|
336
|
+
) {
|
|
337
|
+
out.pagination = input.pagination;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (input.sorting.length > 0) out.sorting = input.sorting;
|
|
341
|
+
if (input.columnOrder.length > 0) out.columnOrder = input.columnOrder;
|
|
342
|
+
if (Object.keys(input.columnVisibility).length > 0) {
|
|
343
|
+
out.columnVisibility = input.columnVisibility;
|
|
344
|
+
}
|
|
345
|
+
if (input.columnPinning.start.length || input.columnPinning.end.length) {
|
|
346
|
+
out.columnPinning = input.columnPinning;
|
|
347
|
+
}
|
|
348
|
+
if (Object.keys(input.columnSizing).length > 0) {
|
|
349
|
+
out.columnSizing = input.columnSizing;
|
|
350
|
+
}
|
|
351
|
+
if (input.grouping.length > 0) out.grouping = input.grouping;
|
|
352
|
+
// Persist expansion only when it deviates from the default - otherwise the
|
|
353
|
+
// default (`true` = all groups expanded) writes a noisy `?expanded=true` even
|
|
354
|
+
// when the user never touched a group.
|
|
355
|
+
if (
|
|
356
|
+
isNonEmptyExpanded(input.expanded) &&
|
|
357
|
+
!expandedEquals(input.expanded, input.defaultExpanded)
|
|
358
|
+
) {
|
|
359
|
+
out.expanded = input.expanded;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
return out;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** A pinning side's column ids: an absent side is empty, anything else null. */
|
|
366
|
+
const pinnedIds = (side: unknown): string[] | null =>
|
|
367
|
+
side === undefined
|
|
368
|
+
? []
|
|
369
|
+
: Array.isArray(side) && side.every((id) => typeof id === "string")
|
|
370
|
+
? side
|
|
371
|
+
: null;
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* A persisted pinning blob in the shape the table reads, or undefined. The URL
|
|
375
|
+
* and storage are outside the app's control (a hand-edited link, a blob an
|
|
376
|
+
* older build wrote), and pinning is read on every render, so a malformed
|
|
377
|
+
* blob must not reach the table: it is ignored and the default pins stay.
|
|
378
|
+
* Either side may be left out, but not both.
|
|
379
|
+
*/
|
|
380
|
+
const persistedPinning = (value: unknown): ColumnPinningState | undefined => {
|
|
381
|
+
if (typeof value !== "object" || value === null) return undefined;
|
|
382
|
+
const { start, end } = value as Record<string, unknown>;
|
|
383
|
+
if (start === undefined && end === undefined) return undefined;
|
|
384
|
+
const startIds = pinnedIds(start);
|
|
385
|
+
const endIds = pinnedIds(end);
|
|
386
|
+
return startIds && endIds ? { start: startIds, end: endIds } : undefined;
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Apply a `PersistedState` blob back onto the controlled setters. Each slice
|
|
391
|
+
* is independently optional - partial blobs (e.g. only `sorting`) work fine.
|
|
392
|
+
* Unknown filter keys from a stale URL/blob are silently ignored, and so is
|
|
393
|
+
* a pinning blob of the wrong shape.
|
|
394
|
+
*/
|
|
395
|
+
export function applyPersistedState(
|
|
396
|
+
state: PersistedState,
|
|
397
|
+
target: ApplyPersistedTarget,
|
|
398
|
+
): void {
|
|
399
|
+
if (state.filterValues) {
|
|
400
|
+
const next = { ...target.filterValues };
|
|
401
|
+
for (const key of target.filterKeys) {
|
|
402
|
+
const v = state.filterValues[key];
|
|
403
|
+
if (v !== undefined) next[key] = v;
|
|
404
|
+
}
|
|
405
|
+
target.setFilterValues(next);
|
|
406
|
+
}
|
|
407
|
+
if (state.pagination) target.setPagination(state.pagination);
|
|
408
|
+
if (state.sorting) target.setSorting(state.sorting);
|
|
409
|
+
if (state.columnOrder) target.setColumnOrder(state.columnOrder);
|
|
410
|
+
if (state.columnVisibility)
|
|
411
|
+
target.setColumnVisibility(state.columnVisibility);
|
|
412
|
+
const columnPinning = persistedPinning(state.columnPinning);
|
|
413
|
+
if (columnPinning) target.setColumnPinning(columnPinning);
|
|
414
|
+
if (state.columnSizing) target.setColumnSizing(state.columnSizing);
|
|
415
|
+
if (state.grouping) target.setGrouping(state.grouping);
|
|
416
|
+
if (state.expanded !== undefined) target.setExpanded(state.expanded);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Decode a URL `?...` query string into a `PersistedState` blob. Flat
|
|
421
|
+
* encoding, hand-editable. `pageIndex` is stored 1-based for human
|
|
422
|
+
* readability; `pageSize` falls back to `defaultPageSize` when absent.
|
|
423
|
+
*/
|
|
424
|
+
export function readSearchParams(
|
|
425
|
+
filterKeys: string[],
|
|
426
|
+
paramMap: Partial<Record<string, string>>,
|
|
427
|
+
defaultPageSize: number,
|
|
428
|
+
): PersistedState {
|
|
429
|
+
const out: PersistedState = {};
|
|
430
|
+
const params = new URLSearchParams(window.location.search);
|
|
431
|
+
const p = (k: string) => paramMap[k] ?? k;
|
|
432
|
+
|
|
433
|
+
const filterValues: Record<string, string> = {};
|
|
434
|
+
for (const key of filterKeys) {
|
|
435
|
+
const raw = params.get(p(key));
|
|
436
|
+
if (raw !== null) filterValues[key] = raw;
|
|
437
|
+
}
|
|
438
|
+
if (Object.keys(filterValues).length > 0) out.filterValues = filterValues;
|
|
439
|
+
|
|
440
|
+
const rawPage = params.get(p(PARAM_KEYS.pageIndex));
|
|
441
|
+
const rawSize = params.get(p(PARAM_KEYS.pageSize));
|
|
442
|
+
if (rawPage !== null || rawSize !== null) {
|
|
443
|
+
const pageIndex = rawPage !== null ? Math.max(0, Number(rawPage) - 1) : 0;
|
|
444
|
+
const pageSize =
|
|
445
|
+
rawSize !== null && Number.isFinite(Number(rawSize))
|
|
446
|
+
? Number(rawSize)
|
|
447
|
+
: defaultPageSize;
|
|
448
|
+
out.pagination = {
|
|
449
|
+
pageIndex: Number.isFinite(pageIndex) ? pageIndex : 0,
|
|
450
|
+
pageSize,
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const rawSortBy = params.get(p(PARAM_KEYS.sortBy));
|
|
455
|
+
const rawSortDir = params.get(p(PARAM_KEYS.sortDir));
|
|
456
|
+
if (rawSortBy) {
|
|
457
|
+
out.sorting = [{ id: rawSortBy, desc: rawSortDir === "desc" }];
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const rawColOrder = params.get(p(PARAM_KEYS.columnOrder));
|
|
461
|
+
if (rawColOrder) out.columnOrder = rawColOrder.split(",");
|
|
462
|
+
|
|
463
|
+
const tryJson = <T>(key: ParamKey): T | undefined => {
|
|
464
|
+
const raw = params.get(p(key));
|
|
465
|
+
if (!raw) return undefined;
|
|
466
|
+
try {
|
|
467
|
+
return JSON.parse(raw) as T;
|
|
468
|
+
} catch {
|
|
469
|
+
return undefined;
|
|
470
|
+
}
|
|
471
|
+
};
|
|
472
|
+
|
|
473
|
+
const colVis = tryJson<ColumnVisibilityState>(PARAM_KEYS.columnVisibility);
|
|
474
|
+
if (colVis) out.columnVisibility = colVis;
|
|
475
|
+
const colPin = tryJson<ColumnPinningState>(PARAM_KEYS.columnPinning);
|
|
476
|
+
if (colPin) out.columnPinning = colPin;
|
|
477
|
+
const colSize = tryJson<ColumnSizingState>(PARAM_KEYS.columnSizing);
|
|
478
|
+
if (colSize) out.columnSizing = colSize;
|
|
479
|
+
|
|
480
|
+
const rawGrouping = params.get(p(PARAM_KEYS.grouping));
|
|
481
|
+
if (rawGrouping) out.grouping = rawGrouping.split(",");
|
|
482
|
+
|
|
483
|
+
const expanded = tryJson<ExpandedState>(PARAM_KEYS.expanded);
|
|
484
|
+
if (expanded !== undefined) out.expanded = expanded;
|
|
485
|
+
|
|
486
|
+
return out;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* The longest query string written to the URL. A reload or a shared link
|
|
491
|
+
* sends it to the server, and proxies refuse a request line past a few
|
|
492
|
+
* kilobytes (nginx's default is 8 KB): past this, the open rows leave it.
|
|
493
|
+
*/
|
|
494
|
+
export const MAX_SEARCH_LENGTH = 4000;
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Encode a `PersistedState` blob into the URL via `history.replaceState`.
|
|
498
|
+
* `pageSize` is omitted when it matches `defaultPageSize` so a fresh URL
|
|
499
|
+
* stays clean; reserved param keys are protected against filter-key collisions.
|
|
500
|
+
* `expanded` is the one value that grows with use (every row opened adds to
|
|
501
|
+
* it): once the query string passes `MAX_SEARCH_LENGTH`, it leaves the URL
|
|
502
|
+
* while the rows stay open on the page, so opening rows never pushes a reload
|
|
503
|
+
* or a shared link past a proxy's limit.
|
|
504
|
+
*/
|
|
505
|
+
export function writeSearchParams(
|
|
506
|
+
state: PersistedState,
|
|
507
|
+
filterKeys: string[],
|
|
508
|
+
paramMap: Partial<Record<string, string>>,
|
|
509
|
+
defaultPageSize: number,
|
|
510
|
+
): void {
|
|
511
|
+
const params = new URLSearchParams(window.location.search);
|
|
512
|
+
const p = (k: string) => paramMap[k] ?? k;
|
|
513
|
+
|
|
514
|
+
// Helper: set a JSON-serialised param or delete it when absent.
|
|
515
|
+
// Handles all truthy checks uniformly including `false` and `{}` for
|
|
516
|
+
// `expanded`, which are valid non-default states but would be dropped
|
|
517
|
+
// by a plain `if (value)` guard.
|
|
518
|
+
const setJson = (key: ParamKey, value: unknown): void => {
|
|
519
|
+
if (value !== undefined && value !== null) {
|
|
520
|
+
params.set(p(key), JSON.stringify(value));
|
|
521
|
+
} else {
|
|
522
|
+
params.delete(p(key));
|
|
523
|
+
}
|
|
524
|
+
};
|
|
525
|
+
|
|
526
|
+
// Filters - every declared filter key must be set or deleted (absent
|
|
527
|
+
// value === "cleared"). Keys outside the universe are left untouched.
|
|
528
|
+
const active = state.filterValues ?? {};
|
|
529
|
+
for (const key of filterKeys) {
|
|
530
|
+
if (RESERVED_PARAM_KEYS.has(key)) continue;
|
|
531
|
+
const value = active[key];
|
|
532
|
+
if (value) params.set(p(key), value);
|
|
533
|
+
else params.delete(p(key));
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
if (state.pagination && state.pagination.pageIndex > 0) {
|
|
537
|
+
params.set(p(PARAM_KEYS.pageIndex), String(state.pagination.pageIndex + 1));
|
|
538
|
+
} else {
|
|
539
|
+
params.delete(p(PARAM_KEYS.pageIndex));
|
|
540
|
+
}
|
|
541
|
+
if (state.pagination && state.pagination.pageSize !== defaultPageSize) {
|
|
542
|
+
params.set(p(PARAM_KEYS.pageSize), String(state.pagination.pageSize));
|
|
543
|
+
} else {
|
|
544
|
+
params.delete(p(PARAM_KEYS.pageSize));
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const sort = state.sorting?.[0];
|
|
548
|
+
if (sort) {
|
|
549
|
+
params.set(p(PARAM_KEYS.sortBy), sort.id);
|
|
550
|
+
params.set(p(PARAM_KEYS.sortDir), sort.desc ? "desc" : "asc");
|
|
551
|
+
} else {
|
|
552
|
+
params.delete(p(PARAM_KEYS.sortBy));
|
|
553
|
+
params.delete(p(PARAM_KEYS.sortDir));
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// Guard against empty arrays serialising to "" which reads back as [''].
|
|
557
|
+
if (state.columnOrder?.length) {
|
|
558
|
+
params.set(p(PARAM_KEYS.columnOrder), state.columnOrder.join(","));
|
|
559
|
+
} else {
|
|
560
|
+
params.delete(p(PARAM_KEYS.columnOrder));
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
setJson(PARAM_KEYS.columnVisibility, state.columnVisibility ?? null);
|
|
564
|
+
setJson(PARAM_KEYS.columnPinning, state.columnPinning ?? null);
|
|
565
|
+
setJson(PARAM_KEYS.columnSizing, state.columnSizing ?? null);
|
|
566
|
+
|
|
567
|
+
// Same empty-array guard for grouping.
|
|
568
|
+
if (state.grouping?.length) {
|
|
569
|
+
params.set(p(PARAM_KEYS.grouping), state.grouping.join(","));
|
|
570
|
+
} else {
|
|
571
|
+
params.delete(p(PARAM_KEYS.grouping));
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// `expanded` can be `false` or `{}` - both are valid and must be preserved.
|
|
575
|
+
setJson(
|
|
576
|
+
PARAM_KEYS.expanded,
|
|
577
|
+
state.expanded !== undefined ? state.expanded : null,
|
|
578
|
+
);
|
|
579
|
+
if (params.toString().length > MAX_SEARCH_LENGTH) {
|
|
580
|
+
params.delete(p(PARAM_KEYS.expanded));
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const qs = params.toString();
|
|
584
|
+
window.history.replaceState(
|
|
585
|
+
null,
|
|
586
|
+
"",
|
|
587
|
+
qs ? `${window.location.pathname}?${qs}` : window.location.pathname,
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** Read a persisted blob from `localStorage` under `storageKey`. */
|
|
592
|
+
export function readLocalStorage(storageKey: string): PersistedState {
|
|
593
|
+
try {
|
|
594
|
+
const raw = window.localStorage.getItem(storageKey);
|
|
595
|
+
if (!raw) return {};
|
|
596
|
+
return JSON.parse(raw) as PersistedState;
|
|
597
|
+
} catch {
|
|
598
|
+
return {};
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/** Write (or remove, if empty) the persisted blob under `storageKey`. */
|
|
603
|
+
export function writeLocalStorage(
|
|
604
|
+
storageKey: string,
|
|
605
|
+
state: PersistedState,
|
|
606
|
+
): void {
|
|
607
|
+
try {
|
|
608
|
+
if (Object.keys(state).length === 0) {
|
|
609
|
+
window.localStorage.removeItem(storageKey);
|
|
610
|
+
} else {
|
|
611
|
+
window.localStorage.setItem(storageKey, JSON.stringify(state));
|
|
612
|
+
}
|
|
613
|
+
} catch {
|
|
614
|
+
/* quota exceeded or storage disabled - silently no-op */
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Order a pinning state around its anchors: the select column first at the
|
|
620
|
+
* start when rows are selectable, then the default start pins, then the rest;
|
|
621
|
+
* at the end, the default end pins stay on the outer edge with newer pins
|
|
622
|
+
* inside them. Undefined when nothing is pinned and nothing anchors a pin.
|
|
623
|
+
*/
|
|
624
|
+
export function normalizeColumnPinningState(
|
|
625
|
+
pinning: ColumnPinningState | undefined,
|
|
626
|
+
pinSelectionColumn: boolean,
|
|
627
|
+
defaultColumnPinning?: Partial<ColumnPinningState>,
|
|
628
|
+
): ColumnPinningState | undefined {
|
|
629
|
+
const start = pinning?.start ?? [];
|
|
630
|
+
const end = pinning?.end ?? [];
|
|
631
|
+
|
|
632
|
+
const defaultStart = defaultColumnPinning?.start ?? [];
|
|
633
|
+
const defaultEnd = defaultColumnPinning?.end ?? [];
|
|
634
|
+
|
|
635
|
+
const anchoredStart =
|
|
636
|
+
pinSelectionColumn && !defaultStart.includes(SELECT_COLUMN_ID)
|
|
637
|
+
? [SELECT_COLUMN_ID, ...defaultStart]
|
|
638
|
+
: defaultStart;
|
|
639
|
+
|
|
640
|
+
const finalStart =
|
|
641
|
+
pinSelectionColumn && !start.includes(SELECT_COLUMN_ID)
|
|
642
|
+
? [SELECT_COLUMN_ID, ...start]
|
|
643
|
+
: start;
|
|
644
|
+
|
|
645
|
+
if (!pinning && anchoredStart.length === 0 && defaultEnd.length === 0) {
|
|
646
|
+
return undefined;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// Use a Set for O(n) lookup instead of repeated Array.includes (O(n²)).
|
|
650
|
+
const anchoredStartSet = new Set(anchoredStart);
|
|
651
|
+
const defaultEndSet = new Set(defaultEnd);
|
|
652
|
+
return {
|
|
653
|
+
start: [
|
|
654
|
+
...anchoredStart.filter((id) => finalStart.includes(id)),
|
|
655
|
+
...finalStart.filter((id) => !anchoredStartSet.has(id)),
|
|
656
|
+
],
|
|
657
|
+
end: [
|
|
658
|
+
...end.filter((id) => !defaultEndSet.has(id)),
|
|
659
|
+
...defaultEnd.filter((id) => end.includes(id)),
|
|
660
|
+
],
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/** Where a filter sits: its own placement, or `auto`. */
|
|
665
|
+
export const placementOf = (field: FilterField): FilterPlacement =>
|
|
666
|
+
field.placement ?? "auto";
|
|
667
|
+
|
|
668
|
+
/** An input filter's value: what it holds, or its default until someone sets it. */
|
|
669
|
+
const filterValueOf = (
|
|
670
|
+
field: FilterInputField,
|
|
671
|
+
values: Record<string, string>,
|
|
672
|
+
): string => values[field.key] ?? field.defaultValue ?? "";
|
|
673
|
+
|
|
674
|
+
/** Whether an input filter holds something other than its default. */
|
|
675
|
+
export const isFilterSet = (
|
|
676
|
+
field: FilterInputField,
|
|
677
|
+
values: Record<string, string>,
|
|
678
|
+
): boolean => filterValueOf(field, values) !== (field.defaultValue ?? "");
|
|
679
|
+
|
|
680
|
+
/** A filter's control: an action as it renders, a field with its value. */
|
|
681
|
+
export const renderFilter = (
|
|
682
|
+
field: FilterField,
|
|
683
|
+
values: Record<string, string>,
|
|
684
|
+
onChange: (key: string, value: string) => void,
|
|
685
|
+
): ReactNode =>
|
|
686
|
+
field.type === "action"
|
|
687
|
+
? field.render()
|
|
688
|
+
: field.render({
|
|
689
|
+
value: filterValueOf(field, values),
|
|
690
|
+
onChange: (value) => onChange(field.key, value),
|
|
691
|
+
});
|