@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
@@ -53,7 +53,7 @@ function isEntryCellEditable(
53
53
  * every other draft, keyed by their temporary ids.
54
54
  *
55
55
  * Enter commits the entry (`onRowAdd`) under the immediate modes and parks
56
- * it under batch, where `submitAll` reports it in `added`; Escape discards
56
+ * it under draft, where `submitAll` reports it in `added`; Escape discards
57
57
  * it. The edit lane, when present, offers the same pair as buttons.
58
58
  */
59
59
  export function TMDataGridEntryRows({
@@ -65,11 +65,23 @@ export function TMDataGridEntryRows({
65
65
  layoutFor: (columnId: string) => TMDataGridColumnLayout;
66
66
  rowHeight: number;
67
67
  }) {
68
- const { table, edit } = useTMDataGridContext();
68
+ const { table, edit, features } = useTMDataGridContext();
69
69
  const newRows = useSelector(edit.store, (state) => state.newRows);
70
+ // The reopen gesture: `begin` on a confirmed entry row flips it back to
71
+ // editors and names the cell double-clicked - where the caret goes.
72
+ const activeEntry = useSelector(edit.store, (state) =>
73
+ state.active !== null &&
74
+ state.newRows.some(
75
+ (newRow) => newRow.tempId === state.active?.rowId && !newRow.confirmed,
76
+ )
77
+ ? state.active
78
+ : null,
79
+ );
70
80
  const blockRef = useRef<HTMLDivElement>(null);
71
81
  /** The entry row the caret has already been placed in. */
72
82
  const focusedTempIdRef = useRef<string | null>(null);
83
+ /** The reopen target (`rowId:columnId`) the caret has already landed in. */
84
+ const focusedActiveRef = useRef<string | null>(null);
73
85
 
74
86
  /**
75
87
  * The caret goes into a row the moment `edit.addRow()` opens it, landing in
@@ -96,14 +108,44 @@ export function TMDataGridEntryRows({
96
108
  focusEditorContent(editor);
97
109
  });
98
110
 
99
- // The seed values, frozen at addRow: the live values belong to the forms,
100
- // which the editors read directly - this table only provides row and cell
101
- // identity.
111
+ /**
112
+ * The caret for a reopen: `begin` on a confirmed row re-arms its editors
113
+ * and the double-clicked cell's editor should hold the caret, not the
114
+ * row's first. Keyed separately from the add effect above - the row was
115
+ * already focused once when it was added.
116
+ */
117
+ useLayoutEffect(() => {
118
+ if (activeEntry === null) {
119
+ focusedActiveRef.current = null;
120
+ return;
121
+ }
122
+ const key = `${activeEntry.rowId}:${activeEntry.columnId ?? ""}`;
123
+ if (focusedActiveRef.current === key) return;
124
+ const block = blockRef.current;
125
+ if (block === null) return;
126
+ const editor = block.querySelector<HTMLElement>(
127
+ activeEntry.columnId !== null
128
+ ? `[data-dg-part="editor"][data-row-id="${CSS.escape(activeEntry.rowId)}"][data-column-id="${CSS.escape(activeEntry.columnId)}"]`
129
+ : `[data-dg-part="editor"][data-row-id="${CSS.escape(activeEntry.rowId)}"]`,
130
+ );
131
+ // Not mounted yet; the next render tries again, as the add effect does.
132
+ if (editor === null) return;
133
+ focusedActiveRef.current = key;
134
+ focusEditorContent(editor);
135
+ });
136
+
137
+ // Unconfirmed rows keep the seed values frozen at addRow - the live values
138
+ // belong to the forms, which the editors read directly, and this table only
139
+ // provides row and cell identity. A confirmed row's cells render values, so
140
+ // there the draft itself is the row; the memo recomputes on every
141
+ // confirm/reopen because either flips `newRows`' identity.
102
142
  const data = useMemo(
103
143
  () =>
104
144
  newRows.map(
105
- ({ tempId }) =>
106
- (edit.getForm(tempId)?.options.defaultValues ??
145
+ ({ tempId, confirmed }) =>
146
+ ((confirmed
147
+ ? edit.getForm(tempId)?.state.values
148
+ : edit.getForm(tempId)?.options.defaultValues) ??
107
149
  {}) as TMDataGridRowData,
108
150
  ),
109
151
  [newRows, edit],
@@ -119,77 +161,134 @@ export function TMDataGridEntryRows({
119
161
  if (newRows.length === 0) return null;
120
162
 
121
163
  const entryRows = entryTable.getCoreRowModel().rows;
164
+ const confirmedById = new Map(
165
+ newRows.map((newRow) => [newRow.tempId, newRow.confirmed]),
166
+ );
167
+ // A row being *typed* into is always sticky - it exists nowhere else to
168
+ // scroll back to. A confirmed row joins the scrolling flow unless
169
+ // `newRowsSticky` keeps it pinned, so entering many rows cannot fill the
170
+ // viewport with sticky chrome.
171
+ const stickyRows = entryRows.filter(
172
+ (entryRow) =>
173
+ features.editNewRowsSticky || confirmedById.get(entryRow.id) !== true,
174
+ );
175
+ const flowRows = features.editNewRowsSticky
176
+ ? []
177
+ : entryRows.filter((entryRow) => confirmedById.get(entryRow.id) === true);
178
+
179
+ const renderEntryRow = (
180
+ entryRow: (typeof entryRows)[number],
181
+ pinnedZ: string,
182
+ ) => {
183
+ const cellsById = new Map(
184
+ entryRow.getAllCells().map((cell) => [cell.column.id, cell]),
185
+ );
186
+ const confirmed = confirmedById.get(entryRow.id) === true;
187
+ return (
188
+ <div
189
+ key={entryRow.id}
190
+ role="row"
191
+ data-dg-part="entry-row"
192
+ data-row-id={entryRow.id}
193
+ data-new
194
+ data-confirmed={confirmed}
195
+ className={classes.entryRow}
196
+ >
197
+ {orderedColumns.map((column) => {
198
+ const cell = cellsById.get(column.id);
199
+ const layout = layoutFor(column.id);
200
+ const editable =
201
+ cell !== undefined && isEntryCellEditable(entryRow, column);
202
+ return (
203
+ <div
204
+ key={column.id}
205
+ role="cell"
206
+ data-column-id={column.id}
207
+ data-align={getColumnAlign(column)}
208
+ data-control-column={isControlColumn(column.id)}
209
+ // A confirmed row re-opens where it is double-clicked, the
210
+ // same gesture a body cell answers.
211
+ onDoubleClick={
212
+ confirmed && !isControlColumn(column.id)
213
+ ? () =>
214
+ edit.begin({
215
+ rowId: entryRow.id,
216
+ columnId: editable ? column.id : null,
217
+ })
218
+ : undefined
219
+ }
220
+ className={[
221
+ classes.entryCell,
222
+ layout.isBoundary && layout.pinnedAt === "left"
223
+ ? sticky.stickyLeft
224
+ : "",
225
+ layout.isBoundary && layout.pinnedAt === "right"
226
+ ? sticky.stickyRight
227
+ : "",
228
+ ]
229
+ .filter(Boolean)
230
+ .join(" ")}
231
+ style={{
232
+ minHeight: rowHeight,
233
+ left: layout.pinnedAt === "left" ? layout.offset : undefined,
234
+ right: layout.pinnedAt === "right" ? layout.offset : undefined,
235
+ position: layout.pinnedAt ? "sticky" : undefined,
236
+ zIndex: layout.pinnedAt ? pinnedZ : undefined,
237
+ }}
238
+ >
239
+ {cell !== undefined && column.id === EDIT_COLUMN_ID ? (
240
+ // The lane's cell - the entry row's controls.
241
+ flexRender(cell.column.columnDef.cell, cell.getContext())
242
+ ) : confirmed && cell !== undefined ? (
243
+ // Entered, awaiting Save all: a value row through the
244
+ // columns' own renderers, over the draft the memo above
245
+ // fed this table.
246
+ <span className={classes.cellContent}>
247
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
248
+ </span>
249
+ ) : editable && cell !== undefined ? (
250
+ <TMDataGridCellEditor
251
+ cell={cell}
252
+ row={entryRow}
253
+ takeSeedText={() => undefined}
254
+ onClose={() => {}}
255
+ inEntryBlock
256
+ />
257
+ ) : null}
258
+ </div>
259
+ );
260
+ })}
261
+ </div>
262
+ );
263
+ };
122
264
 
123
265
  return (
124
- <div
125
- ref={blockRef}
126
- role="rowgroup"
127
- data-dg-entry-block
128
- className={classes.entryBlock}
129
- >
130
- {entryRows.map((entryRow) => {
131
- const cellsById = new Map(
132
- entryRow.getAllCells().map((cell) => [cell.column.id, cell]),
133
- );
134
- return (
135
- <div
136
- key={entryRow.id}
137
- role="row"
138
- data-dg-part="entry-row"
139
- data-row-id={entryRow.id}
140
- className={classes.entryRow}
141
- >
142
- {orderedColumns.map((column) => {
143
- const cell = cellsById.get(column.id);
144
- const layout = layoutFor(column.id);
145
- const editable =
146
- cell !== undefined && isEntryCellEditable(entryRow, column);
147
- return (
148
- <div
149
- key={column.id}
150
- role="cell"
151
- data-column-id={column.id}
152
- data-align={getColumnAlign(column)}
153
- data-control-column={isControlColumn(column.id)}
154
- className={[
155
- classes.entryCell,
156
- layout.isBoundary && layout.pinnedAt === "left"
157
- ? sticky.stickyLeft
158
- : "",
159
- layout.isBoundary && layout.pinnedAt === "right"
160
- ? sticky.stickyRight
161
- : "",
162
- ]
163
- .filter(Boolean)
164
- .join(" ")}
165
- style={{
166
- minHeight: rowHeight,
167
- left: layout.pinnedAt === "left" ? layout.offset : undefined,
168
- right:
169
- layout.pinnedAt === "right" ? layout.offset : undefined,
170
- position: layout.pinnedAt ? "sticky" : undefined,
171
- zIndex: layout.pinnedAt
172
- ? "var(--dg-z-pinned-row-pinned-cell, 5)"
173
- : undefined,
174
- }}
175
- >
176
- {editable && cell !== undefined ? (
177
- <TMDataGridCellEditor
178
- cell={cell}
179
- row={entryRow}
180
- takeSeedText={() => undefined}
181
- onClose={() => {}}
182
- />
183
- ) : cell !== undefined && column.id === EDIT_COLUMN_ID ? (
184
- // The lane's cell - the entry row's ✓/✕ pair.
185
- flexRender(cell.column.columnDef.cell, cell.getContext())
186
- ) : null}
187
- </div>
188
- );
189
- })}
190
- </div>
191
- );
192
- })}
193
- </div>
266
+ <>
267
+ {stickyRows.length > 0 && (
268
+ <div
269
+ ref={blockRef}
270
+ role="rowgroup"
271
+ data-dg-entry-block
272
+ className={classes.entryBlock}
273
+ >
274
+ {stickyRows.map((entryRow) =>
275
+ renderEntryRow(entryRow, "var(--dg-z-pinned-row-pinned-cell, 5)"),
276
+ )}
277
+ </div>
278
+ )}
279
+ {flowRows.length > 0 && (
280
+ <div
281
+ role="rowgroup"
282
+ data-dg-entry-flow-block
283
+ className={classes.entryFlowBlock}
284
+ >
285
+ {/* In flow, so a pinned cell stacks like a body row's, under the
286
+ sticky blocks it scrolls past. */}
287
+ {flowRows.map((entryRow) =>
288
+ renderEntryRow(entryRow, "var(--dg-z-pinned-cell, 2)"),
289
+ )}
290
+ </div>
291
+ )}
292
+ </>
194
293
  );
195
294
  }
@@ -15,6 +15,7 @@ import { autosizeColumn } from "../core/autosize";
15
15
  import {
16
16
  getColumnRegion,
17
17
  getStepTargetColumn,
18
+ keepGeneratedColumnsOutermost,
18
19
  moveColumn,
19
20
  moveColumnByStep,
20
21
  type TMDataGridDropSide,
@@ -238,6 +239,10 @@ export function TMDataGridHeaderCell({
238
239
  }
239
240
  }
240
241
  column.pin(position);
242
+ // `pin("right")` appends, which would leave the column outside the edit
243
+ // lane. Normalising after the pin rather than writing the lane by hand
244
+ // keeps TanStack's own handling of a header group's leaves.
245
+ table.setColumnPinning(keepGeneratedColumnsOutermost);
241
246
  }
242
247
 
243
248
  const menuItems: ReactNode[] = [];
@@ -497,8 +502,8 @@ export function TMDataGridHeaderCell({
497
502
  // A control lane is a fixed track, so it cannot take the cell padding the
498
503
  // scale grows for text. See isControlColumn.
499
504
  data-control-column={isControlColumn(column.id)}
500
- // Only meaningful on a sortable column: "none" advertises that this
501
- // header sorts, which is a lie on one that doesn't.
505
+ // Only meaningful on a sortable column: "none" announces that this
506
+ // header sorts, which is wrong on one that does not.
502
507
  aria-sort={
503
508
  canSort
504
509
  ? isSorted
@@ -583,10 +588,10 @@ export function TMDataGridHeaderCell({
583
588
  <ActionIcon
584
589
  className={`${classes.headerAction} ${classes.sortAction}`}
585
590
  data-pinned-visible={isSorted}
586
- // The arrow is the whole signal now that the title no longer
587
- // tints, so it takes the colour while the sort holds. On an
588
- // unsorted column it is only the hover affordance, and stays a
589
- // faded grey so the two never read alike.
591
+ // The arrow is the only sort indicator now that the title no
592
+ // longer tints, so it takes the colour while the sort holds. On
593
+ // an unsorted column it is only the hover affordance, and stays a
594
+ // faded grey so the two are not confused.
590
595
  data-sorted={isSorted}
591
596
  variant="subtle"
592
597
  color={isSorted ? undefined : "gray"}
@@ -87,11 +87,10 @@ function SelectRowCheckbox<TData extends RowData>({
87
87
  const tabIndex = useCellControlTabIndex();
88
88
  const isGroupRow = row.subRows.length > 0;
89
89
 
90
- // A group row is never selected by id: `rowSelection` only ever holds the
91
- // leaves, and TanStack's `getIsSelected()` is a plain lookup in that map. So
92
- // a group asks about its descendants instead, which is also the honest
93
- // reading - the box means "all of these", and it goes indeterminate as soon
94
- // as that stops being true.
90
+ // A group row is never selected by id: `rowSelection` only holds the leaves,
91
+ // and TanStack's `getIsSelected()` is a plain lookup in that map. A group
92
+ // therefore asks about its descendants instead, so the box means "all of
93
+ // these" and goes indeterminate as soon as that stops being true.
95
94
  const selected = useSelector(row.table.store, () =>
96
95
  isGroupRow ? row.getIsAllSubRowsSelected() : row.getIsSelected(),
97
96
  );
@@ -177,6 +176,11 @@ export function createSelectColumn<TData extends RowData>(
177
176
  enableSorting: false,
178
177
  enableColumnFilter: false,
179
178
  enableGlobalFilter: false,
179
+ // Not a column the user chose, so not one they can switch off: hiding the
180
+ // lane would take the grid's only way to select a row with it, with the
181
+ // row-selection state left behind and no way back to it. Keeping it out of
182
+ // "Manage columns" follows from this - the panel lists what can be hidden.
183
+ enableHiding: false,
180
184
  // Structurally pinned to the left; users shouldn't be able to move it.
181
185
  enablePinning: false,
182
186
  header: ({ table }) => <SelectAllHeader table={table} />,
@@ -329,12 +329,18 @@
329
329
  padding: var(--mantine-spacing-xl);
330
330
  }
331
331
 
332
- /* Marked deleted under batch: struck through and inert until submitAll
333
- reports it. Interaction off wholesale - a row about to go away must not
334
- keep taking edits. */
332
+ /* Marked deleted under draft: struck through and inert until submitAll
333
+ reports it - a row about to go away must not keep taking edits. Interaction
334
+ comes off the data cells, not the row: `pointer-events: none` on the row
335
+ would also kill the edit lane's Restore, which is the one control the row
336
+ still needs (jsdom does not enforce pointer-events, so only a real browser
337
+ ever saw that bug). */
335
338
  .bodyRow[data-deleted="true"] {
336
339
  text-decoration: line-through;
337
340
  opacity: 0.55;
341
+ }
342
+
343
+ .bodyRow[data-deleted="true"] .bodyCell:not([data-control-column="true"]) {
338
344
  pointer-events: none;
339
345
  }
340
346
 
@@ -359,6 +365,15 @@
359
365
  grid-template-columns: subgrid;
360
366
  }
361
367
 
368
+ /* Confirmed entry rows in the scrolling flow (the default; `newRowsSticky`
369
+ keeps them in the sticky block instead). Same tracks, no stickiness - they
370
+ sit above the body rows and scroll with them. */
371
+ .entryFlowBlock {
372
+ display: grid;
373
+ grid-column: 1 / -1;
374
+ grid-template-columns: subgrid;
375
+ }
376
+
362
377
  /* The pinned edge blocks - user-pinned rows held at the top or bottom of the
363
378
  body, on the entry block's sticky mechanics: the block is the sticky
364
379
  element, its rows flow normally inside it. The top block stacks under the
@@ -407,6 +422,35 @@
407
422
  justify-content: center;
408
423
  }
409
424
 
425
+ /* A confirmed entry row - entered, awaiting Save all. It reads as a value
426
+ row: text padding, the body's border weight, and the "new" tint instead of
427
+ the editing underline. */
428
+ .entryRow[data-confirmed="true"] .entryCell {
429
+ padding: 0 var(--dg-padding);
430
+ border-bottom: 1px solid var(--mantine-color-default-border);
431
+ background-color: var(--dg-row-new-bg, var(--mantine-color-body));
432
+ }
433
+
434
+ .entryRow[data-confirmed="true"] .entryCell[data-control-column="true"] {
435
+ padding: 0;
436
+ }
437
+
438
+ /* Draft mode's change marker in the edit lane - dimmed beside the buttons
439
+ that act, red when validation blocks the row. */
440
+ .rowStateIndicator {
441
+ display: inline-flex;
442
+ align-items: center;
443
+ justify-content: center;
444
+ width: 22px;
445
+ height: 22px;
446
+ flex: 0 0 auto;
447
+ color: var(--mantine-color-dimmed);
448
+ }
449
+
450
+ .rowStateIndicator[data-error] {
451
+ color: var(--mantine-color-error);
452
+ }
453
+
410
454
  .entryCell[data-align="right"] {
411
455
  justify-content: flex-end;
412
456
  }