@jielga/tmdatagrid 0.5.0 → 1.0.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 (62) hide show
  1. package/README.md +25 -2
  2. package/dist/index.d.ts +856 -14
  3. package/dist/index.js +3370 -1546
  4. package/dist/index.js.map +1 -1
  5. package/dist/styles.css +1 -1
  6. package/dist/styles.layer.css +1 -0
  7. package/package.json +12 -2
  8. package/skills/columns/SKILL.md +1 -1
  9. package/skills/features/SKILL.md +1 -1
  10. package/skills/getting-started/SKILL.md +1 -1
  11. package/skills/options/SKILL.md +1 -1
  12. package/skills/server-side/SKILL.md +1 -1
  13. package/skills/testing/SKILL.md +198 -0
  14. package/src/tmdatagrid/components/TMDataGrid.module.css +11 -0
  15. package/src/tmdatagrid/components/TMDataGrid.tsx +34 -0
  16. package/src/tmdatagrid/components/TMDataGridCellEditor.tsx +256 -0
  17. package/src/tmdatagrid/components/TMDataGridColumnsPanel.tsx +35 -12
  18. package/src/tmdatagrid/components/TMDataGridDetailsColumn.tsx +3 -0
  19. package/src/tmdatagrid/components/TMDataGridEditActions.tsx +62 -0
  20. package/src/tmdatagrid/components/TMDataGridEditColumn.tsx +237 -0
  21. package/src/tmdatagrid/components/TMDataGridEntryRows.tsx +164 -0
  22. package/src/tmdatagrid/components/TMDataGridFilterPanel.tsx +77 -62
  23. package/src/tmdatagrid/components/TMDataGridFilterPills.tsx +6 -0
  24. package/src/tmdatagrid/components/TMDataGridFooter.tsx +10 -3
  25. package/src/tmdatagrid/components/TMDataGridGroupColumn.tsx +2 -0
  26. package/src/tmdatagrid/components/TMDataGridHeaderCell.module.css +15 -2
  27. package/src/tmdatagrid/components/TMDataGridHeaderCell.tsx +21 -4
  28. package/src/tmdatagrid/components/TMDataGridRowNumberColumn.tsx +44 -0
  29. package/src/tmdatagrid/components/TMDataGridSearch.tsx +2 -0
  30. package/src/tmdatagrid/components/TMDataGridSelectColumn.tsx +3 -0
  31. package/src/tmdatagrid/components/TMDataGridTable.module.css +206 -0
  32. package/src/tmdatagrid/components/TMDataGridTable.tsx +841 -57
  33. package/src/tmdatagrid/components/TMDataGridToolbar.tsx +12 -7
  34. package/src/tmdatagrid/components/editors/TMDataGridBooleanEditor.tsx +29 -0
  35. package/src/tmdatagrid/components/editors/TMDataGridDateEditor.tsx +57 -0
  36. package/src/tmdatagrid/components/editors/TMDataGridMultiSelectEditor.tsx +45 -0
  37. package/src/tmdatagrid/components/editors/TMDataGridNumberEditor.tsx +38 -0
  38. package/src/tmdatagrid/components/editors/TMDataGridSelectEditor.tsx +53 -0
  39. package/src/tmdatagrid/components/editors/TMDataGridStringEditor.tsx +31 -0
  40. package/src/tmdatagrid/components/editors/editorShared.ts +48 -0
  41. package/src/tmdatagrid/components/filters/DgAutocompleteFilter.tsx +46 -0
  42. package/src/tmdatagrid/components/filters/DgDateRangeFilter.tsx +43 -0
  43. package/src/tmdatagrid/components/filters/DgRangeSliderFilter.tsx +52 -0
  44. package/src/tmdatagrid/components/filters/DgTriStateFilter.tsx +38 -0
  45. package/src/tmdatagrid/components/filters/TMDataGridFilterValueInput.tsx +122 -0
  46. package/src/tmdatagrid/components/icons.ts +4 -0
  47. package/src/tmdatagrid/core/autosize.ts +1 -1
  48. package/src/tmdatagrid/core/capabilities.ts +37 -1
  49. package/src/tmdatagrid/core/cellNavigation.ts +41 -0
  50. package/src/tmdatagrid/core/columnUtils.ts +31 -6
  51. package/src/tmdatagrid/core/editEngine.ts +857 -0
  52. package/src/tmdatagrid/core/filterControls.ts +45 -0
  53. package/src/tmdatagrid/core/filterOperators.ts +72 -4
  54. package/src/tmdatagrid/core/labels.ts +68 -1
  55. package/src/tmdatagrid/core/labelsSv.ts +24 -1
  56. package/src/tmdatagrid/core/matchHighlight.ts +96 -0
  57. package/src/tmdatagrid/core/persistence.ts +97 -3
  58. package/src/tmdatagrid/core/quickSearch.ts +144 -0
  59. package/src/tmdatagrid/core/rowPinning.ts +37 -0
  60. package/src/tmdatagrid/core/rowSelection.ts +10 -1
  61. package/src/tmdatagrid/index.ts +51 -0
  62. package/src/tmdatagrid/useTMDataGrid.tsx +456 -15
@@ -0,0 +1,45 @@
1
+ import type { Column } from "@tanstack/react-table";
2
+ import type { ComponentType } from "react";
3
+ import type { TMDataGridRowData } from "../TMDataGridContext";
4
+ import type { TMDataGridFeatures, TMDataGridTable } from "../useTMDataGrid";
5
+ import type { TMDataGridOption } from "./columnOptions";
6
+ import type { TMDataGridFilterOperator } from "./filterOperators";
7
+ import type { TMDataGridLabels } from "./labels";
8
+ import type { TMDataGridSize } from "./sizes";
9
+
10
+ /**
11
+ * What a filter control is handed — the value slot of one filter-panel row.
12
+ *
13
+ * The contract is value-only: the control reads `operator` to shape itself
14
+ * (a `between` pair renders two ends, an `equals` a single input) and calls
15
+ * `onChange` with the bare value; the grid composes the operator-aware
16
+ * `TMDataGridFilterValue` around it. A control never builds that wrapper and
17
+ * never writes the operator — `table` is the escape hatch for the rare
18
+ * control that must.
19
+ */
20
+ export type TMDataGridFilterControlArgs = {
21
+ column: Column<TMDataGridFeatures, TMDataGridRowData, unknown>;
22
+ table: TMDataGridTable<TMDataGridRowData>;
23
+ /** The row's currently selected operator. Read-only for the control. */
24
+ operator: TMDataGridFilterOperator;
25
+ /** The bare filter value — never the `{ operator, value }` wrapper. */
26
+ value: string | ReadonlyArray<string>;
27
+ /** Writes the bare value; the grid pairs it with the current operator. */
28
+ onChange: (next: string | ReadonlyArray<string>) => void;
29
+ /**
30
+ * The column's options through `resolveColumnOptions`, pre-resolved for a
31
+ * column that declares `meta.options` or is select-shaped. Empty otherwise —
32
+ * a control wanting faceted values on other columns resolves them itself.
33
+ */
34
+ options: ReadonlyArray<TMDataGridOption>;
35
+ size: TMDataGridSize;
36
+ labels: TMDataGridLabels;
37
+ };
38
+
39
+ /**
40
+ * `meta.filterControl` — replaces the built-in value control for this column.
41
+ * Rendered as JSX, never invoked as a bare function, so hooks are legal
42
+ * inside. Define it at module scope so its identity is stable across renders.
43
+ */
44
+ export type TMDataGridFilterControlComponent =
45
+ ComponentType<TMDataGridFilterControlArgs>;
@@ -9,8 +9,10 @@ import type { Row, RowData, TableFeatures } from "@tanstack/react-table";
9
9
  * `manualFiltering` table (just forward `columnFilters` to the API).
10
10
  *
11
11
  * `value` is a string array under `isAnyOf` / `isNoneOf` (the set the cell is
12
- * tested against) and a single string everywhere else — dates travel as ISO
13
- * `YYYY-MM-DD` strings, booleans as `"true"` / `"false"`. Still plain JSON.
12
+ * tested against), a `[min, max]` pair under `between` (an empty string means
13
+ * that end is open), and a single string everywhere else — dates travel as
14
+ * ISO `YYYY-MM-DD` strings, booleans as `"true"` / `"false"`. Still plain
15
+ * JSON.
14
16
  */
15
17
  export type TMDataGridFilterValue = {
16
18
  operator: TMDataGridFilterOperator;
@@ -35,6 +37,7 @@ export type TMDataGridFilterOperator =
35
37
  | "greaterThanOrEqual"
36
38
  | "lessThan"
37
39
  | "lessThanOrEqual"
40
+ | "between"
38
41
  | "before"
39
42
  | "after"
40
43
  | "onOrBefore"
@@ -54,6 +57,7 @@ export const FILTER_OPERATOR_LABELS: Record<TMDataGridFilterOperator, string> =
54
57
  greaterThanOrEqual: "is greater than or equal to",
55
58
  lessThan: "is less than",
56
59
  lessThanOrEqual: "is less than or equal to",
60
+ between: "is between",
57
61
  before: "is before",
58
62
  after: "is after",
59
63
  onOrBefore: "is on or before",
@@ -81,6 +85,7 @@ const NUMBER_OPERATORS: readonly TMDataGridFilterOperator[] = [
81
85
  "greaterThanOrEqual",
82
86
  "lessThan",
83
87
  "lessThanOrEqual",
88
+ "between",
84
89
  "isEmpty",
85
90
  "isNotEmpty",
86
91
  ];
@@ -95,6 +100,7 @@ const DATE_OPERATORS: readonly TMDataGridFilterOperator[] = [
95
100
  "after",
96
101
  "onOrBefore",
97
102
  "onOrAfter",
103
+ "between",
98
104
  "isEmpty",
99
105
  "isNotEmpty",
100
106
  ];
@@ -127,6 +133,9 @@ const ARRAY_OPERATORS: readonly TMDataGridFilterOperator[] = [
127
133
  "isNoneOf",
128
134
  ];
129
135
 
136
+ /** Operators whose value is a `[min, max]` pair. */
137
+ const RANGE_OPERATORS: readonly TMDataGridFilterOperator[] = ["between"];
138
+
130
139
  const OPERATORS_BY_TYPE: Record<
131
140
  TMDataGridColumnType,
132
141
  readonly TMDataGridFilterOperator[]
@@ -174,6 +183,22 @@ export function operatorTakesArrayValue(
174
183
  return ARRAY_OPERATORS.includes(operator);
175
184
  }
176
185
 
186
+ /** Whether the operator's value is a `[min, max]` pair — `between`. */
187
+ export function operatorTakesRangeValue(
188
+ operator: TMDataGridFilterOperator,
189
+ ): boolean {
190
+ return RANGE_OPERATORS.includes(operator);
191
+ }
192
+
193
+ /** The untouched value a fresh filter starts with — the operator's shape, empty. */
194
+ export function emptyValueForOperator(
195
+ operator: TMDataGridFilterOperator,
196
+ ): string | ReadonlyArray<string> {
197
+ if (operatorTakesArrayValue(operator)) return [];
198
+ if (operatorTakesRangeValue(operator)) return ["", ""];
199
+ return "";
200
+ }
201
+
177
202
  export function isTMDataGridFilterValue(
178
203
  value: unknown,
179
204
  ): value is TMDataGridFilterValue {
@@ -193,8 +218,10 @@ export function isTMDataGridFilterValue(
193
218
  export function isFilterActive(value: unknown): boolean {
194
219
  if (!isTMDataGridFilterValue(value)) return false;
195
220
  if (!operatorNeedsValue(value.operator)) return true;
221
+ // A `between` pair is `["", ""]` while untouched, so an array is active
222
+ // once some entry says something, not merely by existing.
196
223
  return Array.isArray(value.value)
197
- ? value.value.length > 0
224
+ ? value.value.some((entry) => String(entry).trim() !== "")
198
225
  : typeof value.value === "string" && value.value.trim() !== "";
199
226
  }
200
227
 
@@ -221,7 +248,12 @@ export function formatFilterLabel({
221
248
  if (!operatorNeedsValue(operator)) {
222
249
  return `${label} ${operatorLabels[operator]}`;
223
250
  }
224
- const text = Array.isArray(value) ? value.join(", ") : String(value).trim();
251
+ const text =
252
+ operator === "between" && Array.isArray(value)
253
+ ? value.map((entry) => String(entry).trim()).join("–")
254
+ : Array.isArray(value)
255
+ ? value.join(", ")
256
+ : String(value).trim();
225
257
  if (operator === getDefaultOperator(type)) return `${label}: ${text}`;
226
258
  return `${label} ${operatorLabels[operator]} ${text}`;
227
259
  }
@@ -246,6 +278,29 @@ function toIsoDay(value: unknown): string | null {
246
278
  return null;
247
279
  }
248
280
 
281
+ /** One end of a `between` interval. An empty bound is an open end. */
282
+ function boundHolds(
283
+ cellValue: unknown,
284
+ bound: string,
285
+ end: "min" | "max",
286
+ ): boolean {
287
+ const boundText = bound.trim();
288
+ if (boundText === "") return true;
289
+
290
+ const numericCell = typeof cellValue === "number" ? cellValue : Number.NaN;
291
+ const numericBound = Number(boundText);
292
+ if (!Number.isNaN(numericCell) && !Number.isNaN(numericBound)) {
293
+ return end === "min"
294
+ ? numericCell >= numericBound
295
+ : numericCell <= numericBound;
296
+ }
297
+
298
+ const cellDay = toIsoDay(cellValue);
299
+ const boundDay = toIsoDay(boundText);
300
+ if (cellDay === null || boundDay === null) return false;
301
+ return end === "min" ? cellDay >= boundDay : cellDay <= boundDay;
302
+ }
303
+
249
304
  function isEmptyCell(cellValue: unknown): boolean {
250
305
  return (
251
306
  cellValue === null ||
@@ -276,6 +331,19 @@ export function matchesFilter(
276
331
  return operator === "isAnyOf" ? hit : !hit;
277
332
  }
278
333
 
334
+ // A closed or half-open interval. Each present end must hold; an empty end
335
+ // is open — and a fully empty pair matches everything, the same way a blank
336
+ // needle does. Ends compare the way the single-ended operators would:
337
+ // numerically when both sides are numbers, by calendar day otherwise.
338
+ if (operator === "between") {
339
+ const [min, max] = Array.isArray(value)
340
+ ? [String(value[0] ?? ""), String(value[1] ?? "")]
341
+ : [String(value), ""];
342
+ return (
343
+ boundHolds(cellValue, min, "min") && boundHolds(cellValue, max, "max")
344
+ );
345
+ }
346
+
279
347
  const filterText =
280
348
  typeof value === "string" ? value.trim() : value.join(",").trim();
281
349
  if (filterText === "") return true;
@@ -30,12 +30,27 @@ export type TMDataGridLabels = {
30
30
  columnsNoMatch: (search: string) => string;
31
31
  columnsShowHideAll: string;
32
32
  columnsReset: string;
33
+ /** Tooltip on the reset button, stating everything the reset covers. */
34
+ columnsResetHint: string;
35
+ /** Menu/panel name of the generated row-number gutter. */
36
+ rowNumberColumnLabel: string;
37
+ /**
38
+ * The truly-empty message — no data and no filters. Filtered-empty says
39
+ * {@link noResults} instead; only one of the two is the user's own doing.
40
+ */
41
+ noRows: string;
33
42
 
34
43
  // Filter panel
35
44
  filterColumn: string;
36
45
  filterOperator: string;
37
46
  filterValue: string;
38
47
  filterValuePlaceholder: string;
48
+ /** The lower bound of a `between` filter. */
49
+ filterFrom: string;
50
+ /** The upper bound of a `between` filter. */
51
+ filterTo: string;
52
+ /** `DgTriStateFilter`'s no-filter segment. */
53
+ filterAll: string;
39
54
  /** What a boolean column's `true` reads as — its filter choice and cell text. */
40
55
  booleanTrue: string;
41
56
  booleanFalse: string;
@@ -79,6 +94,36 @@ export type TMDataGridLabels = {
79
94
  noResults: string;
80
95
  loading: string;
81
96
 
97
+ // Cell editing
98
+ /** `aria-label` of an open cell editor's input. */
99
+ editCell: (column: string) => string;
100
+ /** cellConfirm's ✓ beside the input. */
101
+ confirmEdit: string;
102
+ /** cellConfirm's ✕ beside the input. */
103
+ cancelEdit: string;
104
+ /** The generated edit lane's name in the column manager. */
105
+ editColumnLabel: string;
106
+ /** The lane's pencil. */
107
+ editRow: string;
108
+ /** The lane's ✓ while a row edit is open. */
109
+ saveRow: string;
110
+ /** The lane's ✕ while a row edit is open. */
111
+ cancelRowEdit: string;
112
+ /** Save tooltip when field errors block the row, with no row message. */
113
+ editRowErrors: string;
114
+ /** `EditActions`' Save, with the dirty-row count. */
115
+ saveAllEdits: (rows: number) => string;
116
+ /** `EditActions`' Discard. */
117
+ discardAllEdits: string;
118
+ /** The entry row's ✓ — commit the add. */
119
+ confirmNewRow: string;
120
+ /** The entry row's ✕ — drop the entry. */
121
+ discardNewRow: string;
122
+ /** The lane's trash can. */
123
+ deleteRow: string;
124
+ /** The lane's undo on a row marked deleted (batch). */
125
+ restoreRow: string;
126
+
82
127
  // Cell selection menu
83
128
  cellCount: (count: number) => string;
84
129
  copy: string;
@@ -128,12 +173,19 @@ export const TMDATAGRID_LABELS_EN: TMDataGridLabels = {
128
173
  columnsSearchPlaceholder: "Search",
129
174
  columnsNoMatch: (search) => `No columns match “${search}”`,
130
175
  columnsShowHideAll: "Show/Hide All",
131
- columnsReset: "RESET",
176
+ columnsReset: "RESET LAYOUT",
177
+ columnsResetHint:
178
+ "Resets column visibility, order, widths, pinning and grouping",
179
+ rowNumberColumnLabel: "Row number",
180
+ noRows: "No rows to show",
132
181
 
133
182
  filterColumn: "Column",
134
183
  filterOperator: "Operator",
135
184
  filterValue: "Value",
136
185
  filterValuePlaceholder: "Filter value",
186
+ filterFrom: "From",
187
+ filterTo: "To",
188
+ filterAll: "All",
137
189
  booleanTrue: "Yes",
138
190
  booleanFalse: "No",
139
191
  addFilter: "Add filter",
@@ -173,6 +225,21 @@ export const TMDATAGRID_LABELS_EN: TMDataGridLabels = {
173
225
  noResults: "No rows match your filters",
174
226
  loading: "Loading",
175
227
 
228
+ editCell: (column) => `Edit ${column}`,
229
+ confirmEdit: "Save",
230
+ cancelEdit: "Cancel",
231
+ editColumnLabel: "Edit",
232
+ editRow: "Edit row",
233
+ saveRow: "Save row",
234
+ cancelRowEdit: "Cancel edit",
235
+ editRowErrors: "Fix the marked cells",
236
+ saveAllEdits: (rows) => (rows === 1 ? "Save 1 row" : `Save ${rows} rows`),
237
+ discardAllEdits: "Discard",
238
+ confirmNewRow: "Add row",
239
+ discardNewRow: "Discard new row",
240
+ deleteRow: "Delete row",
241
+ restoreRow: "Restore row",
242
+
176
243
  cellCount: (count) => (count === 1 ? "1 cell" : `${count} cells`),
177
244
  copy: "Copy",
178
245
  exportCsv: "Export as CSV for Excel",
@@ -21,6 +21,7 @@ export const TMDATAGRID_LABELS_SV: TMDataGridLabels = {
21
21
  greaterThanOrEqual: "är större än eller lika med",
22
22
  lessThan: "är mindre än",
23
23
  lessThanOrEqual: "är mindre än eller lika med",
24
+ between: "är mellan",
24
25
  before: "är före",
25
26
  after: "är efter",
26
27
  onOrBefore: "är på eller före",
@@ -40,12 +41,19 @@ export const TMDATAGRID_LABELS_SV: TMDataGridLabels = {
40
41
  columnsSearchPlaceholder: "Sök",
41
42
  columnsNoMatch: (search) => `Inga kolumner matchar ”${search}”`,
42
43
  columnsShowHideAll: "Visa/dölj alla",
43
- columnsReset: "ÅTERSTÄLL",
44
+ columnsReset: "ÅTERSTÄLL LAYOUT",
45
+ columnsResetHint:
46
+ "Återställer kolumnernas synlighet, ordning, bredder, fästning och gruppering",
47
+ rowNumberColumnLabel: "Radnummer",
48
+ noRows: "Inga rader att visa",
44
49
 
45
50
  filterColumn: "Kolumn",
46
51
  filterOperator: "Operator",
47
52
  filterValue: "Värde",
48
53
  filterValuePlaceholder: "Filtervärde",
54
+ filterFrom: "Från",
55
+ filterTo: "Till",
56
+ filterAll: "Alla",
49
57
  booleanTrue: "Ja",
50
58
  booleanFalse: "Nej",
51
59
  addFilter: "Lägg till filter",
@@ -85,6 +93,21 @@ export const TMDATAGRID_LABELS_SV: TMDataGridLabels = {
85
93
  noResults: "Inga rader matchar dina filter",
86
94
  loading: "Laddar",
87
95
 
96
+ editCell: (column) => `Redigera ${column}`,
97
+ confirmEdit: "Spara",
98
+ cancelEdit: "Avbryt",
99
+ editColumnLabel: "Redigera",
100
+ editRow: "Redigera rad",
101
+ saveRow: "Spara rad",
102
+ cancelRowEdit: "Avbryt redigering",
103
+ editRowErrors: "Åtgärda de markerade cellerna",
104
+ saveAllEdits: (rows) => (rows === 1 ? "Spara 1 rad" : `Spara ${rows} rader`),
105
+ discardAllEdits: "Förkasta",
106
+ confirmNewRow: "Lägg till rad",
107
+ discardNewRow: "Släng ny rad",
108
+ deleteRow: "Ta bort rad",
109
+ restoreRow: "Återställ rad",
110
+
88
111
  cellCount: (count) => (count === 1 ? "1 cell" : `${count} celler`),
89
112
  copy: "Kopiera",
90
113
  exportCsv: "Exportera som CSV för Excel",
@@ -0,0 +1,96 @@
1
+ import type { RowData } from "@tanstack/react-table";
2
+ import {
3
+ isTMDataGridFilterValue,
4
+ type TMDataGridFilterOperator,
5
+ } from "./filterOperators";
6
+ import type { TMDataGridTable } from "../useTMDataGrid";
7
+
8
+ /** A matched slice of a cell's text, `start` inclusive to `end` exclusive. */
9
+ export type TMDataGridMatchRange = { start: number; end: number };
10
+
11
+ /**
12
+ * The operators whose match is a contiguous substring — the ones highlighting
13
+ * can point at. Equality highlights nothing: marking the whole cell says
14
+ * nothing the filter did not already say.
15
+ */
16
+ const HIGHLIGHTABLE_OPERATORS: ReadonlyArray<TMDataGridFilterOperator> = [
17
+ "contains",
18
+ "startsWith",
19
+ "endsWith",
20
+ ];
21
+
22
+ /**
23
+ * Every needle that could highlight in a given column, keyed by column id —
24
+ * the quick search for the columns it searches, plus any contains-family
25
+ * column filter. `null` while nothing highlightable is active, which is the
26
+ * common case and the cheap one.
27
+ *
28
+ * The fuzzy quick search still contributes its raw text: when the needle
29
+ * occurs contiguously it is highlighted, and a typo-match simply shows no
30
+ * highlight — the honest answer to what a non-contiguous match "is".
31
+ */
32
+ export function buildMatchNeedles<TData extends RowData>(
33
+ table: TMDataGridTable<TData>,
34
+ ): Map<string, ReadonlyArray<string>> | null {
35
+ const state = table.store.state;
36
+ const global =
37
+ typeof state.globalFilter === "string" ? state.globalFilter.trim() : "";
38
+
39
+ const byColumn = new Map<string, Array<string>>();
40
+ for (const filter of state.columnFilters) {
41
+ if (!isTMDataGridFilterValue(filter.value)) continue;
42
+ const { operator, value } = filter.value;
43
+ if (!HIGHLIGHTABLE_OPERATORS.includes(operator)) continue;
44
+ if (typeof value !== "string" || value === "") continue;
45
+ const needles = byColumn.get(filter.id) ?? [];
46
+ needles.push(value);
47
+ byColumn.set(filter.id, needles);
48
+ }
49
+
50
+ if (global === "" && byColumn.size === 0) return null;
51
+
52
+ const map = new Map<string, ReadonlyArray<string>>();
53
+ for (const column of table.getAllLeafColumns()) {
54
+ const needles: Array<string> = [];
55
+ if (global !== "" && column.getCanGlobalFilter()) needles.push(global);
56
+ const own = byColumn.get(column.id);
57
+ if (own !== undefined) needles.push(...own);
58
+ if (needles.length > 0) map.set(column.id, needles);
59
+ }
60
+ return map.size > 0 ? map : null;
61
+ }
62
+
63
+ /**
64
+ * Where the needles occur in the text, case-insensitively, merged where they
65
+ * overlap or touch — so two needles covering "St" and "tock" come back as one
66
+ * range rather than nested marks. `null` when nothing matches.
67
+ */
68
+ export function findMatchRanges(
69
+ text: string,
70
+ needles: ReadonlyArray<string>,
71
+ ): Array<TMDataGridMatchRange> | null {
72
+ const haystack = text.toLowerCase();
73
+ const found: Array<TMDataGridMatchRange> = [];
74
+ for (const needle of needles) {
75
+ const lowered = needle.toLowerCase();
76
+ if (lowered === "") continue;
77
+ let index = haystack.indexOf(lowered);
78
+ while (index !== -1) {
79
+ found.push({ start: index, end: index + lowered.length });
80
+ index = haystack.indexOf(lowered, index + 1);
81
+ }
82
+ }
83
+ if (found.length === 0) return null;
84
+
85
+ found.sort((a, b) => a.start - b.start || a.end - b.end);
86
+ const merged: Array<TMDataGridMatchRange> = [];
87
+ for (const range of found) {
88
+ const last = merged.at(-1);
89
+ if (last !== undefined && range.start <= last.end) {
90
+ last.end = Math.max(last.end, range.end);
91
+ } else {
92
+ merged.push({ ...range });
93
+ }
94
+ }
95
+ return merged;
96
+ }
@@ -78,6 +78,17 @@ export type TMDataGridPersistence = {
78
78
  deserialize?: (value: string) => unknown;
79
79
  };
80
80
 
81
+ /**
82
+ * Version stamped into every stored payload, under a key no state slice can
83
+ * collide with. A payload whose version does not match is dropped whole: a
84
+ * migration would be guessing at a shape this code no longer knows, and the
85
+ * cost of dropping is one lost layout. Payloads written before versioning
86
+ * existed (≤0.4.0) have no stamp and are dropped the same way — the 1.0
87
+ * wave's one-time break, named in its changeset.
88
+ */
89
+ export const PERSIST_PAYLOAD_VERSION = 1;
90
+ const VERSION_FIELD = "__v";
91
+
81
92
  type ResolvedKey = { key: string; slices: ReadonlyArray<keyof GridState> };
82
93
 
83
94
  function resolveKey<TSlice extends string>(
@@ -201,6 +212,7 @@ function readSlices(
201
212
  if (typeof parsed !== "object" || parsed === null) return {};
202
213
 
203
214
  const record = parsed as Record<string, unknown>;
215
+ if (record[VERSION_FIELD] !== PERSIST_PAYLOAD_VERSION) return {};
204
216
  const restored: Record<string, unknown> = {};
205
217
  // Only copy the selected slices. A payload written before the selection was
206
218
  // narrowed cannot reintroduce slices the caller has since opted out of.
@@ -222,7 +234,7 @@ function writeSlices(
222
234
  serialize: (value: Partial<GridState>) => string,
223
235
  ): void {
224
236
  if (!storage || !resolved) return;
225
- const payload: Record<string, unknown> = {};
237
+ const payload: Record<string, unknown> = { [VERSION_FIELD]: PERSIST_PAYLOAD_VERSION };
226
238
  for (const slice of resolved.slices) payload[slice] = state[slice];
227
239
  try {
228
240
  storage.setItem(resolved.key, serialize(payload as Partial<GridState>));
@@ -250,17 +262,99 @@ const defaultDeserialize = (value: string): unknown => JSON.parse(value);
250
262
  const defaultSerialize = (value: Partial<GridState>): string =>
251
263
  JSON.stringify(value);
252
264
 
253
- /** State to merge into `initialState`, read once on mount. */
265
+ /**
266
+ * The leaf column ids a set of column defs will produce, by TanStack's own
267
+ * derivation: `id`, else `accessorKey` with dots replaced by underscores,
268
+ * else a string `header`. Mirrored here so restored state can be realigned
269
+ * against the columns that will actually exist — deriving ids any other way
270
+ * would realign against names the table never uses.
271
+ */
272
+ export function collectLeafColumnIds(
273
+ defs: ReadonlyArray<unknown>,
274
+ into: string[] = [],
275
+ ): string[] {
276
+ for (const def of defs) {
277
+ if (typeof def !== "object" || def === null) continue;
278
+ const record = def as Record<string, unknown>;
279
+ if (Array.isArray(record.columns) && record.columns.length > 0) {
280
+ collectLeafColumnIds(record.columns, into);
281
+ continue;
282
+ }
283
+ const accessorKey =
284
+ typeof record.accessorKey === "string" ? record.accessorKey : undefined;
285
+ const id =
286
+ (typeof record.id === "string" ? record.id : undefined) ??
287
+ accessorKey?.replaceAll(".", "_") ??
288
+ (typeof record.header === "string" ? record.header : undefined);
289
+ if (id !== undefined) into.push(id);
290
+ }
291
+ return into;
292
+ }
293
+
294
+ /**
295
+ * Drops restored entries that name columns the grid no longer has.
296
+ *
297
+ * A stored layout outlives the column set: a column removed between deploys
298
+ * would otherwise linger forever — a ghost id in the order, a width for
299
+ * nothing, and worse, a *filter or sort that is active but has no column to
300
+ * show for itself*. New columns need no counterpart here: TanStack appends
301
+ * columns missing from `columnOrder` on its own, in definition order.
302
+ */
303
+ function realignToColumns(
304
+ restored: Partial<GridState>,
305
+ known: ReadonlySet<string>,
306
+ ): Partial<GridState> {
307
+ const has = (id: unknown): boolean =>
308
+ typeof id === "string" && known.has(id);
309
+ const keepIds = (values: ReadonlyArray<string>) => values.filter(has);
310
+ const keepKeys = <T,>(record: Record<string, T>) =>
311
+ Object.fromEntries(Object.entries(record).filter(([id]) => has(id)));
312
+ const keepEntries = <T extends { id: string }>(entries: ReadonlyArray<T>) =>
313
+ entries.filter((entry) => has(entry.id));
314
+
315
+ const realigned: Partial<GridState> = { ...restored };
316
+ if (restored.columnOrder) realigned.columnOrder = keepIds(restored.columnOrder);
317
+ if (restored.grouping) realigned.grouping = keepIds(restored.grouping);
318
+ if (restored.columnVisibility) {
319
+ realigned.columnVisibility = keepKeys(restored.columnVisibility);
320
+ }
321
+ if (restored.columnSizing) {
322
+ realigned.columnSizing = keepKeys(restored.columnSizing);
323
+ }
324
+ if (restored.columnPinning) {
325
+ realigned.columnPinning = {
326
+ left: keepIds(restored.columnPinning.left ?? []),
327
+ right: keepIds(restored.columnPinning.right ?? []),
328
+ };
329
+ }
330
+ if (restored.sorting) realigned.sorting = keepEntries(restored.sorting);
331
+ if (restored.columnFilters) {
332
+ realigned.columnFilters = keepEntries(restored.columnFilters);
333
+ }
334
+ return realigned;
335
+ }
336
+
337
+ /**
338
+ * State to merge into `initialState`, read once on mount.
339
+ *
340
+ * With `knownColumnIds` — every id the grid is about to construct, generated
341
+ * lanes included — the column-shaped slices are realigned first; see
342
+ * {@link realignToColumns}. Without it the payload is returned as stored.
343
+ */
254
344
  export function readPersistedState(
255
345
  persistence?: TMDataGridPersistence,
346
+ knownColumnIds?: ReadonlyArray<string>,
256
347
  ): Partial<GridState> {
257
348
  const storage = resolveStorage(persistence);
258
349
  const { data, settings } = resolveKeys(persistence);
259
350
  const deserialize = persistence?.deserialize ?? defaultDeserialize;
260
- return {
351
+ const restored = {
261
352
  ...readSlices(storage, settings, deserialize),
262
353
  ...readSlices(storage, data, deserialize),
263
354
  };
355
+ return knownColumnIds === undefined
356
+ ? restored
357
+ : realignToColumns(restored, new Set(knownColumnIds));
264
358
  }
265
359
 
266
360
  /** Writes both payloads. Called on every table state change. */