@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.
- package/README.md +5 -212
- package/dist/index.d.ts +1664 -632
- package/dist/index.js +5226 -3223
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/docs/anatomy.md +102 -0
- package/docs/cell-selection.md +154 -0
- package/docs/column-layout.md +204 -0
- package/docs/columns.md +262 -0
- package/docs/components.md +304 -0
- package/docs/editing.md +603 -0
- package/docs/editors.md +250 -0
- package/docs/export.md +326 -0
- package/docs/filtering.md +358 -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/pagination.md +144 -0
- package/docs/persistence.md +111 -0
- package/docs/portfolio-rebalancer.md +94 -0
- package/docs/query-builder.md +175 -0
- package/docs/quick-search.md +83 -0
- package/docs/row-details.md +113 -0
- package/docs/row-interaction.md +148 -0
- package/docs/row-pinning.md +132 -0
- package/docs/row-selection.md +134 -0
- package/docs/row-styling.md +133 -0
- package/docs/scrolling.md +111 -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 +309 -0
- package/docs/toolbar.md +161 -0
- package/docs/use-tm-data-grid.md +361 -0
- package/package.json +21 -45
- package/skills/appearance/SKILL.md +70 -17
- package/skills/cell-selection/SKILL.md +70 -76
- package/skills/columns/SKILL.md +131 -32
- package/skills/data/SKILL.md +100 -23
- package/skills/editing/SKILL.md +217 -96
- package/skills/editing/references/common-mistakes.md +111 -24
- package/skills/editing/references/editing-api.md +63 -39
- package/skills/editing/references/editors-and-validation.md +77 -19
- package/skills/filtering/SKILL.md +148 -40
- package/skills/getting-started/SKILL.md +18 -16
- package/skills/grouping/SKILL.md +32 -15
- package/skills/options/SKILL.md +39 -9
- package/skills/rows/SKILL.md +22 -18
- package/skills/server-side/SKILL.md +170 -17
- package/skills/testing/SKILL.md +10 -7
- 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 +39 -23
- package/src/{tmdatagrid/components → components}/TMDataGridCellEditor.tsx +106 -38
- 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 +4 -4
- package/src/components/TMDataGridDraftActions.tsx +307 -0
- package/src/{tmdatagrid/components → components}/TMDataGridEditColumn.tsx +58 -50
- package/src/{tmdatagrid/components → components}/TMDataGridEntryRows.tsx +150 -115
- 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 +7 -5
- package/src/components/TMDataGridFilterSurface.module.css +54 -0
- package/src/components/TMDataGridFilterSurface.tsx +167 -0
- package/src/{tmdatagrid/components → components}/TMDataGridFooter.tsx +53 -13
- package/src/{tmdatagrid/components → components}/TMDataGridGroupColumn.tsx +4 -3
- package/src/{tmdatagrid/components → components}/TMDataGridHeaderCell.module.css +10 -0
- package/src/{tmdatagrid/components → components}/TMDataGridHeaderCell.tsx +100 -28
- package/src/components/TMDataGridHeaderFilterRow.module.css +51 -0
- package/src/components/TMDataGridHeaderFilterRow.tsx +301 -0
- package/src/components/TMDataGridMenu.tsx +354 -0
- package/src/{tmdatagrid/components → components}/TMDataGridSelectColumn.tsx +12 -7
- package/src/{tmdatagrid/components → components}/TMDataGridTable.module.css +90 -67
- package/src/{tmdatagrid/components → components}/TMDataGridTable.tsx +678 -156
- package/src/components/TMDataGridToolbar.module.css +21 -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/components/editors/TMDataGridNumberEditor.tsx +70 -0
- 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 +17 -31
- 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/{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 +14 -6
- package/src/{tmdatagrid/core → core}/columnOptions.ts +46 -0
- package/src/{tmdatagrid/core → core}/columnOrdering.ts +33 -14
- package/src/{tmdatagrid/core → core}/columnUtils.ts +44 -5
- package/src/core/controlledState.ts +179 -0
- package/src/core/controlledStateSync.ts +108 -0
- package/src/core/deletedRows.ts +34 -0
- package/src/core/dom.ts +74 -0
- package/src/core/editEngine.ts +2476 -0
- package/src/{tmdatagrid/core → core}/editorFocus.ts +8 -4
- package/src/core/export.ts +843 -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}/labels.ts +66 -8
- package/src/{tmdatagrid/core → core}/labelsSv.ts +26 -3
- package/src/core/pageReset.ts +120 -0
- package/src/{tmdatagrid/core → core}/persistence.ts +22 -5
- package/src/core/resizePreview.ts +141 -0
- package/src/core/summary.ts +59 -0
- package/src/core/useSettledTableState.ts +36 -0
- package/src/{tmdatagrid/index.ts → index.ts} +75 -12
- package/src/{tmdatagrid/useTMDataGrid.tsx → useTMDataGrid.tsx} +734 -135
- package/src/useTMDataGridExport.ts +78 -0
- package/src/tmdatagrid/components/TMDataGridEditActions.tsx +0 -162
- package/src/tmdatagrid/components/TMDataGridFilterPanel.module.css +0 -35
- package/src/tmdatagrid/components/TMDataGridFilterPanel.tsx +0 -354
- package/src/tmdatagrid/components/TMDataGridToolbar.module.css +0 -12
- package/src/tmdatagrid/components/TMDataGridToolbar.tsx +0 -162
- package/src/tmdatagrid/components/editors/TMDataGridNumberEditor.tsx +0 -40
- package/src/tmdatagrid/core/cellExport.ts +0 -320
- package/src/tmdatagrid/core/editEngine.ts +0 -1006
- package/src/tmdatagrid/core/summary.ts +0 -35
- /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}/draftCellContext.ts +0 -0
- /package/src/{tmdatagrid/core → core}/expanding.ts +0 -0
- /package/src/{tmdatagrid/core → core}/grouping.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}/rowPinning.ts +0 -0
- /package/src/{tmdatagrid/core → core}/rowSelection.ts +0 -0
- /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
|
-
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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
|
+
}
|