@jielga/tmdatagrid 2.0.0-beta.0 → 2.0.0-beta.2

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 (52) hide show
  1. package/README.md +32 -34
  2. package/dist/index.d.ts +164 -102
  3. package/dist/index.js +2060 -1705
  4. package/dist/index.js.map +1 -1
  5. package/dist/styles.css +1 -1
  6. package/package.json +1 -1
  7. package/skills/appearance/SKILL.md +37 -34
  8. package/skills/cell-selection/SKILL.md +19 -19
  9. package/skills/columns/SKILL.md +9 -9
  10. package/skills/data/SKILL.md +53 -53
  11. package/skills/editing/SKILL.md +152 -311
  12. package/skills/editing/references/common-mistakes.md +177 -0
  13. package/skills/editing/references/editing-api.md +56 -28
  14. package/skills/editing/references/editors-and-validation.md +26 -19
  15. package/skills/filtering/SKILL.md +51 -50
  16. package/skills/getting-started/SKILL.md +10 -10
  17. package/skills/grouping/SKILL.md +41 -43
  18. package/skills/options/SKILL.md +4 -4
  19. package/skills/rows/SKILL.md +59 -59
  20. package/skills/rows/references/rows-api.md +7 -7
  21. package/skills/server-side/SKILL.md +1 -1
  22. package/skills/testing/SKILL.md +11 -10
  23. package/src/tmdatagrid/components/TMDataGrid.module.css +13 -1
  24. package/src/tmdatagrid/components/TMDataGridCellEditor.tsx +20 -8
  25. package/src/tmdatagrid/components/TMDataGridColumnsPanel.tsx +29 -18
  26. package/src/tmdatagrid/components/TMDataGridDetailsColumn.tsx +5 -8
  27. package/src/tmdatagrid/components/TMDataGridEditActions.tsx +2 -2
  28. package/src/tmdatagrid/components/TMDataGridEditColumn.tsx +246 -86
  29. package/src/tmdatagrid/components/TMDataGridEntryRows.tsx +176 -77
  30. package/src/tmdatagrid/components/TMDataGridHeaderCell.tsx +11 -6
  31. package/src/tmdatagrid/components/TMDataGridSelectColumn.tsx +9 -5
  32. package/src/tmdatagrid/components/TMDataGridTable.module.css +47 -3
  33. package/src/tmdatagrid/components/TMDataGridTable.tsx +101 -40
  34. package/src/tmdatagrid/components/editors/TMDataGridNumberEditor.tsx +1 -1
  35. package/src/tmdatagrid/components/icons.ts +1 -0
  36. package/src/tmdatagrid/core/autosize.ts +30 -6
  37. package/src/tmdatagrid/core/capabilities.ts +15 -5
  38. package/src/tmdatagrid/core/cellExport.ts +6 -7
  39. package/src/tmdatagrid/core/cellNavigation.ts +2 -2
  40. package/src/tmdatagrid/core/cellRange.ts +6 -6
  41. package/src/tmdatagrid/core/columnOrdering.ts +30 -1
  42. package/src/tmdatagrid/core/columnUtils.ts +14 -0
  43. package/src/tmdatagrid/core/draftCellContext.ts +68 -0
  44. package/src/tmdatagrid/core/editEngine.ts +111 -37
  45. package/src/tmdatagrid/core/filterOperators.ts +6 -6
  46. package/src/tmdatagrid/core/labels.ts +13 -1
  47. package/src/tmdatagrid/core/labelsSv.ts +4 -0
  48. package/src/tmdatagrid/core/matchHighlight.ts +3 -3
  49. package/src/tmdatagrid/core/persistence.ts +3 -3
  50. package/src/tmdatagrid/core/rowSelection.ts +3 -3
  51. package/src/tmdatagrid/index.ts +3 -1
  52. package/src/tmdatagrid/useTMDataGrid.tsx +141 -99
@@ -41,6 +41,11 @@
41
41
  }
42
42
 
43
43
  .root {
44
+ /* The frame's corner radius. Any length or Mantine radius token; `0` squares
45
+ the grid off against a card or a full-bleed page. Override it on the grid:
46
+ <TMDataGrid style={{ "--dg-radius": 0 }} />
47
+ The root clips its overflow, so the header and the last row follow it. */
48
+ --dg-radius: var(--mantine-radius-md);
44
49
  /* Fallbacks, so the parts still work if rendered outside a sized root. */
45
50
  --dg-row-height: 52px;
46
51
  --dg-header-height: 44px;
@@ -66,6 +71,13 @@
66
71
  var(--mantine-color-text) 3.5%,
67
72
  var(--mantine-color-body)
68
73
  );
74
+ /* A confirmed entry row under draft mode - entered, awaiting Save all. A
75
+ whisper of green over the body, one declaration for both themes. */
76
+ --dg-row-new-bg: color-mix(
77
+ in srgb,
78
+ var(--mantine-color-green-6) 8%,
79
+ var(--mantine-color-body)
80
+ );
69
81
 
70
82
  /* The stacking ladder, stated once. Body cells sit at auto (0); everything
71
83
  sticky stacks over them in this order, and a sticky row crossing a sticky
@@ -89,6 +101,6 @@
89
101
  from the host page would centre headers away from their cell content. */
90
102
  text-align: left;
91
103
  border: 1px solid var(--mantine-color-default-border);
92
- border-radius: var(--mantine-radius-md);
104
+ border-radius: var(--dg-radius);
93
105
  background-color: var(--mantine-color-body);
94
106
  }
@@ -43,7 +43,7 @@ const BUILT_IN_EDITORS: Record<
43
43
  export type TMDataGridCellEditorClose =
44
44
  | { committed: true; via: "enter" | "tab" | "shift-tab" | "pick" }
45
45
  | { committed: false; via: "escape" }
46
- /** Batch: the editor closed, the draft stays - Enter parks, Tab moves on. */
46
+ /** Draft: the editor closed, the draft stays - Enter parks, Tab moves on. */
47
47
  | { committed: false; via: "defer" | "defer-tab" | "defer-shift-tab" };
48
48
 
49
49
  /**
@@ -59,6 +59,7 @@ export function TMDataGridCellEditor({
59
59
  row,
60
60
  takeSeedText,
61
61
  onClose,
62
+ inEntryBlock = false,
62
63
  }: {
63
64
  cell: Cell<TMDataGridFeatures, TMDataGridRowData, unknown>;
64
65
  row: Row<TMDataGridFeatures, TMDataGridRowData>;
@@ -66,6 +67,13 @@ export function TMDataGridCellEditor({
66
67
  takeSeedText: () => string | undefined;
67
68
  /** After the editor closed itself - the table moves the focus. */
68
69
  onClose: (args: TMDataGridCellEditorClose) => void;
70
+ /**
71
+ * Hosted by the entry block rather than a body cell. Under draft the keys
72
+ * change meaning there: Enter confirms the whole entry (the engine parks
73
+ * it), and Tab stays the browser's, walking the entry row's own inputs the
74
+ * way row mode's do.
75
+ */
76
+ inEntryBlock?: boolean;
69
77
  }) {
70
78
  const { table, edit, features, labels, controlSize } = useTMDataGridContext();
71
79
  // Row mode mounts one host per editable cell of the row; every key that
@@ -164,7 +172,7 @@ export function TMDataGridCellEditor({
164
172
  onClose({ committed: false, via: "escape" });
165
173
  };
166
174
 
167
- /** Batch: the key closes the editor and leaves the draft for submitAll. */
175
+ /** Draft: the key closes the editor and leaves the draft for submitAll. */
168
176
  const deferAndClose = (via: "defer" | "defer-tab" | "defer-shift-tab") => {
169
177
  if (closingRef.current) return;
170
178
  closingRef.current = true;
@@ -173,23 +181,27 @@ export function TMDataGridCellEditor({
173
181
  };
174
182
 
175
183
  const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
176
- const isBatch = features.editMode === "batch";
184
+ const isDraft = features.editMode === "draft";
177
185
  if (event.key === "Enter") {
178
186
  event.preventDefault();
179
187
  event.stopPropagation();
180
188
  // In row mode Enter (and Ctrl+Enter, the documented pair) saves the
181
- // row; in batch nothing commits until submitAll, so Enter just parks
182
- // the draft.
183
- if (isBatch) deferAndClose("defer");
189
+ // row; in draft nothing reaches a callback until submitAll, so Enter
190
+ // parks the draft - and in the entry block it confirms the entry, which
191
+ // the engine parks the same way.
192
+ if (isDraft && !inEntryBlock) deferAndClose("defer");
184
193
  else void commitAndClose("enter");
185
194
  } else if (event.key === "Escape") {
186
195
  event.preventDefault();
187
196
  event.stopPropagation();
188
197
  cancelAndClose();
189
- } else if (event.key === "Tab" && !isRowMode) {
198
+ } else if (event.key === "Tab" && !isRowMode && !(isDraft && inEntryBlock)) {
199
+ // In the draft entry block Tab stays the browser's: every editable
200
+ // cell's editor is mounted, so it walks the row's inputs as row mode's
201
+ // does.
190
202
  event.preventDefault();
191
203
  event.stopPropagation();
192
- if (isBatch) {
204
+ if (isDraft) {
193
205
  deferAndClose(event.shiftKey ? "defer-shift-tab" : "defer-tab");
194
206
  } else {
195
207
  void commitAndClose(event.shiftKey ? "shift-tab" : "tab");
@@ -11,8 +11,6 @@ import { useState } from "react";
11
11
  import classes from "./TMDataGridColumnsPanel.module.css";
12
12
  import { useTMDataGridContext } from "../TMDataGridContext";
13
13
  import { getColumnLabel } from "../core/columnUtils";
14
- import { GROUP_COLUMN_ID } from "./TMDataGridGroupColumn";
15
- import { ROW_NUMBER_COLUMN_ID } from "./TMDataGridRowNumberColumn";
16
14
  import { SearchIcon } from "./icons";
17
15
 
18
16
  /**
@@ -29,16 +27,15 @@ export function TMDataGridColumnsPanel() {
29
27
  (state) => state.columnVisibility,
30
28
  );
31
29
 
32
- // The tree column is left out rather than listed and disabled: its visibility
33
- // is not a setting at all, it follows the grouping state, so an unchecked box
34
- // that cannot be ticked would only invite the question. The row-number
35
- // gutter follows `enableRowNumbers` the same way.
30
+ // Only what can actually be hidden. A column with `enableHiding: false` is
31
+ // left out rather than listed and disabled: a box that cannot be ticked only
32
+ // invites the question, and every generated lane is one - the checkbox and
33
+ // edit lanes hold the controls the grid needs, the tree column follows the
34
+ // grouping state, the row-number gutter follows `enableRowNumbers`. None of
35
+ // them is a setting.
36
36
  const columns = table
37
37
  .getAllLeafColumns()
38
- .filter(
39
- (column) =>
40
- column.id !== GROUP_COLUMN_ID && column.id !== ROW_NUMBER_COLUMN_ID,
41
- );
38
+ .filter((column) => column.getCanHide());
42
39
  const needle = search.trim().toLowerCase();
43
40
  const visibleInPanel = needle
44
41
  ? columns.filter((column) =>
@@ -46,11 +43,28 @@ export function TMDataGridColumnsPanel() {
46
43
  )
47
44
  : columns;
48
45
 
49
- const hideableColumns = columns.filter((column) => column.getCanHide());
50
- const shownCount = hideableColumns.filter(
46
+ const shownCount = columns.filter(
51
47
  (column) => columnVisibility[column.id] !== false,
52
48
  ).length;
53
49
 
50
+ /**
51
+ * Show or hide every listed column.
52
+ *
53
+ * Not `table.toggleAllColumnsVisible`, which writes a visibility entry for
54
+ * *every* leaf column: showing all would publish the tree column - hidden
55
+ * because nothing is grouped, not because the user hid it - and hiding all
56
+ * would force the same column visible, since it writes `!getCanHide()` for
57
+ * the columns it will not touch. Either way a lane the panel never listed
58
+ * changes state, and persistence then keeps it that way.
59
+ */
60
+ const setAllVisible = (visible: boolean) => {
61
+ table.setColumnVisibility((previous) => {
62
+ const next = { ...previous };
63
+ for (const column of columns) next[column.id] = visible;
64
+ return next;
65
+ });
66
+ };
67
+
54
68
  return (
55
69
  <div data-dg-part="columns-panel" className={classes.columnsPanel}>
56
70
  <div className={classes.columnsPanelSearch}>
@@ -74,7 +88,6 @@ export function TMDataGridColumnsPanel() {
74
88
  label={getColumnLabel(column)}
75
89
  data-dg-part="columns-toggle"
76
90
  data-column-id={column.id}
77
- disabled={!column.getCanHide()}
78
91
  checked={columnVisibility[column.id] !== false}
79
92
  onChange={(event) =>
80
93
  column.toggleVisibility(event.currentTarget.checked)
@@ -94,11 +107,9 @@ export function TMDataGridColumnsPanel() {
94
107
  size={controlSize}
95
108
  label={labels.columnsShowHideAll}
96
109
  data-dg-part="columns-toggle-all"
97
- checked={shownCount === hideableColumns.length}
98
- indeterminate={shownCount > 0 && shownCount < hideableColumns.length}
99
- onChange={(event) =>
100
- table.toggleAllColumnsVisible(event.currentTarget.checked)
101
- }
110
+ checked={shownCount === columns.length}
111
+ indeterminate={shownCount > 0 && shownCount < columns.length}
112
+ onChange={(event) => setAllVisible(event.currentTarget.checked)}
102
113
  />
103
114
  {/* The whole layout, not only visibility: one reset with an honest
104
115
  scope, stated in the tooltip. `table.resetColumnVisibility()` would
@@ -117,16 +117,13 @@ function DetailsHeader<TData extends RowData>({
117
117
  * The generated details lane, prepended whenever `renderDetails` is set.
118
118
  *
119
119
  * Structural, like the checkbox and tree columns: fixed width, pinned to the
120
- * left after both of them, not hideable, not movable and not resizable. A
121
- * toggle that wandered off to the right of the grid (or hid itself) would
122
- * leave rows with panels no one can open.
120
+ * left after both of them, not hideable, not movable and not resizable. Moving
121
+ * or hiding the toggle would leave rows with panels that cannot be opened.
123
122
  *
124
- * Last of the three because it acts on one record: the checkbox picks rows out
125
- * and the tree says which group they are in, and only then is there a row to
126
- * open.
123
+ * Last of the three because it acts on a single record.
127
124
  *
128
- * Nothing stops a second toggle elsewhere: `row.toggleExpanded()` is the whole
129
- * interface, and this lane is only the one the grid ships.
125
+ * A second toggle elsewhere is supported: `row.toggleExpanded()` is the entire
126
+ * interface, and this lane is only the control the grid ships.
130
127
  */
131
128
  export function createDetailsColumn<TData extends RowData>(
132
129
  label = "Details",
@@ -120,9 +120,9 @@ export type TMDataGridEditActionsProps = {
120
120
  };
121
121
 
122
122
  /**
123
- * Batch mode's toolbar chrome: Save with the dirty-row count, and Discard.
123
+ * Draft mode's toolbar chrome: Save with the dirty-row count, and Discard.
124
124
  * Both read the edit store, so they grey out while nothing is dirty and the
125
- * Save spins while a submit is in flight. Works under any `editMode` - a
125
+ * Save spins while a submit is in flight. Works under any `editing.mode` - a
126
126
  * cellConfirm grid accumulating drafts can offer the same pair - and renders
127
127
  * nothing while editing is off.
128
128
  *
@@ -1,6 +1,7 @@
1
1
  import { ActionIcon, Group, Loader, Tooltip } from "@mantine/core";
2
2
  import { useSelector } from "@tanstack/react-store";
3
3
  import type { ColumnDef, Row, RowData } from "@tanstack/react-table";
4
+ import classes from "./TMDataGridTable.module.css";
4
5
  import {
5
6
  useCellControlTabIndex,
6
7
  useTMDataGridContext,
@@ -11,6 +12,7 @@ import {
11
12
  CheckIcon,
12
13
  CloseIcon,
13
14
  PencilIcon,
15
+ PlusIcon,
14
16
  RestoreIcon,
15
17
  TrashIcon,
16
18
  } from "./icons";
@@ -18,10 +20,49 @@ import {
18
20
  export const EDIT_COLUMN_ID = "__edit__";
19
21
 
20
22
  /**
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.
23
+ * Draft mode's change marker in the lane - what kind of uncommitted change
24
+ * the row carries. Non-interactive; the buttons beside it act. `data-state`
25
+ * is the styling hook, `data-error` flips it red and puts the message in the
26
+ * tooltip in place of the state's name.
27
+ */
28
+ function RowStateIndicator({
29
+ state,
30
+ rowId,
31
+ label,
32
+ error,
33
+ }: {
34
+ state: "new" | "edited" | "deleted";
35
+ rowId: string;
36
+ label: string;
37
+ error: string | null;
38
+ }) {
39
+ const Icon =
40
+ state === "new" ? PlusIcon : state === "edited" ? PencilIcon : TrashIcon;
41
+ return (
42
+ <Tooltip label={error ?? label} withArrow>
43
+ <span
44
+ role="img"
45
+ aria-label={label}
46
+ data-dg-part="row-state"
47
+ data-state={state}
48
+ data-row-id={rowId}
49
+ data-error={error !== null || undefined}
50
+ className={classes.rowStateIndicator}
51
+ >
52
+ <Icon size={16} stroke={1.6} />
53
+ </span>
54
+ </Tooltip>
55
+ );
56
+ }
57
+
58
+ /**
59
+ * One row's slot in the edit lane, driven off the edit store. Outside draft
60
+ * mode: a pencil while idle, Save/Cancel while the row's form is open, a
61
+ * loader while its commit is in flight; a row error turns the Save red with
62
+ * the message in its tooltip - the pathless `.refine()` has nowhere else to
63
+ * land. Under draft mode the lane is the change marker and the per-row
64
+ * revert instead: a state icon (new/edited/deleted) beside undo, discard or
65
+ * restore - never a per-row save, which the engine would only park anyway.
25
66
  */
26
67
  function EditLaneCell<TData extends RowData>({
27
68
  row,
@@ -38,18 +79,97 @@ function EditLaneCell<TData extends RowData>({
38
79
  const isNew = useSelector(edit.store, (state) =>
39
80
  state.newRows.some((newRow) => newRow.tempId === rowId),
40
81
  );
82
+ const isConfirmedNew = useSelector(
83
+ edit.store,
84
+ (state) =>
85
+ state.newRows.find((newRow) => newRow.tempId === rowId)?.confirmed ===
86
+ true,
87
+ );
41
88
  const isMarkedDeleted = useSelector(edit.store, (state) =>
42
89
  state.deletedRowIds.includes(rowId),
43
90
  );
44
91
 
45
92
  if (row.getIsGrouped()) return null;
46
93
 
47
- // An entry row: ✓ adds it (not under batch, where submitAll does), ✕
48
- // discards the entry.
94
+ const isDraftMode = features.editMode === "draft";
95
+ const hasErrors =
96
+ projection !== undefined &&
97
+ (projection.hasRowError || projection.errorFields.length > 0);
98
+ const rowError = hasErrors
99
+ ? (firstErrorText(edit.getForm(rowId)?.state.errors ?? []) ??
100
+ labels.editRowErrors)
101
+ : null;
102
+
103
+ const trash = edit.canDeleteRows() ? (
104
+ <Tooltip label={labels.deleteRow} withArrow>
105
+ <ActionIcon
106
+ variant="subtle"
107
+ color="gray"
108
+ size="sm"
109
+ tabIndex={tabIndex}
110
+ aria-label={labels.deleteRow}
111
+ data-dg-part="delete-row"
112
+ data-row-id={rowId}
113
+ onClick={(event) => {
114
+ event.stopPropagation();
115
+ edit.deleteRow(rowId);
116
+ }}
117
+ >
118
+ <TrashIcon size={16} stroke={1.6} />
119
+ </ActionIcon>
120
+ </Tooltip>
121
+ ) : null;
122
+
123
+ // An entry row: ✓ enters it - an add under the immediate modes, a parked
124
+ // confirm under draft - and ✕ discards the entry. A confirmed entry waits
125
+ // for Save all as a value row: marked new, re-openable, still discardable.
49
126
  if (isNew) {
127
+ if (isConfirmedNew) {
128
+ return (
129
+ <Group gap={0} wrap="nowrap">
130
+ <RowStateIndicator
131
+ state="new"
132
+ rowId={rowId}
133
+ label={labels.rowStateNew}
134
+ error={rowError}
135
+ />
136
+ <Tooltip label={labels.editRow} withArrow>
137
+ <ActionIcon
138
+ variant="subtle"
139
+ color="gray"
140
+ size="sm"
141
+ tabIndex={tabIndex}
142
+ aria-label={labels.editRow}
143
+ data-dg-part="edit-row"
144
+ data-row-id={rowId}
145
+ onClick={(event) => {
146
+ event.stopPropagation();
147
+ edit.begin({ rowId, columnId: null });
148
+ }}
149
+ >
150
+ <PencilIcon size={16} stroke={1.6} />
151
+ </ActionIcon>
152
+ </Tooltip>
153
+ <Tooltip label={labels.discardNewRow} withArrow>
154
+ <ActionIcon
155
+ variant="subtle"
156
+ color="gray"
157
+ size="sm"
158
+ tabIndex={tabIndex}
159
+ aria-label={labels.discardNewRow}
160
+ data-dg-part="discard-new-row"
161
+ data-row-id={rowId}
162
+ onClick={() => edit.cancel(rowId)}
163
+ >
164
+ <CloseIcon size={16} stroke={1.6} />
165
+ </ActionIcon>
166
+ </Tooltip>
167
+ </Group>
168
+ );
169
+ }
50
170
  return (
51
171
  <Group gap={0} wrap="nowrap">
52
- {features.editMode !== "batch" && (
172
+ <Tooltip label={labels.confirmNewRow} withArrow>
53
173
  <ActionIcon
54
174
  variant="subtle"
55
175
  color="green"
@@ -62,84 +182,121 @@ function EditLaneCell<TData extends RowData>({
62
182
  >
63
183
  <CheckIcon size={16} stroke={2} />
64
184
  </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>
185
+ </Tooltip>
186
+ <Tooltip label={labels.discardNewRow} withArrow>
187
+ <ActionIcon
188
+ variant="subtle"
189
+ color="gray"
190
+ size="sm"
191
+ tabIndex={tabIndex}
192
+ aria-label={labels.discardNewRow}
193
+ data-dg-part="discard-new-row"
194
+ data-row-id={rowId}
195
+ onClick={() => edit.cancel(rowId)}
196
+ >
197
+ <CloseIcon size={16} stroke={1.6} />
198
+ </ActionIcon>
199
+ </Tooltip>
78
200
  </Group>
79
201
  );
80
202
  }
81
203
 
82
- // A batch deletion mark is a draft like any other - restorable in place.
204
+ // A draft-mode deletion mark is a draft like any other - restorable in place.
83
205
  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
206
  return (
108
207
  <Group gap={0} wrap="nowrap">
109
- {features.editMode === "row" && (
208
+ <RowStateIndicator
209
+ state="deleted"
210
+ rowId={rowId}
211
+ label={labels.rowStateDeleted}
212
+ error={null}
213
+ />
214
+ <Tooltip label={labels.restoreRow} withArrow>
110
215
  <ActionIcon
111
216
  variant="subtle"
112
217
  color="gray"
113
218
  size="sm"
114
219
  tabIndex={tabIndex}
115
- aria-label={labels.editRow}
116
- data-dg-part="edit-row"
220
+ aria-label={labels.restoreRow}
221
+ data-dg-part="restore-row"
117
222
  data-row-id={rowId}
118
223
  onClick={(event) => {
119
224
  event.stopPropagation();
120
- edit.begin({ rowId, columnId: null });
225
+ edit.deleteRow(rowId);
121
226
  }}
122
227
  >
123
- <PencilIcon size={16} stroke={1.6} />
228
+ <RestoreIcon size={16} stroke={1.6} />
124
229
  </ActionIcon>
125
- )}
126
- {edit.canDeleteRows() && (
230
+ </Tooltip>
231
+ </Group>
232
+ );
233
+ }
234
+
235
+ // The pencil means "this row edits"; a row that does not gets nothing.
236
+ if (!edit.canEditRow(row as never)) return null;
237
+
238
+ // Draft mode never offers a per-row save - the engine would only park it,
239
+ // and Save all is the commit. The lane is the state marker and the revert.
240
+ // No trash beside the revert: the two undo-shaped icons were too easy to
241
+ // mistake for one another, so a dirty row reverts first, then deletes.
242
+ if (isDraftMode) {
243
+ if (projection?.isSubmitting) {
244
+ return <Loader size="xs" aria-label={labels.loading} />;
245
+ }
246
+ const isDirty = (projection?.dirtyFields.length ?? 0) > 0;
247
+ if (!isDirty) return trash;
248
+ return (
249
+ <Group gap={0} wrap="nowrap">
250
+ <RowStateIndicator
251
+ state="edited"
252
+ rowId={rowId}
253
+ label={labels.rowStateEdited}
254
+ error={rowError}
255
+ />
256
+ <Tooltip label={labels.revertRow} withArrow>
127
257
  <ActionIcon
128
258
  variant="subtle"
129
259
  color="gray"
130
260
  size="sm"
131
261
  tabIndex={tabIndex}
132
- aria-label={labels.deleteRow}
133
- data-dg-part="delete-row"
262
+ aria-label={labels.revertRow}
263
+ data-dg-part="revert-row"
134
264
  data-row-id={rowId}
135
265
  onClick={(event) => {
136
266
  event.stopPropagation();
137
- edit.deleteRow(rowId);
267
+ edit.cancel(rowId);
138
268
  }}
139
269
  >
140
- <TrashIcon size={16} stroke={1.6} />
270
+ <RestoreIcon size={16} stroke={1.6} />
141
271
  </ActionIcon>
272
+ </Tooltip>
273
+ </Group>
274
+ );
275
+ }
276
+
277
+ if (!isOpen) {
278
+ return (
279
+ <Group gap={0} wrap="nowrap">
280
+ {features.editMode === "row" && (
281
+ <Tooltip label={labels.editRow} withArrow>
282
+ <ActionIcon
283
+ variant="subtle"
284
+ color="gray"
285
+ size="sm"
286
+ tabIndex={tabIndex}
287
+ aria-label={labels.editRow}
288
+ data-dg-part="edit-row"
289
+ data-row-id={rowId}
290
+ onClick={(event) => {
291
+ event.stopPropagation();
292
+ edit.begin({ rowId, columnId: null });
293
+ }}
294
+ >
295
+ <PencilIcon size={16} stroke={1.6} />
296
+ </ActionIcon>
297
+ </Tooltip>
142
298
  )}
299
+ {trash}
143
300
  </Group>
144
301
  );
145
302
  }
@@ -148,14 +305,6 @@ function EditLaneCell<TData extends RowData>({
148
305
  return <Loader size="xs" aria-label={labels.loading} />;
149
306
  }
150
307
 
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
308
  const save = (
160
309
  <ActionIcon
161
310
  variant="subtle"
@@ -177,39 +326,46 @@ function EditLaneCell<TData extends RowData>({
177
326
  return (
178
327
  <Group gap={0} wrap="nowrap">
179
328
  {rowError === null ? (
180
- save
329
+ <Tooltip label={labels.saveRow} withArrow>
330
+ {save}
331
+ </Tooltip>
181
332
  ) : (
182
333
  <Tooltip label={rowError} withArrow>
183
334
  {save}
184
335
  </Tooltip>
185
336
  )}
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>
337
+ <Tooltip label={labels.cancelRowEdit} withArrow>
338
+ <ActionIcon
339
+ variant="subtle"
340
+ color="gray"
341
+ size="sm"
342
+ tabIndex={tabIndex}
343
+ aria-label={labels.cancelRowEdit}
344
+ data-dg-part="cancel-row"
345
+ data-row-id={rowId}
346
+ onClick={(event) => {
347
+ event.stopPropagation();
348
+ edit.cancel(rowId);
349
+ }}
350
+ >
351
+ <CloseIcon size={16} stroke={1.6} />
352
+ </ActionIcon>
353
+ </Tooltip>
201
354
  </Group>
202
355
  );
203
356
  }
204
357
 
205
358
  /**
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.
359
+ * The generated edit lane, appended and pinned right - the row's Save at the
360
+ * end of the row under `mode: "row"`, the state marker and revert under
361
+ * `mode: "draft"`, mirroring the checkbox lane's build on the left.
209
362
  */
210
363
  export function createEditColumn<TData extends RowData>(
211
364
  label = "Edit",
365
+ /** Draft mode holds three controls where the others hold two. */
366
+ wide = false,
212
367
  ): ColumnDef<TMDataGridFeatures, TData, unknown> {
368
+ const width = wide ? 88 : 64;
213
369
  return {
214
370
  id: EDIT_COLUMN_ID,
215
371
  meta: {
@@ -217,14 +373,18 @@ export function createEditColumn<TData extends RowData>(
217
373
  align: "center",
218
374
  enableOrdering: false,
219
375
  },
220
- // Wide enough for the Save/Cancel pair it holds while editing.
221
- size: 64,
222
- minSize: 64,
223
- maxSize: 64,
376
+ // Wide enough for the pair (or draft's trio) it holds while editing.
377
+ size: width,
378
+ minSize: width,
379
+ maxSize: width,
224
380
  enableResizing: false,
225
381
  enableSorting: false,
226
382
  enableColumnFilter: false,
227
383
  enableGlobalFilter: false,
384
+ // The row's Save, Cancel and Delete live here, so hiding the lane would
385
+ // strand an open row with no way to commit or discard it. Same rule as the
386
+ // checkbox lane: chrome the grid generates is not a user setting.
387
+ enableHiding: false,
228
388
  // Structurally pinned to the right; not movable.
229
389
  enablePinning: false,
230
390
  header: () => null,