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

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 (150) hide show
  1. package/README.md +5 -212
  2. package/dist/index.d.ts +1664 -632
  3. package/dist/index.js +5226 -3223
  4. package/dist/index.js.map +1 -1
  5. package/dist/styles.css +1 -1
  6. package/docs/anatomy.md +102 -0
  7. package/docs/cell-selection.md +154 -0
  8. package/docs/column-layout.md +204 -0
  9. package/docs/columns.md +262 -0
  10. package/docs/components.md +304 -0
  11. package/docs/editing.md +603 -0
  12. package/docs/editors.md +250 -0
  13. package/docs/export.md +326 -0
  14. package/docs/filtering.md +358 -0
  15. package/docs/getting-started.md +123 -0
  16. package/docs/grouping.md +165 -0
  17. package/docs/loading-and-empty.md +92 -0
  18. package/docs/localization.md +79 -0
  19. package/docs/menu.md +143 -0
  20. package/docs/pagination.md +144 -0
  21. package/docs/persistence.md +111 -0
  22. package/docs/portfolio-rebalancer.md +94 -0
  23. package/docs/query-builder.md +175 -0
  24. package/docs/quick-search.md +83 -0
  25. package/docs/row-details.md +113 -0
  26. package/docs/row-interaction.md +148 -0
  27. package/docs/row-pinning.md +132 -0
  28. package/docs/row-selection.md +134 -0
  29. package/docs/row-styling.md +133 -0
  30. package/docs/scrolling.md +111 -0
  31. package/docs/server-query.md +246 -0
  32. package/docs/server-side.md +206 -0
  33. package/docs/sorting.md +101 -0
  34. package/docs/styling.md +126 -0
  35. package/docs/summary-row.md +76 -0
  36. package/docs/testing.md +309 -0
  37. package/docs/toolbar.md +161 -0
  38. package/docs/use-tm-data-grid.md +361 -0
  39. package/package.json +21 -45
  40. package/skills/appearance/SKILL.md +70 -17
  41. package/skills/cell-selection/SKILL.md +70 -76
  42. package/skills/columns/SKILL.md +131 -32
  43. package/skills/data/SKILL.md +100 -23
  44. package/skills/editing/SKILL.md +217 -96
  45. package/skills/editing/references/common-mistakes.md +111 -24
  46. package/skills/editing/references/editing-api.md +63 -39
  47. package/skills/editing/references/editors-and-validation.md +77 -19
  48. package/skills/filtering/SKILL.md +148 -40
  49. package/skills/getting-started/SKILL.md +18 -16
  50. package/skills/grouping/SKILL.md +32 -15
  51. package/skills/options/SKILL.md +39 -9
  52. package/skills/rows/SKILL.md +22 -18
  53. package/skills/server-side/SKILL.md +170 -17
  54. package/skills/testing/SKILL.md +10 -7
  55. package/src/{tmdatagrid/TMDataGridContext.ts → TMDataGridContext.ts} +7 -19
  56. package/src/{tmdatagrid/components → components}/TMDataGrid.module.css +7 -1
  57. package/src/{tmdatagrid/components → components}/TMDataGrid.tsx +39 -23
  58. package/src/{tmdatagrid/components → components}/TMDataGridCellEditor.tsx +106 -38
  59. package/src/{tmdatagrid/components → components}/TMDataGridColumnsPanel.module.css +5 -1
  60. package/src/{tmdatagrid/components → components}/TMDataGridColumnsPanel.tsx +47 -55
  61. package/src/{tmdatagrid/components → components}/TMDataGridDetailsColumn.tsx +4 -4
  62. package/src/components/TMDataGridDraftActions.tsx +307 -0
  63. package/src/{tmdatagrid/components → components}/TMDataGridEditColumn.tsx +58 -50
  64. package/src/{tmdatagrid/components → components}/TMDataGridEntryRows.tsx +150 -115
  65. package/src/components/TMDataGridExportPicker.module.css +77 -0
  66. package/src/components/TMDataGridExportPicker.tsx +234 -0
  67. package/src/components/TMDataGridFilterPanel.module.css +54 -0
  68. package/src/components/TMDataGridFilterPanel.tsx +348 -0
  69. package/src/{tmdatagrid/components → components}/TMDataGridFilterPills.tsx +7 -5
  70. package/src/components/TMDataGridFilterSurface.module.css +54 -0
  71. package/src/components/TMDataGridFilterSurface.tsx +167 -0
  72. package/src/{tmdatagrid/components → components}/TMDataGridFooter.tsx +53 -13
  73. package/src/{tmdatagrid/components → components}/TMDataGridGroupColumn.tsx +4 -3
  74. package/src/{tmdatagrid/components → components}/TMDataGridHeaderCell.module.css +10 -0
  75. package/src/{tmdatagrid/components → components}/TMDataGridHeaderCell.tsx +100 -28
  76. package/src/components/TMDataGridHeaderFilterRow.module.css +51 -0
  77. package/src/components/TMDataGridHeaderFilterRow.tsx +301 -0
  78. package/src/components/TMDataGridMenu.tsx +354 -0
  79. package/src/{tmdatagrid/components → components}/TMDataGridSelectColumn.tsx +12 -7
  80. package/src/{tmdatagrid/components → components}/TMDataGridTable.module.css +90 -67
  81. package/src/{tmdatagrid/components → components}/TMDataGridTable.tsx +678 -156
  82. package/src/components/TMDataGridToolbar.module.css +21 -0
  83. package/src/components/TMDataGridToolbar.tsx +181 -0
  84. package/src/{tmdatagrid/components → components}/editors/TMDataGridBooleanEditor.tsx +3 -3
  85. package/src/{tmdatagrid/components → components}/editors/TMDataGridDateEditor.tsx +3 -3
  86. package/src/{tmdatagrid/components → components}/editors/TMDataGridMultiSelectEditor.tsx +3 -3
  87. package/src/components/editors/TMDataGridNumberEditor.tsx +70 -0
  88. package/src/{tmdatagrid/components → components}/editors/TMDataGridSelectEditor.tsx +3 -3
  89. package/src/{tmdatagrid/components → components}/editors/TMDataGridStringEditor.tsx +3 -3
  90. package/src/{tmdatagrid/components → components}/editors/editorShared.ts +17 -31
  91. package/src/{tmdatagrid/components → components}/filters/DgAutocompleteFilter.tsx +5 -4
  92. package/src/{tmdatagrid/components → components}/filters/DgDateRangeFilter.tsx +22 -5
  93. package/src/{tmdatagrid/components → components}/filters/DgRangeSliderFilter.tsx +5 -1
  94. package/src/{tmdatagrid/components → components}/filters/DgTriStateFilter.tsx +5 -1
  95. package/src/{tmdatagrid/components → components}/filters/TMDataGridFilterValueInput.tsx +44 -28
  96. package/src/components/filters/controlLayout.ts +32 -0
  97. package/src/components/filters/filterControlFor.ts +65 -0
  98. package/src/{tmdatagrid/components → components}/icons.ts +1 -0
  99. package/src/{tmdatagrid/components → components}/sticky.module.css +44 -0
  100. package/src/components/useHideableColumns.ts +52 -0
  101. package/src/{tmdatagrid/core → core}/autosize.ts +5 -2
  102. package/src/{tmdatagrid/core → core}/capabilities.ts +14 -6
  103. package/src/{tmdatagrid/core → core}/columnOptions.ts +46 -0
  104. package/src/{tmdatagrid/core → core}/columnOrdering.ts +33 -14
  105. package/src/{tmdatagrid/core → core}/columnUtils.ts +44 -5
  106. package/src/core/controlledState.ts +179 -0
  107. package/src/core/controlledStateSync.ts +108 -0
  108. package/src/core/deletedRows.ts +34 -0
  109. package/src/core/dom.ts +74 -0
  110. package/src/core/editEngine.ts +2476 -0
  111. package/src/{tmdatagrid/core → core}/editorFocus.ts +8 -4
  112. package/src/core/export.ts +843 -0
  113. package/src/{tmdatagrid/core → core}/filterControls.ts +38 -1
  114. package/src/{tmdatagrid/core → core}/filterOperators.ts +64 -1
  115. package/src/core/filterSurface.ts +99 -0
  116. package/src/{tmdatagrid/core → core}/labels.ts +66 -8
  117. package/src/{tmdatagrid/core → core}/labelsSv.ts +26 -3
  118. package/src/core/pageReset.ts +120 -0
  119. package/src/{tmdatagrid/core → core}/persistence.ts +22 -5
  120. package/src/core/resizePreview.ts +141 -0
  121. package/src/core/summary.ts +59 -0
  122. package/src/core/useSettledTableState.ts +36 -0
  123. package/src/{tmdatagrid/index.ts → index.ts} +75 -12
  124. package/src/{tmdatagrid/useTMDataGrid.tsx → useTMDataGrid.tsx} +734 -135
  125. package/src/useTMDataGridExport.ts +78 -0
  126. package/src/tmdatagrid/components/TMDataGridEditActions.tsx +0 -162
  127. package/src/tmdatagrid/components/TMDataGridFilterPanel.module.css +0 -35
  128. package/src/tmdatagrid/components/TMDataGridFilterPanel.tsx +0 -354
  129. package/src/tmdatagrid/components/TMDataGridToolbar.module.css +0 -12
  130. package/src/tmdatagrid/components/TMDataGridToolbar.tsx +0 -162
  131. package/src/tmdatagrid/components/editors/TMDataGridNumberEditor.tsx +0 -40
  132. package/src/tmdatagrid/core/cellExport.ts +0 -320
  133. package/src/tmdatagrid/core/editEngine.ts +0 -1006
  134. package/src/tmdatagrid/core/summary.ts +0 -35
  135. /package/src/{tmdatagrid/components → components}/TMDataGridDetailsColumn.module.css +0 -0
  136. /package/src/{tmdatagrid/components → components}/TMDataGridFilterPills.module.css +0 -0
  137. /package/src/{tmdatagrid/components → components}/TMDataGridFooter.module.css +0 -0
  138. /package/src/{tmdatagrid/components → components}/TMDataGridGroupColumn.module.css +0 -0
  139. /package/src/{tmdatagrid/components → components}/TMDataGridRowNumberColumn.tsx +0 -0
  140. /package/src/{tmdatagrid/components → components}/TMDataGridSearch.tsx +0 -0
  141. /package/src/{tmdatagrid/core → core}/cellNavigation.ts +0 -0
  142. /package/src/{tmdatagrid/core → core}/cellRange.ts +0 -0
  143. /package/src/{tmdatagrid/core → core}/draftCellContext.ts +0 -0
  144. /package/src/{tmdatagrid/core → core}/expanding.ts +0 -0
  145. /package/src/{tmdatagrid/core → core}/grouping.ts +0 -0
  146. /package/src/{tmdatagrid/core → core}/matchHighlight.ts +0 -0
  147. /package/src/{tmdatagrid/core → core}/quickSearch.ts +0 -0
  148. /package/src/{tmdatagrid/core → core}/rowPinning.ts +0 -0
  149. /package/src/{tmdatagrid/core → core}/rowSelection.ts +0 -0
  150. /package/src/{tmdatagrid/core → core}/sizes.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)}
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,354 @@
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
+ <Menu.Target>
79
+ <Tooltip label={triggerLabel} openDelay={400}>
80
+ <ActionIcon
81
+ variant="subtle"
82
+ color="gray"
83
+ size={controlSize}
84
+ aria-label={triggerLabel}
85
+ data-dg-part="menu-button"
86
+ >
87
+ {icon ?? <BurgerIcon size={18} stroke={1.6} />}
88
+ </ActionIcon>
89
+ </Tooltip>
90
+ </Menu.Target>
91
+ <Menu.Dropdown>{children}</Menu.Dropdown>
92
+ </Menu>
93
+ );
94
+ }
95
+
96
+ /**
97
+ * The whole column chooser as menu items: a search box, one checkbox item per
98
+ * hideable column, show/hide all, and Reset layout. Renders nothing when no
99
+ * column can be hidden.
100
+ *
101
+ * `searchable` is only for a block at the top level of a dropdown.
102
+ * `Menu.Search` registers on the root menu context (`hasSearch`), which
103
+ * switches off type-ahead and the arrow-key handling of every dropdown of that
104
+ * menu, so a search inside a `Menu.Sub` breaks the parent menu's keyboard
105
+ * behaviour.
106
+ */
107
+ export function TMDataGridMenuColumns({
108
+ searchable = "auto",
109
+ }: TMDataGridMenuColumnsProps) {
110
+ const { labels, controlSize } = useTMDataGridContext();
111
+ const { columns } = useHideableColumns();
112
+ const [search, setSearch] = useState("");
113
+
114
+ if (columns.length === 0) return null;
115
+
116
+ const withSearch = showColumnSearch(searchable, columns.length);
117
+
118
+ // `Menu.Search` walks every item of the dropdown from the top, so with items
119
+ // placed above this block ArrowDown from the search lands on the first of
120
+ // those - and Enter runs it. When something precedes the block, ArrowDown
121
+ // moves focus to the first listed column instead; when nothing does,
122
+ // Mantine's own walk already starts there and is left alone.
123
+ const handleSearchKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
124
+ if (event.key !== "ArrowDown") return;
125
+ const dropdown = event.currentTarget.closest('[role="menu"]');
126
+ const first = dropdown?.querySelector('[role^="menuitem"]');
127
+ const firstToggle = dropdown?.querySelector<HTMLElement>(
128
+ '[data-dg-part="columns-toggle"]',
129
+ );
130
+ if (!first || !firstToggle || first === firstToggle) return;
131
+ event.preventDefault();
132
+ firstToggle.focus();
133
+ };
134
+
135
+ return (
136
+ <>
137
+ {withSearch && (
138
+ <Menu.Search
139
+ value={search}
140
+ onChange={(event) => setSearch(event.currentTarget.value)}
141
+ onKeyDown={handleSearchKeyDown}
142
+ placeholder={labels.columnsSearchPlaceholder}
143
+ size={controlSize}
144
+ data-dg-part="columns-search"
145
+ />
146
+ )}
147
+ <TMDataGridMenuColumnToggles search={search} />
148
+ <Menu.Divider />
149
+ <TMDataGridMenuShowHideAll />
150
+ <TMDataGridMenuResetLayout />
151
+ </>
152
+ );
153
+ }
154
+
155
+ /**
156
+ * One checkbox item per hideable column, narrowed to `search` when one is
157
+ * given. Renders nothing when no column can be hidden.
158
+ */
159
+ export function TMDataGridMenuColumnToggles({ search }: { search?: string }) {
160
+ const { labels, controlSize } = useTMDataGridContext();
161
+ const { columns, columnVisibility } = useHideableColumns();
162
+
163
+ if (columns.length === 0) return null;
164
+
165
+ const needle = (search ?? "").trim().toLowerCase();
166
+ const listed = needle
167
+ ? columns.filter((column) =>
168
+ getColumnLabel(column).toLowerCase().includes(needle),
169
+ )
170
+ : columns;
171
+
172
+ return (
173
+ <ScrollArea.Autosize mah={260} type="auto">
174
+ {listed.length === 0 ? (
175
+ <Menu.Label>{labels.columnsNoMatch(search ?? "")}</Menu.Label>
176
+ ) : (
177
+ listed.map((column) => {
178
+ const visible = columnVisibility[column.id] !== false;
179
+ return (
180
+ <Menu.Item
181
+ key={column.id}
182
+ renderRoot={asCheckboxItem}
183
+ aria-checked={visible}
184
+ closeMenuOnClick={false}
185
+ leftSection={
186
+ <Checkbox.Indicator checked={visible} size={controlSize} />
187
+ }
188
+ onClick={() => column.toggleVisibility(!visible)}
189
+ data-dg-part="columns-toggle"
190
+ data-column-id={column.id}
191
+ >
192
+ {getColumnLabel(column)}
193
+ </Menu.Item>
194
+ );
195
+ })
196
+ )}
197
+ </ScrollArea.Autosize>
198
+ );
199
+ }
200
+
201
+ /**
202
+ * Shows or hides every listed column at once. Renders nothing when no column
203
+ * can be hidden.
204
+ */
205
+ export function TMDataGridMenuShowHideAll() {
206
+ const { labels, controlSize } = useTMDataGridContext();
207
+ const { columns, shownCount, setAllVisible } = useHideableColumns();
208
+
209
+ if (columns.length === 0) return null;
210
+
211
+ const all = shownCount === columns.length;
212
+ // Some shown but not all: an indeterminate box, and a click shows all - the
213
+ // same reading as the panel's checkbox.
214
+ const some = shownCount > 0 && !all;
215
+
216
+ return (
217
+ <Menu.Item
218
+ renderRoot={asCheckboxItem}
219
+ aria-checked={some ? "mixed" : all}
220
+ closeMenuOnClick={false}
221
+ leftSection={
222
+ <Checkbox.Indicator
223
+ checked={all}
224
+ indeterminate={some}
225
+ size={controlSize}
226
+ />
227
+ }
228
+ onClick={() => setAllVisible(!all)}
229
+ data-dg-part="columns-toggle-all"
230
+ >
231
+ {labels.columnsShowHideAll}
232
+ </Menu.Item>
233
+ );
234
+ }
235
+
236
+ /**
237
+ * Resets the whole layout, not only visibility: order, widths, pinning and
238
+ * grouping go back too. Not `table.resetColumnVisibility()`, which resets to
239
+ * `initialState` - and under persistence the mount built that *from* the
240
+ * persisted payload.
241
+ */
242
+ export function TMDataGridMenuResetLayout() {
243
+ const { labels, resetSettings } = useTMDataGridContext();
244
+
245
+ return (
246
+ <Menu.Item
247
+ leftSection={<RestoreIcon size={16} stroke={1.6} />}
248
+ onClick={() => resetSettings()}
249
+ data-dg-part="columns-reset"
250
+ >
251
+ {labels.columnsReset}
252
+ </Menu.Item>
253
+ );
254
+ }
255
+
256
+ /** Per-item overrides of the grid's `exportOptions`, and the item's text. */
257
+ export type TMDataGridMenuExportProps = Omit<
258
+ TMDataGridExportOptions,
259
+ "columns"
260
+ > & {
261
+ /**
262
+ * Which columns the item writes: `"visible"`, `"all"`, a list of ids, or
263
+ * `"custom"` - a picker listing every exportable column with the visible
264
+ * ones ticked, and the download on its Export button. Defaults to the grid's
265
+ * `exportOptions.columns`.
266
+ */
267
+ columns?: TMDataGridExportColumns | "custom";
268
+ /**
269
+ * The item's text. Defaults to `labels.exportAll`, or for the selected-rows
270
+ * item `labels.exportSelected(count)`. Two items offering two formats need
271
+ * two texts, which is what this is for.
272
+ */
273
+ label?: ReactNode;
274
+ };
275
+
276
+ /** The item's props as export options, with `"custom"` set aside for the picker. */
277
+ function splitExportProps({ label, columns, ...rest }: TMDataGridMenuExportProps) {
278
+ const custom = columns === "custom";
279
+ const options: TMDataGridExportOptions = custom
280
+ ? rest
281
+ : { ...rest, columns };
282
+ return { label, custom, options };
283
+ }
284
+
285
+ /**
286
+ * Downloads every filtered and sorted row, all pages, in the grid's export
287
+ * format. Props override `exportOptions` for this item alone, which is how one
288
+ * menu offers two formats.
289
+ */
290
+ export function TMDataGridMenuExport(props: TMDataGridMenuExportProps) {
291
+ const { label, custom, options } = splitExportProps(props);
292
+ const { ui, labels } = useTMDataGridContext();
293
+ const { exportAll } = useTMDataGridExport(options);
294
+
295
+ return (
296
+ <Menu.Item
297
+ leftSection={<DownloadIcon size={16} stroke={1.6} />}
298
+ onClick={() => {
299
+ if (custom) ui.actions.openExportPicker({ rows: "all", options });
300
+ else void exportAll();
301
+ }}
302
+ data-dg-part="menu-export"
303
+ >
304
+ {label ?? labels.exportAll}
305
+ </Menu.Item>
306
+ );
307
+ }
308
+
309
+ /**
310
+ * Downloads the selected rows, in grid order. Disabled while nothing is
311
+ * selected; renders nothing when row selection is off, since then there is
312
+ * never anything for it to do.
313
+ */
314
+ export function TMDataGridMenuExportSelected(props: TMDataGridMenuExportProps) {
315
+ const { label, custom, options } = splitExportProps(props);
316
+ const { ui, labels } = useTMDataGridContext();
317
+ const { exportSelected, selectedCount, canExportSelected } =
318
+ useTMDataGridExport(options);
319
+
320
+ if (!canExportSelected) return null;
321
+
322
+ return (
323
+ <Menu.Item
324
+ leftSection={<DownloadIcon size={16} stroke={1.6} />}
325
+ disabled={selectedCount === 0}
326
+ onClick={() => {
327
+ if (custom) ui.actions.openExportPicker({ rows: "selected", options });
328
+ else void exportSelected();
329
+ }}
330
+ data-dg-part="menu-export-selected"
331
+ >
332
+ {label ?? labels.exportSelected(selectedCount)}
333
+ </Menu.Item>
334
+ );
335
+ }
336
+
337
+ /**
338
+ * Burger menu in the grid's top-right corner, holding whatever the consumer
339
+ * puts in it. Every Mantine `Menu` prop is accepted and wins over the defaults
340
+ * below.
341
+ *
342
+ * It always renders. It cannot know whether its children render anything, so
343
+ * a menu holding only `TMDataGrid.Menu.Columns` on a grid where nothing can be
344
+ * hidden still shows its button over an empty dropdown - leave the menu out of
345
+ * the toolbar in that case.
346
+ */
347
+ export const TMDataGridMenu = Object.assign(TMDataGridMenuRoot, {
348
+ Columns: TMDataGridMenuColumns,
349
+ ColumnToggles: TMDataGridMenuColumnToggles,
350
+ ShowHideAll: TMDataGridMenuShowHideAll,
351
+ ResetLayout: TMDataGridMenuResetLayout,
352
+ Export: TMDataGridMenuExport,
353
+ ExportSelected: TMDataGridMenuExportSelected,
354
+ });
@@ -2,9 +2,10 @@ import { Checkbox } from "@mantine/core";
2
2
  import { useSelector } from "@tanstack/react-store";
3
3
  import type { ColumnDef, Row, RowData } from "@tanstack/react-table";
4
4
  import {
5
- useCellControlTabIndex,
5
+ useBodyControlTabIndex,
6
6
  useTMDataGridContext,
7
7
  } from "../TMDataGridContext";
8
+ import { isMouseEvent } from "../core/dom";
8
9
  import {
9
10
  getDisplayedRows,
10
11
  getSelectableRowIds,
@@ -80,12 +81,14 @@ function SelectRowCheckbox<TData extends RowData>({
80
81
  // Cells render inside the grid's provider, so the checkbox can reach the
81
82
  // chrome store - it needs the shift-click pivot, and the feature flags to know
82
83
  // which row model a range is measured over.
83
- const { ui, features, labels } = useTMDataGridContext();
84
- // Out of the tab order once the grid has a cell cursor - see
85
- // useCellControlTabIndex. Enter on the lane still steps in, and Space on any
86
- // cell of the row ticks it.
87
- const tabIndex = useCellControlTabIndex();
84
+ const { ui, features, labels, edit } = useTMDataGridContext();
85
+ const tabIndex = useBodyControlTabIndex();
88
86
  const isGroupRow = row.subRows.length > 0;
87
+ // A deletion mark makes the row unselectable (see `enableRowSelection` in
88
+ // the hook), and the box below reads that through `getCanSelect`. The
89
+ // mark lives in the edit store, so the subscription is what re-renders
90
+ // the box when it lands - the row and cell identities do not change.
91
+ useSelector(edit.store, (state) => state.deletedRowIds.includes(row.id));
89
92
 
90
93
  // A group row is never selected by id: `rowSelection` only holds the leaves,
91
94
  // and TanStack's `getIsSelected()` is a plain lookup in that map. A group
@@ -109,6 +112,8 @@ function SelectRowCheckbox<TData extends RowData>({
109
112
  return (
110
113
  <Checkbox
111
114
  size="xs"
115
+ // See useBodyControlTabIndex: under cell selection Enter steps into the
116
+ // lane, the row's Tab walk reaches it, and Space on any cell ticks it.
112
117
  tabIndex={tabIndex}
113
118
  aria-label={isGroupRow ? labels.selectGroup : labels.selectRow}
114
119
  data-dg-part="select-row"
@@ -127,7 +132,7 @@ function SelectRowCheckbox<TData extends RowData>({
127
132
  // silently loses the clicked row, which `onChange` then toggles back off.
128
133
  onChange={(event) => {
129
134
  const native = event.nativeEvent;
130
- const isMouse = native instanceof MouseEvent;
135
+ const isMouse = isMouseEvent(native);
131
136
  const resolved = resolveRowSelectionClick({
132
137
  rows: getDisplayedRows(row.table, features),
133
138
  rowId: row.id,