@jielga/tmdatagrid 2.0.0-beta.9 → 2.0.1

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 (157) hide show
  1. package/README.md +5 -212
  2. package/dist/index.d.ts +1281 -768
  3. package/dist/index.js +4607 -3250
  4. package/dist/index.js.map +1 -1
  5. package/dist/styles.css +1 -1
  6. package/docs/adding-rows.md +132 -0
  7. package/docs/anatomy.md +119 -0
  8. package/docs/card-view.md +108 -0
  9. package/docs/cell-selection.md +194 -0
  10. package/docs/column-layout.md +182 -0
  11. package/docs/column-menu.md +66 -0
  12. package/docs/columns.md +269 -0
  13. package/docs/components.md +311 -0
  14. package/docs/draft-store.md +242 -0
  15. package/docs/editing.md +303 -0
  16. package/docs/editors.md +250 -0
  17. package/docs/export.md +319 -0
  18. package/docs/filtering.md +362 -0
  19. package/docs/getting-started.md +123 -0
  20. package/docs/grouping.md +165 -0
  21. package/docs/loading-and-empty.md +92 -0
  22. package/docs/localization.md +79 -0
  23. package/docs/menu.md +143 -0
  24. package/docs/migrating-to-2.md +163 -0
  25. package/docs/pagination.md +144 -0
  26. package/docs/persistence.md +114 -0
  27. package/docs/portfolio-rebalancer.md +94 -0
  28. package/docs/query-builder.md +179 -0
  29. package/docs/quick-search.md +84 -0
  30. package/docs/row-details.md +115 -0
  31. package/docs/row-interaction.md +149 -0
  32. package/docs/row-pinning.md +132 -0
  33. package/docs/row-selection.md +136 -0
  34. package/docs/row-styling.md +133 -0
  35. package/docs/scrolling.md +112 -0
  36. package/docs/server-query.md +246 -0
  37. package/docs/server-side.md +206 -0
  38. package/docs/sorting.md +101 -0
  39. package/docs/styling.md +126 -0
  40. package/docs/summary-row.md +76 -0
  41. package/docs/testing.md +744 -0
  42. package/docs/toolbar.md +161 -0
  43. package/docs/use-tm-data-grid.md +361 -0
  44. package/package.json +22 -46
  45. package/skills/appearance/SKILL.md +72 -19
  46. package/skills/cell-selection/SKILL.md +49 -48
  47. package/skills/columns/SKILL.md +125 -70
  48. package/skills/columns/references/columns-api.md +59 -0
  49. package/skills/data/SKILL.md +112 -18
  50. package/skills/editing/SKILL.md +76 -42
  51. package/skills/editing/references/common-mistakes.md +77 -69
  52. package/skills/editing/references/editing-api.md +25 -20
  53. package/skills/editing/references/editors-and-validation.md +80 -18
  54. package/skills/filtering/SKILL.md +155 -41
  55. package/skills/getting-started/SKILL.md +116 -16
  56. package/skills/grouping/SKILL.md +31 -16
  57. package/skills/migrating-to-2/SKILL.md +244 -0
  58. package/skills/options/SKILL.md +24 -12
  59. package/skills/rows/SKILL.md +22 -18
  60. package/skills/rows/references/rows-api.md +10 -6
  61. package/skills/server-side/SKILL.md +170 -17
  62. package/skills/testing/SKILL.md +150 -32
  63. package/skills/testing-components/SKILL.md +230 -0
  64. package/skills/testing-editing/SKILL.md +240 -0
  65. package/src/{tmdatagrid/components → components}/TMDataGrid.tsx +38 -21
  66. package/src/{tmdatagrid/components → components}/TMDataGridCellEditor.tsx +54 -8
  67. package/src/{tmdatagrid/components → components}/TMDataGridColumnsPanel.module.css +5 -1
  68. package/src/{tmdatagrid/components → components}/TMDataGridColumnsPanel.tsx +47 -55
  69. package/src/{tmdatagrid/components → components}/TMDataGridDetailsColumn.tsx +5 -51
  70. package/src/{tmdatagrid/components → components}/TMDataGridDraftActions.tsx +41 -24
  71. package/src/{tmdatagrid/components → components}/TMDataGridEditColumn.tsx +12 -59
  72. package/src/{tmdatagrid/components → components}/TMDataGridEntryRows.tsx +164 -92
  73. package/src/components/TMDataGridExportPicker.module.css +77 -0
  74. package/src/components/TMDataGridExportPicker.tsx +234 -0
  75. package/src/components/TMDataGridFilterPanel.module.css +54 -0
  76. package/src/components/TMDataGridFilterPanel.tsx +348 -0
  77. package/src/{tmdatagrid/components → components}/TMDataGridFilterPills.tsx +15 -8
  78. package/src/components/TMDataGridFilterSurface.module.css +54 -0
  79. package/src/components/TMDataGridFilterSurface.tsx +167 -0
  80. package/src/{tmdatagrid/components → components}/TMDataGridFooter.tsx +53 -90
  81. package/src/{tmdatagrid/components → components}/TMDataGridGroupColumn.tsx +5 -69
  82. package/src/{tmdatagrid/components → components}/TMDataGridHeaderCell.module.css +12 -2
  83. package/src/{tmdatagrid/components → components}/TMDataGridHeaderCell.tsx +58 -21
  84. package/src/components/TMDataGridHeaderFilterRow.module.css +51 -0
  85. package/src/components/TMDataGridHeaderFilterRow.tsx +301 -0
  86. package/src/components/TMDataGridMenu.tsx +357 -0
  87. package/src/{tmdatagrid/components → components}/TMDataGridSelectColumn.tsx +11 -48
  88. package/src/{tmdatagrid/components → components}/TMDataGridTable.module.css +69 -56
  89. package/src/{tmdatagrid/components → components}/TMDataGridTable.tsx +240 -138
  90. package/src/{tmdatagrid/components → components}/TMDataGridToolbar.module.css +5 -0
  91. package/src/components/TMDataGridToolbar.tsx +181 -0
  92. package/src/{tmdatagrid/components → components}/editors/TMDataGridBooleanEditor.tsx +3 -3
  93. package/src/{tmdatagrid/components → components}/editors/TMDataGridDateEditor.tsx +3 -3
  94. package/src/{tmdatagrid/components → components}/editors/TMDataGridMultiSelectEditor.tsx +3 -3
  95. package/src/{tmdatagrid/components → components}/editors/TMDataGridNumberEditor.tsx +3 -3
  96. package/src/{tmdatagrid/components → components}/editors/TMDataGridSelectEditor.tsx +3 -3
  97. package/src/{tmdatagrid/components → components}/editors/TMDataGridStringEditor.tsx +3 -3
  98. package/src/{tmdatagrid/components → components}/editors/editorShared.ts +16 -2
  99. package/src/{tmdatagrid/components → components}/filters/DgAutocompleteFilter.tsx +5 -4
  100. package/src/{tmdatagrid/components → components}/filters/DgDateRangeFilter.tsx +22 -5
  101. package/src/{tmdatagrid/components → components}/filters/DgRangeSliderFilter.tsx +5 -1
  102. package/src/{tmdatagrid/components → components}/filters/DgTriStateFilter.tsx +5 -1
  103. package/src/{tmdatagrid/components → components}/filters/TMDataGridFilterValueInput.tsx +44 -28
  104. package/src/components/filters/controlLayout.ts +32 -0
  105. package/src/components/filters/filterControlFor.ts +65 -0
  106. package/src/components/generatedColumns.tsx +187 -0
  107. package/src/{tmdatagrid/components → components}/icons.ts +1 -0
  108. package/src/{tmdatagrid/components → components}/sticky.module.css +44 -0
  109. package/src/components/useHideableColumns.ts +52 -0
  110. package/src/{tmdatagrid/core → core}/autosize.ts +5 -2
  111. package/src/{tmdatagrid/core → core}/columnOptions.ts +60 -8
  112. package/src/{tmdatagrid/core → core}/columnOrdering.ts +33 -14
  113. package/src/{tmdatagrid/core → core}/columnUtils.ts +44 -5
  114. package/src/core/controlledStateSync.ts +108 -0
  115. package/src/core/deletedRows.ts +34 -0
  116. package/src/core/dom.ts +74 -0
  117. package/src/{tmdatagrid/core → core}/editEngine.ts +1107 -460
  118. package/src/core/export.ts +704 -0
  119. package/src/{tmdatagrid/core → core}/filterControls.ts +38 -1
  120. package/src/{tmdatagrid/core → core}/filterOperators.ts +64 -1
  121. package/src/core/filterSurface.ts +99 -0
  122. package/src/{tmdatagrid/core → core}/grouping.ts +21 -0
  123. package/src/{tmdatagrid/core → core}/labels.ts +51 -6
  124. package/src/{tmdatagrid/core → core}/labelsSv.ts +27 -6
  125. package/src/core/pageReset.ts +120 -0
  126. package/src/core/pagination.ts +81 -0
  127. package/src/{tmdatagrid/core → core}/persistence.ts +22 -5
  128. package/src/{tmdatagrid/core → core}/summary.ts +20 -4
  129. package/src/{tmdatagrid/index.ts → index.ts} +69 -35
  130. package/src/{tmdatagrid/useTMDataGrid.tsx → useTMDataGrid.tsx} +428 -109
  131. package/src/useTMDataGridExport.ts +78 -0
  132. package/src/tmdatagrid/components/TMDataGridFilterPanel.module.css +0 -35
  133. package/src/tmdatagrid/components/TMDataGridFilterPanel.tsx +0 -354
  134. package/src/tmdatagrid/components/TMDataGridToolbar.tsx +0 -161
  135. package/src/tmdatagrid/core/cellExport.ts +0 -320
  136. /package/src/{tmdatagrid/TMDataGridContext.ts → TMDataGridContext.ts} +0 -0
  137. /package/src/{tmdatagrid/components → components}/TMDataGrid.module.css +0 -0
  138. /package/src/{tmdatagrid/components → components}/TMDataGridDetailsColumn.module.css +0 -0
  139. /package/src/{tmdatagrid/components → components}/TMDataGridFilterPills.module.css +0 -0
  140. /package/src/{tmdatagrid/components → components}/TMDataGridFooter.module.css +0 -0
  141. /package/src/{tmdatagrid/components → components}/TMDataGridGroupColumn.module.css +0 -0
  142. /package/src/{tmdatagrid/components → components}/TMDataGridRowNumberColumn.tsx +0 -0
  143. /package/src/{tmdatagrid/components → components}/TMDataGridSearch.tsx +0 -0
  144. /package/src/{tmdatagrid/core → core}/capabilities.ts +0 -0
  145. /package/src/{tmdatagrid/core → core}/cellNavigation.ts +0 -0
  146. /package/src/{tmdatagrid/core → core}/cellRange.ts +0 -0
  147. /package/src/{tmdatagrid/core → core}/controlledState.ts +0 -0
  148. /package/src/{tmdatagrid/core → core}/draftCellContext.ts +0 -0
  149. /package/src/{tmdatagrid/core → core}/editorFocus.ts +0 -0
  150. /package/src/{tmdatagrid/core → core}/expanding.ts +0 -0
  151. /package/src/{tmdatagrid/core → core}/matchHighlight.ts +0 -0
  152. /package/src/{tmdatagrid/core → core}/quickSearch.ts +0 -0
  153. /package/src/{tmdatagrid/core → core}/resizePreview.ts +0 -0
  154. /package/src/{tmdatagrid/core → core}/rowPinning.ts +0 -0
  155. /package/src/{tmdatagrid/core → core}/rowSelection.ts +0 -0
  156. /package/src/{tmdatagrid/core → core}/sizes.ts +0 -0
  157. /package/src/{tmdatagrid/core → core}/useSettledTableState.ts +0 -0
@@ -0,0 +1,301 @@
1
+ import { ActionIcon, Menu, Tooltip } from "@mantine/core";
2
+ import { useSelector } from "@tanstack/react-store";
3
+ import { useEffect, useMemo, useRef, useState } from "react";
4
+ import classes from "./TMDataGridHeaderFilterRow.module.css";
5
+ import sticky from "./sticky.module.css";
6
+ import { useTMDataGridContext } from "../TMDataGridContext";
7
+ import {
8
+ getColumnDefaultOperator,
9
+ getColumnLabel,
10
+ getColumnOperators,
11
+ isControlColumn,
12
+ } from "../core/columnUtils";
13
+ import {
14
+ type TMDataGridFilterOperator,
15
+ type TMDataGridFilterValue,
16
+ emptyValueForOperator,
17
+ filterValueShape,
18
+ isTMDataGridFilterValue,
19
+ } from "../core/filterOperators";
20
+ import { FilterIcon } from "./icons";
21
+ import {
22
+ filterControlFor,
23
+ filterOptionsUseFacets,
24
+ } from "./filters/filterControlFor";
25
+ import type { TMDataGridColumnLayout } from "./TMDataGridTable";
26
+ import type { TMDataGridHeader } from "./TMDataGridHeaderCell";
27
+
28
+ /**
29
+ * Whether a value says nothing at all - untrimmed, unlike `isFilterActive`,
30
+ * which is about whether a filter *narrows* anything. A lone space narrows
31
+ * nothing but is very much something the user typed.
32
+ */
33
+ function isEmptyValue(value: string | ReadonlyArray<string>): boolean {
34
+ return Array.isArray(value)
35
+ ? value.every((entry) => entry === "")
36
+ : value === "";
37
+ }
38
+
39
+ /**
40
+ * One column's filter control, as it appears in the header row: the value
41
+ * control the column would get in the panel, plus a button for the operator
42
+ * when the column's type offers more than one.
43
+ *
44
+ * The column and operator dropdowns of a panel row have no place here - the
45
+ * column is the one the cell sits over, and a header cell has room for a value
46
+ * and not much else. Everything else is the same filter: the same operators,
47
+ * the same `meta.filter.control`, the same `columnFilters` state.
48
+ */
49
+ function HeaderFilterControl({ column }: { column: TMDataGridHeader["column"] }) {
50
+ const { table, labels, controlSize } = useTMDataGridContext();
51
+ const filterValue = useSelector(
52
+ table.store,
53
+ (state) => state.columnFilters.find((filter) => filter.id === column.id)?.value,
54
+ );
55
+
56
+ const defaultOperator = getColumnDefaultOperator(column);
57
+ const current = isTMDataGridFilterValue(filterValue) ? filterValue : undefined;
58
+ const operator = current?.operator ?? defaultOperator;
59
+ const value = current?.value ?? emptyValueForOperator(operator);
60
+ const label = getColumnLabel(column);
61
+
62
+ /**
63
+ * Writes the filter, or drops it entirely once nothing is left to say. A
64
+ * header control is always on screen, so an empty one has no row to keep
65
+ * alive the way a panel row does, and leaving the entry behind would put a
66
+ * filter in `columnFilters` that says nothing.
67
+ *
68
+ * Emptiness here is the literal one, not `isFilterActive`'s: that one trims,
69
+ * and dropping the filter on a value of `" "` would take the space back out
70
+ * of the input the moment it was typed. An operator the user picked is worth
71
+ * keeping even with an empty value; the type's own default is not.
72
+ */
73
+ function write(next: TMDataGridFilterValue) {
74
+ const worthKeeping =
75
+ !isEmptyValue(next.value) || next.operator !== defaultOperator;
76
+ column.setFilterValue(worthKeeping ? next : undefined);
77
+ }
78
+
79
+ function changeOperator(next: TMDataGridFilterOperator) {
80
+ // Same arity rule as the panel: keep the value across operators of the
81
+ // same shape, reset it across any shape boundary.
82
+ write({
83
+ operator: next,
84
+ value:
85
+ filterValueShape(next) === filterValueShape(operator)
86
+ ? value
87
+ : emptyValueForOperator(next),
88
+ });
89
+ }
90
+
91
+ // Memoized because this row re-renders with the table - on every scroll
92
+ // frame, for every column - while resolving faceted options walks the
93
+ // faceted map into a sorted set. TanStack swaps that map's identity whenever
94
+ // the values behind it change, so it is exactly the right key.
95
+ const facetKey = filterOptionsUseFacets(column)
96
+ ? column.getFacetedUniqueValues()
97
+ : undefined;
98
+ const { options, ValueControl } = useMemo(
99
+ () => filterControlFor(table, column),
100
+ // `facetKey` is a cache key, not something the body reads: the resolution
101
+ // reaches the faceted map through the column, so nothing here names it.
102
+ // eslint-disable-next-line react-hooks/exhaustive-deps
103
+ [table, column, facetKey],
104
+ );
105
+ const operators = getColumnOperators(column);
106
+
107
+ return (
108
+ <>
109
+ <div className={classes.headerFilterValue} data-dg-filter-value-slot>
110
+ <ValueControl
111
+ column={column}
112
+ table={table}
113
+ operator={operator}
114
+ value={value}
115
+ onChange={(next) => write({ operator, value: next })}
116
+ options={options}
117
+ size={controlSize}
118
+ labels={labels}
119
+ layout="header"
120
+ />
121
+ </div>
122
+
123
+ <OperatorMenu
124
+ columnId={column.id}
125
+ columnLabel={label}
126
+ operator={operator}
127
+ isDefault={operator === defaultOperator}
128
+ operators={operators}
129
+ onChange={changeOperator}
130
+ />
131
+ </>
132
+ );
133
+ }
134
+
135
+ /**
136
+ * The operator picker beside a header filter's value: which comparison the
137
+ * column is on, and the list to change it to.
138
+ *
139
+ * The tooltip is the only thing saying what the current operator is while the
140
+ * menu is shut, and the only thing in the way once it is open - so it is held
141
+ * back while the dropdown shows.
142
+ */
143
+ function OperatorMenu({
144
+ columnId,
145
+ columnLabel,
146
+ operator,
147
+ isDefault,
148
+ operators,
149
+ onChange,
150
+ }: {
151
+ columnId: string;
152
+ columnLabel: string;
153
+ operator: TMDataGridFilterOperator;
154
+ isDefault: boolean;
155
+ operators: ReadonlyArray<TMDataGridFilterOperator>;
156
+ onChange: (next: TMDataGridFilterOperator) => void;
157
+ }) {
158
+ const { labels } = useTMDataGridContext();
159
+ const [opened, setOpened] = useState(false);
160
+
161
+ return (
162
+ <Menu
163
+ opened={opened}
164
+ onChange={setOpened}
165
+ position="bottom-end"
166
+ shadow="md"
167
+ width={220}
168
+ withinPortal
169
+ >
170
+ <Menu.Target>
171
+ <Tooltip
172
+ label={labels.operators[operator]}
173
+ openDelay={400}
174
+ disabled={opened}
175
+ >
176
+ <ActionIcon
177
+ className={classes.headerFilterOperator}
178
+ variant="subtle"
179
+ // Tinted while the column is on anything but its default operator -
180
+ // the one part of the filter the value cannot show.
181
+ color={isDefault ? "gray" : undefined}
182
+ size="sm"
183
+ aria-label={labels.filterOperatorFor(columnLabel)}
184
+ data-dg-part="header-filter-operator"
185
+ data-column-id={columnId}
186
+ >
187
+ <FilterIcon size={14} stroke={1.6} />
188
+ </ActionIcon>
189
+ </Tooltip>
190
+ </Menu.Target>
191
+ <Menu.Dropdown>
192
+ {operators.map((candidate) => (
193
+ <Menu.Item
194
+ key={candidate}
195
+ className={
196
+ candidate === operator ? classes.operatorItemActive : undefined
197
+ }
198
+ onClick={() => onChange(candidate)}
199
+ >
200
+ {labels.operators[candidate]}
201
+ </Menu.Item>
202
+ ))}
203
+ </Menu.Dropdown>
204
+ </Menu>
205
+ );
206
+ }
207
+
208
+ /**
209
+ * `filters.inHeader` - a second header row holding one value control per
210
+ * filterable column, always visible.
211
+ *
212
+ * A header row like the group rows above it, on the same column tracks and
213
+ * with the same pinned lanes, so resizing, reordering and pinning move the
214
+ * controls with their columns. It sticks below the group rows rather than at
215
+ * the top, and carries `data-dg-header-row` so the body's scroll offset counts
216
+ * it in.
217
+ *
218
+ * @internal Rendered by `TMDataGrid.Table`.
219
+ */
220
+ export function TMDataGridHeaderFilterRow({
221
+ leafHeaders,
222
+ layoutFor,
223
+ ariaRowIndex,
224
+ }: {
225
+ leafHeaders: ReadonlyArray<TMDataGridHeader>;
226
+ layoutFor: (columnId: string) => TMDataGridColumnLayout;
227
+ ariaRowIndex: number;
228
+ }) {
229
+ const { ui, labels } = useTMDataGridContext();
230
+ const focusColumnId = useSelector(ui, (state) => state.headerFilterColumnId);
231
+ const rowRef = useRef<HTMLDivElement>(null);
232
+
233
+ // `openColumnFilter` under header filters means "put me in that column's
234
+ // control" - there is no panel to open. The column may be scrolled out
235
+ // sideways, so it is brought into view first.
236
+ useEffect(() => {
237
+ if (focusColumnId === null) return;
238
+ const cell = rowRef.current?.querySelector<HTMLElement>(
239
+ `[data-dg-part="header-filter-cell"][data-column-id="${CSS.escape(focusColumnId)}"]`,
240
+ );
241
+ cell?.scrollIntoView({ block: "nearest", inline: "nearest" });
242
+ cell
243
+ ?.querySelector<HTMLElement>(
244
+ "input:not([type='hidden']), textarea, select, [tabindex]:not([tabindex='-1'])",
245
+ )
246
+ ?.focus();
247
+ ui.actions.focusHeaderFilter(null);
248
+ }, [focusColumnId, ui]);
249
+
250
+ return (
251
+ <div
252
+ ref={rowRef}
253
+ role="row"
254
+ aria-rowindex={ariaRowIndex}
255
+ // Counted by the same measurement the group rows are: it is part of what
256
+ // the body scrolls under.
257
+ data-dg-header-row
258
+ data-dg-header-filter-row
259
+ data-dg-part="header-filter-row"
260
+ // The header/body boundary is this row once it exists, so it wears the
261
+ // scrolled-under shadow the last group row wears otherwise.
262
+ className={`${classes.headerFilterRow} ${sticky.headerBoundary}`}
263
+ >
264
+ {leafHeaders.map((header) => {
265
+ const column = header.column;
266
+ const layout = layoutFor(column.id);
267
+ return (
268
+ <div
269
+ key={header.id}
270
+ role="columnheader"
271
+ aria-label={labels.filterOn(getColumnLabel(column))}
272
+ data-dg-part="header-filter-cell"
273
+ data-column-id={column.id}
274
+ data-control-column={isControlColumn(column.id) || undefined}
275
+ className={[
276
+ classes.headerFilterCell,
277
+ layout.isBoundary && layout.pinnedAt === "left"
278
+ ? sticky.stickyLeft
279
+ : "",
280
+ layout.isBoundary && layout.pinnedAt === "right"
281
+ ? sticky.stickyRight
282
+ : "",
283
+ ]
284
+ .filter(Boolean)
285
+ .join(" ")}
286
+ style={{
287
+ left: layout.pinnedAt === "left" ? layout.offset : undefined,
288
+ right: layout.pinnedAt === "right" ? layout.offset : undefined,
289
+ position: layout.pinnedAt ? "sticky" : undefined,
290
+ zIndex: layout.pinnedAt
291
+ ? "var(--dg-z-header-pinned-cell, 7)"
292
+ : undefined,
293
+ }}
294
+ >
295
+ {column.getCanFilter() && <HeaderFilterControl column={column} />}
296
+ </div>
297
+ );
298
+ })}
299
+ </div>
300
+ );
301
+ }
@@ -0,0 +1,357 @@
1
+ import {
2
+ ActionIcon,
3
+ Checkbox,
4
+ Menu,
5
+ ScrollArea,
6
+ Tooltip,
7
+ type MenuProps,
8
+ } from "@mantine/core";
9
+ import {
10
+ useState,
11
+ type ComponentPropsWithoutRef,
12
+ type KeyboardEvent,
13
+ type ReactNode,
14
+ } from "react";
15
+ import { useTMDataGridContext } from "../TMDataGridContext";
16
+ import {
17
+ getColumnLabel,
18
+ showColumnSearch,
19
+ type TMDataGridColumnSearchable,
20
+ } from "../core/columnUtils";
21
+ import type {
22
+ TMDataGridExportColumns,
23
+ TMDataGridExportOptions,
24
+ } from "../core/export";
25
+ import { useTMDataGridExport } from "../useTMDataGridExport";
26
+ import { useHideableColumns } from "./useHideableColumns";
27
+ import { BurgerIcon, DownloadIcon, RestoreIcon } from "./icons";
28
+
29
+ /**
30
+ * The column toggles are `Menu.Item`s with a `Checkbox.Indicator` in front,
31
+ * not `Menu.CheckboxItem`: that one draws its tick only when checked, so an
32
+ * unchecked column next to the consumer's own items reads as a plain action.
33
+ * A box that is visibly empty says "off, click to turn on".
34
+ *
35
+ * `Menu.Item` writes `role="menuitem"` after the props it is given, so the
36
+ * checkbox role goes on through `renderRoot`, the polymorphic escape hatch.
37
+ */
38
+ const asCheckboxItem = (props: ComponentPropsWithoutRef<"button">) => (
39
+ <button {...props} role="menuitemcheckbox" />
40
+ );
41
+
42
+ export type TMDataGridMenuProps = Omit<MenuProps, "children"> & {
43
+ /** The dropdown's content: Mantine `Menu.Item`s and the `TMDataGrid.Menu.*` items. */
44
+ children: ReactNode;
45
+ /** Replaces the burger icon on the trigger. */
46
+ icon?: ReactNode;
47
+ /** Tooltip and `aria-label` of the trigger. Default `labels.menuButton`. */
48
+ label?: string;
49
+ };
50
+
51
+ export type TMDataGridMenuColumnsProps = {
52
+ /**
53
+ * Renders a `Menu.Search` above the toggles: `"auto"` (the default) from
54
+ * six hideable columns, `true` always, `false` never. Use `false` inside a
55
+ * `Menu.Sub`.
56
+ */
57
+ searchable?: TMDataGridColumnSearchable;
58
+ };
59
+
60
+ // Documented on the `TMDataGridMenu` export below.
61
+ function TMDataGridMenuRoot({
62
+ children,
63
+ icon,
64
+ label,
65
+ ...menuProps
66
+ }: TMDataGridMenuProps) {
67
+ const { labels, controlSize } = useTMDataGridContext();
68
+ const triggerLabel = label ?? labels.menuButton;
69
+
70
+ return (
71
+ <Menu
72
+ position="bottom-end"
73
+ shadow="md"
74
+ width={260}
75
+ withinPortal
76
+ {...menuProps}
77
+ >
78
+ {/* The Tooltip goes outside the Target. Inside it, the Target's
79
+ `aria-expanded` / `aria-controls` reach the Tooltip, which hands
80
+ unknown props to its own floating label, not to the button. */}
81
+ <Tooltip label={triggerLabel} openDelay={400}>
82
+ <Menu.Target>
83
+ <ActionIcon
84
+ variant="subtle"
85
+ color="gray"
86
+ size={controlSize}
87
+ aria-label={triggerLabel}
88
+ data-dg-part="menu-button"
89
+ >
90
+ {icon ?? <BurgerIcon size={18} stroke={1.6} />}
91
+ </ActionIcon>
92
+ </Menu.Target>
93
+ </Tooltip>
94
+ <Menu.Dropdown>{children}</Menu.Dropdown>
95
+ </Menu>
96
+ );
97
+ }
98
+
99
+ /**
100
+ * The whole column chooser as menu items: a search box, one checkbox item per
101
+ * hideable column, show/hide all, and Reset layout. Renders nothing when no
102
+ * column can be hidden.
103
+ *
104
+ * `searchable` is only for a block at the top level of a dropdown.
105
+ * `Menu.Search` registers on the root menu context (`hasSearch`), which
106
+ * switches off type-ahead and the arrow-key handling of every dropdown of that
107
+ * menu, so a search inside a `Menu.Sub` breaks the parent menu's keyboard
108
+ * behaviour.
109
+ */
110
+ export function TMDataGridMenuColumns({
111
+ searchable = "auto",
112
+ }: TMDataGridMenuColumnsProps) {
113
+ const { labels, controlSize } = useTMDataGridContext();
114
+ const { columns } = useHideableColumns();
115
+ const [search, setSearch] = useState("");
116
+
117
+ if (columns.length === 0) return null;
118
+
119
+ const withSearch = showColumnSearch(searchable, columns.length);
120
+
121
+ // `Menu.Search` walks every item of the dropdown from the top, so with items
122
+ // placed above this block ArrowDown from the search lands on the first of
123
+ // those - and Enter runs it. When something precedes the block, ArrowDown
124
+ // moves focus to the first listed column instead; when nothing does,
125
+ // Mantine's own walk already starts there and is left alone.
126
+ const handleSearchKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
127
+ if (event.key !== "ArrowDown") return;
128
+ const dropdown = event.currentTarget.closest('[role="menu"]');
129
+ const first = dropdown?.querySelector('[role^="menuitem"]');
130
+ const firstToggle = dropdown?.querySelector<HTMLElement>(
131
+ '[data-dg-part="columns-toggle"]',
132
+ );
133
+ if (!first || !firstToggle || first === firstToggle) return;
134
+ event.preventDefault();
135
+ firstToggle.focus();
136
+ };
137
+
138
+ return (
139
+ <>
140
+ {withSearch && (
141
+ <Menu.Search
142
+ value={search}
143
+ onChange={(event) => setSearch(event.currentTarget.value)}
144
+ onKeyDown={handleSearchKeyDown}
145
+ placeholder={labels.columnsSearchPlaceholder}
146
+ size={controlSize}
147
+ data-dg-part="columns-search"
148
+ />
149
+ )}
150
+ <TMDataGridMenuColumnToggles search={search} />
151
+ <Menu.Divider />
152
+ <TMDataGridMenuShowHideAll />
153
+ <TMDataGridMenuResetLayout />
154
+ </>
155
+ );
156
+ }
157
+
158
+ /**
159
+ * One checkbox item per hideable column, narrowed to `search` when one is
160
+ * given. Renders nothing when no column can be hidden.
161
+ */
162
+ export function TMDataGridMenuColumnToggles({ search }: { search?: string }) {
163
+ const { labels, controlSize } = useTMDataGridContext();
164
+ const { columns, columnVisibility } = useHideableColumns();
165
+
166
+ if (columns.length === 0) return null;
167
+
168
+ const needle = (search ?? "").trim().toLowerCase();
169
+ const listed = needle
170
+ ? columns.filter((column) =>
171
+ getColumnLabel(column).toLowerCase().includes(needle),
172
+ )
173
+ : columns;
174
+
175
+ return (
176
+ <ScrollArea.Autosize mah={260} type="auto">
177
+ {listed.length === 0 ? (
178
+ <Menu.Label>{labels.columnsNoMatch(search ?? "")}</Menu.Label>
179
+ ) : (
180
+ listed.map((column) => {
181
+ const visible = columnVisibility[column.id] !== false;
182
+ return (
183
+ <Menu.Item
184
+ key={column.id}
185
+ renderRoot={asCheckboxItem}
186
+ aria-checked={visible}
187
+ closeMenuOnClick={false}
188
+ leftSection={
189
+ <Checkbox.Indicator checked={visible} size={controlSize} />
190
+ }
191
+ onClick={() => column.toggleVisibility(!visible)}
192
+ data-dg-part="columns-toggle"
193
+ data-column-id={column.id}
194
+ >
195
+ {getColumnLabel(column)}
196
+ </Menu.Item>
197
+ );
198
+ })
199
+ )}
200
+ </ScrollArea.Autosize>
201
+ );
202
+ }
203
+
204
+ /**
205
+ * Shows or hides every listed column at once. Renders nothing when no column
206
+ * can be hidden.
207
+ */
208
+ export function TMDataGridMenuShowHideAll() {
209
+ const { labels, controlSize } = useTMDataGridContext();
210
+ const { columns, shownCount, setAllVisible } = useHideableColumns();
211
+
212
+ if (columns.length === 0) return null;
213
+
214
+ const all = shownCount === columns.length;
215
+ // Some shown but not all: an indeterminate box, and a click shows all - the
216
+ // same reading as the panel's checkbox.
217
+ const some = shownCount > 0 && !all;
218
+
219
+ return (
220
+ <Menu.Item
221
+ renderRoot={asCheckboxItem}
222
+ aria-checked={some ? "mixed" : all}
223
+ closeMenuOnClick={false}
224
+ leftSection={
225
+ <Checkbox.Indicator
226
+ checked={all}
227
+ indeterminate={some}
228
+ size={controlSize}
229
+ />
230
+ }
231
+ onClick={() => setAllVisible(!all)}
232
+ data-dg-part="columns-toggle-all"
233
+ >
234
+ {labels.columnsShowHideAll}
235
+ </Menu.Item>
236
+ );
237
+ }
238
+
239
+ /**
240
+ * Resets the whole layout, not only visibility: order, widths, pinning and
241
+ * grouping go back too. Not `table.resetColumnVisibility()`, which resets to
242
+ * `initialState` - and under persistence the mount built that *from* the
243
+ * persisted payload.
244
+ */
245
+ export function TMDataGridMenuResetLayout() {
246
+ const { labels, resetSettings } = useTMDataGridContext();
247
+
248
+ return (
249
+ <Menu.Item
250
+ leftSection={<RestoreIcon size={16} stroke={1.6} />}
251
+ onClick={() => resetSettings()}
252
+ data-dg-part="columns-reset"
253
+ >
254
+ {labels.columnsReset}
255
+ </Menu.Item>
256
+ );
257
+ }
258
+
259
+ /** Per-item overrides of the grid's `exportOptions`, and the item's text. */
260
+ export type TMDataGridMenuExportProps = Omit<
261
+ TMDataGridExportOptions,
262
+ "columns"
263
+ > & {
264
+ /**
265
+ * Which columns the item writes: `"visible"`, `"all"`, a list of ids, or
266
+ * `"custom"` - a picker listing every exportable column with the visible
267
+ * ones ticked, and the download on its Export button. Defaults to the grid's
268
+ * `exportOptions.columns`.
269
+ */
270
+ columns?: TMDataGridExportColumns | "custom";
271
+ /**
272
+ * The item's text. Defaults to `labels.exportAll`, or for the selected-rows
273
+ * item `labels.exportSelected(count)`. Two items offering two formats need
274
+ * two texts, which is what this is for.
275
+ */
276
+ label?: ReactNode;
277
+ };
278
+
279
+ /** The item's props as export options, with `"custom"` set aside for the picker. */
280
+ function splitExportProps({ label, columns, ...rest }: TMDataGridMenuExportProps) {
281
+ const custom = columns === "custom";
282
+ const options: TMDataGridExportOptions = custom
283
+ ? rest
284
+ : { ...rest, columns };
285
+ return { label, custom, options };
286
+ }
287
+
288
+ /**
289
+ * Downloads every filtered and sorted row, all pages, in the grid's export
290
+ * format. Props override `exportOptions` for this item alone, which is how one
291
+ * menu offers two formats.
292
+ */
293
+ export function TMDataGridMenuExport(props: TMDataGridMenuExportProps) {
294
+ const { label, custom, options } = splitExportProps(props);
295
+ const { ui, labels } = useTMDataGridContext();
296
+ const { exportAll } = useTMDataGridExport(options);
297
+
298
+ return (
299
+ <Menu.Item
300
+ leftSection={<DownloadIcon size={16} stroke={1.6} />}
301
+ onClick={() => {
302
+ if (custom) ui.actions.openExportPicker({ rows: "all", options });
303
+ else void exportAll();
304
+ }}
305
+ data-dg-part="menu-export"
306
+ >
307
+ {label ?? labels.exportAll}
308
+ </Menu.Item>
309
+ );
310
+ }
311
+
312
+ /**
313
+ * Downloads the selected rows, in grid order. Disabled while nothing is
314
+ * selected; renders nothing when row selection is off, since then there is
315
+ * never anything for it to do.
316
+ */
317
+ export function TMDataGridMenuExportSelected(props: TMDataGridMenuExportProps) {
318
+ const { label, custom, options } = splitExportProps(props);
319
+ const { ui, labels } = useTMDataGridContext();
320
+ const { exportSelected, selectedCount, canExportSelected } =
321
+ useTMDataGridExport(options);
322
+
323
+ if (!canExportSelected) return null;
324
+
325
+ return (
326
+ <Menu.Item
327
+ leftSection={<DownloadIcon size={16} stroke={1.6} />}
328
+ disabled={selectedCount === 0}
329
+ onClick={() => {
330
+ if (custom) ui.actions.openExportPicker({ rows: "selected", options });
331
+ else void exportSelected();
332
+ }}
333
+ data-dg-part="menu-export-selected"
334
+ >
335
+ {label ?? labels.exportSelected(selectedCount)}
336
+ </Menu.Item>
337
+ );
338
+ }
339
+
340
+ /**
341
+ * Burger menu in the grid's top-right corner, holding whatever the consumer
342
+ * puts in it. Every Mantine `Menu` prop is accepted and wins over the defaults
343
+ * below.
344
+ *
345
+ * It always renders. It cannot know whether its children render anything, so
346
+ * a menu holding only `TMDataGrid.Menu.Columns` on a grid where nothing can be
347
+ * hidden still shows its button over an empty dropdown - leave the menu out of
348
+ * the toolbar in that case.
349
+ */
350
+ export const TMDataGridMenu = Object.assign(TMDataGridMenuRoot, {
351
+ Columns: TMDataGridMenuColumns,
352
+ ColumnToggles: TMDataGridMenuColumnToggles,
353
+ ShowHideAll: TMDataGridMenuShowHideAll,
354
+ ResetLayout: TMDataGridMenuResetLayout,
355
+ Export: TMDataGridMenuExport,
356
+ ExportSelected: TMDataGridMenuExportSelected,
357
+ });