@jielga/tmdatagrid 1.0.2 → 1.1.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.
@@ -1,7 +1,124 @@
1
1
  import { Button, Group } from "@mantine/core";
2
2
  import { useSelector } from "@tanstack/react-store";
3
+ import type { ReactNode } from "react";
3
4
  import { useTMDataGridContext } from "../TMDataGridContext";
4
5
 
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.
9
+ */
10
+ function usePendingCount(): number {
11
+ const { edit } = useTMDataGridContext();
12
+ return useSelector(
13
+ edit.store,
14
+ (state) =>
15
+ state.openRowIds.filter(
16
+ (rowId) =>
17
+ state.newRows.some((newRow) => newRow.tempId === rowId) ||
18
+ (state.rows[rowId]?.dirtyFields.length ?? 0) > 0,
19
+ ).length + state.deletedRowIds.length,
20
+ );
21
+ }
22
+
23
+ /*
24
+ * Module scope, like the pagination controls and for the same reason: a
25
+ * component defined inside the render body is a new type every render, so
26
+ * React would remount the buttons underneath an interaction.
27
+ */
28
+
29
+ function EditSaveButton() {
30
+ const { edit, labels } = useTMDataGridContext();
31
+ const pendingCount = usePendingCount();
32
+ const isSubmitting = useSelector(edit.store, (state) =>
33
+ state.openRowIds.some((rowId) => state.rows[rowId]?.isSubmitting === true),
34
+ );
35
+
36
+ return (
37
+ <Button
38
+ size="compact-sm"
39
+ disabled={pendingCount === 0}
40
+ loading={isSubmitting}
41
+ data-dg-part="save-all"
42
+ onClick={() => void edit.submitAll()}
43
+ >
44
+ {labels.saveAllEdits(pendingCount)}
45
+ </Button>
46
+ );
47
+ }
48
+
49
+ function EditDiscardButton() {
50
+ const { edit, labels } = useTMDataGridContext();
51
+ const pendingCount = usePendingCount();
52
+
53
+ return (
54
+ <Button
55
+ variant="subtle"
56
+ color="gray"
57
+ size="compact-sm"
58
+ disabled={pendingCount === 0}
59
+ data-dg-part="discard-all"
60
+ onClick={() => edit.cancelAll()}
61
+ >
62
+ {labels.discardAllEdits}
63
+ </Button>
64
+ );
65
+ }
66
+
67
+ const EDIT_ACTIONS_CONTROLS: TMDataGridEditActionsControls = {
68
+ Save: EditSaveButton,
69
+ Discard: EditDiscardButton,
70
+ };
71
+
72
+ /** What the edit chrome is showing. */
73
+ export type TMDataGridEditActionsState = {
74
+ /** Rows with unsaved work: dirty edits, entry rows, and deletion marks. */
75
+ pendingCount: number;
76
+ /** Whether a submit is in flight. */
77
+ isSubmitting: boolean;
78
+ };
79
+
80
+ /** What the edit chrome can do. */
81
+ export type TMDataGridEditActionsActions = {
82
+ /** Commits every open row. Resolves `false` when a row stayed open. */
83
+ save: () => Promise<boolean>;
84
+ /** Drops every draft. */
85
+ discard: () => void;
86
+ };
87
+
88
+ /** The pre-bound pieces of the built-in edit chrome. */
89
+ export type TMDataGridEditActionsControls = {
90
+ /** Save, with the pending count, disabled while nothing is pending. */
91
+ Save: () => ReactNode;
92
+ /** Discard, disabled while nothing is pending. */
93
+ Discard: () => ReactNode;
94
+ };
95
+
96
+ /** What {@link TMDataGridEditActionsProps.renderActions} is handed. */
97
+ export type TMDataGridEditActionsSlotArgs = {
98
+ state: TMDataGridEditActionsState;
99
+ actions: TMDataGridEditActionsActions;
100
+ Controls: TMDataGridEditActionsControls;
101
+ };
102
+
103
+ export type TMDataGridEditActionsProps = {
104
+ /**
105
+ * Replaces the built-in Save/Discard pair, and is handed the pieces of it.
106
+ *
107
+ * ```tsx
108
+ * <TMDataGrid.EditActions
109
+ * renderActions={({ state, Controls }) => (
110
+ * <Group>
111
+ * {state.pendingCount > 0 && <Badge>{state.pendingCount}</Badge>}
112
+ * <Controls.Save />
113
+ * <Controls.Discard />
114
+ * </Group>
115
+ * )}
116
+ * />
117
+ * ```
118
+ */
119
+ renderActions?: (args: TMDataGridEditActionsSlotArgs) => ReactNode;
120
+ };
121
+
5
122
  /**
6
123
  * Batch mode's toolbar chrome: Save with the dirty-row count, and Discard.
7
124
  * Both read the edit store, so they grey out while nothing is dirty and the
@@ -17,46 +134,29 @@ import { useTMDataGridContext } from "../TMDataGridContext";
17
134
  * </TMDataGrid.Toolbar>
18
135
  * ```
19
136
  */
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
- );
137
+ export function TMDataGridEditActions({
138
+ renderActions,
139
+ }: TMDataGridEditActionsProps = {}) {
140
+ const { edit, features } = useTMDataGridContext();
141
+ const pendingCount = usePendingCount();
33
142
  const isSubmitting = useSelector(edit.store, (state) =>
34
143
  state.openRowIds.some((rowId) => state.rows[rowId]?.isSubmitting === true),
35
144
  );
36
145
 
37
146
  if (!features.editing) return null;
38
147
 
148
+ if (renderActions) {
149
+ return renderActions({
150
+ state: { pendingCount, isSubmitting },
151
+ actions: { save: () => edit.submitAll(), discard: () => edit.cancelAll() },
152
+ Controls: EDIT_ACTIONS_CONTROLS,
153
+ });
154
+ }
155
+
39
156
  return (
40
157
  <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>
158
+ <EditSaveButton />
159
+ <EditDiscardButton />
60
160
  </Group>
61
161
  );
62
162
  }
@@ -1,7 +1,7 @@
1
1
  import { ActionIcon, Group, Select, Text, Tooltip } from "@mantine/core";
2
2
  import type { RowData } from "@tanstack/react-table";
3
3
  import { useSelector } from "@tanstack/react-store";
4
- import type { ReactNode } from "react";
4
+ import { useMemo, type ReactNode } from "react";
5
5
  import classes from "./TMDataGridFooter.module.css";
6
6
  import { useTMDataGridContext } from "../TMDataGridContext";
7
7
  import { getGridCapabilities } from "../core/capabilities";
@@ -9,8 +9,8 @@ import { isPagingActive } from "../core/rowSelection";
9
9
  import { ChevronLeftIcon, ChevronRightIcon } from "./icons";
10
10
  import type { TMDataGridTable } from "../useTMDataGrid";
11
11
 
12
- /** Distilled pagination state and actions for building a custom pager. */
13
- export type TMDataGridPaginationApi = {
12
+ /** What the pager is showing. The read half of {@link TMDataGridPaginationApi}. */
13
+ export type TMDataGridPaginationState = {
14
14
  pageIndex: number;
15
15
  pageSize: number;
16
16
  /** `-1` when a manual grid declares `pageCount: -1` (unknown total). */
@@ -18,6 +18,18 @@ export type TMDataGridPaginationApi = {
18
18
  rowCount: number;
19
19
  canPreviousPage: boolean;
20
20
  canNextPage: boolean;
21
+ /**
22
+ * Whether the pager is slicing anything right now. `false` while a grouping
23
+ * is active, which suspends paging - see `isPagingActive`.
24
+ */
25
+ isPagingActive: boolean;
26
+ /** First and last row number on this page, 1-based, for a range label. */
27
+ from: number;
28
+ to: number;
29
+ };
30
+
31
+ /** What the pager can do. The write half of {@link TMDataGridPaginationApi}. */
32
+ export type TMDataGridPaginationActions = {
21
33
  setPageIndex: (pageIndex: number) => void;
22
34
  setPageSize: (pageSize: number) => void;
23
35
  previousPage: () => void;
@@ -26,37 +38,203 @@ export type TMDataGridPaginationApi = {
26
38
  lastPage: () => void;
27
39
  };
28
40
 
41
+ /**
42
+ * Pagination state and actions, split into the half you read and the half you
43
+ * call.
44
+ *
45
+ * The split is what makes a partial override possible: a consumer replacing
46
+ * only the range label reads `state` and never touches `actions`, and one
47
+ * replacing only the buttons does the opposite.
48
+ */
49
+ export type TMDataGridPaginationApi = {
50
+ state: TMDataGridPaginationState;
51
+ actions: TMDataGridPaginationActions;
52
+ };
53
+
54
+ /**
55
+ * The pre-bound pieces of the built-in pager.
56
+ *
57
+ * Each renders exactly what the default footer renders, already wired to the
58
+ * table and already respecting the suspended-paging state - so a custom layout
59
+ * keeps the parts it likes rather than rebuilding them to look the same.
60
+ */
61
+ export type TMDataGridPaginationControls = {
62
+ /** "Rows per page" and its select. */
63
+ PageSize: () => ReactNode;
64
+ /** The "1–25 of 300" range label. */
65
+ Range: () => ReactNode;
66
+ /** The previous/next buttons. */
67
+ Pager: () => ReactNode;
68
+ };
69
+
70
+ /** What {@link TMDataGridFooterProps.renderPagination} is handed. */
71
+ export type TMDataGridPaginationSlotArgs = TMDataGridPaginationApi & {
72
+ Controls: TMDataGridPaginationControls;
73
+ };
74
+
29
75
  /**
30
76
  * Reads {@link TMDataGridPaginationApi} off a table. The Footer feeds it to the
31
- * `pagination` render prop; a pager living outside the Footer can call it with
77
+ * `renderPagination` slot; a pager living outside the Footer can call it with
32
78
  * the table from `useTMDataGrid` directly.
79
+ *
80
+ * `Controls` are not here: they are components bound to the grid context, and
81
+ * this function takes only a table.
33
82
  */
34
83
  export function getTMDataGridPaginationApi<TData extends RowData>(
35
84
  table: TMDataGridTable<TData>,
85
+ isPaging = true,
36
86
  ): TMDataGridPaginationApi {
37
87
  const { pageIndex, pageSize } = table.store.state.pagination;
88
+ const rowCount = table.getRowCount();
38
89
  return {
39
- pageIndex,
40
- pageSize,
41
- pageCount: table.getPageCount(),
42
- rowCount: table.getRowCount(),
43
- canPreviousPage: table.getCanPreviousPage(),
44
- canNextPage: table.getCanNextPage(),
45
- setPageIndex: (index) => table.setPageIndex(index),
46
- setPageSize: (size) => table.setPageSize(size),
47
- previousPage: () => table.previousPage(),
48
- nextPage: () => table.nextPage(),
49
- firstPage: () => table.firstPage(),
50
- lastPage: () => table.lastPage(),
90
+ state: {
91
+ pageIndex,
92
+ pageSize,
93
+ pageCount: table.getPageCount(),
94
+ rowCount,
95
+ canPreviousPage: table.getCanPreviousPage(),
96
+ canNextPage: table.getCanNextPage(),
97
+ isPagingActive: isPaging,
98
+ from: rowCount === 0 ? 0 : pageIndex * pageSize + 1,
99
+ to: Math.min(rowCount, (pageIndex + 1) * pageSize),
100
+ },
101
+ actions: {
102
+ setPageIndex: (index) => table.setPageIndex(index),
103
+ setPageSize: (size) => table.setPageSize(size),
104
+ previousPage: () => table.previousPage(),
105
+ nextPage: () => table.nextPage(),
106
+ firstPage: () => table.firstPage(),
107
+ lastPage: () => table.lastPage(),
108
+ },
51
109
  };
52
110
  }
53
111
 
112
+ /*
113
+ * The three controls are module-scope components on purpose. Defined inside
114
+ * the Footer they would be a new component type on every render, so React
115
+ * would unmount and remount the pager between one render and the next - which
116
+ * detaches the button a click is landing on mid-interaction.
117
+ */
118
+
119
+ function PaginationPageSize({
120
+ pageSizeOptions,
121
+ }: {
122
+ pageSizeOptions: ReadonlyArray<number>;
123
+ }) {
124
+ const { table, features, labels, controlSize } = useTMDataGridContext();
125
+ useSelector(table.store);
126
+ const paging = isPagingActive(table, features);
127
+ const { pageSize } = table.store.state.pagination;
128
+ // A page size restored from storage, or set through `initialState`, need not
129
+ // be one of the offered sizes. Without it in the list the Select renders
130
+ // blank, so the current size is folded in rather than shown as nothing.
131
+ const sizeOptions = pageSizeOptions.includes(pageSize)
132
+ ? pageSizeOptions
133
+ : [...pageSizeOptions, pageSize].sort((a, b) => a - b);
134
+
135
+ return (
136
+ <Group gap="xs" wrap="nowrap">
137
+ {/* `span`, here and in every other label the grid renders: Mantine's
138
+ Text is a `<p>` by default, and a grid dropped into prose - a docs
139
+ page, a CMS body, Mantine's own Typography - picks up that context's
140
+ `p` margins. A bottom margin on a flex item shifts its border box up
141
+ off centre, which is how the pager came to sit a half-line below its
142
+ own label. Chrome labels are not prose. */}
143
+ <Text span size={controlSize} c="dimmed">
144
+ {labels.rowsPerPage}
145
+ </Text>
146
+ <Select
147
+ size={controlSize}
148
+ w={78}
149
+ allowDeselect={false}
150
+ variant="unstyled"
151
+ disabled={!paging}
152
+ data-dg-part="page-size"
153
+ data={sizeOptions.map(String)}
154
+ value={String(pageSize)}
155
+ onChange={(value) => {
156
+ table.setPageSize(Number(value) || pageSizeOptions[0]);
157
+ table.setPageIndex(0);
158
+ }}
159
+ />
160
+ </Group>
161
+ );
162
+ }
163
+
164
+ function PaginationRange() {
165
+ const { table, features, labels, controlSize } = useTMDataGridContext();
166
+ useSelector(table.store);
167
+ const paging = isPagingActive(table, features);
168
+ const { pageIndex, pageSize } = table.store.state.pagination;
169
+ const total = table.getRowCount();
170
+ const from = total === 0 ? 0 : pageIndex * pageSize + 1;
171
+ const to = Math.min(total, (pageIndex + 1) * pageSize);
172
+
173
+ return (
174
+ <Text span size={controlSize} c="dimmed" data-dg-part="page-range">
175
+ {paging
176
+ ? labels.pageRange({ from, to, total })
177
+ : // Not a range: nothing is being sliced, so a range would be a lie.
178
+ // Counted before grouping too - `getRowCount()` would be counting
179
+ // the tree, so a collapsed grid would claim to hold eight rows.
180
+ labels.groupedAllRows(table.getFilteredRowModel().rows.length)}
181
+ </Text>
182
+ );
183
+ }
184
+
185
+ function PaginationPager() {
186
+ const { table, features, labels } = useTMDataGridContext();
187
+ useSelector(table.store);
188
+ const paging = isPagingActive(table, features);
189
+
190
+ return (
191
+ <Group gap={4} wrap="nowrap">
192
+ <ActionIcon
193
+ variant="subtle"
194
+ color="gray"
195
+ aria-label={labels.previousPage}
196
+ data-dg-part="page-prev"
197
+ disabled={!paging || !table.getCanPreviousPage()}
198
+ onClick={() => table.previousPage()}
199
+ >
200
+ <ChevronLeftIcon size={18} stroke={1.6} />
201
+ </ActionIcon>
202
+ <ActionIcon
203
+ variant="subtle"
204
+ color="gray"
205
+ aria-label={labels.nextPage}
206
+ data-dg-part="page-next"
207
+ disabled={!paging || !table.getCanNextPage()}
208
+ onClick={() => table.nextPage()}
209
+ >
210
+ <ChevronRightIcon size={18} stroke={1.6} />
211
+ </ActionIcon>
212
+ </Group>
213
+ );
214
+ }
215
+
54
216
  export type TMDataGridFooterProps = {
55
217
  pageSizeOptions?: ReadonlyArray<number>;
56
- /** Replaces the built-in pager. Receives the distilled pagination API. */
57
- pagination?: (api: TMDataGridPaginationApi) => ReactNode;
218
+ /**
219
+ * Replaces the built-in pager, and is handed the pieces of it.
220
+ *
221
+ * ```tsx
222
+ * <TMDataGrid.Footer
223
+ * renderPagination={({ state, actions, Controls }) => (
224
+ * <Group>
225
+ * <Controls.PageSize />
226
+ * <MyJumpToPage page={state.pageIndex} onJump={actions.setPageIndex} />
227
+ * <Controls.Pager />
228
+ * </Group>
229
+ * )}
230
+ * />
231
+ * ```
232
+ */
233
+ renderPagination?: (args: TMDataGridPaginationSlotArgs) => ReactNode;
58
234
  };
59
235
 
236
+ const DEFAULT_PAGE_SIZE_OPTIONS = [10, 25, 50, 100];
237
+
60
238
  /**
61
239
  * MUI-style pager: "Rows per page · from–to of total · ‹ ›".
62
240
  *
@@ -68,37 +246,39 @@ export type TMDataGridFooterProps = {
68
246
  * - so a server-paged grid shows the server's total without changes here.
69
247
  */
70
248
  export function TMDataGridFooter({
71
- pageSizeOptions = [10, 25, 50, 100],
72
- pagination,
249
+ pageSizeOptions = DEFAULT_PAGE_SIZE_OPTIONS,
250
+ renderPagination,
73
251
  }: TMDataGridFooterProps) {
74
- const { table, features, labels, controlSize } = useTMDataGridContext();
252
+ const { table, features, labels } = useTMDataGridContext();
75
253
  useSelector(table.store);
76
254
 
255
+ // Bound once per option list rather than per render, so the controls keep
256
+ // their identity and the elements they render keep theirs.
257
+ const Controls = useMemo<TMDataGridPaginationControls>(
258
+ () => ({
259
+ PageSize: () => <PaginationPageSize pageSizeOptions={pageSizeOptions} />,
260
+ Range: PaginationRange,
261
+ Pager: PaginationPager,
262
+ }),
263
+ [pageSizeOptions],
264
+ );
265
+
77
266
  if (!getGridCapabilities(table, features).canPaginate) return null;
78
267
 
79
- if (pagination) {
268
+ // Grouping suspends paging - see isPagingActive. The pager is greyed out
269
+ // rather than dropped, so that a footer going quiet reads as a state the grid
270
+ // is in rather than as something that broke.
271
+ const paging = isPagingActive(table, features);
272
+
273
+ if (renderPagination) {
274
+ const { state, actions } = getTMDataGridPaginationApi(table, paging);
80
275
  return (
81
276
  <div data-dg-part="footer" className={classes.footer}>
82
- {pagination(getTMDataGridPaginationApi(table))}
277
+ {renderPagination({ state, actions, Controls })}
83
278
  </div>
84
279
  );
85
280
  }
86
281
 
87
- // Grouping suspends paging - see isPagingActive. The pager is greyed out
88
- // rather than dropped, so that a footer going quiet reads as a state the grid
89
- // is in rather than as something that broke.
90
- const paging = isPagingActive(table, features);
91
- const { pageIndex, pageSize } = table.store.state.pagination;
92
- const total = table.getRowCount();
93
- const from = total === 0 ? 0 : pageIndex * pageSize + 1;
94
- const to = Math.min(total, (pageIndex + 1) * pageSize);
95
- // A page size restored from storage, or set through `initialState`, need not
96
- // be one of the offered sizes. Without it in the list the Select renders
97
- // blank, so the current size is folded in rather than shown as nothing.
98
- const sizeOptions = pageSizeOptions.includes(pageSize)
99
- ? pageSizeOptions
100
- : [...pageSizeOptions, pageSize].sort((a, b) => a - b);
101
-
102
282
  return (
103
283
  <Tooltip
104
284
  label={labels.pagingSuspendedHint}
@@ -113,63 +293,11 @@ export function TMDataGridFooter({
113
293
  className={classes.footer}
114
294
  data-paging-suspended={!paging}
115
295
  >
116
- <Group gap="xs" wrap="nowrap">
117
- {/* `span`, here and in every other label the grid renders: Mantine's
118
- Text is a `<p>` by default, and a grid dropped into prose - a
119
- docs page, a CMS body, Mantine's own Typography - picks up that
120
- context's `p` margins. A bottom margin on a flex item shifts its
121
- border box up off centre, which is how the pager came to sit a
122
- half-line below its own label. Chrome labels are not prose. */}
123
- <Text span size={controlSize} c="dimmed">
124
- {labels.rowsPerPage}
125
- </Text>
126
- <Select
127
- size={controlSize}
128
- w={78}
129
- allowDeselect={false}
130
- variant="unstyled"
131
- disabled={!paging}
132
- data-dg-part="page-size"
133
- data={sizeOptions.map(String)}
134
- value={String(pageSize)}
135
- onChange={(value) => {
136
- table.setPageSize(Number(value) || pageSizeOptions[0]);
137
- table.setPageIndex(0);
138
- }}
139
- />
140
- </Group>
141
-
142
- <Text span size={controlSize} c="dimmed" data-dg-part="page-range">
143
- {paging
144
- ? labels.pageRange({ from, to, total })
145
- : // Not a range: nothing is being sliced, so a range would be a lie.
146
- // Counted before grouping too - `getRowCount()` would be counting
147
- // the tree, so a collapsed grid would claim to hold eight rows.
148
- labels.groupedAllRows(table.getFilteredRowModel().rows.length)}
149
- </Text>
150
-
151
- <Group gap={4} wrap="nowrap">
152
- <ActionIcon
153
- variant="subtle"
154
- color="gray"
155
- aria-label={labels.previousPage}
156
- data-dg-part="page-prev"
157
- disabled={!paging || !table.getCanPreviousPage()}
158
- onClick={() => table.previousPage()}
159
- >
160
- <ChevronLeftIcon size={18} stroke={1.6} />
161
- </ActionIcon>
162
- <ActionIcon
163
- variant="subtle"
164
- color="gray"
165
- aria-label={labels.nextPage}
166
- data-dg-part="page-next"
167
- disabled={!paging || !table.getCanNextPage()}
168
- onClick={() => table.nextPage()}
169
- >
170
- <ChevronRightIcon size={18} stroke={1.6} />
171
- </ActionIcon>
172
- </Group>
296
+ {/* The default render is the controls in order - which is what makes
297
+ `renderPagination` a rearrangement rather than a rebuild. */}
298
+ <Controls.PageSize />
299
+ <Controls.Range />
300
+ <Controls.Pager />
173
301
  </div>
174
302
  </Tooltip>
175
303
  );
@@ -47,18 +47,45 @@ import {
47
47
  import { GROUP_COLUMN_ID } from "./TMDataGridGroupColumn";
48
48
  import { type TMDataGridFeatures, openColumnFilter } from "../useTMDataGrid";
49
49
 
50
+ /** The table as the header cell sees it, after the context type erasure. */
51
+ type TMDataGridTableInstance = ReturnType<
52
+ typeof useTMDataGridContext
53
+ >["table"];
54
+
50
55
  export type TMDataGridHeader = Header<
51
56
  TMDataGridFeatures,
52
57
  TMDataGridRowData,
53
58
  unknown
54
59
  >;
55
60
 
61
+ /** What {@link TMDataGridColumnMenuItemsRenderer} is handed. */
62
+ export type TMDataGridColumnMenuItemsArgs = {
63
+ column: TMDataGridHeader["column"];
64
+ table: TMDataGridTableInstance;
65
+ /**
66
+ * The items the grid would have rendered, in order, dividers included.
67
+ * Return them to keep the built-in menu, splice around them to extend it,
68
+ * or drop them to replace it.
69
+ */
70
+ internalItems: Array<ReactNode>;
71
+ };
72
+
73
+ /**
74
+ * Replaces a column's menu contents. Returning an empty list leaves the column
75
+ * with no menu button at all, the same as a column whose every feature is off.
76
+ */
77
+ export type TMDataGridColumnMenuItemsRenderer = (
78
+ args: TMDataGridColumnMenuItemsArgs,
79
+ ) => Array<ReactNode>;
80
+
56
81
  export function TMDataGridHeaderCell({
57
82
  header,
58
83
  layout,
84
+ renderColumnMenuItems,
59
85
  }: {
60
86
  header: TMDataGridHeader;
61
87
  layout: TMDataGridColumnLayout;
88
+ renderColumnMenuItems?: TMDataGridColumnMenuItemsRenderer;
62
89
  }) {
63
90
  const api = useTMDataGridContext();
64
91
  const { table, features, labels } = api;
@@ -426,11 +453,21 @@ export function TMDataGridHeaderCell({
426
453
  menuItems.pop();
427
454
  }
428
455
 
456
+ // The handback: the consumer is given what the grid would have rendered and
457
+ // returns what should be. Trimmed again afterwards, because a consumer that
458
+ // drops the last group can leave a divider stranded the same way.
459
+ const finalItems = renderColumnMenuItems
460
+ ? [...renderColumnMenuItems({ column, table, internalItems: menuItems })]
461
+ : menuItems;
462
+ while (finalItems.length > 0 && isDivider(finalItems.at(-1))) {
463
+ finalItems.pop();
464
+ }
465
+
429
466
  // One gate for both ways into the menu - the button and the right-click -
430
467
  // so the two can never disagree about which headers have one. A control
431
468
  // lane's header is itself a control (select-all, expand-all), so it carries
432
469
  // no column menu, and a right-click there falls through to the browser's.
433
- const hasMenu = isLeaf && menuItems.length > 0 && !isControlColumn(column.id);
470
+ const hasMenu = isLeaf && finalItems.length > 0 && !isControlColumn(column.id);
434
471
 
435
472
  const cellClass = [
436
473
  classes.headerCell,
@@ -607,7 +644,7 @@ export function TMDataGridHeaderCell({
607
644
  </ActionIcon>
608
645
  </Menu.Target>
609
646
  <Menu.Dropdown onClick={(event) => event.stopPropagation()}>
610
- {menuItems}
647
+ {finalItems}
611
648
  </Menu.Dropdown>
612
649
  </Menu>
613
650
  )}
@@ -658,7 +695,7 @@ export function TMDataGridHeaderCell({
658
695
 
659
696
  // A second Menu for the same items, rather than one Menu serving both ways
660
697
  // in: the button's dropdown hangs off the button, while a context menu
661
- // belongs at the pointer, and a Mantine Menu has one target. `menuItems` is
698
+ // belongs at the pointer, and a Mantine Menu has one target. `finalItems` is
662
699
  // built once and rendered by whichever dropdown is open (never both), so
663
700
  // the two can't drift apart.
664
701
  //
@@ -681,7 +718,7 @@ export function TMDataGridHeaderCell({
681
718
  >
682
719
  <Menu.ContextMenu>{cell}</Menu.ContextMenu>
683
720
  <Menu.Dropdown onClick={(event) => event.stopPropagation()}>
684
- {menuItems}
721
+ {finalItems}
685
722
  </Menu.Dropdown>
686
723
  </Menu>
687
724
  );