aplance-ui 1.5.1 → 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.
Files changed (30) hide show
  1. package/README.md +1 -1
  2. package/dist/api.d.ts +2 -1
  3. package/dist/api.js +103 -68
  4. package/dist/api.js.map +1 -1
  5. package/dist/frameworks/vite-react/vite.config.ts.template +1 -1
  6. package/dist/index.js +103 -68
  7. package/dist/index.js.map +1 -1
  8. package/package.json +12 -6
  9. package/registry/components/primitives/modal/modal.module.css +9 -3
  10. package/registry/components/primitives/popover/popover-context.ts +2 -0
  11. package/registry/components/primitives/popover/popover.tsx +47 -2
  12. package/registry/components/ui/chart/chart.tsx +51 -30
  13. package/registry/components/ui/chart/types.ts +5 -1
  14. package/registry/components/ui/data-table/checkbox-column.tsx +12 -8
  15. package/registry/components/ui/data-table/data-table.module.css +41 -19
  16. package/registry/components/ui/data-table/data-table.tsx +1285 -1238
  17. package/registry/components/ui/data-table/filter-bar.tsx +56 -13
  18. package/registry/components/ui/data-table/filter-modal.tsx +16 -82
  19. package/registry/components/ui/data-table/filter-sheet.tsx +97 -0
  20. package/registry/components/ui/data-table/filters.tsx +21 -21
  21. package/registry/components/ui/data-table/pagination-bar.tsx +4 -6
  22. package/registry/components/ui/data-table/types.ts +149 -80
  23. package/registry/components/ui/data-table/useDataTable.tsx +35 -16
  24. package/registry/components/ui/data-table/utils.ts +691 -471
  25. package/registry/components/ui/input-fields/date-picker/calendar.tsx +7 -15
  26. package/registry/components/ui/input-fields/date-picker/date-picker.tsx +1 -1
  27. package/registry/components/ui/input-fields/date-picker/types.ts +3 -3
  28. package/registry/components/ui/input-fields/emoji-picker/emoji-palette.tsx +1 -1
  29. package/registry/registry.json +17 -12
  30. package/registry/styles/media.css +1 -1
@@ -1,471 +1,691 @@
1
- import type {
2
- Column,
3
- ColumnOrderState,
4
- ColumnPinningState,
5
- ColumnSizingState,
6
- ExpandedState,
7
- Updater,
8
- VisibilityState,
9
- } from "@tanstack/react-table";
10
- import type { CSSProperties } from "react";
11
- import type {
12
- ApplyPersistedTarget,
13
- ColumnIdSource,
14
- PersistedState,
15
- SnapshotInput,
16
- } from "./types";
17
-
18
- /**
19
- * Per-cell style map for one column. Returns the fixed width plus the
20
- * sticky offset when the column is pinned. Reading offsets straight from
21
- * tanstack (`getStart`/`getAfter`) keeps header and body cells in sync
22
- * even after resizing or reordering - no manual bookkeeping needed.
23
- */
24
- export function getColumnStyle<TData>(
25
- column: Column<TData, unknown>,
26
- size: number,
27
- extraWidth = 0,
28
- ): CSSProperties {
29
- const isPinned = column.getIsPinned();
30
- const computedWidth = size + extraWidth;
31
- const style: CSSProperties = {
32
- width: `${computedWidth}px`,
33
- minWidth: `${computedWidth}px`,
34
- maxWidth: `${computedWidth}px`,
35
- };
36
- if (isPinned === "left") {
37
- style.left = `${column.getStart("left")}px`;
38
- } else if (isPinned === "right") {
39
- style.right = `${column.getAfter("right")}px`;
40
- }
41
- return style;
42
- }
43
-
44
- /** Move `sourceId` to the position currently held by `targetId`. */
45
- export function reorderColumnOrder(
46
- order: ColumnOrderState,
47
- sourceId: string,
48
- targetId: string,
49
- ): ColumnOrderState {
50
- const next = order.slice();
51
- const fromIdx = next.indexOf(sourceId);
52
- const toIdx = next.indexOf(targetId);
53
- if (fromIdx === -1 || toIdx === -1) return order;
54
- next.splice(fromIdx, 1);
55
- next.splice(toIdx, 0, sourceId);
56
- return next;
57
- }
58
-
59
- /**
60
- * Last visible pinned column on its side. Used by the divider style so
61
- * we don't draw an inset line between adjacent pinned columns - only at
62
- * the boundary between the pinned region and the scrolling center.
63
- */
64
- export function isLastPinned<TData>(column: Column<TData, unknown>): boolean {
65
- const pinned = column.getIsPinned();
66
- if (pinned !== "left" && pinned !== "right") return false;
67
- return column.getIsLastColumn(pinned);
68
- }
69
-
70
- /**
71
- * Default formatter for aggregated values when a column does not provide
72
- * an `aggregatedCell`. Numbers fall back to `toLocaleString` and arrays
73
- * to a count; everything else is rendered as-is.
74
- */
75
- export function formatAggregated(value: unknown): string {
76
- if (value == null) return "";
77
- if (typeof value === "number") return value.toLocaleString();
78
- if (Array.isArray(value)) return `${value.length} items`;
79
- return String(value);
80
- }
81
-
82
- /**
83
- * Resolve the runtime column id from a raw `ColumnDef`. Mirrors how
84
- * tanstack derives ids: explicit `id` first, then `accessorKey` for
85
- * accessor columns. Useful when consumers need to build menus (visibility,
86
- * grouping, etc.) from the same column array they hand to the table -
87
- * tanstack only assigns the resolved id once the table is built, so
88
- * reading `c.id` directly off the def returns `undefined` for accessor
89
- * columns and breaks state maps keyed by id.
90
- */
91
- export function columnId(def: ColumnIdSource): string {
92
- if (def.id) return def.id;
93
- if (def.accessorKey != null) return String(def.accessorKey);
94
- return "";
95
- }
96
-
97
- /**
98
- * TanStack `Updater<T>` is `T | ((old: T) => T)`. When `T` itself extends
99
- * `Function`, TypeScript widens the function branch to `((old: T) => T) |
100
- * (T & Function)` after the `typeof` narrowing - and `T & Function` has no
101
- * call signatures, so a cast is required to keep the compiler happy.
102
- */
103
- export function resolveUpdater<T>(updater: Updater<T>, prev: T): T {
104
- return typeof updater === "function"
105
- ? (updater as (old: T) => T)(prev)
106
- : updater;
107
- }
108
-
109
- export const SELECT_COLUMN_ID = "select";
110
-
111
- // ─────────────────────────────────────────────────────────────────────────────
112
- // State persistence helpers (URL search params / localStorage / manual).
113
- // Pure functions - no React. The hook-level orchestration in `useDataTable`
114
- // supplies the current state + setters and these functions take care of the
115
- // snapshot/apply/read/write mechanics.
116
- // ─────────────────────────────────────────────────────────────────────────────
117
-
118
- /**
119
- * Single source-of-truth for every URL param key used by the persistence
120
- * layer. Both `readSearchParams` and `writeSearchParams` reference these
121
- * values - adding or renaming a key is one edit here, not scattered string
122
- * literals across multiple functions.
123
- */
124
- export const PARAM_KEYS = {
125
- pageIndex: "pageIndex",
126
- pageSize: "pageSize",
127
- sortBy: "sortBy",
128
- sortDir: "sortDir",
129
- columnOrder: "columnOrder",
130
- columnVisibility: "columnVisibility",
131
- columnPinning: "columnPinning",
132
- columnSizing: "columnSizing",
133
- grouping: "grouping",
134
- expanded: "expanded",
135
- } as const;
136
-
137
- export type ParamKey = (typeof PARAM_KEYS)[keyof typeof PARAM_KEYS];
138
-
139
- /**
140
- * Reserved param keys used by the snapshot encoding itself. Filter keys must
141
- * not collide with these - when iterating filter keys we skip any reserved
142
- * names so a filter named `pageIndex` cannot stomp on the pagination param.
143
- * Derived from `PARAM_KEYS` so the two never drift apart.
144
- */
145
- export const RESERVED_PARAM_KEYS = new Set<string>(Object.values(PARAM_KEYS));
146
-
147
- const isNonEmptyExpanded = (e: ExpandedState | undefined): boolean =>
148
- e !== undefined && (e === true || Object.keys(e).length > 0);
149
-
150
- /**
151
- * Structural equality for two `ExpandedState`s (each a boolean or an
152
- * id -> boolean record). Lets the snapshot skip persisting `expanded` while it
153
- * still matches the table's default.
154
- */
155
- const expandedEquals = (
156
- a: ExpandedState | undefined,
157
- b: ExpandedState | undefined,
158
- ): boolean => {
159
- if (a === b) return true;
160
- if (typeof a === "boolean" || typeof b === "boolean") return false;
161
- if (!a || !b) return false;
162
- const aKeys = Object.keys(a);
163
- if (aKeys.length !== Object.keys(b).length) return false;
164
- return aKeys.every((k) => a[k] === b[k]);
165
- };
166
-
167
- /**
168
- * Build a `PersistedState` snapshot from current state, omitting empty/default
169
- * slices so the persisted blob (URL or storage) stays minimal.
170
- * `defaultPageSize` lives on `SnapshotInput` so callers don't need to thread
171
- * it as a separate argument - it's part of the snapshot's own context.
172
- */
173
- export function snapshotPersistedState(input: SnapshotInput): PersistedState {
174
- const out: PersistedState = {};
175
-
176
- const activeFilters: Record<string, string> = {};
177
- for (const [k, v] of Object.entries(input.filterValues)) {
178
- if (v) activeFilters[k] = v;
179
- }
180
- if (Object.keys(activeFilters).length > 0) out.filterValues = activeFilters;
181
-
182
- // Only persist pagination when it actually differs from defaults -
183
- // pageSize is always a truthy number so `if (pageSize)` would always fire.
184
- if (
185
- input.pagination.pageIndex > 0 ||
186
- input.pagination.pageSize !== input.defaultPageSize
187
- ) {
188
- out.pagination = input.pagination;
189
- }
190
-
191
- if (input.sorting.length > 0) out.sorting = input.sorting;
192
- if (input.columnOrder.length > 0) out.columnOrder = input.columnOrder;
193
- if (Object.keys(input.columnVisibility).length > 0) {
194
- out.columnVisibility = input.columnVisibility;
195
- }
196
- if (input.columnPinning.left?.length || input.columnPinning.right?.length) {
197
- out.columnPinning = input.columnPinning;
198
- }
199
- if (Object.keys(input.columnSizing).length > 0) {
200
- out.columnSizing = input.columnSizing;
201
- }
202
- if (input.grouping.length > 0) out.grouping = input.grouping;
203
- // Persist expansion only when it deviates from the default - otherwise the
204
- // default (`true` = all groups expanded) writes a noisy `?expanded=true` even
205
- // when the user never touched a group.
206
- if (
207
- isNonEmptyExpanded(input.expanded) &&
208
- !expandedEquals(input.expanded, input.defaultExpanded)
209
- ) {
210
- out.expanded = input.expanded;
211
- }
212
-
213
- return out;
214
- }
215
-
216
- /**
217
- * Apply a `PersistedState` blob back onto the controlled setters. Each slice
218
- * is independently optional - partial blobs (e.g. only `sorting`) work fine.
219
- * Unknown filter keys from a stale URL/blob are silently ignored.
220
- */
221
- export function applyPersistedState(
222
- state: PersistedState,
223
- target: ApplyPersistedTarget,
224
- ): void {
225
- if (state.filterValues) {
226
- const next = { ...target.filterValues };
227
- for (const key of target.filterKeys) {
228
- const v = state.filterValues[key];
229
- if (v !== undefined) next[key] = v;
230
- }
231
- target.setFilterValues(next);
232
- }
233
- if (state.pagination) target.setPagination(state.pagination);
234
- if (state.sorting) target.setSorting(state.sorting);
235
- if (state.columnOrder) target.setColumnOrder(state.columnOrder);
236
- if (state.columnVisibility)
237
- target.setColumnVisibility(state.columnVisibility);
238
- if (state.columnPinning) target.setColumnPinning(state.columnPinning);
239
- if (state.columnSizing) target.setColumnSizing(state.columnSizing);
240
- if (state.grouping) target.setGrouping(state.grouping);
241
- if (state.expanded !== undefined) target.setExpanded(state.expanded);
242
- }
243
-
244
- /**
245
- * Decode a URL `?...` query string into a `PersistedState` blob. Flat
246
- * encoding, hand-editable. `pageIndex` is stored 1-based for human
247
- * readability; `pageSize` falls back to `defaultPageSize` when absent.
248
- */
249
- export function readSearchParams(
250
- filterKeys: string[],
251
- paramMap: Partial<Record<string, string>>,
252
- defaultPageSize: number,
253
- ): PersistedState {
254
- const out: PersistedState = {};
255
- const params = new URLSearchParams(window.location.search);
256
- const p = (k: string) => paramMap[k] ?? k;
257
-
258
- const filterValues: Record<string, string> = {};
259
- for (const key of filterKeys) {
260
- const raw = params.get(p(key));
261
- if (raw !== null) filterValues[key] = raw;
262
- }
263
- if (Object.keys(filterValues).length > 0) out.filterValues = filterValues;
264
-
265
- const rawPage = params.get(p(PARAM_KEYS.pageIndex));
266
- const rawSize = params.get(p(PARAM_KEYS.pageSize));
267
- if (rawPage !== null || rawSize !== null) {
268
- const pageIndex = rawPage !== null ? Math.max(0, Number(rawPage) - 1) : 0;
269
- const pageSize =
270
- rawSize !== null && Number.isFinite(Number(rawSize))
271
- ? Number(rawSize)
272
- : defaultPageSize;
273
- out.pagination = {
274
- pageIndex: Number.isFinite(pageIndex) ? pageIndex : 0,
275
- pageSize,
276
- };
277
- }
278
-
279
- const rawSortBy = params.get(p(PARAM_KEYS.sortBy));
280
- const rawSortDir = params.get(p(PARAM_KEYS.sortDir));
281
- if (rawSortBy) {
282
- out.sorting = [{ id: rawSortBy, desc: rawSortDir === "desc" }];
283
- }
284
-
285
- const rawColOrder = params.get(p(PARAM_KEYS.columnOrder));
286
- if (rawColOrder) out.columnOrder = rawColOrder.split(",");
287
-
288
- const tryJson = <T>(key: ParamKey): T | undefined => {
289
- const raw = params.get(p(key));
290
- if (!raw) return undefined;
291
- try {
292
- return JSON.parse(raw) as T;
293
- } catch {
294
- return undefined;
295
- }
296
- };
297
-
298
- const colVis = tryJson<VisibilityState>(PARAM_KEYS.columnVisibility);
299
- if (colVis) out.columnVisibility = colVis;
300
- const colPin = tryJson<ColumnPinningState>(PARAM_KEYS.columnPinning);
301
- if (colPin) out.columnPinning = colPin;
302
- const colSize = tryJson<ColumnSizingState>(PARAM_KEYS.columnSizing);
303
- if (colSize) out.columnSizing = colSize;
304
-
305
- const rawGrouping = params.get(p(PARAM_KEYS.grouping));
306
- if (rawGrouping) out.grouping = rawGrouping.split(",");
307
-
308
- const expanded = tryJson<ExpandedState>(PARAM_KEYS.expanded);
309
- if (expanded !== undefined) out.expanded = expanded;
310
-
311
- return out;
312
- }
313
-
314
- /**
315
- * Encode a `PersistedState` blob into the URL via `history.replaceState`.
316
- * `pageSize` is omitted when it matches `defaultPageSize` so a fresh URL
317
- * stays clean; reserved param keys are protected against filter-key collisions.
318
- */
319
- export function writeSearchParams(
320
- state: PersistedState,
321
- filterKeys: string[],
322
- paramMap: Partial<Record<string, string>>,
323
- defaultPageSize: number,
324
- ): void {
325
- const params = new URLSearchParams(window.location.search);
326
- const p = (k: string) => paramMap[k] ?? k;
327
-
328
- // Helper: set a JSON-serialised param or delete it when absent.
329
- // Handles all truthy checks uniformly including `false` and `{}` for
330
- // `expanded`, which are valid non-default states but would be dropped
331
- // by a plain `if (value)` guard.
332
- const setJson = (key: ParamKey, value: unknown): void => {
333
- if (value !== undefined && value !== null) {
334
- params.set(p(key), JSON.stringify(value));
335
- } else {
336
- params.delete(p(key));
337
- }
338
- };
339
-
340
- // Filters - every declared filter key must be set or deleted (absent
341
- // value === "cleared"). Keys outside the universe are left untouched.
342
- const active = state.filterValues ?? {};
343
- for (const key of filterKeys) {
344
- if (RESERVED_PARAM_KEYS.has(key)) continue;
345
- const value = active[key];
346
- if (value) params.set(p(key), value);
347
- else params.delete(p(key));
348
- }
349
-
350
- if (state.pagination && state.pagination.pageIndex > 0) {
351
- params.set(p(PARAM_KEYS.pageIndex), String(state.pagination.pageIndex + 1));
352
- } else {
353
- params.delete(p(PARAM_KEYS.pageIndex));
354
- }
355
- if (state.pagination && state.pagination.pageSize !== defaultPageSize) {
356
- params.set(p(PARAM_KEYS.pageSize), String(state.pagination.pageSize));
357
- } else {
358
- params.delete(p(PARAM_KEYS.pageSize));
359
- }
360
-
361
- const sort = state.sorting?.[0];
362
- if (sort) {
363
- params.set(p(PARAM_KEYS.sortBy), sort.id);
364
- params.set(p(PARAM_KEYS.sortDir), sort.desc ? "desc" : "asc");
365
- } else {
366
- params.delete(p(PARAM_KEYS.sortBy));
367
- params.delete(p(PARAM_KEYS.sortDir));
368
- }
369
-
370
- // Guard against empty arrays serialising to "" which reads back as [''].
371
- if (state.columnOrder?.length) {
372
- params.set(p(PARAM_KEYS.columnOrder), state.columnOrder.join(","));
373
- } else {
374
- params.delete(p(PARAM_KEYS.columnOrder));
375
- }
376
-
377
- setJson(PARAM_KEYS.columnVisibility, state.columnVisibility ?? null);
378
- setJson(PARAM_KEYS.columnPinning, state.columnPinning ?? null);
379
- setJson(PARAM_KEYS.columnSizing, state.columnSizing ?? null);
380
-
381
- // Same empty-array guard for grouping.
382
- if (state.grouping?.length) {
383
- params.set(p(PARAM_KEYS.grouping), state.grouping.join(","));
384
- } else {
385
- params.delete(p(PARAM_KEYS.grouping));
386
- }
387
-
388
- // `expanded` can be `false` or `{}` - both are valid and must be preserved.
389
- setJson(
390
- PARAM_KEYS.expanded,
391
- state.expanded !== undefined ? state.expanded : null,
392
- );
393
-
394
- const qs = params.toString();
395
- window.history.replaceState(
396
- null,
397
- "",
398
- qs ? `${window.location.pathname}?${qs}` : window.location.pathname,
399
- );
400
- }
401
-
402
- /** Read a persisted blob from `localStorage` under `storageKey`. */
403
- export function readLocalStorage(storageKey: string): PersistedState {
404
- try {
405
- const raw = window.localStorage.getItem(storageKey);
406
- if (!raw) return {};
407
- return JSON.parse(raw) as PersistedState;
408
- } catch {
409
- return {};
410
- }
411
- }
412
-
413
- /** Write (or remove, if empty) the persisted blob under `storageKey`. */
414
- export function writeLocalStorage(
415
- storageKey: string,
416
- state: PersistedState,
417
- ): void {
418
- try {
419
- if (Object.keys(state).length === 0) {
420
- window.localStorage.removeItem(storageKey);
421
- } else {
422
- window.localStorage.setItem(storageKey, JSON.stringify(state));
423
- }
424
- } catch {
425
- /* quota exceeded or storage disabled - silently no-op */
426
- }
427
- }
428
-
429
- export function normalizeColumnPinningState(
430
- pinning: ColumnPinningState | undefined,
431
- pinSelectionColumn: boolean,
432
- defaultColumnPinning?: ColumnPinningState,
433
- ): ColumnPinningState | undefined {
434
- const left = pinning?.left ?? [];
435
- const right = pinning?.right ?? [];
436
-
437
- const defaultLeft = defaultColumnPinning?.left ?? [];
438
- const defaultRight = defaultColumnPinning?.right ?? [];
439
-
440
- const actualDefaultLeft =
441
- pinSelectionColumn && !defaultLeft.includes(SELECT_COLUMN_ID)
442
- ? [SELECT_COLUMN_ID, ...defaultLeft]
443
- : defaultLeft;
444
-
445
- const finalLeft =
446
- pinSelectionColumn && !left.includes(SELECT_COLUMN_ID)
447
- ? [SELECT_COLUMN_ID, ...left]
448
- : left;
449
-
450
- // Use a Set for O(n) lookup instead of repeated Array.includes (O(n²)).
451
- const defaultLeftSet = new Set(actualDefaultLeft);
452
- const orderedLeft = [
453
- ...actualDefaultLeft.filter((id) => finalLeft.includes(id)),
454
- ...finalLeft.filter((id) => !defaultLeftSet.has(id)),
455
- ];
456
-
457
- const dynamicRight = right.filter((id) => !defaultRight.includes(id));
458
- const orderedRight = [
459
- ...dynamicRight,
460
- ...defaultRight.filter((id) => right.includes(id)),
461
- ];
462
-
463
- if (!pinning && actualDefaultLeft.length === 0 && defaultRight.length === 0) {
464
- return undefined;
465
- }
466
-
467
- return {
468
- left: orderedLeft,
469
- right: orderedRight,
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
+ });