@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,256 @@
1
+ import { ActionIcon } from "@mantine/core";
2
+ import { FieldApi } from "@tanstack/react-form";
3
+ import type { Cell, Row } from "@tanstack/react-table";
4
+ import {
5
+ type ComponentType,
6
+ type FocusEvent,
7
+ type KeyboardEvent,
8
+ useEffect,
9
+ useRef,
10
+ useState,
11
+ } from "react";
12
+ import classes from "./TMDataGridTable.module.css";
13
+ import { type TMDataGridRowData, useTMDataGridContext } from "../TMDataGridContext";
14
+ import {
15
+ getEditFieldName,
16
+ normalizeFieldValidate,
17
+ type TMDataGridEditField,
18
+ type TMDataGridEditorArgs,
19
+ } from "../core/editEngine";
20
+ import type { TMDataGridFeatures } from "../useTMDataGrid";
21
+ import { CheckIcon, CloseIcon } from "./icons";
22
+ import { TMDataGridBooleanEditor } from "./editors/TMDataGridBooleanEditor";
23
+ import { TMDataGridDateEditor } from "./editors/TMDataGridDateEditor";
24
+ import { TMDataGridMultiSelectEditor } from "./editors/TMDataGridMultiSelectEditor";
25
+ import { TMDataGridNumberEditor } from "./editors/TMDataGridNumberEditor";
26
+ import { TMDataGridSelectEditor } from "./editors/TMDataGridSelectEditor";
27
+ import { TMDataGridStringEditor } from "./editors/TMDataGridStringEditor";
28
+
29
+ const BUILT_IN_EDITORS: Record<
30
+ string,
31
+ ComponentType<TMDataGridEditorArgs>
32
+ > = {
33
+ string: TMDataGridStringEditor,
34
+ number: TMDataGridNumberEditor,
35
+ boolean: TMDataGridBooleanEditor,
36
+ date: TMDataGridDateEditor,
37
+ select: TMDataGridSelectEditor,
38
+ multiSelect: TMDataGridMultiSelectEditor,
39
+ };
40
+
41
+ /** How an editor closed, for the table to decide where the focus goes. */
42
+ export type TMDataGridCellEditorClose =
43
+ | { committed: true; via: "enter" | "tab" | "shift-tab" | "pick" }
44
+ | { committed: false; via: "escape" }
45
+ /** Batch: the editor closed, the draft stays — Enter parks, Tab moves on. */
46
+ | { committed: false; via: "defer" | "defer-tab" | "defer-shift-tab" };
47
+
48
+ /**
49
+ * The host an editing cell mounts — it owns the field, the keys and the blur
50
+ * policy; the editor inside it (a built-in picked by `meta.type`, or the
51
+ * column's `meta.editor`) owns the input.
52
+ *
53
+ * The form outlives this component by design: scrolling the row away
54
+ * unmounts the host, and the form in the engine's map keeps the draft.
55
+ */
56
+ export function TMDataGridCellEditor({
57
+ cell,
58
+ row,
59
+ takeSeedText,
60
+ autoFocus = true,
61
+ onClose,
62
+ }: {
63
+ cell: Cell<TMDataGridFeatures, TMDataGridRowData, unknown>;
64
+ row: Row<TMDataGridFeatures, TMDataGridRowData>;
65
+ /** Consumes the pending type-to-edit seed, when typing opened this editor. */
66
+ takeSeedText: () => string | undefined;
67
+ /** In row mode only the first editable cell takes the focus. */
68
+ autoFocus?: boolean;
69
+ /** After the editor closed itself — the table moves the focus. */
70
+ onClose: (args: TMDataGridCellEditorClose) => void;
71
+ }) {
72
+ const { table, edit, features, labels, controlSize } = useTMDataGridContext();
73
+ // Row mode mounts one host per editable cell of the row; every key that
74
+ // commits or cancels acts on the whole row's form either way, but Tab must
75
+ // stay the browser's (it walks the row's inputs) and blur must do nothing
76
+ // (focus moves between this row's own editors).
77
+ const isRowMode = features.editMode === "row";
78
+ const column = cell.column;
79
+ const form = edit.getForm(row.id);
80
+ const fieldName = getEditFieldName(column);
81
+ const [seedText] = useState(() => takeSeedText());
82
+
83
+ // One FieldApi per open editor, over the row's long-lived form. Created
84
+ // imperatively (the documented core usage) because the form was too.
85
+ const [field] = useState<TMDataGridEditField>(
86
+ () =>
87
+ new FieldApi({
88
+ form: form as never,
89
+ name: fieldName as never,
90
+ validators: normalizeFieldValidate(
91
+ column.columnDef.meta?.validate,
92
+ ) as never,
93
+ }) as unknown as TMDataGridEditField,
94
+ );
95
+ useEffect(() => field.mount(), [field]);
96
+
97
+ // Type-to-edit: the seed replaces the value, as it does in a spreadsheet.
98
+ // Only for the types where keystrokes are the input.
99
+ useEffect(() => {
100
+ if (seedText === undefined) return;
101
+ const type = column.columnDef.meta?.type ?? "string";
102
+ if (type === "string") {
103
+ field.handleChange(seedText as never);
104
+ } else if (type === "number") {
105
+ const seeded = Number(seedText);
106
+ if (Number.isFinite(seeded)) field.handleChange(seeded as never);
107
+ }
108
+ // Once, at open.
109
+ // eslint-disable-next-line react-hooks/exhaustive-deps
110
+ }, []);
111
+
112
+ const closingRef = useRef(false);
113
+
114
+ const commitAndClose = async (
115
+ via: "enter" | "tab" | "shift-tab" | "pick",
116
+ ): Promise<boolean> => {
117
+ if (closingRef.current) return false;
118
+ const ok = await edit.commit(row.id);
119
+ if (ok && !closingRef.current) {
120
+ closingRef.current = true;
121
+ onClose({ committed: true, via });
122
+ }
123
+ return ok;
124
+ };
125
+
126
+ const cancelAndClose = () => {
127
+ if (closingRef.current) return;
128
+ closingRef.current = true;
129
+ edit.cancel(row.id);
130
+ onClose({ committed: false, via: "escape" });
131
+ };
132
+
133
+ /** Batch: the key closes the editor and leaves the draft for submitAll. */
134
+ const deferAndClose = (via: "defer" | "defer-tab" | "defer-shift-tab") => {
135
+ if (closingRef.current) return;
136
+ closingRef.current = true;
137
+ edit.deactivate();
138
+ onClose({ committed: false, via });
139
+ };
140
+
141
+ const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
142
+ const isBatch = features.editMode === "batch";
143
+ if (event.key === "Enter") {
144
+ event.preventDefault();
145
+ event.stopPropagation();
146
+ // In row mode Enter (and Ctrl+Enter, the documented pair) saves the
147
+ // row; in batch nothing commits until submitAll, so Enter just parks
148
+ // the draft.
149
+ if (isBatch) deferAndClose("defer");
150
+ else void commitAndClose("enter");
151
+ } else if (event.key === "Escape") {
152
+ event.preventDefault();
153
+ event.stopPropagation();
154
+ cancelAndClose();
155
+ } else if (event.key === "Tab" && !isRowMode) {
156
+ event.preventDefault();
157
+ event.stopPropagation();
158
+ if (isBatch) {
159
+ deferAndClose(event.shiftKey ? "defer-shift-tab" : "defer-tab");
160
+ } else {
161
+ void commitAndClose(event.shiftKey ? "shift-tab" : "tab");
162
+ }
163
+ }
164
+ };
165
+
166
+ /**
167
+ * Focus left the editor entirely — a click somewhere else. Under `"cell"`
168
+ * that commits (Sheets); a commit blocked by validation keeps the form and
169
+ * its invalid marker, with the editor closed. The confirming modes keep
170
+ * the draft open-but-inactive, waiting for their explicit ✓.
171
+ */
172
+ const handleBlur = (event: FocusEvent<HTMLDivElement>) => {
173
+ if (closingRef.current) return;
174
+ // Row mode: the row's other editors are outside this host, and focus
175
+ // moving between them is not leaving the edit. Blur means nothing here;
176
+ // the lane's Save/Cancel and the keys end the edit.
177
+ if (isRowMode) return;
178
+ const next = event.relatedTarget;
179
+ if (next instanceof Node && event.currentTarget.contains(next)) return;
180
+ closingRef.current = true;
181
+ if (features.editMode === "cell") {
182
+ void edit.commit(row.id).then((ok) => {
183
+ if (!ok) edit.deactivate();
184
+ });
185
+ } else {
186
+ // cellConfirm: the draft stays, dirty-marked, waiting for its ✓.
187
+ edit.deactivate();
188
+ }
189
+ };
190
+
191
+ if (form === undefined || fieldName === null) return null;
192
+
193
+ const args: TMDataGridEditorArgs = {
194
+ field,
195
+ form,
196
+ cell,
197
+ row,
198
+ column,
199
+ table,
200
+ commit: () => commitAndClose("pick"),
201
+ cancel: cancelAndClose,
202
+ size: controlSize,
203
+ autoFocus,
204
+ seedText,
205
+ };
206
+
207
+ const Editor =
208
+ column.columnDef.meta?.editor ??
209
+ BUILT_IN_EDITORS[column.columnDef.meta?.type ?? "string"] ??
210
+ TMDataGridStringEditor;
211
+
212
+ return (
213
+ <div
214
+ className={classes.cellEditor}
215
+ data-dg-part="editor"
216
+ data-row-id={row.id}
217
+ data-column-id={column.id}
218
+ onKeyDown={handleKeyDown}
219
+ onBlur={handleBlur}
220
+ // The cell underneath listens for selection gestures; a click inside
221
+ // the editor is about the editor.
222
+ onMouseDown={(event) => event.stopPropagation()}
223
+ onDoubleClick={(event) => event.stopPropagation()}
224
+ >
225
+ <Editor {...args} />
226
+ {/* cellConfirm's chrome: the draft only commits through the ✓ (or
227
+ Enter), so the pair sits right beside the input. */}
228
+ {features.editMode === "cellConfirm" && (
229
+ <div className={classes.cellEditorActions}>
230
+ <ActionIcon
231
+ size="sm"
232
+ variant="subtle"
233
+ color="green"
234
+ aria-label={labels.confirmEdit}
235
+ data-dg-part="editor-confirm"
236
+ onMouseDown={(event) => event.preventDefault()}
237
+ onClick={() => void commitAndClose("pick")}
238
+ >
239
+ <CheckIcon size={14} stroke={2} />
240
+ </ActionIcon>
241
+ <ActionIcon
242
+ size="sm"
243
+ variant="subtle"
244
+ color="gray"
245
+ aria-label={labels.cancelEdit}
246
+ data-dg-part="editor-cancel"
247
+ onMouseDown={(event) => event.preventDefault()}
248
+ onClick={cancelAndClose}
249
+ >
250
+ <CloseIcon size={14} stroke={2} />
251
+ </ActionIcon>
252
+ </div>
253
+ )}
254
+ </div>
255
+ );
256
+ }
@@ -1,10 +1,18 @@
1
- import { Button, Checkbox, ScrollArea, Text, TextInput } from "@mantine/core";
1
+ import {
2
+ Button,
3
+ Checkbox,
4
+ ScrollArea,
5
+ Text,
6
+ TextInput,
7
+ Tooltip,
8
+ } from "@mantine/core";
2
9
  import { useSelector } from "@tanstack/react-store";
3
10
  import { useState } from "react";
4
11
  import classes from "./TMDataGridColumnsPanel.module.css";
5
12
  import { useTMDataGridContext } from "../TMDataGridContext";
6
13
  import { getColumnLabel } from "../core/columnUtils";
7
14
  import { GROUP_COLUMN_ID } from "./TMDataGridGroupColumn";
15
+ import { ROW_NUMBER_COLUMN_ID } from "./TMDataGridRowNumberColumn";
8
16
  import { SearchIcon } from "./icons";
9
17
 
10
18
  /**
@@ -13,7 +21,7 @@ import { SearchIcon } from "./icons";
13
21
  * `TMDataGrid.ColumnsButton`.
14
22
  */
15
23
  export function TMDataGridColumnsPanel() {
16
- const { table, labels, controlSize } = useTMDataGridContext();
24
+ const { table, labels, controlSize, resetSettings } = useTMDataGridContext();
17
25
  const [search, setSearch] = useState("");
18
26
 
19
27
  const columnVisibility = useSelector(
@@ -23,10 +31,14 @@ export function TMDataGridColumnsPanel() {
23
31
 
24
32
  // The tree column is left out rather than listed and disabled: its visibility
25
33
  // is not a setting at all, it follows the grouping state, so an unchecked box
26
- // that cannot be ticked would only invite the question.
34
+ // that cannot be ticked would only invite the question. The row-number
35
+ // gutter follows `enableRowNumbers` the same way.
27
36
  const columns = table
28
37
  .getAllLeafColumns()
29
- .filter((column) => column.id !== GROUP_COLUMN_ID);
38
+ .filter(
39
+ (column) =>
40
+ column.id !== GROUP_COLUMN_ID && column.id !== ROW_NUMBER_COLUMN_ID,
41
+ );
30
42
  const needle = search.trim().toLowerCase();
31
43
  const visibleInPanel = needle
32
44
  ? columns.filter((column) =>
@@ -40,7 +52,7 @@ export function TMDataGridColumnsPanel() {
40
52
  ).length;
41
53
 
42
54
  return (
43
- <div className={classes.columnsPanel}>
55
+ <div data-dg-part="columns-panel" className={classes.columnsPanel}>
44
56
  <div className={classes.columnsPanelSearch}>
45
57
  <TextInput
46
58
  value={search}
@@ -48,6 +60,7 @@ export function TMDataGridColumnsPanel() {
48
60
  placeholder={labels.columnsSearchPlaceholder}
49
61
  leftSection={<SearchIcon size={16} stroke={1.6} />}
50
62
  size={controlSize}
63
+ data-dg-part="columns-search"
51
64
  data-autofocus
52
65
  />
53
66
  </div>
@@ -59,6 +72,8 @@ export function TMDataGridColumnsPanel() {
59
72
  key={column.id}
60
73
  size={controlSize}
61
74
  label={getColumnLabel(column)}
75
+ data-dg-part="columns-toggle"
76
+ data-column-id={column.id}
62
77
  disabled={!column.getCanHide()}
63
78
  checked={columnVisibility[column.id] !== false}
64
79
  onChange={(event) =>
@@ -78,19 +93,27 @@ export function TMDataGridColumnsPanel() {
78
93
  <Checkbox
79
94
  size={controlSize}
80
95
  label={labels.columnsShowHideAll}
96
+ data-dg-part="columns-toggle-all"
81
97
  checked={shownCount === hideableColumns.length}
82
98
  indeterminate={shownCount > 0 && shownCount < hideableColumns.length}
83
99
  onChange={(event) =>
84
100
  table.toggleAllColumnsVisible(event.currentTarget.checked)
85
101
  }
86
102
  />
87
- <Button
88
- variant="subtle"
89
- size="compact-sm"
90
- onClick={() => table.resetColumnVisibility()}
91
- >
92
- {labels.columnsReset}
93
- </Button>
103
+ {/* The whole layout, not only visibility: one reset with an honest
104
+ scope, stated in the tooltip. `table.resetColumnVisibility()` would
105
+ also be wrong under persistence — it resets to `initialState`,
106
+ which the mount built *from* the persisted payload. */}
107
+ <Tooltip label={labels.columnsResetHint} withArrow>
108
+ <Button
109
+ variant="subtle"
110
+ size="compact-sm"
111
+ data-dg-part="columns-reset"
112
+ onClick={() => resetSettings()}
113
+ >
114
+ {labels.columnsReset}
115
+ </Button>
116
+ </Tooltip>
94
117
  </div>
95
118
  </div>
96
119
  );
@@ -42,6 +42,8 @@ function DetailsCell<TData extends RowData>({
42
42
  tabIndex={tabIndex}
43
43
  aria-expanded={expanded}
44
44
  aria-label={expanded ? labels.hideDetails : labels.showDetails}
45
+ data-dg-part="details-toggle"
46
+ data-row-id={row.id}
45
47
  // The row underneath may select or highlight on click; opening a panel is
46
48
  // its own gesture and must not also trigger those.
47
49
  onClick={(event) => {
@@ -90,6 +92,7 @@ function DetailsHeader<TData extends RowData>({
90
92
  aria-label={
91
93
  allExpanded ? labels.collapseAllDetails : labels.expandAllDetails
92
94
  }
95
+ data-dg-part="details-toggle-all"
93
96
  // Partly expanded opens the rest rather than closing what is already
94
97
  // open — the same reading as an indeterminate select-all box.
95
98
  onClick={() =>
@@ -0,0 +1,62 @@
1
+ import { Button, Group } from "@mantine/core";
2
+ import { useSelector } from "@tanstack/react-store";
3
+ import { useTMDataGridContext } from "../TMDataGridContext";
4
+
5
+ /**
6
+ * Batch mode's toolbar chrome: Save with the dirty-row count, and Discard.
7
+ * Both read the edit store, so they grey out while nothing is dirty and the
8
+ * Save spins while a submit is in flight. Works under any `editMode` — a
9
+ * cellConfirm grid accumulating drafts can offer the same pair — and renders
10
+ * nothing while editing is off.
11
+ *
12
+ * ```tsx
13
+ * <TMDataGrid.Toolbar>
14
+ * <TMDataGrid.SummaryCount />
15
+ * <TMDataGrid.Spacer />
16
+ * <TMDataGrid.EditActions />
17
+ * </TMDataGrid.Toolbar>
18
+ * ```
19
+ */
20
+ export function TMDataGridEditActions() {
21
+ const { edit, features, labels } = useTMDataGridContext();
22
+ // Everything pending counts: dirty edits, entry rows (an add is pending
23
+ // whether or not it was touched), and deletion marks.
24
+ const dirtyCount = useSelector(
25
+ edit.store,
26
+ (state) =>
27
+ state.openRowIds.filter(
28
+ (rowId) =>
29
+ state.newRows.some((newRow) => newRow.tempId === rowId) ||
30
+ (state.rows[rowId]?.dirtyFields.length ?? 0) > 0,
31
+ ).length + state.deletedRowIds.length,
32
+ );
33
+ const isSubmitting = useSelector(edit.store, (state) =>
34
+ state.openRowIds.some((rowId) => state.rows[rowId]?.isSubmitting === true),
35
+ );
36
+
37
+ if (!features.editing) return null;
38
+
39
+ return (
40
+ <Group gap="xs" wrap="nowrap">
41
+ <Button
42
+ size="compact-sm"
43
+ disabled={dirtyCount === 0}
44
+ loading={isSubmitting}
45
+ data-dg-part="save-all"
46
+ onClick={() => void edit.submitAll()}
47
+ >
48
+ {labels.saveAllEdits(dirtyCount)}
49
+ </Button>
50
+ <Button
51
+ variant="subtle"
52
+ color="gray"
53
+ size="compact-sm"
54
+ disabled={dirtyCount === 0}
55
+ data-dg-part="discard-all"
56
+ onClick={() => edit.cancelAll()}
57
+ >
58
+ {labels.discardAllEdits}
59
+ </Button>
60
+ </Group>
61
+ );
62
+ }
@@ -0,0 +1,237 @@
1
+ import { ActionIcon, Group, Loader, Tooltip } from "@mantine/core";
2
+ import { useSelector } from "@tanstack/react-store";
3
+ import type { ColumnDef, Row, RowData } from "@tanstack/react-table";
4
+ import {
5
+ useCellControlTabIndex,
6
+ useTMDataGridContext,
7
+ } from "../TMDataGridContext";
8
+ import type { TMDataGridFeatures } from "../useTMDataGrid";
9
+ import { firstErrorText } from "./editors/editorShared";
10
+ import {
11
+ CheckIcon,
12
+ CloseIcon,
13
+ PencilIcon,
14
+ RestoreIcon,
15
+ TrashIcon,
16
+ } from "./icons";
17
+
18
+ export const EDIT_COLUMN_ID = "__edit__";
19
+
20
+ /**
21
+ * One row's slot in the edit lane. Three states, driven off the edit store:
22
+ * a pencil while idle, Save/Cancel while the row's form is open, a loader
23
+ * while its commit is in flight. A row error turns the Save red and puts the
24
+ * message in its tooltip — the pathless `.refine()` has nowhere else to land.
25
+ */
26
+ function EditLaneCell<TData extends RowData>({
27
+ row,
28
+ }: {
29
+ row: Row<TMDataGridFeatures, TData>;
30
+ }) {
31
+ const { edit, features, labels } = useTMDataGridContext();
32
+ const tabIndex = useCellControlTabIndex();
33
+ const rowId = row.id;
34
+ const isOpen = useSelector(edit.store, (state) =>
35
+ state.openRowIds.includes(rowId),
36
+ );
37
+ const projection = useSelector(edit.store, (state) => state.rows[rowId]);
38
+ const isNew = useSelector(edit.store, (state) =>
39
+ state.newRows.some((newRow) => newRow.tempId === rowId),
40
+ );
41
+ const isMarkedDeleted = useSelector(edit.store, (state) =>
42
+ state.deletedRowIds.includes(rowId),
43
+ );
44
+
45
+ if (row.getIsGrouped()) return null;
46
+
47
+ // An entry row: ✓ adds it (not under batch, where submitAll does), ✕
48
+ // discards the entry.
49
+ if (isNew) {
50
+ return (
51
+ <Group gap={0} wrap="nowrap">
52
+ {features.editMode !== "batch" && (
53
+ <ActionIcon
54
+ variant="subtle"
55
+ color="green"
56
+ size="sm"
57
+ tabIndex={tabIndex}
58
+ aria-label={labels.confirmNewRow}
59
+ data-dg-part="confirm-new-row"
60
+ data-row-id={rowId}
61
+ onClick={() => void edit.commit(rowId)}
62
+ >
63
+ <CheckIcon size={16} stroke={2} />
64
+ </ActionIcon>
65
+ )}
66
+ <ActionIcon
67
+ variant="subtle"
68
+ color="gray"
69
+ size="sm"
70
+ tabIndex={tabIndex}
71
+ aria-label={labels.discardNewRow}
72
+ data-dg-part="discard-new-row"
73
+ data-row-id={rowId}
74
+ onClick={() => edit.cancel(rowId)}
75
+ >
76
+ <CloseIcon size={16} stroke={1.6} />
77
+ </ActionIcon>
78
+ </Group>
79
+ );
80
+ }
81
+
82
+ // A batch deletion mark is a draft like any other — restorable in place.
83
+ if (isMarkedDeleted) {
84
+ return (
85
+ <ActionIcon
86
+ variant="subtle"
87
+ color="gray"
88
+ size="sm"
89
+ tabIndex={tabIndex}
90
+ aria-label={labels.restoreRow}
91
+ data-dg-part="restore-row"
92
+ data-row-id={rowId}
93
+ onClick={(event) => {
94
+ event.stopPropagation();
95
+ edit.deleteRow(rowId);
96
+ }}
97
+ >
98
+ <RestoreIcon size={16} stroke={1.6} />
99
+ </ActionIcon>
100
+ );
101
+ }
102
+
103
+ // The pencil means "this row edits"; a row that does not gets nothing.
104
+ if (!edit.canEditRow(row as never)) return null;
105
+
106
+ if (!isOpen) {
107
+ return (
108
+ <Group gap={0} wrap="nowrap">
109
+ {features.editMode === "row" && (
110
+ <ActionIcon
111
+ variant="subtle"
112
+ color="gray"
113
+ size="sm"
114
+ tabIndex={tabIndex}
115
+ aria-label={labels.editRow}
116
+ data-dg-part="edit-row"
117
+ data-row-id={rowId}
118
+ onClick={(event) => {
119
+ event.stopPropagation();
120
+ edit.begin({ rowId, columnId: null });
121
+ }}
122
+ >
123
+ <PencilIcon size={16} stroke={1.6} />
124
+ </ActionIcon>
125
+ )}
126
+ {edit.canDeleteRows() && (
127
+ <ActionIcon
128
+ variant="subtle"
129
+ color="gray"
130
+ size="sm"
131
+ tabIndex={tabIndex}
132
+ aria-label={labels.deleteRow}
133
+ data-dg-part="delete-row"
134
+ data-row-id={rowId}
135
+ onClick={(event) => {
136
+ event.stopPropagation();
137
+ edit.deleteRow(rowId);
138
+ }}
139
+ >
140
+ <TrashIcon size={16} stroke={1.6} />
141
+ </ActionIcon>
142
+ )}
143
+ </Group>
144
+ );
145
+ }
146
+
147
+ if (projection?.isSubmitting) {
148
+ return <Loader size="xs" aria-label={labels.loading} />;
149
+ }
150
+
151
+ const hasErrors =
152
+ projection !== undefined &&
153
+ (projection.hasRowError || projection.errorFields.length > 0);
154
+ const rowError = hasErrors
155
+ ? (firstErrorText(edit.getForm(rowId)?.state.errors ?? []) ??
156
+ labels.editRowErrors)
157
+ : null;
158
+
159
+ const save = (
160
+ <ActionIcon
161
+ variant="subtle"
162
+ color={hasErrors ? "red" : "green"}
163
+ size="sm"
164
+ tabIndex={tabIndex}
165
+ aria-label={labels.saveRow}
166
+ data-dg-part="save-row"
167
+ data-row-id={rowId}
168
+ onClick={(event) => {
169
+ event.stopPropagation();
170
+ void edit.commit(rowId);
171
+ }}
172
+ >
173
+ <CheckIcon size={16} stroke={2} />
174
+ </ActionIcon>
175
+ );
176
+
177
+ return (
178
+ <Group gap={0} wrap="nowrap">
179
+ {rowError === null ? (
180
+ save
181
+ ) : (
182
+ <Tooltip label={rowError} withArrow>
183
+ {save}
184
+ </Tooltip>
185
+ )}
186
+ <ActionIcon
187
+ variant="subtle"
188
+ color="gray"
189
+ size="sm"
190
+ tabIndex={tabIndex}
191
+ aria-label={labels.cancelRowEdit}
192
+ data-dg-part="cancel-row"
193
+ data-row-id={rowId}
194
+ onClick={(event) => {
195
+ event.stopPropagation();
196
+ edit.cancel(rowId);
197
+ }}
198
+ >
199
+ <CloseIcon size={16} stroke={1.6} />
200
+ </ActionIcon>
201
+ </Group>
202
+ );
203
+ }
204
+
205
+ /**
206
+ * The generated edit lane, appended and pinned right under
207
+ * `editMode: "row"` — the row's Save at the end of the row, mirroring the
208
+ * checkbox lane's build on the left.
209
+ */
210
+ export function createEditColumn<TData extends RowData>(
211
+ label = "Edit",
212
+ ): ColumnDef<TMDataGridFeatures, TData, unknown> {
213
+ return {
214
+ id: EDIT_COLUMN_ID,
215
+ meta: {
216
+ label,
217
+ align: "center",
218
+ enableOrdering: false,
219
+ },
220
+ // Wide enough for the Save/Cancel pair it holds while editing.
221
+ size: 64,
222
+ minSize: 64,
223
+ maxSize: 64,
224
+ enableResizing: false,
225
+ enableSorting: false,
226
+ enableColumnFilter: false,
227
+ enableGlobalFilter: false,
228
+ // Structurally pinned to the right; not movable.
229
+ enablePinning: false,
230
+ header: () => null,
231
+ cell: ({ row }) => <EditLaneCell row={row} />,
232
+ // Group rows: same reasoning as the checkbox lane — without this the
233
+ // cell renders blank on aggregated rows, but here blank is also correct,
234
+ // so the aggregated cell renders the same (null for groups).
235
+ aggregatedCell: ({ row }) => <EditLaneCell row={row} />,
236
+ };
237
+ }