@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,54 @@
1
+ /* A plain block of controls. Nothing here positions it - the popup and the
2
+ sidebar own their own placement, and a consumer placing the panel by hand
3
+ places it the way any other block. */
4
+ .filterPanel {
5
+ display: flex;
6
+ flex-direction: column;
7
+ gap: var(--mantine-spacing-xs);
8
+ }
9
+
10
+ /* Column, operator and value side by side, bottom-aligned so their fields line
11
+ up under labels of differing heights. */
12
+ .filterRow {
13
+ display: flex;
14
+ align-items: flex-end;
15
+ gap: var(--mantine-spacing-xs);
16
+ }
17
+
18
+ /* The narrow-host layout: the same three fields one under the other, each
19
+ filling the width, with the ✕ beside the column it removes. A card rather
20
+ than a bare stack, because without the side-by-side alignment there is
21
+ nothing else saying where one filter ends and the next begins. */
22
+ .filterRow[data-layout="stacked"] {
23
+ display: grid;
24
+ grid-template-columns: 1fr auto;
25
+ align-items: end;
26
+ gap: 6px var(--mantine-spacing-xs);
27
+ padding: var(--mantine-spacing-xs);
28
+ border: 1px solid var(--mantine-color-default-border);
29
+ border-radius: var(--mantine-radius-sm);
30
+ }
31
+
32
+ .filterRow[data-layout="stacked"] .filterRemove {
33
+ grid-column: 2;
34
+ grid-row: 1;
35
+ }
36
+
37
+ .filterRow[data-layout="stacked"] .filterColumnSlot {
38
+ grid-column: 1;
39
+ grid-row: 1;
40
+ min-width: 0;
41
+ }
42
+
43
+ .filterRow[data-layout="stacked"] .filterOperatorSlot,
44
+ .filterRow[data-layout="stacked"] .filterValueSlot {
45
+ grid-column: 1 / -1;
46
+ min-width: 0;
47
+ }
48
+
49
+ .filterPanelFooter {
50
+ display: flex;
51
+ align-items: center;
52
+ justify-content: space-between;
53
+ gap: var(--mantine-spacing-md);
54
+ }
@@ -0,0 +1,348 @@
1
+ import {
2
+ ActionIcon,
3
+ Box,
4
+ type BoxProps,
5
+ Button,
6
+ Select,
7
+ Stack,
8
+ TextInput,
9
+ } from "@mantine/core";
10
+ import { useSelector } from "@tanstack/react-store";
11
+ import { useEffect, useRef } from "react";
12
+ import classes from "./TMDataGridFilterPanel.module.css";
13
+ import { useTMDataGridContext } from "../TMDataGridContext";
14
+ import {
15
+ getColumnDefaultOperator,
16
+ getColumnOperators,
17
+ getColumnLabel,
18
+ getColumnType,
19
+ } from "../core/columnUtils";
20
+ import {
21
+ type TMDataGridFilterOperator,
22
+ type TMDataGridFilterValue,
23
+ emptyValueForOperator,
24
+ filterValueShape,
25
+ getDefaultOperator,
26
+ getOperatorsForType,
27
+ isTMDataGridFilterValue,
28
+ operatorNeedsValue,
29
+ } from "../core/filterOperators";
30
+ import type { TMDataGridFilterPanelLayout } from "../core/filterControls";
31
+ import { CloseIcon } from "./icons";
32
+ import { filterControlFor } from "./filters/filterControlFor";
33
+ import { TMDataGridFilterValueInput } from "./filters/TMDataGridFilterValueInput";
34
+
35
+ const FALLBACK_FILTER: TMDataGridFilterValue = { operator: "contains", value: "" };
36
+
37
+ function asFilterValue(value: unknown): TMDataGridFilterValue {
38
+ return isTMDataGridFilterValue(value) ? value : FALLBACK_FILTER;
39
+ }
40
+
41
+ /** Mantine's style props (`p="md"`, `w={320}`) are set on the panel block. */
42
+ export type TMDataGridFilterPanelProps = BoxProps & {
43
+ /**
44
+ * How one filter row is laid out.
45
+ *
46
+ * `"row"` - the default - puts column, operator and value side by side,
47
+ * which wants about 550px. `"stacked"` puts them one under the other, each
48
+ * filling the width, for a host too narrow for that: the sidebar surface
49
+ * uses it, and so should a panel you place in a drawer or a narrow column.
50
+ *
51
+ * Passed through to every value control as its `layout`, so a
52
+ * `meta.filter.control` can size itself to the same decision.
53
+ */
54
+ layout?: TMDataGridFilterPanelLayout;
55
+ };
56
+
57
+ /**
58
+ * The MUI-style filter surface: one row per active column filter, each row a
59
+ * column / operator / value triple, over an "Add filter" / "Clear all" footer.
60
+ *
61
+ * A plain block of controls, the way `TMDataGrid.ColumnsPanel` is - it renders
62
+ * whenever it is mounted and knows nothing about floating. The chrome that
63
+ * makes a floating surface (a title, a close button, click-away, Escape)
64
+ * belongs to the popup and the sidebar, so this can equally be dropped into a
65
+ * drawer, a page column or a form. See the `filters.surface` option.
66
+ *
67
+ * It only ever reads and writes the table's `columnFilters` state, so a
68
+ * `manualFiltering` grid gets the same panel for free - the state is forwarded
69
+ * to the server instead of a row model.
70
+ */
71
+ export function TMDataGridFilterPanel({
72
+ layout = "row",
73
+ className,
74
+ ...others
75
+ }: TMDataGridFilterPanelProps = {}) {
76
+ const { table, ui, labels, controlSize } = useTMDataGridContext();
77
+ const columnFilters = useSelector(
78
+ table.store,
79
+ (state) => state.columnFilters,
80
+ );
81
+ const focusColumnId = useSelector(ui, (state) => state.filterPanelColumnId);
82
+ const panelRef = useRef<HTMLDivElement>(null);
83
+ const stacked = layout === "stacked";
84
+
85
+ // Whoever pointed at a column - the column menu, a pill, the toolbar button -
86
+ // meant "let me type here", so the value slot takes the focus. Cleared once
87
+ // taken, which is what lets the same column be pointed at twice.
88
+ useEffect(() => {
89
+ if (focusColumnId === null) return;
90
+ const row = panelRef.current?.querySelector<HTMLElement>(
91
+ `[data-dg-part="filter-row"][data-column-id="${CSS.escape(focusColumnId)}"]`,
92
+ );
93
+ const slot = row?.querySelector<HTMLElement>("[data-dg-filter-value-slot]");
94
+ slot
95
+ ?.querySelector<HTMLElement>(
96
+ "input:not([type='hidden']), textarea, select, [tabindex]:not([tabindex='-1'])",
97
+ )
98
+ ?.focus();
99
+ ui.actions.focusPanelFilter(null);
100
+ }, [focusColumnId, ui]);
101
+
102
+ const filterableColumns = table
103
+ .getAllLeafColumns()
104
+ .filter((column) => column.getCanFilter());
105
+
106
+ const columnOptions = filterableColumns.map((column) => ({
107
+ value: column.id,
108
+ label: getColumnLabel(column),
109
+ }));
110
+
111
+ function removeFilter(columnId: string) {
112
+ table.setColumnFilters(
113
+ columnFilters.filter((filter) => filter.id !== columnId),
114
+ );
115
+ }
116
+
117
+ function changeFilterColumn(fromColumnId: string, toColumnId: string) {
118
+ const current = asFilterValue(
119
+ columnFilters.find((filter) => filter.id === fromColumnId)?.value,
120
+ );
121
+ const target = table.getColumn(toColumnId);
122
+ const operator = target
123
+ ? getColumnDefaultOperator(target)
124
+ : getDefaultOperator("string");
125
+ // The typed value only survives the move while it still fits the new
126
+ // operator - a text needle has no meaning to an `isAnyOf` set, or a set
127
+ // to a text input.
128
+ const value =
129
+ filterValueShape(operator) === filterValueShape(current.operator)
130
+ ? current.value
131
+ : emptyValueForOperator(operator);
132
+ table.setColumnFilters(
133
+ columnFilters
134
+ .filter((filter) => filter.id !== toColumnId)
135
+ .map((filter) =>
136
+ filter.id === fromColumnId
137
+ ? {
138
+ id: toColumnId,
139
+ value: { operator, value } satisfies TMDataGridFilterValue,
140
+ }
141
+ : filter,
142
+ ),
143
+ );
144
+ // The row now names a different column; put the caret back in its value
145
+ // slot rather than leaving it on a dropdown whose meaning just changed.
146
+ ui.actions.focusPanelFilter(toColumnId);
147
+ }
148
+
149
+ function patchFilter(columnId: string, patch: Partial<TMDataGridFilterValue>) {
150
+ table.setColumnFilters(
151
+ columnFilters.map((filter) =>
152
+ filter.id === columnId
153
+ ? { ...filter, value: { ...asFilterValue(filter.value), ...patch } }
154
+ : filter,
155
+ ),
156
+ );
157
+ }
158
+
159
+ function changeOperator(columnId: string, operator: TMDataGridFilterOperator) {
160
+ const current = asFilterValue(
161
+ columnFilters.find((filter) => filter.id === columnId)?.value,
162
+ );
163
+ // Same arity rule as changing the column: keep the value across operators
164
+ // of the same shape, reset it across any shape boundary.
165
+ const value =
166
+ filterValueShape(operator) === filterValueShape(current.operator)
167
+ ? current.value
168
+ : emptyValueForOperator(operator);
169
+ patchFilter(columnId, { operator, value });
170
+ }
171
+
172
+ function addFilter() {
173
+ const nextColumn = filterableColumns.find(
174
+ (column) => !columnFilters.some((filter) => filter.id === column.id),
175
+ );
176
+ if (!nextColumn) return;
177
+ const operator = getColumnDefaultOperator(nextColumn);
178
+ table.setColumnFilters([
179
+ ...columnFilters,
180
+ {
181
+ id: nextColumn.id,
182
+ value: {
183
+ operator,
184
+ value: emptyValueForOperator(operator),
185
+ } satisfies TMDataGridFilterValue,
186
+ },
187
+ ]);
188
+ ui.actions.focusPanelFilter(nextColumn.id);
189
+ }
190
+
191
+ const canAddFilter = filterableColumns.some(
192
+ (column) => !columnFilters.some((filter) => filter.id === column.id),
193
+ );
194
+
195
+ return (
196
+ <Box
197
+ ref={panelRef}
198
+ role="group"
199
+ aria-label={labels.filters}
200
+ data-dg-part="filter-panel"
201
+ data-layout={layout}
202
+ className={[classes.filterPanel, className].filter(Boolean).join(" ")}
203
+ {...others}
204
+ >
205
+ <Stack gap="xs">
206
+ {columnFilters.map((filter) => {
207
+ const column = table.getColumn(filter.id);
208
+ const value = asFilterValue(filter.value);
209
+ const type = column ? getColumnType(column) : "string";
210
+ const needsValue = operatorNeedsValue(value.operator);
211
+ const scalarValue = typeof value.value === "string" ? value.value : "";
212
+ // The same options-and-control decision the header cells make.
213
+ const { options, ValueControl } =
214
+ column === undefined
215
+ ? { options: [], ValueControl: TMDataGridFilterValueInput }
216
+ : filterControlFor(table, column);
217
+
218
+ return (
219
+ <div
220
+ key={filter.id}
221
+ // The row is the handle: a `meta.filter.control` renders whatever
222
+ // it likes in the value slot, so the one thing a test can always
223
+ // count on is which row it is in.
224
+ data-dg-part="filter-row"
225
+ data-column-id={filter.id}
226
+ data-layout={layout}
227
+ className={classes.filterRow}
228
+ >
229
+ <ActionIcon
230
+ variant="subtle"
231
+ color="gray"
232
+ size="lg"
233
+ className={classes.filterRemove}
234
+ aria-label={labels.removeFilter}
235
+ data-dg-part="filter-remove"
236
+ onClick={() => removeFilter(filter.id)}
237
+ >
238
+ <CloseIcon size={18} stroke={1.6} />
239
+ </ActionIcon>
240
+
241
+ <div className={classes.filterColumnSlot}>
242
+ <Select
243
+ label={labels.filterColumn}
244
+ size={controlSize}
245
+ w={stacked ? "100%" : 160}
246
+ allowDeselect={false}
247
+ // Both dropdowns portal, like every dropdown in the panel:
248
+ // drawn inline they are clipped by the grid frame's
249
+ // `overflow: hidden` under the popup, or by the sidebar's
250
+ // scroller. The popup's click-away exempts portal nodes.
251
+ data-dg-part="filter-column"
252
+ data={columnOptions}
253
+ value={filter.id}
254
+ onChange={(next) =>
255
+ next && changeFilterColumn(filter.id, next)
256
+ }
257
+ />
258
+ </div>
259
+
260
+ <div className={classes.filterOperatorSlot}>
261
+ <Select
262
+ label={labels.filterOperator}
263
+ size={controlSize}
264
+ w={stacked ? "100%" : 170}
265
+ allowDeselect={false}
266
+ data-dg-part="filter-operator"
267
+ data={(column
268
+ ? getColumnOperators(column)
269
+ : getOperatorsForType(type)
270
+ ).map((operator) => ({
271
+ value: operator,
272
+ label: labels.operators[operator],
273
+ }))}
274
+ value={value.operator}
275
+ onChange={(next) =>
276
+ next &&
277
+ changeOperator(filter.id, next as TMDataGridFilterOperator)
278
+ }
279
+ />
280
+ </div>
281
+
282
+ {/* What the focus effect above aims at. A `meta.filter.control`
283
+ may render anything, so the slot is the only landmark the
284
+ panel can count on. */}
285
+ <div className={classes.filterValueSlot} data-dg-filter-value-slot>
286
+ {column ? (
287
+ // `meta.filter.control` if the column declares one, the
288
+ // built-in shape-by-operator input otherwise - both through
289
+ // the same value-only contract.
290
+ <ValueControl
291
+ column={column}
292
+ table={table}
293
+ operator={value.operator}
294
+ value={value.value}
295
+ onChange={(next) => patchFilter(filter.id, { value: next })}
296
+ options={options}
297
+ size={controlSize}
298
+ labels={labels}
299
+ layout={layout}
300
+ />
301
+ ) : (
302
+ // The column is gone from the definition; the row survives
303
+ // only to be re-pointed or removed.
304
+ <TextInput
305
+ label={labels.filterValue}
306
+ size={controlSize}
307
+ w={stacked ? "100%" : 180}
308
+ data-dg-part="filter-value"
309
+ disabled={!needsValue}
310
+ placeholder={needsValue ? labels.filterValuePlaceholder : ""}
311
+ value={needsValue ? scalarValue : ""}
312
+ onChange={(event) =>
313
+ patchFilter(filter.id, {
314
+ value: event.currentTarget.value,
315
+ })
316
+ }
317
+ />
318
+ )}
319
+ </div>
320
+ </div>
321
+ );
322
+ })}
323
+
324
+ <div className={classes.filterPanelFooter}>
325
+ <Button
326
+ variant="subtle"
327
+ size="compact-sm"
328
+ disabled={!canAddFilter}
329
+ data-dg-part="filter-add"
330
+ onClick={addFilter}
331
+ >
332
+ {labels.addFilter}
333
+ </Button>
334
+ <Button
335
+ variant="subtle"
336
+ color="gray"
337
+ size="compact-sm"
338
+ disabled={columnFilters.length === 0}
339
+ data-dg-part="filter-clear-all"
340
+ onClick={() => table.setColumnFilters([])}
341
+ >
342
+ {labels.clearAllFilters}
343
+ </Button>
344
+ </div>
345
+ </Stack>
346
+ </Box>
347
+ );
348
+ }
@@ -1,4 +1,4 @@
1
- import { Button, Pill, UnstyledButton } from "@mantine/core";
1
+ import { Box, type BoxProps, Button, Pill, UnstyledButton } from "@mantine/core";
2
2
  import type { RowData } from "@tanstack/react-table";
3
3
  import { useSelector } from "@tanstack/react-store";
4
4
  import classes from "./TMDataGridFilterPills.module.css";
@@ -11,7 +11,8 @@ import {
11
11
  import type { TMDataGridSize } from "../core/sizes";
12
12
  import { openColumnFilter, type TMDataGridApi } from "../useTMDataGrid";
13
13
 
14
- export type TMDataGridFilterPillsProps<TData extends RowData> = {
14
+ /** Mantine's style props (`mb="sm"`, `hiddenFrom="sm"`) are set on the wrapper. */
15
+ export type TMDataGridFilterPillsProps<TData extends RowData> = BoxProps & {
15
16
  /** The object returned by `useTMDataGrid`. */
16
17
  api: TMDataGridApi<TData>;
17
18
  /** Mantine size of the pills. Defaults to `"sm"`. */
@@ -23,7 +24,6 @@ export type TMDataGridFilterPillsProps<TData extends RowData> = {
23
24
  * filter panel on that column.
24
25
  */
25
26
  onPillClick?: (columnId: string) => void;
26
- className?: string;
27
27
  };
28
28
 
29
29
  /**
@@ -52,6 +52,7 @@ export function TMDataGridFilterPills<TData extends RowData>({
52
52
  showClearAll = true,
53
53
  onPillClick,
54
54
  className,
55
+ ...others
55
56
  }: TMDataGridFilterPillsProps<TData>) {
56
57
  const { table, labels } = api;
57
58
  const columnFilters = useSelector(
@@ -65,11 +66,12 @@ export function TMDataGridFilterPills<TData extends RowData>({
65
66
  if (activeFilters.length === 0) return null;
66
67
 
67
68
  return (
68
- <div
69
+ <Box
69
70
  role="group"
70
71
  aria-label={labels.activeFilters}
71
72
  data-dg-part="filter-pills"
72
73
  className={[classes.filterPills, className].filter(Boolean).join(" ")}
74
+ {...others}
73
75
  >
74
76
  <Pill.Group size={size}>
75
77
  {activeFilters.map((filter) => {
@@ -137,6 +139,6 @@ export function TMDataGridFilterPills<TData extends RowData>({
137
139
  {labels.clearAllFilters}
138
140
  </Button>
139
141
  )}
140
- </div>
142
+ </Box>
141
143
  );
142
144
  }
@@ -0,0 +1,54 @@
1
+ /* Chrome shared by the two automatic surfaces. The panel inside them is a
2
+ plain block; everything that makes one float or sit beside the rows is
3
+ here. */
4
+
5
+ .surfaceHeader {
6
+ display: flex;
7
+ align-items: center;
8
+ justify-content: space-between;
9
+ gap: var(--mantine-spacing-md);
10
+ padding-bottom: var(--mantine-spacing-xs);
11
+ }
12
+
13
+ /* Floats over the first body rows, anchored to `.tableWrapper`. Sits below the
14
+ whole header - group rows and the filter row included - which the table
15
+ measures onto the wrapper as `--dg-header-stack-height`. The size scale's
16
+ single-row value is the fallback for the frame before the first
17
+ measurement. */
18
+ .filterPopup {
19
+ position: absolute;
20
+ top: calc(var(--dg-header-stack-height, var(--dg-header-height)) + 2px);
21
+ left: var(--mantine-spacing-md);
22
+ z-index: var(--dg-z-filter-popup, 20);
23
+ padding: var(--mantine-spacing-sm);
24
+ border-radius: var(--mantine-radius-md);
25
+ border: 1px solid var(--mantine-color-default-border);
26
+ background-color: var(--mantine-color-body);
27
+ box-shadow: var(--mantine-shadow-md);
28
+ }
29
+
30
+ /* A column of the table wrapper rather than a layer over it: the rows give up
31
+ the width instead of being covered by it. `flex: none` so the declared
32
+ width holds whatever the panel inside asks for. */
33
+ .filterSidebar {
34
+ display: flex;
35
+ flex-direction: column;
36
+ flex: none;
37
+ min-width: 0;
38
+ padding: var(--mantine-spacing-sm);
39
+ background-color: var(--mantine-color-body);
40
+ border-left: 1px solid var(--mantine-color-default-border);
41
+ }
42
+
43
+ /* On the left it is the same panel with its divider on the other edge, and
44
+ ahead of the scroll container in the flex order. */
45
+ .filterSidebar[data-side="left"] {
46
+ order: -1;
47
+ border-left: none;
48
+ border-right: 1px solid var(--mantine-color-default-border);
49
+ }
50
+
51
+ .filterSidebarBody {
52
+ flex: 1;
53
+ min-height: 0;
54
+ }
@@ -0,0 +1,167 @@
1
+ import { ActionIcon, ScrollArea, Text } from "@mantine/core";
2
+ import { useSelector } from "@tanstack/react-store";
3
+ import { useEffect, useRef } from "react";
4
+ import classes from "./TMDataGridFilterSurface.module.css";
5
+ import { useTMDataGridContext } from "../TMDataGridContext";
6
+ import { isElement } from "../core/dom";
7
+ import { CloseIcon } from "./icons";
8
+ import { TMDataGridFilterPanel } from "./TMDataGridFilterPanel";
9
+
10
+ /**
11
+ * The title row both automatic surfaces wear: the "Filters" heading and the
12
+ * close button that puts the surface away without touching the filters.
13
+ */
14
+ function FilterSurfaceHeader() {
15
+ const { ui, labels, controlSize } = useTMDataGridContext();
16
+ return (
17
+ <div className={classes.surfaceHeader}>
18
+ <Text span size={controlSize} fw={600}>
19
+ {labels.filters}
20
+ </Text>
21
+ <ActionIcon
22
+ variant="subtle"
23
+ color="gray"
24
+ size="sm"
25
+ aria-label={labels.closeFilters}
26
+ data-dg-part="filter-panel-close"
27
+ onClick={ui.actions.closeFilterPanel}
28
+ >
29
+ <CloseIcon size={16} stroke={1.6} />
30
+ </ActionIcon>
31
+ </div>
32
+ );
33
+ }
34
+
35
+ /**
36
+ * Closes the popup once its last filter row goes, covering both ways that
37
+ * happens - removed by hand, or "Clear all" - without either of them having
38
+ * to know which surface is showing.
39
+ *
40
+ * On the transition to zero, not on being zero: a popup asked to start open
41
+ * with no filters yet would otherwise close itself before its first paint.
42
+ */
43
+ function useCloseWhenEmptied(opened: boolean) {
44
+ const { table, ui } = useTMDataGridContext();
45
+ const filterCount = useSelector(
46
+ table.store,
47
+ (state) => state.columnFilters.length,
48
+ );
49
+ const previousCount = useRef(filterCount);
50
+ useEffect(() => {
51
+ const emptied = previousCount.current > 0 && filterCount === 0;
52
+ previousCount.current = filterCount;
53
+ if (opened && emptied) ui.actions.closeFilterPanel();
54
+ }, [opened, filterCount, ui]);
55
+ }
56
+
57
+ /**
58
+ * `filters.surface: "popup"` - the panel floating over the first body rows,
59
+ * anchored under the header. The default surface, and the only one before
60
+ * `filters` existed.
61
+ *
62
+ * Everything that makes it floating lives here rather than in the panel: a
63
+ * pointerdown outside closes it, Escape closes it, and emptying it closes it.
64
+ *
65
+ * @internal Rendered by `TMDataGrid.Table`.
66
+ */
67
+ export function TMDataGridFilterPopup() {
68
+ const { ui, labels } = useTMDataGridContext();
69
+ const opened = useSelector(ui, (state) => state.filterPanelOpen);
70
+ const popupRef = useRef<HTMLDivElement>(null);
71
+ useCloseWhenEmptied(opened);
72
+
73
+ // Clicking away hides the popup, the way any floating surface behaves. On
74
+ // pointerdown rather than click, so a press that starts outside dismisses it
75
+ // even when the pointer is released somewhere else.
76
+ useEffect(() => {
77
+ if (!opened) return;
78
+
79
+ const handlePointerDown = (event: PointerEvent) => {
80
+ const target = event.target;
81
+ if (!isElement(target)) return;
82
+ if (popupRef.current?.contains(target)) return;
83
+ // The toolbar button toggles the popup itself. Closing from here first
84
+ // would leave its click reopening what the user meant to close.
85
+ if (target.closest('[data-dg-part="filter-button"]')) return;
86
+ // A portalled overlay - a header filter's dropdown, a custom control's
87
+ // portalled picker - is outside the popup in the DOM but not in meaning:
88
+ // a press in one is a press in a control, not a click away. Mantine
89
+ // stamps `data-portal` on every portal node it creates.
90
+ if (target.closest("[data-portal]")) return;
91
+ ui.actions.closeFilterPanel();
92
+ };
93
+
94
+ // The popup's own document, not the global one: rendered through a
95
+ // portal into a window opened with `window.open`, the global `document`
96
+ // is the opener's and never sees a press in the grid's window.
97
+ const doc = popupRef.current?.ownerDocument ?? document;
98
+ doc.addEventListener("pointerdown", handlePointerDown);
99
+ return () => doc.removeEventListener("pointerdown", handlePointerDown);
100
+ }, [opened, ui]);
101
+
102
+ if (!opened) return null;
103
+
104
+ return (
105
+ <div
106
+ ref={popupRef}
107
+ data-dg-part="filter-popup"
108
+ className={classes.filterPopup}
109
+ aria-label={labels.filters}
110
+ // Escape is what closes a floating surface, and every control that can
111
+ // hold focus in here sits inside this element.
112
+ onKeyDown={(event) => {
113
+ if (event.key !== "Escape") return;
114
+ event.stopPropagation();
115
+ ui.actions.closeFilterPanel();
116
+ }}
117
+ >
118
+ <FilterSurfaceHeader />
119
+ <TMDataGridFilterPanel />
120
+ </div>
121
+ );
122
+ }
123
+
124
+ /**
125
+ * `filters.surface: "sidebar"` - the panel beside the table, inside the grid
126
+ * frame and under the toolbar, taking width from the rows rather than covering
127
+ * them.
128
+ *
129
+ * Not a floating surface: a click in the table is a click on the rows the
130
+ * sidebar is filtering, so nothing about it dismisses the panel, and clearing
131
+ * the filters leaves it standing with its "Add filter" button - a place to
132
+ * work from rather than a thing that appears and goes away. Escape closes it,
133
+ * because a keyboard user inside it needs a way back out to the grid.
134
+ *
135
+ * @internal Rendered by `TMDataGrid.Table`.
136
+ */
137
+ export function TMDataGridFilterSidebar() {
138
+ const { ui, filters, labels } = useTMDataGridContext();
139
+ const opened = useSelector(ui, (state) => state.filterPanelOpen);
140
+
141
+ if (!opened) return null;
142
+
143
+ return (
144
+ <div
145
+ data-dg-part="filter-sidebar"
146
+ data-side={filters.sidebarSide}
147
+ className={classes.filterSidebar}
148
+ style={{ width: filters.sidebarWidth }}
149
+ aria-label={labels.filters}
150
+ onKeyDown={(event) => {
151
+ if (event.key !== "Escape") return;
152
+ event.stopPropagation();
153
+ ui.actions.closeFilterPanel();
154
+ }}
155
+ >
156
+ <FilterSurfaceHeader />
157
+ {/* The panel's rows are as tall as the filters put in them, and the
158
+ sidebar is as tall as the table beside it - so the overflow is the
159
+ sidebar's to scroll, not the page's. */}
160
+ <ScrollArea type="auto" className={classes.filterSidebarBody}>
161
+ {/* Stacked, not side by side: a filter row wants about 550px laid out
162
+ in a line, and the sidebar is 280. */}
163
+ <TMDataGridFilterPanel layout="stacked" />
164
+ </ScrollArea>
165
+ </div>
166
+ );
167
+ }