@jielga/tmdatagrid 0.1.0 → 0.3.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 (47) hide show
  1. package/README.md +35 -2
  2. package/dist/index.d.ts +792 -327
  3. package/dist/index.js +1581 -892
  4. package/dist/index.js.map +1 -1
  5. package/dist/styles.css +1 -1
  6. package/package.json +15 -7
  7. package/skills/columns/SKILL.md +8 -7
  8. package/skills/features/SKILL.md +104 -16
  9. package/skills/getting-started/SKILL.md +78 -20
  10. package/skills/options/SKILL.md +12 -7
  11. package/skills/server-side/SKILL.md +5 -2
  12. package/src/tmdatagrid/TMDataGridContext.ts +1 -1
  13. package/src/tmdatagrid/components/TMDataGrid.module.css +73 -0
  14. package/src/tmdatagrid/{TMDataGrid.tsx → components/TMDataGrid.tsx} +15 -4
  15. package/src/tmdatagrid/components/TMDataGridColumnsPanel.module.css +25 -0
  16. package/src/tmdatagrid/{TMDataGridColumnsPanel.tsx → components/TMDataGridColumnsPanel.tsx} +10 -4
  17. package/src/tmdatagrid/components/TMDataGridFilterPanel.module.css +35 -0
  18. package/src/tmdatagrid/{TMDataGridFilterPanel.tsx → components/TMDataGridFilterPanel.tsx} +91 -14
  19. package/src/tmdatagrid/components/TMDataGridFilterPills.module.css +35 -0
  20. package/src/tmdatagrid/components/TMDataGridFilterPills.tsx +135 -0
  21. package/src/tmdatagrid/components/TMDataGridFooter.module.css +18 -0
  22. package/src/tmdatagrid/components/TMDataGridFooter.tsx +171 -0
  23. package/src/tmdatagrid/components/TMDataGridGroupColumn.module.css +48 -0
  24. package/src/tmdatagrid/components/TMDataGridGroupColumn.tsx +147 -0
  25. package/src/tmdatagrid/components/TMDataGridHeaderCell.module.css +159 -0
  26. package/src/tmdatagrid/{TMDataGridHeaderCell.tsx → components/TMDataGridHeaderCell.tsx} +99 -14
  27. package/src/tmdatagrid/components/TMDataGridSelectColumn.tsx +177 -0
  28. package/src/tmdatagrid/components/TMDataGridTable.module.css +137 -0
  29. package/src/tmdatagrid/components/TMDataGridTable.tsx +720 -0
  30. package/src/tmdatagrid/components/TMDataGridToolbar.module.css +12 -0
  31. package/src/tmdatagrid/{TMDataGridToolbar.tsx → components/TMDataGridToolbar.tsx} +8 -5
  32. package/src/tmdatagrid/{icons.ts → components/icons.ts} +5 -0
  33. package/src/tmdatagrid/components/sticky.module.css +45 -0
  34. package/src/tmdatagrid/core/capabilities.ts +215 -0
  35. package/src/tmdatagrid/{columnOrdering.ts → core/columnOrdering.ts} +2 -2
  36. package/src/tmdatagrid/{columnUtils.ts → core/columnUtils.ts} +1 -1
  37. package/src/tmdatagrid/{filterOperators.ts → core/filterOperators.ts} +25 -0
  38. package/src/tmdatagrid/core/grouping.ts +21 -0
  39. package/src/tmdatagrid/{persistence.ts → core/persistence.ts} +92 -2
  40. package/src/tmdatagrid/core/rowSelection.ts +202 -0
  41. package/src/tmdatagrid/index.ts +39 -9
  42. package/src/tmdatagrid/useTMDataGrid.tsx +318 -66
  43. package/src/tmdatagrid/TMDataGrid.module.css +0 -410
  44. package/src/tmdatagrid/TMDataGridFooter.tsx +0 -74
  45. package/src/tmdatagrid/TMDataGridTable.tsx +0 -297
  46. package/src/tmdatagrid/capabilities.ts +0 -117
  47. /package/src/tmdatagrid/{sizes.ts → core/sizes.ts} +0 -0
@@ -0,0 +1,12 @@
1
+ .toolbar {
2
+ display: flex;
3
+ align-items: center;
4
+ gap: var(--mantine-spacing-xs);
5
+ padding: 6px var(--mantine-spacing-xs);
6
+ min-height: 42px;
7
+ flex-shrink: 0;
8
+ }
9
+
10
+ .toolbarSpacer {
11
+ flex: 1;
12
+ }
@@ -1,13 +1,13 @@
1
1
  import { ActionIcon, Popover, Text, Tooltip } from "@mantine/core";
2
2
  import { useSelector } from "@tanstack/react-store";
3
3
  import type { ReactNode } from "react";
4
- import classes from "./TMDataGrid.module.css";
4
+ import classes from "./TMDataGridToolbar.module.css";
5
5
  import { TMDataGridColumnsPanel } from "./TMDataGridColumnsPanel";
6
- import { useTMDataGridContext } from "./TMDataGridContext";
7
- import { getGridCapabilities } from "./capabilities";
8
- import { isFilterActive } from "./filterOperators";
6
+ import { useTMDataGridContext } from "../TMDataGridContext";
7
+ import { getGridCapabilities } from "../core/capabilities";
8
+ import { isFilterActive } from "../core/filterOperators";
9
9
  import { BurgerIcon, FilterIcon } from "./icons";
10
- import { openColumnFilter } from "./useTMDataGrid";
10
+ import { openColumnFilter } from "../useTMDataGrid";
11
11
 
12
12
  /** Row above the grid. Compose it from the pieces below, or anything else. */
13
13
  export function TMDataGridToolbar({ children }: { children?: ReactNode }) {
@@ -112,6 +112,9 @@ export function TMDataGridFilterButton() {
112
112
  color={activeCount > 0 ? undefined : "gray"}
113
113
  size={controlSize}
114
114
  aria-label="Filters"
115
+ // Marks this as the panel's own toggle, so the panel's click-away
116
+ // handler leaves it alone and the button stays a toggle.
117
+ data-dg-filter-toggle
115
118
  onClick={() => {
116
119
  if (opened) {
117
120
  ui.actions.closeFilterPanel();
@@ -10,12 +10,17 @@ export {
10
10
  IconArrowLeft as MoveLeftIcon,
11
11
  IconArrowRight as MoveRightIcon,
12
12
  IconArrowUp as ArrowUpIcon,
13
+ IconChevronDown as ChevronDownIcon,
13
14
  IconChevronLeft as ChevronLeftIcon,
14
15
  IconChevronRight as ChevronRightIcon,
15
16
  IconColumns3 as ColumnsIcon,
16
17
  IconDotsVertical as DotsVerticalIcon,
17
18
  IconEyeOff as EyeOffIcon,
18
19
  IconFilter as FilterIcon,
20
+ IconFoldDown as ExpandAllIcon,
21
+ IconFoldUp as CollapseAllIcon,
22
+ IconIndentDecrease as UngroupIcon,
23
+ IconIndentIncrease as GroupIcon,
19
24
  IconArrowBarToLeft as PinLeftIcon,
20
25
  IconArrowBarToRight as PinRightIcon,
21
26
  IconMenu2 as BurgerIcon,
@@ -0,0 +1,45 @@
1
+ /* Boundary of a pinned column group. Shared, because the edge is cast by both
2
+ the header cell and every body cell in the same column — the two have to
3
+ draw an identical band or the seam shows at the header.
4
+
5
+ A box-shadow is drawn per cell, and its blur fades in at each cell's own top
6
+ and bottom edge — stacked down a column that reads as banding at every row
7
+ seam. Instead: a crisp 1px border for the hard edge, plus a gradient pseudo
8
+ element pinned to `top: 0; bottom: 0` so consecutive cells butt together into
9
+ one unbroken band. Mantine sets `color-scheme` on the root, so light-dark()
10
+ resolves without a second selector. */
11
+ .stickyLeft {
12
+ border-right: 1px solid var(--mantine-color-default-border);
13
+ }
14
+
15
+ .stickyRight {
16
+ border-left: 1px solid var(--mantine-color-default-border);
17
+ }
18
+
19
+ .stickyLeft::after,
20
+ .stickyRight::after {
21
+ content: "";
22
+ position: absolute;
23
+ top: 0;
24
+ bottom: 0;
25
+ width: 4px;
26
+ pointer-events: none;
27
+ }
28
+
29
+ .stickyLeft::after {
30
+ left: 100%;
31
+ background: linear-gradient(
32
+ to right,
33
+ light-dark(rgba(0, 0, 0, 0.08), rgba(0, 0, 0, 0.28)),
34
+ transparent
35
+ );
36
+ }
37
+
38
+ .stickyRight::after {
39
+ right: 100%;
40
+ background: linear-gradient(
41
+ to left,
42
+ light-dark(rgba(0, 0, 0, 0.08), rgba(0, 0, 0, 0.28)),
43
+ transparent
44
+ );
45
+ }
@@ -0,0 +1,215 @@
1
+ import type { Column, RowData } from "@tanstack/react-table";
2
+ import type { TMDataGridRowData } from "../TMDataGridContext";
3
+ import { isColumnReorderable } from "./columnUtils";
4
+ import type {
5
+ TMDataGridFeatures,
6
+ TMDataGridTable,
7
+ UseTMDataGridOptions,
8
+ } from "../useTMDataGrid";
9
+
10
+ /**
11
+ * Table-level feature switches, read straight off the options object.
12
+ *
13
+ * Why not just call `column.getCanSort()` everywhere? Because that is a method
14
+ * call on a `column` whose identity survives an options change, so the React
15
+ * Compiler caches the result and the chrome keeps rendering for a feature that
16
+ * was switched off. Reading the options into a fresh object each render gives
17
+ * every derived value something that actually changes to invalidate on.
18
+ *
19
+ * TanStack still decides: these flags are combined with `getCanX()`, which is
20
+ * what applies the per-column overrides.
21
+ */
22
+ /**
23
+ * How rows are selected — see `selectionMode` on {@link UseTMDataGridOptions}.
24
+ *
25
+ * One axis, because the two things a config could set independently — which
26
+ * chrome selects, and what a bare row click does — cannot both be free: a click
27
+ * can either toggle a multi-selection or move the highlight, never both. Folding
28
+ * them into one value makes that conflict unrepresentable rather than something
29
+ * to warn about.
30
+ */
31
+ export type TMDataGridSelectionMode =
32
+ /** Checkbox column, multi-select. Clicking a row elsewhere does nothing. */
33
+ | "checkbox"
34
+ /** No checkbox column; row click multi-selects, with Ctrl/Shift modifiers. */
35
+ | "row"
36
+ /** Checkbox column multi-selects, and a row click highlights one row. */
37
+ | "checkboxAndHighlight"
38
+ /** No selection at all — a row click only highlights. Master-detail. */
39
+ | "highlight";
40
+
41
+ export type TMDataGridFeatureFlags = {
42
+ sorting: boolean;
43
+ filtering: boolean;
44
+ hiding: boolean;
45
+ pinning: boolean;
46
+ resizing: boolean;
47
+ ordering: boolean;
48
+ /** Whether any row selection is possible. Off outright under `"highlight"`. */
49
+ rowSelection: boolean;
50
+ /** Defaults to `"checkbox"`. */
51
+ selectionMode: TMDataGridSelectionMode;
52
+ /** Whether the generated checkbox column is prepended. */
53
+ selectColumn: boolean;
54
+ /** Whether a bare row click toggles the selection. Only under `"row"`. */
55
+ rowClickSelects: boolean;
56
+ /**
57
+ * Whether more than one row can be selected at once — TanStack's
58
+ * `enableMultiRowSelection`. Off, the grid drops the select-all header
59
+ * checkbox: TanStack's `toggleAllRowsSelected` only consults `getCanSelect`,
60
+ * so that control would select every row and walk straight past the limit.
61
+ *
62
+ * A per-row predicate counts as on, since some rows may still multi-select.
63
+ */
64
+ multiRowSelection: boolean;
65
+ /** Whether a selected row takes the highlight colour. Follows the mode. */
66
+ showSelectedBackground: boolean;
67
+ /**
68
+ * The single highlighted row — clicking a row highlights it, for a detail
69
+ * panel to follow. State of its own, not a slice of `rowSelection`, which is
70
+ * what lets it coexist with a checkbox multi-selection.
71
+ */
72
+ highlightRow: boolean;
73
+ /**
74
+ * The one default-off flag: pagination must be asked for, either with the
75
+ * grid's `enablePagination` or implicitly by declaring `manualPagination`.
76
+ * Off, the grid renders every filtered row and relies on virtualization.
77
+ */
78
+ pagination: boolean;
79
+ /**
80
+ * Row grouping: the "Group by" items in the header menu and the generated
81
+ * tree column. On unless `enableGrouping: false`, since an ungrouped grid
82
+ * looks and behaves exactly as it did before — `grouping` starts empty, and
83
+ * an empty grouping state passes straight through the row model.
84
+ *
85
+ * The exception is `manualPagination`, where the client holds one page rather
86
+ * than the whole set: grouping that page would build groups out of an
87
+ * arbitrary slice and quietly show wrong counts. A server-side grid that does
88
+ * its own grouping can still say `enableGrouping: true` to override.
89
+ */
90
+ grouping: boolean;
91
+ };
92
+
93
+ export function readFeatureFlags<TData extends RowData>(
94
+ options: Pick<
95
+ UseTMDataGridOptions<TData>,
96
+ | "enableSorting"
97
+ | "enableColumnFilters"
98
+ | "enableHiding"
99
+ | "enableColumnPinning"
100
+ | "enableColumnResizing"
101
+ | "enableColumnOrdering"
102
+ | "enableRowSelection"
103
+ | "enableMultiRowSelection"
104
+ | "selectionMode"
105
+ | "showSelectedBackground"
106
+ | "enablePagination"
107
+ | "manualPagination"
108
+ | "enableGrouping"
109
+ >,
110
+ ): TMDataGridFeatureFlags {
111
+ const selectionMode = options.selectionMode ?? "checkbox";
112
+ // `"highlight"` is the master-detail mode: no selection to speak of, so the
113
+ // checkbox column and the click-to-select behaviour both fall away. Otherwise
114
+ // `enableRowSelection` still has the final say, including its predicate form.
115
+ const rowSelection =
116
+ selectionMode !== "highlight" && options.enableRowSelection !== false;
117
+
118
+ return {
119
+ sorting: options.enableSorting !== false,
120
+ filtering: options.enableColumnFilters !== false,
121
+ hiding: options.enableHiding !== false,
122
+ pinning: options.enableColumnPinning !== false,
123
+ resizing: options.enableColumnResizing !== false,
124
+ ordering: options.enableColumnOrdering !== false,
125
+ rowSelection,
126
+ selectionMode,
127
+ selectColumn:
128
+ rowSelection &&
129
+ (selectionMode === "checkbox" || selectionMode === "checkboxAndHighlight"),
130
+ rowClickSelects: rowSelection && selectionMode === "row",
131
+ multiRowSelection: options.enableMultiRowSelection !== false,
132
+ // In "row" mode the highlight *is* the feedback for a click, so it is on.
133
+ // With checkboxes the box already says it, so it is off unless asked for.
134
+ showSelectedBackground:
135
+ options.showSelectedBackground ?? selectionMode === "row",
136
+ highlightRow:
137
+ selectionMode === "checkboxAndHighlight" || selectionMode === "highlight",
138
+ pagination:
139
+ options.enablePagination === true || options.manualPagination === true,
140
+ grouping: options.enableGrouping ?? options.manualPagination !== true,
141
+ };
142
+ }
143
+
144
+ /**
145
+ * What one column's header may offer.
146
+ *
147
+ * | Affordance | Turned off by |
148
+ * | ---------- | ------------- |
149
+ * | Sort arrow, Sort ASC/DESC | `enableSorting` (table or column) |
150
+ * | Filter item, panel entry | `enableColumnFilters` / `enableColumnFilter` |
151
+ * | Hide column | `enableHiding` (table or column) |
152
+ * | Pin to left / right | `enableColumnPinning` / `enablePinning` |
153
+ * | Resize dragging | `enableColumnResizing` / `enableResizing` |
154
+ * | Header dragging, Move left / right | `enableColumnOrdering` / `meta.enableOrdering` |
155
+ * | Group by / Ungroup | `enableGrouping` (table or column) |
156
+ */
157
+ export type TMDataGridColumnCapabilities = {
158
+ canSort: boolean;
159
+ canFilter: boolean;
160
+ canHide: boolean;
161
+ canPin: boolean;
162
+ canResize: boolean;
163
+ canReorder: boolean;
164
+ canGroup: boolean;
165
+ };
166
+
167
+ export function getColumnCapabilities(
168
+ column: Column<TMDataGridFeatures, TMDataGridRowData, unknown>,
169
+ features: TMDataGridFeatureFlags,
170
+ ): TMDataGridColumnCapabilities {
171
+ return {
172
+ canSort: features.sorting && column.getCanSort(),
173
+ canFilter: features.filtering && column.getCanFilter(),
174
+ canHide: features.hiding && column.getCanHide(),
175
+ canPin: features.pinning && column.getCanPin(),
176
+ canResize: features.resizing && column.getCanResize(),
177
+ // Ordering has no TanStack capability method — see isColumnReorderable.
178
+ canReorder: features.ordering && isColumnReorderable(column),
179
+ // `getCanGroup()` also insists on an accessor, which is what keeps the
180
+ // generated checkbox and tree columns from offering to group on themselves.
181
+ canGroup: features.grouping && column.getCanGroup(),
182
+ };
183
+ }
184
+
185
+ /** The same questions, asked of the whole grid — used to hide toolbar buttons. */
186
+ export type TMDataGridCapabilities = {
187
+ canSortAny: boolean;
188
+ canFilterAny: boolean;
189
+ canHideAny: boolean;
190
+ canPinAny: boolean;
191
+ canReorderAny: boolean;
192
+ canGroupAny: boolean;
193
+ canSelectRows: boolean;
194
+ canPaginate: boolean;
195
+ };
196
+
197
+ export function getGridCapabilities(
198
+ table: TMDataGridTable<TMDataGridRowData>,
199
+ features: TMDataGridFeatureFlags,
200
+ ): TMDataGridCapabilities {
201
+ const columns = table.getAllLeafColumns();
202
+ const any = (predicate: (capabilities: TMDataGridColumnCapabilities) => boolean) =>
203
+ columns.some((column) => predicate(getColumnCapabilities(column, features)));
204
+
205
+ return {
206
+ canSortAny: any((c) => c.canSort),
207
+ canFilterAny: any((c) => c.canFilter),
208
+ canHideAny: any((c) => c.canHide),
209
+ canPinAny: any((c) => c.canPin),
210
+ canReorderAny: any((c) => c.canReorder),
211
+ canGroupAny: any((c) => c.canGroup),
212
+ canSelectRows: features.rowSelection,
213
+ canPaginate: features.pagination,
214
+ };
215
+ }
@@ -1,7 +1,7 @@
1
1
  import type { Column, ColumnPinningState } from "@tanstack/react-table";
2
- import type { TMDataGridRowData } from "./TMDataGridContext";
2
+ import type { TMDataGridRowData } from "../TMDataGridContext";
3
3
  import { isColumnReorderable } from "./columnUtils";
4
- import type { TMDataGridFeatures, TMDataGridTable } from "./useTMDataGrid";
4
+ import type { TMDataGridFeatures, TMDataGridTable } from "../useTMDataGrid";
5
5
 
6
6
  type GridColumn = Column<TMDataGridFeatures, TMDataGridRowData, unknown>;
7
7
  type GridTable = TMDataGridTable<TMDataGridRowData>;
@@ -1,5 +1,5 @@
1
1
  import type { TMDataGridColumnType } from "./filterOperators";
2
- import type { TMDataGridColumnMeta } from "./useTMDataGrid";
2
+ import type { TMDataGridColumnMeta } from "../useTMDataGrid";
3
3
 
4
4
  /**
5
5
  * Structural shape of the column bits the chrome reads. Kept minimal so these
@@ -106,6 +106,31 @@ export function isFilterActive(value: unknown): boolean {
106
106
  return !operatorNeedsValue(value.operator) || value.value.trim() !== "";
107
107
  }
108
108
 
109
+ /**
110
+ * One-line description of a single filter, as shown on a filter pill.
111
+ *
112
+ * The type's default operator is left implicit — "First name: Sofia" reads the
113
+ * way a person would say it — while any other operator is spelled out, since
114
+ * that is the part a reader cannot guess.
115
+ */
116
+ export function formatFilterLabel({
117
+ label,
118
+ type,
119
+ filter,
120
+ }: {
121
+ label: string;
122
+ type: TMDataGridColumnType;
123
+ filter: TMDataGridFilterValue;
124
+ }): string {
125
+ const { operator, value } = filter;
126
+ if (!operatorNeedsValue(operator)) {
127
+ return `${label} ${FILTER_OPERATOR_LABELS[operator]}`;
128
+ }
129
+ const text = value.trim();
130
+ if (operator === getDefaultOperator(type)) return `${label}: ${text}`;
131
+ return `${label} ${FILTER_OPERATOR_LABELS[operator]} ${text}`;
132
+ }
133
+
109
134
  export function matchesFilter(
110
135
  cellValue: unknown,
111
136
  { operator, value }: TMDataGridFilterValue,
@@ -0,0 +1,21 @@
1
+ import type { Row, RowData } from "@tanstack/react-table";
2
+ import type { TMDataGridFeatures } from "../useTMDataGrid";
3
+
4
+ /**
5
+ * The data rows under a group row, at any depth.
6
+ *
7
+ * Not `row.getLeafRows()`, despite the name: that flattens the whole subtree
8
+ * and keeps the branches, so a group nested two deep reports its sub-groups
9
+ * alongside the records. Grouping `city` then `name` would have Stockholm
10
+ * counting four rows and three names as seven, and one tick on it selecting
11
+ * ids that hold no record.
12
+ *
13
+ * A row with no subRows is a data row, which also makes this the identity on an
14
+ * ungrouped grid.
15
+ */
16
+ export function getGroupDataRows<TData extends RowData>(
17
+ row: Row<TMDataGridFeatures, TData>,
18
+ ): Array<Row<TMDataGridFeatures, TData>> {
19
+ if (row.subRows.length === 0) return [row];
20
+ return row.getLeafRows().filter((leaf) => leaf.subRows.length === 0);
21
+ }
@@ -1,5 +1,5 @@
1
1
  import type { TableState } from "@tanstack/react-table";
2
- import type { TMDataGridFeatures } from "./useTMDataGrid";
2
+ import type { TMDataGridFeatures } from "../useTMDataGrid";
3
3
 
4
4
  type GridState = TableState<TMDataGridFeatures>;
5
5
 
@@ -28,6 +28,10 @@ export const DATA_STATE_SLICES = [
28
28
  "globalFilter",
29
29
  "sorting",
30
30
  "pagination",
31
+ // Which groups are open. Keyed by group ids built from the values themselves
32
+ // (`department:Design`), so a restored entry for a group the data no longer
33
+ // produces is simply never looked up.
34
+ "expanded",
31
35
  ] as const satisfies ReadonlyArray<keyof GridState>;
32
36
 
33
37
  /**
@@ -39,6 +43,10 @@ export const SETTINGS_STATE_SLICES = [
39
43
  "columnSizing",
40
44
  "columnOrder",
41
45
  "columnPinning",
46
+ // Which columns the rows are grouped by. A settings slice rather than a data
47
+ // one: it names columns, not values, so it survives the data changing under
48
+ // it the way the column layout does.
49
+ "grouping",
42
50
  ] as const satisfies ReadonlyArray<keyof GridState>;
43
51
 
44
52
  export type TMDataGridDataSlice = (typeof DATA_STATE_SLICES)[number];
@@ -100,6 +108,86 @@ function resolveStorage(
100
108
  }
101
109
  }
102
110
 
111
+ /**
112
+ * Shape guards for restored state.
113
+ *
114
+ * A stored payload outlives the code that wrote it: a slice can change shape
115
+ * between releases, and nothing stops anyone from editing storage by hand. The
116
+ * value goes straight into `initialState`, where a wrong shape surfaces as a
117
+ * crash inside TanStack rather than as a bad read here. A slice that fails its
118
+ * guard is dropped; the rest still restore.
119
+ */
120
+ function prop(value: unknown, key: string): unknown {
121
+ return typeof value === "object" && value !== null
122
+ ? (value as Record<string, unknown>)[key]
123
+ : undefined;
124
+ }
125
+
126
+ const isString = (value: unknown): value is string => typeof value === "string";
127
+
128
+ const isFiniteNumber = (value: unknown): value is number =>
129
+ typeof value === "number" && Number.isFinite(value);
130
+
131
+ function isArrayOf(
132
+ value: unknown,
133
+ isItem: (item: unknown) => boolean,
134
+ ): boolean {
135
+ return Array.isArray(value) && value.every(isItem);
136
+ }
137
+
138
+ function isRecordOf(
139
+ value: unknown,
140
+ isItem: (item: unknown) => boolean,
141
+ ): boolean {
142
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
143
+ return false;
144
+ }
145
+ return Object.values(value).every(isItem);
146
+ }
147
+
148
+ const hasStringId = (entry: unknown): boolean => isString(prop(entry, "id"));
149
+
150
+ const SLICE_GUARDS: Record<
151
+ TMDataGridDataSlice | TMDataGridSettingsSlice,
152
+ (value: unknown) => boolean
153
+ > = {
154
+ columnFilters: (value) => isArrayOf(value, hasStringId),
155
+ // TanStack leaves the global filter value untyped — the grid's own default
156
+ // filters on a string, but a consumer may store anything here.
157
+ globalFilter: () => true,
158
+ sorting: (value) =>
159
+ isArrayOf(
160
+ value,
161
+ (entry) => hasStringId(entry) && typeof prop(entry, "desc") === "boolean",
162
+ ),
163
+ pagination: (value) => {
164
+ const pageIndex = prop(value, "pageIndex");
165
+ const pageSize = prop(value, "pageSize");
166
+ return (
167
+ isFiniteNumber(pageIndex) &&
168
+ pageIndex >= 0 &&
169
+ isFiniteNumber(pageSize) &&
170
+ pageSize > 0
171
+ );
172
+ },
173
+ // `true` is a legal ExpandedState of its own, meaning every row is open.
174
+ expanded: (value) =>
175
+ value === true || isRecordOf(value, (entry) => typeof entry === "boolean"),
176
+ columnVisibility: (value) =>
177
+ isRecordOf(value, (entry) => typeof entry === "boolean"),
178
+ columnSizing: (value) => isRecordOf(value, isFiniteNumber),
179
+ columnOrder: (value) => isArrayOf(value, isString),
180
+ columnPinning: (value) =>
181
+ isArrayOf(prop(value, "left"), isString) &&
182
+ isArrayOf(prop(value, "right"), isString),
183
+ grouping: (value) => isArrayOf(value, isString),
184
+ };
185
+
186
+ function isValidSlice(slice: keyof GridState, value: unknown): boolean {
187
+ const guard = SLICE_GUARDS[slice as keyof typeof SLICE_GUARDS];
188
+ return guard === undefined || guard(value);
189
+ }
190
+
103
191
  function readSlices(
104
192
  storage: Storage | null,
105
193
  resolved: ResolvedKey | null,
@@ -117,7 +205,9 @@ function readSlices(
117
205
  // Only copy the selected slices. A payload written before the selection was
118
206
  // narrowed cannot reintroduce slices the caller has since opted out of.
119
207
  for (const slice of resolved.slices) {
120
- if (slice in record) restored[slice] = record[slice];
208
+ if (!(slice in record)) continue;
209
+ if (!isValidSlice(slice, record[slice])) continue;
210
+ restored[slice] = record[slice];
121
211
  }
122
212
  return restored as Partial<GridState>;
123
213
  } catch {