@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.
- package/README.md +25 -2
- package/dist/index.d.ts +856 -14
- package/dist/index.js +3370 -1546
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/dist/styles.layer.css +1 -0
- package/package.json +12 -2
- package/skills/columns/SKILL.md +1 -1
- package/skills/features/SKILL.md +1 -1
- package/skills/getting-started/SKILL.md +1 -1
- package/skills/options/SKILL.md +1 -1
- package/skills/server-side/SKILL.md +1 -1
- package/skills/testing/SKILL.md +198 -0
- package/src/tmdatagrid/components/TMDataGrid.module.css +11 -0
- package/src/tmdatagrid/components/TMDataGrid.tsx +34 -0
- package/src/tmdatagrid/components/TMDataGridCellEditor.tsx +256 -0
- package/src/tmdatagrid/components/TMDataGridColumnsPanel.tsx +35 -12
- package/src/tmdatagrid/components/TMDataGridDetailsColumn.tsx +3 -0
- package/src/tmdatagrid/components/TMDataGridEditActions.tsx +62 -0
- package/src/tmdatagrid/components/TMDataGridEditColumn.tsx +237 -0
- package/src/tmdatagrid/components/TMDataGridEntryRows.tsx +164 -0
- package/src/tmdatagrid/components/TMDataGridFilterPanel.tsx +77 -62
- package/src/tmdatagrid/components/TMDataGridFilterPills.tsx +6 -0
- package/src/tmdatagrid/components/TMDataGridFooter.tsx +10 -3
- package/src/tmdatagrid/components/TMDataGridGroupColumn.tsx +2 -0
- package/src/tmdatagrid/components/TMDataGridHeaderCell.module.css +15 -2
- package/src/tmdatagrid/components/TMDataGridHeaderCell.tsx +21 -4
- package/src/tmdatagrid/components/TMDataGridRowNumberColumn.tsx +44 -0
- package/src/tmdatagrid/components/TMDataGridSearch.tsx +2 -0
- package/src/tmdatagrid/components/TMDataGridSelectColumn.tsx +3 -0
- package/src/tmdatagrid/components/TMDataGridTable.module.css +206 -0
- package/src/tmdatagrid/components/TMDataGridTable.tsx +841 -57
- package/src/tmdatagrid/components/TMDataGridToolbar.tsx +12 -7
- package/src/tmdatagrid/components/editors/TMDataGridBooleanEditor.tsx +29 -0
- package/src/tmdatagrid/components/editors/TMDataGridDateEditor.tsx +57 -0
- package/src/tmdatagrid/components/editors/TMDataGridMultiSelectEditor.tsx +45 -0
- package/src/tmdatagrid/components/editors/TMDataGridNumberEditor.tsx +38 -0
- package/src/tmdatagrid/components/editors/TMDataGridSelectEditor.tsx +53 -0
- package/src/tmdatagrid/components/editors/TMDataGridStringEditor.tsx +31 -0
- package/src/tmdatagrid/components/editors/editorShared.ts +48 -0
- package/src/tmdatagrid/components/filters/DgAutocompleteFilter.tsx +46 -0
- package/src/tmdatagrid/components/filters/DgDateRangeFilter.tsx +43 -0
- package/src/tmdatagrid/components/filters/DgRangeSliderFilter.tsx +52 -0
- package/src/tmdatagrid/components/filters/DgTriStateFilter.tsx +38 -0
- package/src/tmdatagrid/components/filters/TMDataGridFilterValueInput.tsx +122 -0
- package/src/tmdatagrid/components/icons.ts +4 -0
- package/src/tmdatagrid/core/autosize.ts +1 -1
- package/src/tmdatagrid/core/capabilities.ts +37 -1
- package/src/tmdatagrid/core/cellNavigation.ts +41 -0
- package/src/tmdatagrid/core/columnUtils.ts +31 -6
- package/src/tmdatagrid/core/editEngine.ts +857 -0
- package/src/tmdatagrid/core/filterControls.ts +45 -0
- package/src/tmdatagrid/core/filterOperators.ts +72 -4
- package/src/tmdatagrid/core/labels.ts +68 -1
- package/src/tmdatagrid/core/labelsSv.ts +24 -1
- package/src/tmdatagrid/core/matchHighlight.ts +96 -0
- package/src/tmdatagrid/core/persistence.ts +97 -3
- package/src/tmdatagrid/core/quickSearch.ts +144 -0
- package/src/tmdatagrid/core/rowPinning.ts +37 -0
- package/src/tmdatagrid/core/rowSelection.ts +10 -1
- package/src/tmdatagrid/index.ts +51 -0
- 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)
|
|
13
|
-
*
|
|
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.
|
|
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 =
|
|
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
|
-
/**
|
|
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
|
-
|
|
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. */
|