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

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.
@@ -1,22 +1,43 @@
1
- import { Button, Group } from "@mantine/core";
1
+ import { Button, Group, Text } from "@mantine/core";
2
2
  import { useSelector } from "@tanstack/react-store";
3
3
  import type { ReactNode } from "react";
4
4
  import { useTMDataGridContext } from "../TMDataGridContext";
5
5
 
6
6
  /**
7
- * How many rows are waiting to be saved: dirty edits, entry rows (an add is
8
- * pending whether or not it was touched), and deletion marks.
7
+ * How many rows the draft store holds - committed edits, committed entry
8
+ * rows and deletion marks. This is what Save sends, so it is what Save
9
+ * counts: a row the user is still typing into is not in here.
9
10
  */
10
- function usePendingCount(): number {
11
+ function useDraftCount(): number {
12
+ const { edit } = useTMDataGridContext();
13
+ return useSelector(
14
+ edit.store,
15
+ (state) =>
16
+ state.committedRowIds.length +
17
+ state.newRows.filter((newRow) => newRow.committed).length +
18
+ state.deletedRowIds.length,
19
+ );
20
+ }
21
+
22
+ /**
23
+ * How many rows are still open - edited or entered but not committed, so not
24
+ * part of the save. Surfaced beside Save so an uncommitted row is visible
25
+ * rather than silently left behind.
26
+ */
27
+ function useOpenCount(): number {
11
28
  const { edit } = useTMDataGridContext();
12
29
  return useSelector(
13
30
  edit.store,
14
31
  (state) =>
15
32
  state.openRowIds.filter(
16
33
  (rowId) =>
17
- state.newRows.some((newRow) => newRow.tempId === rowId) ||
18
- (state.rows[rowId]?.dirtyFields.length ?? 0) > 0,
19
- ).length + state.deletedRowIds.length,
34
+ !state.committedRowIds.includes(rowId) &&
35
+ !state.newRows.some(
36
+ (newRow) => newRow.tempId === rowId && newRow.committed,
37
+ ) &&
38
+ (state.newRows.some((newRow) => newRow.tempId === rowId) ||
39
+ (state.rows[rowId]?.dirtyFields.length ?? 0) > 0),
40
+ ).length,
20
41
  );
21
42
  }
22
43
 
@@ -28,7 +49,7 @@ function usePendingCount(): number {
28
49
 
29
50
  function EditSaveButton() {
30
51
  const { edit, labels } = useTMDataGridContext();
31
- const pendingCount = usePendingCount();
52
+ const draftCount = useDraftCount();
32
53
  const isSubmitting = useSelector(edit.store, (state) =>
33
54
  state.openRowIds.some((rowId) => state.rows[rowId]?.isSubmitting === true),
34
55
  );
@@ -36,19 +57,39 @@ function EditSaveButton() {
36
57
  return (
37
58
  <Button
38
59
  size="compact-sm"
39
- disabled={pendingCount === 0}
60
+ disabled={draftCount === 0}
40
61
  loading={isSubmitting}
41
62
  data-dg-part="save-all"
42
- onClick={() => void edit.submitAll()}
63
+ data-draft-count={draftCount}
64
+ onClick={() => void edit.saveDrafts()}
43
65
  >
44
- {labels.saveAllEdits(pendingCount)}
66
+ {labels.saveAllEdits(draftCount)}
45
67
  </Button>
46
68
  );
47
69
  }
48
70
 
71
+ /** The count of rows left open, or nothing while every row is decided. */
72
+ function EditOpenRowsNote() {
73
+ const { labels } = useTMDataGridContext();
74
+ const openCount = useOpenCount();
75
+ if (openCount === 0) return null;
76
+ return (
77
+ <Text
78
+ size="xs"
79
+ c="dimmed"
80
+ data-dg-part="open-rows-note"
81
+ data-open-count={openCount}
82
+ >
83
+ {labels.editRowsStillOpen(openCount)}
84
+ </Text>
85
+ );
86
+ }
87
+
49
88
  function EditDiscardButton() {
50
89
  const { edit, labels } = useTMDataGridContext();
51
- const pendingCount = usePendingCount();
90
+ // Discard drops everything the grid is holding, open rows included, so it
91
+ // stays live while anything is uncommitted.
92
+ const pendingCount = useDraftCount() + useOpenCount();
52
93
 
53
94
  return (
54
95
  <Button
@@ -67,11 +108,25 @@ function EditDiscardButton() {
67
108
  const EDIT_ACTIONS_CONTROLS: TMDataGridEditActionsControls = {
68
109
  Save: EditSaveButton,
69
110
  Discard: EditDiscardButton,
111
+ OpenRowsNote: EditOpenRowsNote,
70
112
  };
71
113
 
72
114
  /** What the edit chrome is showing. */
73
115
  export type TMDataGridEditActionsState = {
74
- /** Rows with unsaved work: dirty edits, entry rows, and deletion marks. */
116
+ /**
117
+ * Rows in the draft store, which is what Save sends: committed edits,
118
+ * committed entry rows and deletion marks.
119
+ */
120
+ draftCount: number;
121
+ /**
122
+ * Rows still open - edited or entered but not committed, so not part of
123
+ * the save. They stay open across a save.
124
+ */
125
+ openCount: number;
126
+ /**
127
+ * @deprecated Was "everything uncommitted", which Save no longer sends.
128
+ * Reads as `draftCount + openCount`; use whichever you meant.
129
+ */
75
130
  pendingCount: number;
76
131
  /** Whether a submit is in flight. */
77
132
  isSubmitting: boolean;
@@ -79,18 +134,22 @@ export type TMDataGridEditActionsState = {
79
134
 
80
135
  /** What the edit chrome can do. */
81
136
  export type TMDataGridEditActionsActions = {
82
- /** Commits every open row. Resolves `false` when a row stayed open. */
137
+ /** Saves the draft store. Open rows are left alone. */
83
138
  save: () => Promise<boolean>;
84
- /** Drops every draft. */
139
+ /** Submits every open row, committing the ones that validate. */
140
+ commitAll: () => Promise<boolean>;
141
+ /** Drops everything - open form state and the draft store alike. */
85
142
  discard: () => void;
86
143
  };
87
144
 
88
145
  /** The pre-bound pieces of the built-in edit chrome. */
89
146
  export type TMDataGridEditActionsControls = {
90
- /** Save, with the pending count, disabled while nothing is pending. */
147
+ /** Save, with the draft count, disabled while the draft store is empty. */
91
148
  Save: () => ReactNode;
92
149
  /** Discard, disabled while nothing is pending. */
93
150
  Discard: () => ReactNode;
151
+ /** The "N rows still being edited" note, or nothing while there are none. */
152
+ OpenRowsNote: () => ReactNode;
94
153
  };
95
154
 
96
155
  /** What {@link TMDataGridEditActionsProps.renderActions} is handed. */
@@ -108,7 +167,8 @@ export type TMDataGridEditActionsProps = {
108
167
  * <TMDataGrid.EditActions
109
168
  * renderActions={({ state, Controls }) => (
110
169
  * <Group>
111
- * {state.pendingCount > 0 && <Badge>{state.pendingCount}</Badge>}
170
+ * {state.draftCount > 0 && <Badge>{state.draftCount}</Badge>}
171
+ * <Controls.OpenRowsNote />
112
172
  * <Controls.Save />
113
173
  * <Controls.Discard />
114
174
  * </Group>
@@ -120,11 +180,11 @@ export type TMDataGridEditActionsProps = {
120
180
  };
121
181
 
122
182
  /**
123
- * Draft mode's toolbar chrome: Save with the dirty-row count, and Discard.
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 `editing.mode` - a
126
- * cellConfirm grid accumulating drafts can offer the same pair - and renders
127
- * nothing while editing is off.
183
+ * Draft mode's toolbar chrome: Save with the draft-store count, Discard, and
184
+ * a note counting rows still open. Save sends the draft store and leaves open
185
+ * rows alone, so it greys out while nothing is committed however much is
186
+ * being typed - the note is what makes those rows visible. Works under any
187
+ * `editing.mode` and renders nothing while editing is off.
128
188
  *
129
189
  * ```tsx
130
190
  * <TMDataGrid.Toolbar>
@@ -138,7 +198,8 @@ export function TMDataGridEditActions({
138
198
  renderActions,
139
199
  }: TMDataGridEditActionsProps = {}) {
140
200
  const { edit, features } = useTMDataGridContext();
141
- const pendingCount = usePendingCount();
201
+ const draftCount = useDraftCount();
202
+ const openCount = useOpenCount();
142
203
  const isSubmitting = useSelector(edit.store, (state) =>
143
204
  state.openRowIds.some((rowId) => state.rows[rowId]?.isSubmitting === true),
144
205
  );
@@ -147,14 +208,24 @@ export function TMDataGridEditActions({
147
208
 
148
209
  if (renderActions) {
149
210
  return renderActions({
150
- state: { pendingCount, isSubmitting },
151
- actions: { save: () => edit.submitAll(), discard: () => edit.cancelAll() },
211
+ state: {
212
+ draftCount,
213
+ openCount,
214
+ pendingCount: draftCount + openCount,
215
+ isSubmitting,
216
+ },
217
+ actions: {
218
+ save: () => edit.saveDrafts(),
219
+ commitAll: () => edit.commitAll(),
220
+ discard: () => edit.cancelAll(),
221
+ },
152
222
  Controls: EDIT_ACTIONS_CONTROLS,
153
223
  });
154
224
  }
155
225
 
156
226
  return (
157
227
  <Group gap="xs" wrap="nowrap">
228
+ <EditOpenRowsNote />
158
229
  <EditSaveButton />
159
230
  <EditDiscardButton />
160
231
  </Group>
@@ -82,7 +82,7 @@ function EditLaneCell<TData extends RowData>({
82
82
  const isConfirmedNew = useSelector(
83
83
  edit.store,
84
84
  (state) =>
85
- state.newRows.find((newRow) => newRow.tempId === rowId)?.confirmed ===
85
+ state.newRows.find((newRow) => newRow.tempId === rowId)?.committed ===
86
86
  true,
87
87
  );
88
88
  const isMarkedDeleted = useSelector(edit.store, (state) =>
@@ -67,12 +67,12 @@ export function TMDataGridEntryRows({
67
67
  }) {
68
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
70
+ // The reopen gesture: `begin` on a committed entry row flips it back to
71
71
  // editors and names the cell double-clicked - where the caret goes.
72
72
  const activeEntry = useSelector(edit.store, (state) =>
73
73
  state.active !== null &&
74
74
  state.newRows.some(
75
- (newRow) => newRow.tempId === state.active?.rowId && !newRow.confirmed,
75
+ (newRow) => newRow.tempId === state.active?.rowId && !newRow.committed,
76
76
  )
77
77
  ? state.active
78
78
  : null,
@@ -109,7 +109,7 @@ export function TMDataGridEntryRows({
109
109
  });
110
110
 
111
111
  /**
112
- * The caret for a reopen: `begin` on a confirmed row re-arms its editors
112
+ * The caret for a reopen: `begin` on a committed row re-arms its editors
113
113
  * and the double-clicked cell's editor should hold the caret, not the
114
114
  * row's first. Keyed separately from the add effect above - the row was
115
115
  * already focused once when it was added.
@@ -134,16 +134,16 @@ export function TMDataGridEntryRows({
134
134
  focusEditorContent(editor);
135
135
  });
136
136
 
137
- // Unconfirmed rows keep the seed values frozen at addRow - the live values
137
+ // Open (uncommitted) rows keep the seed values frozen at addRow - the live values
138
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
139
+ // provides row and cell identity. A committed row's cells render values, so
140
140
  // there the draft itself is the row; the memo recomputes on every
141
- // confirm/reopen because either flips `newRows`' identity.
141
+ // commit/reopen because either flips `newRows`' identity.
142
142
  const data = useMemo(
143
143
  () =>
144
144
  newRows.map(
145
- ({ tempId, confirmed }) =>
146
- ((confirmed
145
+ ({ tempId, committed }) =>
146
+ ((committed
147
147
  ? edit.getForm(tempId)?.state.values
148
148
  : edit.getForm(tempId)?.options.defaultValues) ??
149
149
  {}) as TMDataGridRowData,
@@ -161,20 +161,20 @@ export function TMDataGridEntryRows({
161
161
  if (newRows.length === 0) return null;
162
162
 
163
163
  const entryRows = entryTable.getCoreRowModel().rows;
164
- const confirmedById = new Map(
165
- newRows.map((newRow) => [newRow.tempId, newRow.confirmed]),
164
+ const committedById = new Map(
165
+ newRows.map((newRow) => [newRow.tempId, newRow.committed]),
166
166
  );
167
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
168
+ // scroll back to. A committed row joins the scrolling flow unless
169
169
  // `newRowsSticky` keeps it pinned, so entering many rows cannot fill the
170
170
  // viewport with sticky chrome.
171
171
  const stickyRows = entryRows.filter(
172
172
  (entryRow) =>
173
- features.editNewRowsSticky || confirmedById.get(entryRow.id) !== true,
173
+ features.editNewRowsSticky || committedById.get(entryRow.id) !== true,
174
174
  );
175
175
  const flowRows = features.editNewRowsSticky
176
176
  ? []
177
- : entryRows.filter((entryRow) => confirmedById.get(entryRow.id) === true);
177
+ : entryRows.filter((entryRow) => committedById.get(entryRow.id) === true);
178
178
 
179
179
  const renderEntryRow = (
180
180
  entryRow: (typeof entryRows)[number],
@@ -183,7 +183,7 @@ export function TMDataGridEntryRows({
183
183
  const cellsById = new Map(
184
184
  entryRow.getAllCells().map((cell) => [cell.column.id, cell]),
185
185
  );
186
- const confirmed = confirmedById.get(entryRow.id) === true;
186
+ const committed = committedById.get(entryRow.id) === true;
187
187
  return (
188
188
  <div
189
189
  key={entryRow.id}
@@ -191,7 +191,10 @@ export function TMDataGridEntryRows({
191
191
  data-dg-part="entry-row"
192
192
  data-row-id={entryRow.id}
193
193
  data-new
194
- data-confirmed={confirmed}
194
+ data-committed={committed}
195
+ // The same marker body rows carry once committed, so one selector
196
+ // reaches everything parked in the draft store.
197
+ data-draft={committed}
195
198
  className={classes.entryRow}
196
199
  >
197
200
  {orderedColumns.map((column) => {
@@ -206,10 +209,10 @@ export function TMDataGridEntryRows({
206
209
  data-column-id={column.id}
207
210
  data-align={getColumnAlign(column)}
208
211
  data-control-column={isControlColumn(column.id)}
209
- // A confirmed row re-opens where it is double-clicked, the
212
+ // A committed row re-opens where it is double-clicked, the
210
213
  // same gesture a body cell answers.
211
214
  onDoubleClick={
212
- confirmed && !isControlColumn(column.id)
215
+ committed && !isControlColumn(column.id)
213
216
  ? () =>
214
217
  edit.begin({
215
218
  rowId: entryRow.id,
@@ -239,7 +242,7 @@ export function TMDataGridEntryRows({
239
242
  {cell !== undefined && column.id === EDIT_COLUMN_ID ? (
240
243
  // The lane's cell - the entry row's controls.
241
244
  flexRender(cell.column.columnDef.cell, cell.getContext())
242
- ) : confirmed && cell !== undefined ? (
245
+ ) : committed && cell !== undefined ? (
243
246
  // Entered, awaiting Save all: a value row through the
244
247
  // columns' own renderers, over the draft the memo above
245
248
  // fed this table.
@@ -750,6 +750,14 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
750
750
  (id) => (state.rows[id]?.dirtyFields.length ?? 0) > 0,
751
751
  ),
752
752
  );
753
+ // Rows parked in the draft store, waiting for Save. A separate marker from
754
+ // `data-dirty`, which covers any row being typed into: after a partial save
755
+ // these are what is left, so one selector highlights everything still
756
+ // pending.
757
+ const editDraftRowIds = useSelector(
758
+ edit.store,
759
+ (state) => state.committedRowIds,
760
+ );
753
761
 
754
762
  const { loading, noResultsLabel = labels.noResults } =
755
763
  table.options.meta ?? {};
@@ -2233,6 +2241,11 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2233
2241
  editDirtyRowIds.length > 0 &&
2234
2242
  editDirtyRowIds.includes(row.id)
2235
2243
  }
2244
+ // Committed into the draft store, waiting for Save.
2245
+ data-draft={
2246
+ editDraftRowIds.length > 0 &&
2247
+ editDraftRowIds.includes(row.id)
2248
+ }
2236
2249
  // The menu is anchored to the rowgroup, so Mantine's own
2237
2250
  // `data-expanded` lands there rather than on a row. This is
2238
2251
  // what says which row the open menu is about.
@@ -0,0 +1,179 @@
1
+ import type { TableState } from "@tanstack/react-table";
2
+ import type { TMDataGridFeatures } from "../useTMDataGrid";
3
+
4
+ type GridState = TableState<TMDataGridFeatures>;
5
+
6
+ /**
7
+ * Support for TanStack's `state` option (controlled state).
8
+ *
9
+ * table-core re-reads `options.state` on every render (`useTable` calls
10
+ * `setOptions` while rendering) and writes any slice whose value is not
11
+ * identical to the table's current value into the table's atom. The write
12
+ * publishes the store and re-renders the consumer, so a slice object built in
13
+ * the render body causes an infinite render loop.
14
+ *
15
+ * {@link stabilizeControlledState} prevents the loop by forwarding the
16
+ * previous render's value for a slice whose contents are unchanged.
17
+ * {@link findFrozenStateSlices} detects a controlled slice without its
18
+ * `onXChange` callback, which the grid cannot write to.
19
+ */
20
+
21
+ /**
22
+ * The `onXChange` option that owns each controlled slice - every slice v9
23
+ * defines. `globalFilterFn` is not among them: in this beta it is a table
24
+ * option, not state.
25
+ */
26
+ export const CONTROLLED_SLICE_HANDLERS = {
27
+ columnFilters: "onColumnFiltersChange",
28
+ columnOrder: "onColumnOrderChange",
29
+ columnPinning: "onColumnPinningChange",
30
+ columnResizing: "onColumnResizingChange",
31
+ columnSizing: "onColumnSizingChange",
32
+ columnVisibility: "onColumnVisibilityChange",
33
+ expanded: "onExpandedChange",
34
+ globalFilter: "onGlobalFilterChange",
35
+ grouping: "onGroupingChange",
36
+ pagination: "onPaginationChange",
37
+ rowPinning: "onRowPinningChange",
38
+ rowSelection: "onRowSelectionChange",
39
+ sorting: "onSortingChange",
40
+ } as const satisfies Partial<Record<keyof GridState, string>>;
41
+
42
+ export type TMDataGridControlledSlice = keyof typeof CONTROLLED_SLICE_HANDLERS;
43
+
44
+ /** The options {@link findFrozenStateSlices} reads - a subset of `TableOptions`. */
45
+ type ControlledOptions = {
46
+ state?: Partial<GridState>;
47
+ /**
48
+ * The v9 way of owning a slice: an atom the table writes through directly.
49
+ * It outranks `state`, so a slice backed by one is never frozen.
50
+ */
51
+ atoms?: Record<string, unknown>;
52
+ } & Partial<
53
+ Record<(typeof CONTROLLED_SLICE_HANDLERS)[TMDataGridControlledSlice], unknown>
54
+ >;
55
+
56
+ const hasOwn = (value: object, key: string): boolean =>
57
+ Object.prototype.hasOwnProperty.call(value, key);
58
+
59
+ /**
60
+ * A plain data object: `Object.prototype` or null prototype. table-core builds
61
+ * its state maps with `Object.create(null)`, so null prototypes must compare
62
+ * as plain objects.
63
+ */
64
+ function isDataObject(value: unknown): value is Record<string, unknown> {
65
+ if (typeof value !== "object" || value === null) return false;
66
+ const proto = Object.getPrototypeOf(value);
67
+ return proto === Object.prototype || proto === null;
68
+ }
69
+
70
+ /**
71
+ * Structural equality for state values.
72
+ *
73
+ * `Date`s compare by time; the built-in date filters hold them in filter
74
+ * values. `Map`s and class instances compare by identity, so a controlled
75
+ * slice containing one rebuilt each render reads as changed on every render
76
+ * and re-renders the table. Use primitives, plain objects, arrays or `Date`s
77
+ * in controlled state.
78
+ */
79
+ export function sameStateValue(a: unknown, b: unknown): boolean {
80
+ if (Object.is(a, b)) return true;
81
+ if (a instanceof Date || b instanceof Date) {
82
+ return (
83
+ a instanceof Date && b instanceof Date && a.getTime() === b.getTime()
84
+ );
85
+ }
86
+ if (Array.isArray(a) || Array.isArray(b)) {
87
+ if (!Array.isArray(a) || !Array.isArray(b)) return false;
88
+ return (
89
+ a.length === b.length &&
90
+ a.every((item, index) => sameStateValue(item, b[index]))
91
+ );
92
+ }
93
+ if (!isDataObject(a) || !isDataObject(b)) return false;
94
+ const keys = Object.keys(a);
95
+ if (keys.length !== Object.keys(b).length) return false;
96
+ return keys.every((key) => hasOwn(b, key) && sameStateValue(a[key], b[key]));
97
+ }
98
+
99
+ /**
100
+ * Removes keys whose value is `undefined`, as in
101
+ * `state: { sorting: cond ? sorting : undefined }`.
102
+ *
103
+ * TanStack's sync writes every present key into the slice's atom, `undefined`
104
+ * included, which breaks everything that reads the slice. A key set to
105
+ * `undefined` is treated as not controlled: the key is removed and the slice
106
+ * falls back to the table's own state. Identity is preserved when there is
107
+ * nothing to remove; the stabilizer compares by it.
108
+ */
109
+ export function withoutUndefinedSlices(
110
+ state: Partial<GridState> | undefined,
111
+ ): Partial<GridState> | undefined {
112
+ if (state === undefined) return state;
113
+ if (!Object.values(state).includes(undefined)) return state;
114
+ const compacted: Record<string, unknown> = {};
115
+ for (const [key, value] of Object.entries(state)) {
116
+ if (value !== undefined) compacted[key] = value;
117
+ }
118
+ return compacted as Partial<GridState>;
119
+ }
120
+
121
+ /**
122
+ * Returns `next` with every slice that structurally equals its counterpart in
123
+ * `previous` replaced by the previous object, so table-core's identity-based
124
+ * sync finds nothing to write. Returns `previous` when nothing changed.
125
+ */
126
+ export function stabilizeControlledState(
127
+ next: Partial<GridState> | undefined,
128
+ previous: Partial<GridState> | undefined,
129
+ ): Partial<GridState> | undefined {
130
+ if (next === undefined || previous === undefined || next === previous) {
131
+ return next;
132
+ }
133
+
134
+ const keys = Object.keys(next);
135
+ const stabilized: Record<string, unknown> = {};
136
+ let changed = keys.length !== Object.keys(previous).length;
137
+
138
+ for (const key of keys) {
139
+ const nextValue = (next as Record<string, unknown>)[key];
140
+ const previousValue = (previous as Record<string, unknown>)[key];
141
+ if (hasOwn(previous, key) && sameStateValue(nextValue, previousValue)) {
142
+ // Unchanged contents: keep the object the table already holds.
143
+ stabilized[key] = previousValue;
144
+ } else {
145
+ stabilized[key] = nextValue;
146
+ changed = true;
147
+ }
148
+ }
149
+
150
+ return changed ? (stabilized as Partial<GridState>) : previous;
151
+ }
152
+
153
+ /**
154
+ * Controlled slices without a write path: `state.x` is set but no `onXChange`
155
+ * was passed and no external atom owns the slice. TanStack routes every write
156
+ * through the callback, so such a slice cannot change. Reported so the hook
157
+ * can warn in development; `initialState` is the option for a starting value.
158
+ */
159
+ export function findFrozenStateSlices(
160
+ options: ControlledOptions,
161
+ ): Array<{ slice: TMDataGridControlledSlice; handler: string }> {
162
+ const state = options.state;
163
+ if (state === undefined) return [];
164
+
165
+ return (
166
+ Object.entries(CONTROLLED_SLICE_HANDLERS) as Array<
167
+ [TMDataGridControlledSlice, string]
168
+ >
169
+ )
170
+ .filter(
171
+ ([slice, handler]) =>
172
+ // Undefined-valued keys are removed before the table sees them; the
173
+ // slice is not controlled.
174
+ (state as Record<string, unknown>)[slice] !== undefined &&
175
+ (options as Record<string, unknown>)[handler] === undefined &&
176
+ options.atoms?.[slice] === undefined,
177
+ )
178
+ .map(([slice, handler]) => ({ slice, handler }));
179
+ }