@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.
- package/README.md +5 -212
- package/dist/index.d.ts +1323 -796
- package/dist/index.js +4719 -3193
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/docs/adding-rows.md +132 -0
- package/docs/anatomy.md +119 -0
- package/docs/card-view.md +108 -0
- package/docs/cell-selection.md +194 -0
- package/docs/column-layout.md +182 -0
- package/docs/column-menu.md +66 -0
- package/docs/columns.md +268 -0
- package/docs/components.md +311 -0
- package/docs/draft-store.md +242 -0
- package/docs/editing.md +303 -0
- package/docs/editors.md +250 -0
- package/docs/export.md +319 -0
- package/docs/filtering.md +362 -0
- package/docs/getting-started.md +123 -0
- package/docs/grouping.md +165 -0
- package/docs/loading-and-empty.md +92 -0
- package/docs/localization.md +79 -0
- package/docs/menu.md +143 -0
- package/docs/migrating-to-2.md +163 -0
- package/docs/pagination.md +144 -0
- package/docs/persistence.md +114 -0
- package/docs/portfolio-rebalancer.md +94 -0
- package/docs/query-builder.md +179 -0
- package/docs/quick-search.md +84 -0
- package/docs/row-details.md +115 -0
- package/docs/row-interaction.md +149 -0
- package/docs/row-pinning.md +132 -0
- package/docs/row-selection.md +136 -0
- package/docs/row-styling.md +133 -0
- package/docs/scrolling.md +112 -0
- package/docs/server-query.md +246 -0
- package/docs/server-side.md +206 -0
- package/docs/sorting.md +101 -0
- package/docs/styling.md +126 -0
- package/docs/summary-row.md +76 -0
- package/docs/testing.md +744 -0
- package/docs/toolbar.md +161 -0
- package/docs/use-tm-data-grid.md +361 -0
- package/package.json +22 -46
- package/skills/appearance/SKILL.md +72 -19
- package/skills/cell-selection/SKILL.md +69 -78
- package/skills/columns/SKILL.md +90 -34
- package/skills/data/SKILL.md +86 -16
- package/skills/editing/SKILL.md +83 -50
- package/skills/editing/references/common-mistakes.md +77 -69
- package/skills/editing/references/editing-api.md +31 -23
- package/skills/editing/references/editors-and-validation.md +24 -17
- package/skills/filtering/SKILL.md +148 -40
- package/skills/getting-started/SKILL.md +17 -15
- package/skills/grouping/SKILL.md +31 -16
- package/skills/options/SKILL.md +8 -8
- package/skills/rows/SKILL.md +22 -18
- package/skills/server-side/SKILL.md +170 -17
- package/skills/testing/SKILL.md +150 -32
- package/skills/testing-components/SKILL.md +230 -0
- package/skills/testing-editing/SKILL.md +240 -0
- package/src/{tmdatagrid/TMDataGridContext.ts → TMDataGridContext.ts} +7 -19
- package/src/{tmdatagrid/components → components}/TMDataGrid.module.css +7 -1
- package/src/{tmdatagrid/components → components}/TMDataGrid.tsx +38 -21
- package/src/{tmdatagrid/components → components}/TMDataGridCellEditor.tsx +73 -7
- package/src/{tmdatagrid/components → components}/TMDataGridColumnsPanel.module.css +5 -1
- package/src/{tmdatagrid/components → components}/TMDataGridColumnsPanel.tsx +47 -55
- package/src/{tmdatagrid/components → components}/TMDataGridDetailsColumn.tsx +9 -55
- package/src/{tmdatagrid/components → components}/TMDataGridDraftActions.tsx +41 -24
- package/src/{tmdatagrid/components → components}/TMDataGridEditColumn.tsx +23 -63
- package/src/components/TMDataGridEntryRows.tsx +354 -0
- package/src/components/TMDataGridExportPicker.module.css +77 -0
- package/src/components/TMDataGridExportPicker.tsx +234 -0
- package/src/components/TMDataGridFilterPanel.module.css +54 -0
- package/src/components/TMDataGridFilterPanel.tsx +348 -0
- package/src/{tmdatagrid/components → components}/TMDataGridFilterPills.tsx +15 -8
- package/src/components/TMDataGridFilterSurface.module.css +54 -0
- package/src/components/TMDataGridFilterSurface.tsx +167 -0
- package/src/{tmdatagrid/components → components}/TMDataGridFooter.tsx +53 -90
- package/src/{tmdatagrid/components → components}/TMDataGridGroupColumn.tsx +9 -72
- package/src/{tmdatagrid/components → components}/TMDataGridHeaderCell.module.css +12 -2
- package/src/{tmdatagrid/components → components}/TMDataGridHeaderCell.tsx +58 -21
- package/src/components/TMDataGridHeaderFilterRow.module.css +51 -0
- package/src/components/TMDataGridHeaderFilterRow.tsx +301 -0
- package/src/components/TMDataGridMenu.tsx +357 -0
- package/src/{tmdatagrid/components → components}/TMDataGridSelectColumn.tsx +15 -53
- package/src/{tmdatagrid/components → components}/TMDataGridTable.module.css +88 -65
- package/src/{tmdatagrid/components → components}/TMDataGridTable.tsx +579 -165
- package/src/{tmdatagrid/components → components}/TMDataGridToolbar.module.css +5 -0
- package/src/components/TMDataGridToolbar.tsx +181 -0
- package/src/{tmdatagrid/components → components}/editors/TMDataGridBooleanEditor.tsx +3 -3
- package/src/{tmdatagrid/components → components}/editors/TMDataGridDateEditor.tsx +3 -3
- package/src/{tmdatagrid/components → components}/editors/TMDataGridMultiSelectEditor.tsx +3 -3
- package/src/{tmdatagrid/components → components}/editors/TMDataGridNumberEditor.tsx +3 -3
- package/src/{tmdatagrid/components → components}/editors/TMDataGridSelectEditor.tsx +3 -3
- package/src/{tmdatagrid/components → components}/editors/TMDataGridStringEditor.tsx +3 -3
- package/src/{tmdatagrid/components → components}/editors/editorShared.ts +16 -2
- package/src/{tmdatagrid/components → components}/filters/DgAutocompleteFilter.tsx +5 -4
- package/src/{tmdatagrid/components → components}/filters/DgDateRangeFilter.tsx +22 -5
- package/src/{tmdatagrid/components → components}/filters/DgRangeSliderFilter.tsx +5 -1
- package/src/{tmdatagrid/components → components}/filters/DgTriStateFilter.tsx +5 -1
- package/src/{tmdatagrid/components → components}/filters/TMDataGridFilterValueInput.tsx +44 -28
- package/src/components/filters/controlLayout.ts +32 -0
- package/src/components/filters/filterControlFor.ts +65 -0
- package/src/components/generatedColumns.tsx +187 -0
- package/src/{tmdatagrid/components → components}/icons.ts +1 -0
- package/src/{tmdatagrid/components → components}/sticky.module.css +44 -0
- package/src/components/useHideableColumns.ts +52 -0
- package/src/{tmdatagrid/core → core}/autosize.ts +5 -2
- package/src/{tmdatagrid/core → core}/capabilities.ts +5 -5
- package/src/{tmdatagrid/core → core}/columnOptions.ts +60 -8
- package/src/{tmdatagrid/core → core}/columnOrdering.ts +33 -14
- package/src/{tmdatagrid/core → core}/columnUtils.ts +44 -5
- package/src/core/controlledStateSync.ts +108 -0
- package/src/core/deletedRows.ts +34 -0
- package/src/core/dom.ts +74 -0
- package/src/{tmdatagrid/core → core}/editEngine.ts +1172 -388
- package/src/{tmdatagrid/core → core}/editorFocus.ts +8 -4
- package/src/core/export.ts +704 -0
- package/src/{tmdatagrid/core → core}/filterControls.ts +38 -1
- package/src/{tmdatagrid/core → core}/filterOperators.ts +64 -1
- package/src/core/filterSurface.ts +99 -0
- package/src/{tmdatagrid/core → core}/grouping.ts +21 -0
- package/src/{tmdatagrid/core → core}/labels.ts +51 -6
- package/src/{tmdatagrid/core → core}/labelsSv.ts +27 -6
- package/src/core/pageReset.ts +120 -0
- package/src/core/pagination.ts +81 -0
- package/src/{tmdatagrid/core → core}/persistence.ts +22 -5
- package/src/{tmdatagrid/core → core}/summary.ts +20 -4
- package/src/{tmdatagrid/index.ts → index.ts} +70 -36
- package/src/{tmdatagrid/useTMDataGrid.tsx → useTMDataGrid.tsx} +534 -123
- package/src/useTMDataGridExport.ts +78 -0
- package/src/tmdatagrid/components/TMDataGridEntryRows.tsx +0 -298
- package/src/tmdatagrid/components/TMDataGridFilterPanel.module.css +0 -35
- package/src/tmdatagrid/components/TMDataGridFilterPanel.tsx +0 -354
- package/src/tmdatagrid/components/TMDataGridToolbar.tsx +0 -161
- package/src/tmdatagrid/core/cellExport.ts +0 -320
- /package/src/{tmdatagrid/components → components}/TMDataGridDetailsColumn.module.css +0 -0
- /package/src/{tmdatagrid/components → components}/TMDataGridFilterPills.module.css +0 -0
- /package/src/{tmdatagrid/components → components}/TMDataGridFooter.module.css +0 -0
- /package/src/{tmdatagrid/components → components}/TMDataGridGroupColumn.module.css +0 -0
- /package/src/{tmdatagrid/components → components}/TMDataGridRowNumberColumn.tsx +0 -0
- /package/src/{tmdatagrid/components → components}/TMDataGridSearch.tsx +0 -0
- /package/src/{tmdatagrid/core → core}/cellNavigation.ts +0 -0
- /package/src/{tmdatagrid/core → core}/cellRange.ts +0 -0
- /package/src/{tmdatagrid/core → core}/controlledState.ts +0 -0
- /package/src/{tmdatagrid/core → core}/draftCellContext.ts +0 -0
- /package/src/{tmdatagrid/core → core}/expanding.ts +0 -0
- /package/src/{tmdatagrid/core → core}/matchHighlight.ts +0 -0
- /package/src/{tmdatagrid/core → core}/quickSearch.ts +0 -0
- /package/src/{tmdatagrid/core → core}/resizePreview.ts +0 -0
- /package/src/{tmdatagrid/core → core}/rowPinning.ts +0 -0
- /package/src/{tmdatagrid/core → core}/rowSelection.ts +0 -0
- /package/src/{tmdatagrid/core → core}/sizes.ts +0 -0
- /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
|
+
}
|