@jielga/tmdatagrid 2.0.0-beta.8 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/README.md +5 -212
  2. package/dist/index.d.ts +1323 -796
  3. package/dist/index.js +4719 -3193
  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 +268 -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 +69 -78
  47. package/skills/columns/SKILL.md +90 -34
  48. package/skills/data/SKILL.md +86 -16
  49. package/skills/editing/SKILL.md +83 -50
  50. package/skills/editing/references/common-mistakes.md +77 -69
  51. package/skills/editing/references/editing-api.md +31 -23
  52. package/skills/editing/references/editors-and-validation.md +24 -17
  53. package/skills/filtering/SKILL.md +148 -40
  54. package/skills/getting-started/SKILL.md +17 -15
  55. package/skills/grouping/SKILL.md +31 -16
  56. package/skills/options/SKILL.md +8 -8
  57. package/skills/rows/SKILL.md +22 -18
  58. package/skills/server-side/SKILL.md +170 -17
  59. package/skills/testing/SKILL.md +150 -32
  60. package/skills/testing-components/SKILL.md +230 -0
  61. package/skills/testing-editing/SKILL.md +240 -0
  62. package/src/{tmdatagrid/TMDataGridContext.ts → TMDataGridContext.ts} +7 -19
  63. package/src/{tmdatagrid/components → components}/TMDataGrid.module.css +7 -1
  64. package/src/{tmdatagrid/components → components}/TMDataGrid.tsx +38 -21
  65. package/src/{tmdatagrid/components → components}/TMDataGridCellEditor.tsx +73 -7
  66. package/src/{tmdatagrid/components → components}/TMDataGridColumnsPanel.module.css +5 -1
  67. package/src/{tmdatagrid/components → components}/TMDataGridColumnsPanel.tsx +47 -55
  68. package/src/{tmdatagrid/components → components}/TMDataGridDetailsColumn.tsx +9 -55
  69. package/src/{tmdatagrid/components → components}/TMDataGridDraftActions.tsx +41 -24
  70. package/src/{tmdatagrid/components → components}/TMDataGridEditColumn.tsx +23 -63
  71. package/src/components/TMDataGridEntryRows.tsx +354 -0
  72. package/src/components/TMDataGridExportPicker.module.css +77 -0
  73. package/src/components/TMDataGridExportPicker.tsx +234 -0
  74. package/src/components/TMDataGridFilterPanel.module.css +54 -0
  75. package/src/components/TMDataGridFilterPanel.tsx +348 -0
  76. package/src/{tmdatagrid/components → components}/TMDataGridFilterPills.tsx +15 -8
  77. package/src/components/TMDataGridFilterSurface.module.css +54 -0
  78. package/src/components/TMDataGridFilterSurface.tsx +167 -0
  79. package/src/{tmdatagrid/components → components}/TMDataGridFooter.tsx +53 -90
  80. package/src/{tmdatagrid/components → components}/TMDataGridGroupColumn.tsx +9 -72
  81. package/src/{tmdatagrid/components → components}/TMDataGridHeaderCell.module.css +12 -2
  82. package/src/{tmdatagrid/components → components}/TMDataGridHeaderCell.tsx +58 -21
  83. package/src/components/TMDataGridHeaderFilterRow.module.css +51 -0
  84. package/src/components/TMDataGridHeaderFilterRow.tsx +301 -0
  85. package/src/components/TMDataGridMenu.tsx +357 -0
  86. package/src/{tmdatagrid/components → components}/TMDataGridSelectColumn.tsx +15 -53
  87. package/src/{tmdatagrid/components → components}/TMDataGridTable.module.css +88 -65
  88. package/src/{tmdatagrid/components → components}/TMDataGridTable.tsx +579 -165
  89. package/src/{tmdatagrid/components → components}/TMDataGridToolbar.module.css +5 -0
  90. package/src/components/TMDataGridToolbar.tsx +181 -0
  91. package/src/{tmdatagrid/components → components}/editors/TMDataGridBooleanEditor.tsx +3 -3
  92. package/src/{tmdatagrid/components → components}/editors/TMDataGridDateEditor.tsx +3 -3
  93. package/src/{tmdatagrid/components → components}/editors/TMDataGridMultiSelectEditor.tsx +3 -3
  94. package/src/{tmdatagrid/components → components}/editors/TMDataGridNumberEditor.tsx +3 -3
  95. package/src/{tmdatagrid/components → components}/editors/TMDataGridSelectEditor.tsx +3 -3
  96. package/src/{tmdatagrid/components → components}/editors/TMDataGridStringEditor.tsx +3 -3
  97. package/src/{tmdatagrid/components → components}/editors/editorShared.ts +16 -2
  98. package/src/{tmdatagrid/components → components}/filters/DgAutocompleteFilter.tsx +5 -4
  99. package/src/{tmdatagrid/components → components}/filters/DgDateRangeFilter.tsx +22 -5
  100. package/src/{tmdatagrid/components → components}/filters/DgRangeSliderFilter.tsx +5 -1
  101. package/src/{tmdatagrid/components → components}/filters/DgTriStateFilter.tsx +5 -1
  102. package/src/{tmdatagrid/components → components}/filters/TMDataGridFilterValueInput.tsx +44 -28
  103. package/src/components/filters/controlLayout.ts +32 -0
  104. package/src/components/filters/filterControlFor.ts +65 -0
  105. package/src/components/generatedColumns.tsx +187 -0
  106. package/src/{tmdatagrid/components → components}/icons.ts +1 -0
  107. package/src/{tmdatagrid/components → components}/sticky.module.css +44 -0
  108. package/src/components/useHideableColumns.ts +52 -0
  109. package/src/{tmdatagrid/core → core}/autosize.ts +5 -2
  110. package/src/{tmdatagrid/core → core}/capabilities.ts +5 -5
  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 +1172 -388
  118. package/src/{tmdatagrid/core → core}/editorFocus.ts +8 -4
  119. package/src/core/export.ts +704 -0
  120. package/src/{tmdatagrid/core → core}/filterControls.ts +38 -1
  121. package/src/{tmdatagrid/core → core}/filterOperators.ts +64 -1
  122. package/src/core/filterSurface.ts +99 -0
  123. package/src/{tmdatagrid/core → core}/grouping.ts +21 -0
  124. package/src/{tmdatagrid/core → core}/labels.ts +51 -6
  125. package/src/{tmdatagrid/core → core}/labelsSv.ts +27 -6
  126. package/src/core/pageReset.ts +120 -0
  127. package/src/core/pagination.ts +81 -0
  128. package/src/{tmdatagrid/core → core}/persistence.ts +22 -5
  129. package/src/{tmdatagrid/core → core}/summary.ts +20 -4
  130. package/src/{tmdatagrid/index.ts → index.ts} +70 -36
  131. package/src/{tmdatagrid/useTMDataGrid.tsx → useTMDataGrid.tsx} +534 -123
  132. package/src/useTMDataGridExport.ts +78 -0
  133. package/src/tmdatagrid/components/TMDataGridEntryRows.tsx +0 -298
  134. package/src/tmdatagrid/components/TMDataGridFilterPanel.module.css +0 -35
  135. package/src/tmdatagrid/components/TMDataGridFilterPanel.tsx +0 -354
  136. package/src/tmdatagrid/components/TMDataGridToolbar.tsx +0 -161
  137. package/src/tmdatagrid/core/cellExport.ts +0 -320
  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}/cellNavigation.ts +0 -0
  145. /package/src/{tmdatagrid/core → core}/cellRange.ts +0 -0
  146. /package/src/{tmdatagrid/core → core}/controlledState.ts +0 -0
  147. /package/src/{tmdatagrid/core → core}/draftCellContext.ts +0 -0
  148. /package/src/{tmdatagrid/core → core}/expanding.ts +0 -0
  149. /package/src/{tmdatagrid/core → core}/matchHighlight.ts +0 -0
  150. /package/src/{tmdatagrid/core → core}/quickSearch.ts +0 -0
  151. /package/src/{tmdatagrid/core → core}/resizePreview.ts +0 -0
  152. /package/src/{tmdatagrid/core → core}/rowPinning.ts +0 -0
  153. /package/src/{tmdatagrid/core → core}/rowSelection.ts +0 -0
  154. /package/src/{tmdatagrid/core → core}/sizes.ts +0 -0
  155. /package/src/{tmdatagrid/core → core}/useSettledTableState.ts +0 -0
@@ -0,0 +1,234 @@
1
+ import {
2
+ Button,
3
+ Checkbox,
4
+ Group,
5
+ type MantineSize,
6
+ Modal,
7
+ ScrollArea,
8
+ Stack,
9
+ Text,
10
+ TextInput,
11
+ } from "@mantine/core";
12
+ import { useSelector } from "@tanstack/react-store";
13
+ import { useState } from "react";
14
+ import classes from "./TMDataGridExportPicker.module.css";
15
+ import { useTMDataGridContext } from "../TMDataGridContext";
16
+ import { getColumnLabel, showColumnSearch } from "../core/columnUtils";
17
+ import {
18
+ countSelectedExportRows,
19
+ getExportableColumns,
20
+ resolveExportOptions,
21
+ type TMDataGridExportPickerRequest,
22
+ } from "../core/export";
23
+ import type { TMDataGridSize } from "../core/sizes";
24
+ import { useTMDataGridExport } from "../useTMDataGridExport";
25
+ import { SearchIcon } from "./icons";
26
+
27
+ /**
28
+ * The column picker behind `columns="custom"` on the export menu items: a
29
+ * list of every exportable column, the visible ones ticked and the hidden
30
+ * ones marked, a select-all row over the list, a search box once the list is
31
+ * long, and an Export button that downloads the ticked ones.
32
+ *
33
+ * Mounted once by the root and driven by `ui.state.exportPicker`, because the
34
+ * menu item that opens it unmounts with the dropdown on the click. The form
35
+ * inside is keyed on the request, so each opening starts from the visible
36
+ * columns again rather than from the last choice.
37
+ */
38
+ export function TMDataGridExportPicker() {
39
+ const { ui, labels, size, controlSize, exportOptions } =
40
+ useTMDataGridContext();
41
+ const request = useSelector(ui, (state) => state.exportPicker);
42
+ const format = request
43
+ ? resolveExportOptions(exportOptions, request.options).format
44
+ : null;
45
+
46
+ return (
47
+ <Modal
48
+ opened={request !== null}
49
+ onClose={() => ui.actions.closeExportPicker()}
50
+ title={
51
+ format && labels.exportPickerTitle(format.extension.toUpperCase())
52
+ }
53
+ size={MODAL_WIDTH[size]}
54
+ closeButtonProps={{ size: CLOSE_BUTTON_SIZE[controlSize] }}
55
+ classNames={{ title: classes.title }}
56
+ styles={{ title: { fontSize: `var(--mantine-font-size-${controlSize})` } }}
57
+ >
58
+ {request && <PickerForm request={request} />}
59
+ </Modal>
60
+ );
61
+ }
62
+
63
+ /** Dialog width per grid size: the same three steps as the controls. */
64
+ const MODAL_WIDTH: Record<TMDataGridSize, MantineSize> = {
65
+ xs: "xs",
66
+ sm: "xs",
67
+ md: "sm",
68
+ lg: "sm",
69
+ xl: "md",
70
+ };
71
+
72
+ /** The close button one step above the controls, which is Mantine's own ratio. */
73
+ const CLOSE_BUTTON_SIZE: Record<string, MantineSize> = {
74
+ xs: "sm",
75
+ sm: "md",
76
+ md: "lg",
77
+ };
78
+
79
+ function PickerForm({ request }: { request: TMDataGridExportPickerRequest }) {
80
+ const { table, ui, labels, controlSize } = useTMDataGridContext();
81
+ const { exportAll, exportSelected } = useTMDataGridExport(request.options);
82
+ const columns = getExportableColumns(table);
83
+ const [search, setSearch] = useState("");
84
+ const [checked, setChecked] = useState<ReadonlySet<string>>(
85
+ () =>
86
+ new Set(
87
+ columns
88
+ .filter((column) => column.getIsVisible())
89
+ .map((column) => column.id),
90
+ ),
91
+ );
92
+
93
+ const searchable = showColumnSearch("auto", columns.length);
94
+ const needle = search.trim().toLowerCase();
95
+ const listed = needle
96
+ ? columns.filter((column) =>
97
+ getColumnLabel(column).toLowerCase().includes(needle),
98
+ )
99
+ : columns;
100
+
101
+ // Select all works on the listed columns: with a search active it ticks the
102
+ // matches and leaves the rest as they are, the way a filtered list reads.
103
+ const listedChecked = listed.filter((column) => checked.has(column.id));
104
+ const allListed = listed.length > 0 && listedChecked.length === listed.length;
105
+ const someListed = listedChecked.length > 0 && !allListed;
106
+
107
+ const toggle = (columnId: string, on: boolean) =>
108
+ setChecked((previous) => {
109
+ const next = new Set(previous);
110
+ if (on) next.add(columnId);
111
+ else next.delete(columnId);
112
+ return next;
113
+ });
114
+
115
+ const toggleListed = (on: boolean) =>
116
+ setChecked((previous) => {
117
+ const next = new Set(previous);
118
+ for (const column of listed) {
119
+ if (on) next.add(column.id);
120
+ else next.delete(column.id);
121
+ }
122
+ return next;
123
+ });
124
+
125
+ const confirm = async () => {
126
+ // Render order, whatever order the boxes were ticked in.
127
+ const ids = columns
128
+ .filter((column) => checked.has(column.id))
129
+ .map((column) => column.id);
130
+ const run = request.rows === "selected" ? exportSelected : exportAll;
131
+ await run({ columns: ids });
132
+ ui.actions.closeExportPicker();
133
+ };
134
+
135
+ const hint = labels.exportPickerHint(
136
+ request.rows === "selected" ? countSelectedExportRows(table) : null,
137
+ );
138
+
139
+ return (
140
+ <Stack gap="sm" data-dg-part="export-picker">
141
+ <Text size={controlSize} c="dimmed" data-dg-part="export-picker-hint">
142
+ {hint}
143
+ </Text>
144
+
145
+ {searchable && (
146
+ <TextInput
147
+ value={search}
148
+ onChange={(event) => setSearch(event.currentTarget.value)}
149
+ placeholder={labels.columnsSearchPlaceholder}
150
+ leftSection={<SearchIcon size={16} stroke={1.6} />}
151
+ size={controlSize}
152
+ data-dg-part="export-picker-search"
153
+ data-autofocus
154
+ />
155
+ )}
156
+
157
+ <div className={classes.list}>
158
+ <div className={classes.listHeader}>
159
+ <Checkbox
160
+ size={controlSize}
161
+ label={labels.exportPickerSelectAll}
162
+ checked={allListed}
163
+ indeterminate={someListed}
164
+ onChange={(event) => toggleListed(event.currentTarget.checked)}
165
+ classNames={{ label: classes.headerLabel }}
166
+ data-dg-part="export-column-all"
167
+ />
168
+ <Text size="xs" c="dimmed" data-dg-part="export-picker-count">
169
+ {labels.exportPickerCount(checked.size, columns.length)}
170
+ </Text>
171
+ </div>
172
+
173
+ <ScrollArea.Autosize mah="50vh" type="auto">
174
+ <div className={classes.rows}>
175
+ {listed.map((column) => (
176
+ <Checkbox
177
+ key={column.id}
178
+ size={controlSize}
179
+ label={
180
+ <>
181
+ <span className={classes.rowText}>
182
+ {getColumnLabel(column)}
183
+ </span>
184
+ {!column.getIsVisible() && (
185
+ <span className={classes.rowHint}>
186
+ {labels.exportPickerHidden}
187
+ </span>
188
+ )}
189
+ </>
190
+ }
191
+ checked={checked.has(column.id)}
192
+ onChange={(event) =>
193
+ toggle(column.id, event.currentTarget.checked)
194
+ }
195
+ classNames={{
196
+ root: classes.row,
197
+ body: classes.rowBody,
198
+ labelWrapper: classes.rowLabelWrapper,
199
+ label: classes.rowLabel,
200
+ }}
201
+ data-dg-part="export-column"
202
+ data-column-id={column.id}
203
+ />
204
+ ))}
205
+ {listed.length === 0 && (
206
+ <Text size={controlSize} c="dimmed" className={classes.empty}>
207
+ {labels.columnsNoMatch(search)}
208
+ </Text>
209
+ )}
210
+ </div>
211
+ </ScrollArea.Autosize>
212
+ </div>
213
+
214
+ <Group justify="flex-end" gap="xs">
215
+ <Button
216
+ variant="default"
217
+ size={controlSize}
218
+ onClick={() => ui.actions.closeExportPicker()}
219
+ data-dg-part="export-picker-cancel"
220
+ >
221
+ {labels.exportPickerCancel}
222
+ </Button>
223
+ <Button
224
+ size={controlSize}
225
+ disabled={checked.size === 0}
226
+ onClick={() => void confirm()}
227
+ data-dg-part="export-picker-confirm"
228
+ >
229
+ {labels.exportPickerConfirm}
230
+ </Button>
231
+ </Group>
232
+ </Stack>
233
+ );
234
+ }
@@ -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
+ }