@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,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
- optionsToComboboxData,
16
- resolveColumnOptions,
17
- } from "../core/columnOptions";
18
- import { getColumnLabel, getColumnType } from "../core/columnUtils";
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("[data-dg-filter-toggle]")) return;
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 = getDefaultOperator(
94
- target ? getColumnType(target) : "string",
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
- operatorTakesArrayValue(operator) === Array.isArray(current.value)
116
+ valueShape(operator) === valueShape(current.operator)
101
117
  ? current.value
102
- : operatorTakesArrayValue(operator)
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 the array/scalar boundary.
149
+ // of the same shape, reset it across any shape boundary.
136
150
  const value =
137
- operatorTakesArrayValue(operator) === Array.isArray(current.value)
151
+ valueShape(operator) === valueShape(current.operator)
138
152
  ? current.value
139
- : operatorTakesArrayValue(operator)
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: getDefaultOperator(getColumnType(nextColumn)),
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 key={filter.id} className={classes.filterRow}>
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
- {takesArray && column ? (
256
- // The set the cell is tested against. Options come from
257
- // `meta.options`; a select column that declares none still
258
- // gets the values present in the data, via the faceted index.
259
- <MultiSelect
260
- label={labels.filterValue}
261
- size={controlSize}
262
- w={180}
263
- comboboxProps={{ withinPortal: false }}
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
- w={180}
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
- type={
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 className={classes.footer} data-paging-suspended={!paging}>
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
- /* Sorted or filtered columns read as active, like the MUI data grid. */
32
- .headerCell[data-active="true"] .headerTitle {
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-testid={`dg-header-${column.id}`}
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-testid="dg-sort-index">
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
+ }