@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,164 @@
|
|
|
1
|
+
import {
|
|
2
|
+
flexRender,
|
|
3
|
+
useTable,
|
|
4
|
+
type Column,
|
|
5
|
+
type Row,
|
|
6
|
+
} from "@tanstack/react-table";
|
|
7
|
+
import { useSelector } from "@tanstack/react-store";
|
|
8
|
+
import { useMemo } from "react";
|
|
9
|
+
import classes from "./TMDataGridTable.module.css";
|
|
10
|
+
import sticky from "./sticky.module.css";
|
|
11
|
+
import { type TMDataGridRowData, useTMDataGridContext } from "../TMDataGridContext";
|
|
12
|
+
import { getColumnAlign, isControlColumn } from "../core/columnUtils";
|
|
13
|
+
import { getEditFieldName } from "../core/editEngine";
|
|
14
|
+
import {
|
|
15
|
+
tmDataGridFeatures,
|
|
16
|
+
type TMDataGridFeatures,
|
|
17
|
+
} from "../useTMDataGrid";
|
|
18
|
+
import { TMDataGridCellEditor } from "./TMDataGridCellEditor";
|
|
19
|
+
import { EDIT_COLUMN_ID } from "./TMDataGridEditColumn";
|
|
20
|
+
import type { TMDataGridColumnLayout } from "./TMDataGridTable";
|
|
21
|
+
|
|
22
|
+
type ErasedColumn = Column<TMDataGridFeatures, TMDataGridRowData, unknown>;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Whether an entry cell edits. `edit.canEditCell` asks the main table for
|
|
26
|
+
* the row, and an entry row is precisely not there yet — so the structural
|
|
27
|
+
* half of the same rule is applied to the entry table's own row.
|
|
28
|
+
*/
|
|
29
|
+
function isEntryCellEditable(
|
|
30
|
+
row: Row<TMDataGridFeatures, TMDataGridRowData>,
|
|
31
|
+
column: ErasedColumn,
|
|
32
|
+
): boolean {
|
|
33
|
+
if (isControlColumn(column.id)) return false;
|
|
34
|
+
if (getEditFieldName(column) === null) return false;
|
|
35
|
+
const editable = column.columnDef.meta?.editable;
|
|
36
|
+
if (editable === false) return false;
|
|
37
|
+
if (typeof editable === "function" && !editable(row)) return false;
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The sticky entry block — one row of open editors per `edit.addRow()`,
|
|
43
|
+
* pinned under the header. The one place stickiness is genuinely required:
|
|
44
|
+
* an existing row that scrolls away has a place to scroll back to, a row
|
|
45
|
+
* being typed into does not exist anywhere else.
|
|
46
|
+
*
|
|
47
|
+
* A second, tiny table instance over the new rows' seed values — same
|
|
48
|
+
* columns, its own `useTable`, no sorting or filtering ever exercised — so
|
|
49
|
+
* these are real `Cell`s and the editor host, the typed editors and the
|
|
50
|
+
* validators all apply unchanged. The forms live in the same engine map as
|
|
51
|
+
* every other draft, keyed by their temporary ids.
|
|
52
|
+
*
|
|
53
|
+
* Enter commits the entry (`onRowAdd`) under the immediate modes and parks
|
|
54
|
+
* it under batch, where `submitAll` reports it in `added`; Escape discards
|
|
55
|
+
* it. The edit lane, when present, offers the same pair as buttons.
|
|
56
|
+
*/
|
|
57
|
+
export function TMDataGridEntryRows({
|
|
58
|
+
orderedColumns,
|
|
59
|
+
layoutFor,
|
|
60
|
+
rowHeight,
|
|
61
|
+
}: {
|
|
62
|
+
orderedColumns: ReadonlyArray<ErasedColumn>;
|
|
63
|
+
layoutFor: (columnId: string) => TMDataGridColumnLayout;
|
|
64
|
+
rowHeight: number;
|
|
65
|
+
}) {
|
|
66
|
+
const { table, edit } = useTMDataGridContext();
|
|
67
|
+
const newRows = useSelector(edit.store, (state) => state.newRows);
|
|
68
|
+
|
|
69
|
+
// The seed values, frozen at addRow: the live values belong to the forms,
|
|
70
|
+
// which the editors read directly — this table only provides row and cell
|
|
71
|
+
// identity.
|
|
72
|
+
const data = useMemo(
|
|
73
|
+
() =>
|
|
74
|
+
newRows.map(
|
|
75
|
+
({ tempId }) =>
|
|
76
|
+
(edit.getForm(tempId)?.options.defaultValues ??
|
|
77
|
+
{}) as TMDataGridRowData,
|
|
78
|
+
),
|
|
79
|
+
[newRows, edit],
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const entryTable = useTable({
|
|
83
|
+
features: tmDataGridFeatures,
|
|
84
|
+
columns: table.options.columns,
|
|
85
|
+
data,
|
|
86
|
+
getRowId: (_row, index) => newRows[index]?.tempId ?? String(index),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
if (newRows.length === 0) return null;
|
|
90
|
+
|
|
91
|
+
const entryRows = entryTable.getCoreRowModel().rows;
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<div role="rowgroup" data-dg-entry-block className={classes.entryBlock}>
|
|
95
|
+
{entryRows.map((entryRow) => {
|
|
96
|
+
const cellsById = new Map(
|
|
97
|
+
entryRow.getAllCells().map((cell) => [cell.column.id, cell]),
|
|
98
|
+
);
|
|
99
|
+
const firstEditableColumnId = orderedColumns.find((column) =>
|
|
100
|
+
isEntryCellEditable(entryRow, column),
|
|
101
|
+
)?.id;
|
|
102
|
+
return (
|
|
103
|
+
<div
|
|
104
|
+
key={entryRow.id}
|
|
105
|
+
role="row"
|
|
106
|
+
data-dg-part="entry-row"
|
|
107
|
+
data-row-id={entryRow.id}
|
|
108
|
+
className={classes.entryRow}
|
|
109
|
+
>
|
|
110
|
+
{orderedColumns.map((column) => {
|
|
111
|
+
const cell = cellsById.get(column.id);
|
|
112
|
+
const layout = layoutFor(column.id);
|
|
113
|
+
const editable =
|
|
114
|
+
cell !== undefined && isEntryCellEditable(entryRow, column);
|
|
115
|
+
return (
|
|
116
|
+
<div
|
|
117
|
+
key={column.id}
|
|
118
|
+
role="cell"
|
|
119
|
+
data-column-id={column.id}
|
|
120
|
+
data-align={getColumnAlign(column)}
|
|
121
|
+
data-control-column={isControlColumn(column.id)}
|
|
122
|
+
className={[
|
|
123
|
+
classes.entryCell,
|
|
124
|
+
layout.isBoundary && layout.pinnedAt === "left"
|
|
125
|
+
? sticky.stickyLeft
|
|
126
|
+
: "",
|
|
127
|
+
layout.isBoundary && layout.pinnedAt === "right"
|
|
128
|
+
? sticky.stickyRight
|
|
129
|
+
: "",
|
|
130
|
+
]
|
|
131
|
+
.filter(Boolean)
|
|
132
|
+
.join(" ")}
|
|
133
|
+
style={{
|
|
134
|
+
minHeight: rowHeight,
|
|
135
|
+
left: layout.pinnedAt === "left" ? layout.offset : undefined,
|
|
136
|
+
right:
|
|
137
|
+
layout.pinnedAt === "right" ? layout.offset : undefined,
|
|
138
|
+
position: layout.pinnedAt ? "sticky" : undefined,
|
|
139
|
+
zIndex: layout.pinnedAt
|
|
140
|
+
? "var(--dg-z-pinned-row-pinned-cell, 5)"
|
|
141
|
+
: undefined,
|
|
142
|
+
}}
|
|
143
|
+
>
|
|
144
|
+
{editable && cell !== undefined ? (
|
|
145
|
+
<TMDataGridCellEditor
|
|
146
|
+
cell={cell}
|
|
147
|
+
row={entryRow}
|
|
148
|
+
takeSeedText={() => undefined}
|
|
149
|
+
autoFocus={column.id === firstEditableColumnId}
|
|
150
|
+
onClose={() => {}}
|
|
151
|
+
/>
|
|
152
|
+
) : cell !== undefined && column.id === EDIT_COLUMN_ID ? (
|
|
153
|
+
// The lane's cell — the entry row's ✓/✕ pair.
|
|
154
|
+
flexRender(cell.column.columnDef.cell, cell.getContext())
|
|
155
|
+
) : null}
|
|
156
|
+
</div>
|
|
157
|
+
);
|
|
158
|
+
})}
|
|
159
|
+
</div>
|
|
160
|
+
);
|
|
161
|
+
})}
|
|
162
|
+
</div>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
ActionIcon,
|
|
3
3
|
Button,
|
|
4
|
-
MultiSelect,
|
|
5
4
|
Select,
|
|
6
5
|
Stack,
|
|
7
6
|
Text,
|
|
@@ -11,21 +10,25 @@ import { useSelector } from "@tanstack/react-store";
|
|
|
11
10
|
import { useEffect, useRef } from "react";
|
|
12
11
|
import classes from "./TMDataGridFilterPanel.module.css";
|
|
13
12
|
import { useTMDataGridContext } from "../TMDataGridContext";
|
|
13
|
+
import { resolveColumnOptions } from "../core/columnOptions";
|
|
14
14
|
import {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
getColumnDefaultOperator,
|
|
16
|
+
getColumnLabel,
|
|
17
|
+
getColumnType,
|
|
18
|
+
} from "../core/columnUtils";
|
|
19
19
|
import {
|
|
20
20
|
type TMDataGridFilterOperator,
|
|
21
21
|
type TMDataGridFilterValue,
|
|
22
|
+
emptyValueForOperator,
|
|
22
23
|
getDefaultOperator,
|
|
23
24
|
getOperatorsForType,
|
|
24
25
|
isTMDataGridFilterValue,
|
|
25
26
|
operatorNeedsValue,
|
|
26
27
|
operatorTakesArrayValue,
|
|
28
|
+
operatorTakesRangeValue,
|
|
27
29
|
} from "../core/filterOperators";
|
|
28
30
|
import { CloseIcon } from "./icons";
|
|
31
|
+
import { TMDataGridFilterValueInput } from "./filters/TMDataGridFilterValueInput";
|
|
29
32
|
|
|
30
33
|
const FALLBACK_FILTER: TMDataGridFilterValue = { operator: "contains", value: "" };
|
|
31
34
|
|
|
@@ -33,6 +36,19 @@ function asFilterValue(value: unknown): TMDataGridFilterValue {
|
|
|
33
36
|
return isTMDataGridFilterValue(value) ? value : FALLBACK_FILTER;
|
|
34
37
|
}
|
|
35
38
|
|
|
39
|
+
/**
|
|
40
|
+
* The three value shapes an operator can take. A typed value survives an
|
|
41
|
+
* operator or column change only within its shape — a set is not a range,
|
|
42
|
+
* even though both are arrays.
|
|
43
|
+
*/
|
|
44
|
+
function valueShape(
|
|
45
|
+
operator: TMDataGridFilterOperator,
|
|
46
|
+
): "scalar" | "set" | "range" {
|
|
47
|
+
if (operatorTakesArrayValue(operator)) return "set";
|
|
48
|
+
if (operatorTakesRangeValue(operator)) return "range";
|
|
49
|
+
return "scalar";
|
|
50
|
+
}
|
|
51
|
+
|
|
36
52
|
/**
|
|
37
53
|
* The MUI-style filter surface: one row per active column filter, each row a
|
|
38
54
|
* column / operator / value triple. It only ever reads and writes the table's
|
|
@@ -60,7 +76,7 @@ export function TMDataGridFilterPanel() {
|
|
|
60
76
|
if (panelRef.current?.contains(target)) return;
|
|
61
77
|
// The toolbar button toggles the panel itself. Closing from here first
|
|
62
78
|
// would leave its click reopening what the user meant to close.
|
|
63
|
-
if (target.closest(
|
|
79
|
+
if (target.closest('[data-dg-part="filter-button"]')) return;
|
|
64
80
|
ui.actions.closeFilterPanel();
|
|
65
81
|
};
|
|
66
82
|
|
|
@@ -90,18 +106,16 @@ export function TMDataGridFilterPanel() {
|
|
|
90
106
|
columnFilters.find((filter) => filter.id === fromColumnId)?.value,
|
|
91
107
|
);
|
|
92
108
|
const target = table.getColumn(toColumnId);
|
|
93
|
-
const operator =
|
|
94
|
-
|
|
95
|
-
|
|
109
|
+
const operator = target
|
|
110
|
+
? getColumnDefaultOperator(target)
|
|
111
|
+
: getDefaultOperator("string");
|
|
96
112
|
// The typed value only survives the move while it still fits the new
|
|
97
113
|
// operator — a text needle has no meaning to an `isAnyOf` set, or a set
|
|
98
114
|
// to a text input.
|
|
99
115
|
const value =
|
|
100
|
-
|
|
116
|
+
valueShape(operator) === valueShape(current.operator)
|
|
101
117
|
? current.value
|
|
102
|
-
:
|
|
103
|
-
? []
|
|
104
|
-
: "";
|
|
118
|
+
: emptyValueForOperator(operator);
|
|
105
119
|
table.setColumnFilters(
|
|
106
120
|
columnFilters
|
|
107
121
|
.filter((filter) => filter.id !== toColumnId)
|
|
@@ -132,13 +146,11 @@ export function TMDataGridFilterPanel() {
|
|
|
132
146
|
columnFilters.find((filter) => filter.id === columnId)?.value,
|
|
133
147
|
);
|
|
134
148
|
// Same arity rule as changing the column: keep the value across operators
|
|
135
|
-
// of the same shape, reset it across
|
|
149
|
+
// of the same shape, reset it across any shape boundary.
|
|
136
150
|
const value =
|
|
137
|
-
|
|
151
|
+
valueShape(operator) === valueShape(current.operator)
|
|
138
152
|
? current.value
|
|
139
|
-
:
|
|
140
|
-
? []
|
|
141
|
-
: "";
|
|
153
|
+
: emptyValueForOperator(operator);
|
|
142
154
|
patchFilter(columnId, { operator, value });
|
|
143
155
|
}
|
|
144
156
|
|
|
@@ -155,13 +167,14 @@ export function TMDataGridFilterPanel() {
|
|
|
155
167
|
(column) => !columnFilters.some((filter) => filter.id === column.id),
|
|
156
168
|
);
|
|
157
169
|
if (!nextColumn) return;
|
|
170
|
+
const operator = getColumnDefaultOperator(nextColumn);
|
|
158
171
|
table.setColumnFilters([
|
|
159
172
|
...columnFilters,
|
|
160
173
|
{
|
|
161
174
|
id: nextColumn.id,
|
|
162
175
|
value: {
|
|
163
|
-
operator
|
|
164
|
-
value:
|
|
176
|
+
operator,
|
|
177
|
+
value: emptyValueForOperator(operator),
|
|
165
178
|
} satisfies TMDataGridFilterValue,
|
|
166
179
|
},
|
|
167
180
|
]);
|
|
@@ -176,6 +189,7 @@ export function TMDataGridFilterPanel() {
|
|
|
176
189
|
ref={panelRef}
|
|
177
190
|
role="group"
|
|
178
191
|
aria-label={labels.filters}
|
|
192
|
+
data-dg-part="filter-panel"
|
|
179
193
|
className={classes.filterPanel}
|
|
180
194
|
// Escape is what closes a floating surface, and every control that can
|
|
181
195
|
// hold focus in here sits inside this element.
|
|
@@ -194,6 +208,7 @@ export function TMDataGridFilterPanel() {
|
|
|
194
208
|
color="gray"
|
|
195
209
|
size="sm"
|
|
196
210
|
aria-label={labels.closeFilters}
|
|
211
|
+
data-dg-part="filter-panel-close"
|
|
197
212
|
onClick={ui.actions.closeFilterPanel}
|
|
198
213
|
>
|
|
199
214
|
<CloseIcon size={16} stroke={1.6} />
|
|
@@ -206,16 +221,38 @@ export function TMDataGridFilterPanel() {
|
|
|
206
221
|
const value = asFilterValue(filter.value);
|
|
207
222
|
const type = column ? getColumnType(column) : "string";
|
|
208
223
|
const needsValue = operatorNeedsValue(value.operator);
|
|
209
|
-
const takesArray = operatorTakesArrayValue(value.operator);
|
|
210
224
|
const scalarValue = typeof value.value === "string" ? value.value : "";
|
|
225
|
+
// Pre-resolved only where options mean something out of the box —
|
|
226
|
+
// a declared set, or a select-shaped column's faceted values. A
|
|
227
|
+
// custom control wanting faceted values elsewhere resolves them
|
|
228
|
+
// itself; resolving here would build the faceted index for every
|
|
229
|
+
// filtered column.
|
|
230
|
+
const options =
|
|
231
|
+
column &&
|
|
232
|
+
(column.columnDef.meta?.options !== undefined ||
|
|
233
|
+
type === "select" ||
|
|
234
|
+
type === "multiSelect")
|
|
235
|
+
? resolveColumnOptions({ table, column, fallback: "faceted" })
|
|
236
|
+
: [];
|
|
237
|
+
const ValueControl =
|
|
238
|
+
column?.columnDef.meta?.filterControl ?? TMDataGridFilterValueInput;
|
|
211
239
|
|
|
212
240
|
return (
|
|
213
|
-
<div
|
|
241
|
+
<div
|
|
242
|
+
key={filter.id}
|
|
243
|
+
// The row is the handle: a `meta.filterControl` renders whatever
|
|
244
|
+
// it likes in the value slot, so the one thing a test can always
|
|
245
|
+
// count on is which row it is in.
|
|
246
|
+
data-dg-part="filter-row"
|
|
247
|
+
data-column-id={filter.id}
|
|
248
|
+
className={classes.filterRow}
|
|
249
|
+
>
|
|
214
250
|
<ActionIcon
|
|
215
251
|
variant="subtle"
|
|
216
252
|
color="gray"
|
|
217
253
|
size="lg"
|
|
218
254
|
aria-label={labels.removeFilter}
|
|
255
|
+
data-dg-part="filter-remove"
|
|
219
256
|
onClick={() => removeFilter(filter.id)}
|
|
220
257
|
>
|
|
221
258
|
<CloseIcon size={18} stroke={1.6} />
|
|
@@ -230,6 +267,7 @@ export function TMDataGridFilterPanel() {
|
|
|
230
267
|
// outside it in the DOM, and picking an option would read as a
|
|
231
268
|
// click away and close the panel under the user.
|
|
232
269
|
comboboxProps={{ withinPortal: false }}
|
|
270
|
+
data-dg-part="filter-column"
|
|
233
271
|
data={columnOptions}
|
|
234
272
|
value={filter.id}
|
|
235
273
|
onChange={(next) => next && changeFilterColumn(filter.id, next)}
|
|
@@ -241,6 +279,7 @@ export function TMDataGridFilterPanel() {
|
|
|
241
279
|
w={170}
|
|
242
280
|
allowDeselect={false}
|
|
243
281
|
comboboxProps={{ withinPortal: false }}
|
|
282
|
+
data-dg-part="filter-operator"
|
|
244
283
|
data={getOperatorsForType(type).map((operator) => ({
|
|
245
284
|
value: operator,
|
|
246
285
|
label: labels.operators[operator],
|
|
@@ -252,54 +291,28 @@ export function TMDataGridFilterPanel() {
|
|
|
252
291
|
}
|
|
253
292
|
/>
|
|
254
293
|
|
|
255
|
-
{
|
|
256
|
-
// The
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
<
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
searchable
|
|
265
|
-
data={optionsToComboboxData(
|
|
266
|
-
resolveColumnOptions({ table, column, fallback: "faceted" }),
|
|
267
|
-
)}
|
|
268
|
-
value={Array.isArray(value.value) ? [...value.value] : []}
|
|
294
|
+
{column ? (
|
|
295
|
+
// The value slot: `meta.filterControl` if the column declares
|
|
296
|
+
// one, the built-in shape-by-operator input otherwise — both
|
|
297
|
+
// through the same value-only contract.
|
|
298
|
+
<ValueControl
|
|
299
|
+
column={column}
|
|
300
|
+
table={table}
|
|
301
|
+
operator={value.operator}
|
|
302
|
+
value={value.value}
|
|
269
303
|
onChange={(next) => patchFilter(filter.id, { value: next })}
|
|
270
|
-
|
|
271
|
-
) : type === "boolean" ? (
|
|
272
|
-
<Select
|
|
273
|
-
label={labels.filterValue}
|
|
304
|
+
options={options}
|
|
274
305
|
size={controlSize}
|
|
275
|
-
|
|
276
|
-
comboboxProps={{ withinPortal: false }}
|
|
277
|
-
disabled={!needsValue}
|
|
278
|
-
clearable
|
|
279
|
-
placeholder={needsValue ? labels.filterValuePlaceholder : ""}
|
|
280
|
-
data={[
|
|
281
|
-
{ value: "true", label: labels.booleanTrue },
|
|
282
|
-
{ value: "false", label: labels.booleanFalse },
|
|
283
|
-
]}
|
|
284
|
-
value={needsValue && scalarValue !== "" ? scalarValue : null}
|
|
285
|
-
onChange={(next) =>
|
|
286
|
-
patchFilter(filter.id, { value: next ?? "" })
|
|
287
|
-
}
|
|
306
|
+
labels={labels}
|
|
288
307
|
/>
|
|
289
308
|
) : (
|
|
309
|
+
// The column is gone from the definition; the row survives
|
|
310
|
+
// only to be re-pointed or removed.
|
|
290
311
|
<TextInput
|
|
291
312
|
label={labels.filterValue}
|
|
292
313
|
size={controlSize}
|
|
293
314
|
w={180}
|
|
294
|
-
|
|
295
|
-
!needsValue
|
|
296
|
-
? "text"
|
|
297
|
-
: type === "number"
|
|
298
|
-
? "number"
|
|
299
|
-
: type === "date"
|
|
300
|
-
? "date"
|
|
301
|
-
: "text"
|
|
302
|
-
}
|
|
315
|
+
data-dg-part="filter-value"
|
|
303
316
|
disabled={!needsValue}
|
|
304
317
|
placeholder={needsValue ? labels.filterValuePlaceholder : ""}
|
|
305
318
|
value={needsValue ? scalarValue : ""}
|
|
@@ -317,6 +330,7 @@ export function TMDataGridFilterPanel() {
|
|
|
317
330
|
variant="subtle"
|
|
318
331
|
size="compact-sm"
|
|
319
332
|
disabled={!canAddFilter}
|
|
333
|
+
data-dg-part="filter-add"
|
|
320
334
|
onClick={addFilter}
|
|
321
335
|
>
|
|
322
336
|
{labels.addFilter}
|
|
@@ -326,6 +340,7 @@ export function TMDataGridFilterPanel() {
|
|
|
326
340
|
color="gray"
|
|
327
341
|
size="compact-sm"
|
|
328
342
|
disabled={columnFilters.length === 0}
|
|
343
|
+
data-dg-part="filter-clear-all"
|
|
329
344
|
onClick={clearAllFilters}
|
|
330
345
|
>
|
|
331
346
|
{labels.clearAllFilters}
|
|
@@ -68,6 +68,7 @@ export function TMDataGridFilterPills<TData extends RowData>({
|
|
|
68
68
|
<div
|
|
69
69
|
role="group"
|
|
70
70
|
aria-label={labels.activeFilters}
|
|
71
|
+
data-dg-part="filter-pills"
|
|
71
72
|
className={[classes.filterPills, className].filter(Boolean).join(" ")}
|
|
72
73
|
>
|
|
73
74
|
<Pill.Group size={size}>
|
|
@@ -83,6 +84,8 @@ export function TMDataGridFilterPills<TData extends RowData>({
|
|
|
83
84
|
key={filter.id}
|
|
84
85
|
size={size}
|
|
85
86
|
className={classes.pill}
|
|
87
|
+
data-dg-part="filter-pill"
|
|
88
|
+
data-column-id={filter.id}
|
|
86
89
|
withRemoveButton
|
|
87
90
|
onRemove={() =>
|
|
88
91
|
table.setColumnFilters(
|
|
@@ -93,6 +96,9 @@ export function TMDataGridFilterPills<TData extends RowData>({
|
|
|
93
96
|
// the tab order, because inside a `PillsInput` Backspace is the
|
|
94
97
|
// way out. These pills stand on their own, so the ✕ has to be a
|
|
95
98
|
// real button.
|
|
99
|
+
// No test id of its own: Mantine types these props as
|
|
100
|
+
// `CloseButtonProps`, which has no room for `data-*`. The pill
|
|
101
|
+
// carries one, and the ✕ is the only button inside it.
|
|
96
102
|
removeButtonProps={{
|
|
97
103
|
"aria-label": labels.clearFilter(label),
|
|
98
104
|
"aria-hidden": false,
|
|
@@ -78,7 +78,7 @@ export function TMDataGridFooter({
|
|
|
78
78
|
|
|
79
79
|
if (pagination) {
|
|
80
80
|
return (
|
|
81
|
-
<div className={classes.footer}>
|
|
81
|
+
<div data-dg-part="footer" className={classes.footer}>
|
|
82
82
|
{pagination(getTMDataGridPaginationApi(table))}
|
|
83
83
|
</div>
|
|
84
84
|
);
|
|
@@ -108,7 +108,11 @@ export function TMDataGridFooter({
|
|
|
108
108
|
w={260}
|
|
109
109
|
position="top"
|
|
110
110
|
>
|
|
111
|
-
<div
|
|
111
|
+
<div
|
|
112
|
+
data-dg-part="footer"
|
|
113
|
+
className={classes.footer}
|
|
114
|
+
data-paging-suspended={!paging}
|
|
115
|
+
>
|
|
112
116
|
<Group gap="xs" wrap="nowrap">
|
|
113
117
|
<Text size={controlSize} c="dimmed">
|
|
114
118
|
{labels.rowsPerPage}
|
|
@@ -119,6 +123,7 @@ export function TMDataGridFooter({
|
|
|
119
123
|
allowDeselect={false}
|
|
120
124
|
variant="unstyled"
|
|
121
125
|
disabled={!paging}
|
|
126
|
+
data-dg-part="page-size"
|
|
122
127
|
data={sizeOptions.map(String)}
|
|
123
128
|
value={String(pageSize)}
|
|
124
129
|
onChange={(value) => {
|
|
@@ -128,7 +133,7 @@ export function TMDataGridFooter({
|
|
|
128
133
|
/>
|
|
129
134
|
</Group>
|
|
130
135
|
|
|
131
|
-
<Text size={controlSize} c="dimmed">
|
|
136
|
+
<Text size={controlSize} c="dimmed" data-dg-part="page-range">
|
|
132
137
|
{paging
|
|
133
138
|
? labels.pageRange({ from, to, total })
|
|
134
139
|
: // Not a range: nothing is being sliced, so a range would be a lie.
|
|
@@ -142,6 +147,7 @@ export function TMDataGridFooter({
|
|
|
142
147
|
variant="subtle"
|
|
143
148
|
color="gray"
|
|
144
149
|
aria-label={labels.previousPage}
|
|
150
|
+
data-dg-part="page-prev"
|
|
145
151
|
disabled={!paging || !table.getCanPreviousPage()}
|
|
146
152
|
onClick={() => table.previousPage()}
|
|
147
153
|
>
|
|
@@ -151,6 +157,7 @@ export function TMDataGridFooter({
|
|
|
151
157
|
variant="subtle"
|
|
152
158
|
color="gray"
|
|
153
159
|
aria-label={labels.nextPage}
|
|
160
|
+
data-dg-part="page-next"
|
|
154
161
|
disabled={!paging || !table.getCanNextPage()}
|
|
155
162
|
onClick={() => table.nextPage()}
|
|
156
163
|
>
|
|
@@ -72,6 +72,8 @@ function GroupCell<TData extends RowData>({
|
|
|
72
72
|
aria-label={
|
|
73
73
|
expanded ? labels.collapseGroup(label) : labels.expandGroup(label)
|
|
74
74
|
}
|
|
75
|
+
data-dg-part="group-toggle"
|
|
76
|
+
data-row-id={row.id}
|
|
75
77
|
// The row underneath may select or highlight on click; expanding is its
|
|
76
78
|
// own gesture and must not also trigger those.
|
|
77
79
|
onClick={(event) => {
|
|
@@ -28,8 +28,11 @@
|
|
|
28
28
|
padding: 0;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
/*
|
|
32
|
-
|
|
31
|
+
/* A filtered column reads as active: its rows are a subset, and nothing else in
|
|
32
|
+
the header says so once the filter panel is closed. A sorted one is not
|
|
33
|
+
tinted — the arrow, pinned visible for as long as the sort lasts, already
|
|
34
|
+
says which column sorts and which way. */
|
|
35
|
+
.headerCellFiltered .headerTitle {
|
|
33
36
|
color: var(--mantine-primary-color-filled);
|
|
34
37
|
}
|
|
35
38
|
|
|
@@ -85,6 +88,16 @@
|
|
|
85
88
|
cursor: pointer;
|
|
86
89
|
}
|
|
87
90
|
|
|
91
|
+
/* See the sort ActionIcon: an unsorted column's arrow is an invitation, not a
|
|
92
|
+
state, so it sits back until the pointer is on it. */
|
|
93
|
+
.sortAction[data-sorted="false"] {
|
|
94
|
+
opacity: 0.55;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.sortAction[data-sorted="false"]:hover {
|
|
98
|
+
opacity: 1;
|
|
99
|
+
}
|
|
100
|
+
|
|
88
101
|
/* Sort priority under multi-sort — "1", "2", … beside the arrow. Only rendered
|
|
89
102
|
while more than one column sorts, so a single sort stays as it always was. */
|
|
90
103
|
.sortIndex {
|
|
@@ -435,6 +435,9 @@ export function TMDataGridHeaderCell({
|
|
|
435
435
|
const cellClass = [
|
|
436
436
|
classes.headerCell,
|
|
437
437
|
canSort ? classes.headerCellSortable : "",
|
|
438
|
+
// Only the filter tints the title. `data-active` below stays
|
|
439
|
+
// sorted-or-filtered — it is the queryable state, not the styling hook.
|
|
440
|
+
isFiltered ? classes.headerCellFiltered : "",
|
|
438
441
|
isDragged ? classes.headerCellDragging : "",
|
|
439
442
|
dropSide === "before" ? classes.headerCellDropBefore : "",
|
|
440
443
|
dropSide === "after" ? classes.headerCellDropAfter : "",
|
|
@@ -448,7 +451,10 @@ export function TMDataGridHeaderCell({
|
|
|
448
451
|
<div
|
|
449
452
|
ref={cellRef}
|
|
450
453
|
role="columnheader"
|
|
451
|
-
data-
|
|
454
|
+
data-dg-part="header"
|
|
455
|
+
// The same coordinate the body, summary and entry cells carry, so one
|
|
456
|
+
// selector reaches a column's header and its cells alike.
|
|
457
|
+
data-column-id={column.id}
|
|
452
458
|
data-active={isSorted || isFiltered}
|
|
453
459
|
data-align={align}
|
|
454
460
|
// A control lane is a fixed track, so it cannot take the cell padding the
|
|
@@ -525,6 +531,8 @@ export function TMDataGridHeaderCell({
|
|
|
525
531
|
color="gray"
|
|
526
532
|
size="xs"
|
|
527
533
|
aria-label={labels.filterOn(label)}
|
|
534
|
+
data-dg-part="header-filter"
|
|
535
|
+
data-column-id={column.id}
|
|
528
536
|
onClick={(event) => {
|
|
529
537
|
event.stopPropagation();
|
|
530
538
|
openColumnFilter(api, column.id);
|
|
@@ -536,12 +544,19 @@ export function TMDataGridHeaderCell({
|
|
|
536
544
|
|
|
537
545
|
{canSort && (
|
|
538
546
|
<ActionIcon
|
|
539
|
-
className={classes.headerAction}
|
|
547
|
+
className={`${classes.headerAction} ${classes.sortAction}`}
|
|
540
548
|
data-pinned-visible={isSorted}
|
|
549
|
+
// The arrow is the whole signal now that the title no longer
|
|
550
|
+
// tints, so it takes the colour while the sort holds. On an
|
|
551
|
+
// unsorted column it is only the hover affordance, and stays a
|
|
552
|
+
// faded grey so the two never read alike.
|
|
553
|
+
data-sorted={isSorted}
|
|
541
554
|
variant="subtle"
|
|
542
|
-
color="gray"
|
|
555
|
+
color={isSorted ? undefined : "gray"}
|
|
543
556
|
size="xs"
|
|
544
557
|
aria-label={labels.sortColumn(label)}
|
|
558
|
+
data-dg-part="header-sort"
|
|
559
|
+
data-column-id={column.id}
|
|
545
560
|
onClick={column.getToggleSortingHandler()}
|
|
546
561
|
>
|
|
547
562
|
{sortDirection ? (
|
|
@@ -556,7 +571,7 @@ export function TMDataGridHeaderCell({
|
|
|
556
571
|
`isMultiSortEvent`); the number is its priority. Outside the
|
|
557
572
|
hover-revealed action so an active multi-sort stays readable. */}
|
|
558
573
|
{sortIndex !== null && (
|
|
559
|
-
<span className={classes.sortIndex} data-
|
|
574
|
+
<span className={classes.sortIndex} data-dg-part="sort-index">
|
|
560
575
|
{sortIndex}
|
|
561
576
|
</span>
|
|
562
577
|
)}
|
|
@@ -581,6 +596,8 @@ export function TMDataGridHeaderCell({
|
|
|
581
596
|
color="gray"
|
|
582
597
|
size="xs"
|
|
583
598
|
aria-label={labels.columnMenu(label)}
|
|
599
|
+
data-dg-part="header-menu"
|
|
600
|
+
data-column-id={column.id}
|
|
584
601
|
onClick={(event) => {
|
|
585
602
|
event.stopPropagation();
|
|
586
603
|
setMenuOpened((opened) => !opened);
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { ColumnDef, RowData } from "@tanstack/react-table";
|
|
2
|
+
import type { TMDataGridFeatures } from "../useTMDataGrid";
|
|
3
|
+
|
|
4
|
+
export const ROW_NUMBER_COLUMN_ID = "__rowNumber__";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The generated row-number gutter, prepended under `enableRowNumbers` —
|
|
8
|
+
* outermost left, before even the checkbox lane, the way a spreadsheet keeps
|
|
9
|
+
* its gutter outside everything.
|
|
10
|
+
*
|
|
11
|
+
* The def renders nothing itself: a cell cannot know its display position,
|
|
12
|
+
* so the body computes the numbers — one pass over the view, group rows
|
|
13
|
+
* skipped (they are headings over the rows being counted), continuing across
|
|
14
|
+
* pages — and substitutes them in. See `TMDataGridTable`.
|
|
15
|
+
*
|
|
16
|
+
* A system lane: fixed width, no menu, no resize, never exported, and its
|
|
17
|
+
* visibility follows the option rather than the columns panel.
|
|
18
|
+
*/
|
|
19
|
+
export function createRowNumberColumn<TData extends RowData>(
|
|
20
|
+
label = "Row number",
|
|
21
|
+
): ColumnDef<TMDataGridFeatures, TData, unknown> {
|
|
22
|
+
return {
|
|
23
|
+
id: ROW_NUMBER_COLUMN_ID,
|
|
24
|
+
meta: {
|
|
25
|
+
label,
|
|
26
|
+
align: "center",
|
|
27
|
+
// Anchors the left edge with the checkbox lane; nothing moves past it.
|
|
28
|
+
enableOrdering: false,
|
|
29
|
+
},
|
|
30
|
+
size: 48,
|
|
31
|
+
minSize: 48,
|
|
32
|
+
maxSize: 48,
|
|
33
|
+
enableResizing: false,
|
|
34
|
+
enableSorting: false,
|
|
35
|
+
enableColumnFilter: false,
|
|
36
|
+
enableGlobalFilter: false,
|
|
37
|
+
enablePinning: false,
|
|
38
|
+
enableHiding: false,
|
|
39
|
+
header: () => "#",
|
|
40
|
+
// The body substitutes the number; a row without one — a group row —
|
|
41
|
+
// falls through to this.
|
|
42
|
+
cell: () => null,
|
|
43
|
+
};
|
|
44
|
+
}
|