@jielga/tmdatagrid 0.2.0 → 0.5.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/dist/index.d.ts +1337 -94
- package/dist/index.js +2812 -1189
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +2 -2
- package/skills/columns/SKILL.md +1 -1
- package/skills/features/SKILL.md +61 -11
- package/skills/getting-started/SKILL.md +41 -6
- package/skills/options/SKILL.md +12 -2
- package/skills/server-side/SKILL.md +1 -1
- package/src/tmdatagrid/TMDataGridContext.ts +26 -0
- package/src/tmdatagrid/components/TMDataGrid.module.css +21 -1
- package/src/tmdatagrid/components/TMDataGrid.tsx +29 -1
- package/src/tmdatagrid/components/TMDataGridColumnsPanel.tsx +12 -6
- package/src/tmdatagrid/components/TMDataGridDetailsColumn.module.css +33 -0
- package/src/tmdatagrid/components/TMDataGridDetailsColumn.tsx +160 -0
- package/src/tmdatagrid/components/TMDataGridFilterPanel.module.css +15 -0
- package/src/tmdatagrid/components/TMDataGridFilterPanel.tsx +192 -37
- package/src/tmdatagrid/components/TMDataGridFilterPills.module.css +35 -0
- package/src/tmdatagrid/components/TMDataGridFilterPills.tsx +136 -0
- package/src/tmdatagrid/components/TMDataGridFooter.module.css +8 -0
- package/src/tmdatagrid/components/TMDataGridFooter.tsx +63 -43
- package/src/tmdatagrid/components/TMDataGridGroupColumn.module.css +48 -0
- package/src/tmdatagrid/components/TMDataGridGroupColumn.tsx +159 -0
- package/src/tmdatagrid/components/TMDataGridHeaderCell.module.css +18 -0
- package/src/tmdatagrid/components/TMDataGridHeaderCell.tsx +249 -44
- package/src/tmdatagrid/components/TMDataGridSearch.tsx +122 -0
- package/src/tmdatagrid/components/TMDataGridSelectColumn.tsx +103 -16
- package/src/tmdatagrid/components/TMDataGridTable.module.css +160 -5
- package/src/tmdatagrid/components/TMDataGridTable.tsx +1380 -43
- package/src/tmdatagrid/components/TMDataGridToolbar.tsx +33 -7
- package/src/tmdatagrid/components/icons.ts +6 -0
- package/src/tmdatagrid/components/sticky.module.css +77 -16
- package/src/tmdatagrid/core/autosize.ts +125 -0
- package/src/tmdatagrid/core/capabilities.ts +122 -11
- package/src/tmdatagrid/core/cellExport.ts +321 -0
- package/src/tmdatagrid/core/cellNavigation.ts +103 -0
- package/src/tmdatagrid/core/cellRange.ts +118 -0
- package/src/tmdatagrid/core/columnOptions.ts +123 -0
- package/src/tmdatagrid/core/columnUtils.ts +18 -0
- package/src/tmdatagrid/core/expanding.ts +93 -0
- package/src/tmdatagrid/core/filterOperators.ts +198 -12
- package/src/tmdatagrid/core/grouping.ts +21 -0
- package/src/tmdatagrid/core/labels.ts +211 -0
- package/src/tmdatagrid/core/labelsSv.ts +108 -0
- package/src/tmdatagrid/core/persistence.ts +12 -0
- package/src/tmdatagrid/core/rowSelection.ts +202 -0
- package/src/tmdatagrid/core/summary.ts +35 -0
- package/src/tmdatagrid/index.ts +96 -2
- package/src/tmdatagrid/useTMDataGrid.tsx +558 -37
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { UnstyledButton } from "@mantine/core";
|
|
2
|
+
import {
|
|
3
|
+
useCellControlTabIndex,
|
|
4
|
+
useTMDataGridContext,
|
|
5
|
+
} from "../TMDataGridContext";
|
|
6
|
+
import type { ColumnDef, Row, RowData } from "@tanstack/react-table";
|
|
7
|
+
import { useSelector } from "@tanstack/react-store";
|
|
8
|
+
import classes from "./TMDataGridDetailsColumn.module.css";
|
|
9
|
+
import { ChevronRightIcon } from "./icons";
|
|
10
|
+
import { areAllRowsExpanded, resolveExpandAll } from "../core/expanding";
|
|
11
|
+
import type { TMDataGridFeatures, TMDataGridTable } from "../useTMDataGrid";
|
|
12
|
+
|
|
13
|
+
export const DETAILS_COLUMN_ID = "__details__";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The chevron that opens a row's detail panel.
|
|
17
|
+
*
|
|
18
|
+
* Expansion is read through a subscription rather than from
|
|
19
|
+
* `row.getIsExpanded()` in the component body: the `row` identity survives an
|
|
20
|
+
* expand, so the React Compiler would cache the call along with it and the
|
|
21
|
+
* chevron would never turn. Same reason the select checkbox subscribes.
|
|
22
|
+
*/
|
|
23
|
+
function DetailsCell<TData extends RowData>({
|
|
24
|
+
row,
|
|
25
|
+
}: {
|
|
26
|
+
row: Row<TMDataGridFeatures, TData>;
|
|
27
|
+
}) {
|
|
28
|
+
const { labels } = useTMDataGridContext();
|
|
29
|
+
const expanded = useSelector(row.table.store, () => row.getIsExpanded());
|
|
30
|
+
// See useCellControlTabIndex: a body control is reached by stepping into its
|
|
31
|
+
// cell, not by Tab, or the grid would have one tab stop per mounted row.
|
|
32
|
+
const tabIndex = useCellControlTabIndex();
|
|
33
|
+
|
|
34
|
+
// Group rows open into their children, not into a panel — see the body's
|
|
35
|
+
// `showsDetails`. Their cells are aggregated, so this lane is already blank
|
|
36
|
+
// on them; the guard is what makes that a decision rather than a side effect.
|
|
37
|
+
if (row.getIsGrouped()) return null;
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<UnstyledButton
|
|
41
|
+
className={classes.detailsToggle}
|
|
42
|
+
tabIndex={tabIndex}
|
|
43
|
+
aria-expanded={expanded}
|
|
44
|
+
aria-label={expanded ? labels.hideDetails : labels.showDetails}
|
|
45
|
+
// The row underneath may select or highlight on click; opening a panel is
|
|
46
|
+
// its own gesture and must not also trigger those.
|
|
47
|
+
onClick={(event) => {
|
|
48
|
+
event.stopPropagation();
|
|
49
|
+
row.toggleExpanded();
|
|
50
|
+
}}
|
|
51
|
+
>
|
|
52
|
+
<span className={classes.chevron} data-expanded={expanded}>
|
|
53
|
+
<ChevronRightIcon size={16} stroke={1.6} />
|
|
54
|
+
</span>
|
|
55
|
+
</UnstyledButton>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Expand-all / collapse-all for the lane, the way the checkbox column's header
|
|
61
|
+
* selects and clears every row.
|
|
62
|
+
*
|
|
63
|
+
* Deliberately not `table.toggleAllRowsExpanded()`: that writes the `expanded`
|
|
64
|
+
* state's whole-table form, and one state holds both the tree and the panels —
|
|
65
|
+
* so it would unfold every group as well. Only the data rows are touched here;
|
|
66
|
+
* whatever the tree was showing, it goes on showing. See resolveExpandAll.
|
|
67
|
+
*/
|
|
68
|
+
function DetailsHeader<TData extends RowData>({
|
|
69
|
+
table,
|
|
70
|
+
}: {
|
|
71
|
+
table: TMDataGridTable<TData>;
|
|
72
|
+
}) {
|
|
73
|
+
const { labels } = useTMDataGridContext();
|
|
74
|
+
// Pre-paginated: expand-all means every row the filters left, not the page
|
|
75
|
+
// that happens to be on screen. Same model `getCanSomeRowsExpand` reads.
|
|
76
|
+
const detailRows = () => table.getPrePaginatedRowModel().flatRows;
|
|
77
|
+
|
|
78
|
+
const allExpanded = useSelector(table.store, (state) =>
|
|
79
|
+
areAllRowsExpanded({
|
|
80
|
+
rows: detailRows(),
|
|
81
|
+
expanded: state.expanded,
|
|
82
|
+
target: "details",
|
|
83
|
+
}),
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<UnstyledButton
|
|
88
|
+
className={classes.detailsToggle}
|
|
89
|
+
aria-expanded={allExpanded}
|
|
90
|
+
aria-label={
|
|
91
|
+
allExpanded ? labels.collapseAllDetails : labels.expandAllDetails
|
|
92
|
+
}
|
|
93
|
+
// Partly expanded opens the rest rather than closing what is already
|
|
94
|
+
// open — the same reading as an indeterminate select-all box.
|
|
95
|
+
onClick={() =>
|
|
96
|
+
table.setExpanded(
|
|
97
|
+
resolveExpandAll({
|
|
98
|
+
rows: detailRows(),
|
|
99
|
+
expanded: table.store.state.expanded,
|
|
100
|
+
target: "details",
|
|
101
|
+
expand: !allExpanded,
|
|
102
|
+
}),
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
>
|
|
106
|
+
<span className={classes.chevron} data-expanded={allExpanded}>
|
|
107
|
+
<ChevronRightIcon size={16} stroke={1.6} />
|
|
108
|
+
</span>
|
|
109
|
+
</UnstyledButton>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The generated details lane, prepended whenever `renderDetails` is set.
|
|
115
|
+
*
|
|
116
|
+
* Structural, like the checkbox and tree columns: fixed width, pinned to the
|
|
117
|
+
* left after both of them, not hideable, not movable and not resizable. A
|
|
118
|
+
* toggle that wandered off to the right of the grid — or hid itself — would
|
|
119
|
+
* leave rows with panels no one can open.
|
|
120
|
+
*
|
|
121
|
+
* Last of the three because it acts on one record: the checkbox picks rows out
|
|
122
|
+
* and the tree says which group they are in, and only then is there a row to
|
|
123
|
+
* open.
|
|
124
|
+
*
|
|
125
|
+
* Nothing stops a second toggle elsewhere: `row.toggleExpanded()` is the whole
|
|
126
|
+
* interface, and this lane is only the one the grid ships.
|
|
127
|
+
*/
|
|
128
|
+
export function createDetailsColumn<TData extends RowData>(
|
|
129
|
+
label = "Details",
|
|
130
|
+
): ColumnDef<TMDataGridFeatures, TData, unknown> {
|
|
131
|
+
return {
|
|
132
|
+
id: DETAILS_COLUMN_ID,
|
|
133
|
+
meta: {
|
|
134
|
+
label,
|
|
135
|
+
align: "center",
|
|
136
|
+
// Structurally the last of the generated lanes.
|
|
137
|
+
enableOrdering: false,
|
|
138
|
+
},
|
|
139
|
+
// A system lane: as wide as the control it holds and no wider. Fixed at
|
|
140
|
+
// every scale — the control does not grow with the font size, so neither
|
|
141
|
+
// should its track.
|
|
142
|
+
size: 36,
|
|
143
|
+
minSize: 36,
|
|
144
|
+
maxSize: 36,
|
|
145
|
+
enableResizing: false,
|
|
146
|
+
enableSorting: false,
|
|
147
|
+
enableColumnFilter: false,
|
|
148
|
+
enableGlobalFilter: false,
|
|
149
|
+
// Its visibility is not the user's to set: hiding it would strand every
|
|
150
|
+
// panel behind a control that is no longer there.
|
|
151
|
+
enableHiding: false,
|
|
152
|
+
// Structurally pinned to the left; users shouldn't be able to move it.
|
|
153
|
+
enablePinning: false,
|
|
154
|
+
header: ({ table }) => <DetailsHeader table={table} />,
|
|
155
|
+
cell: ({ row }) => <DetailsCell row={row} />,
|
|
156
|
+
// Deliberately no `aggregatedCell`: on a group row every cell outside the
|
|
157
|
+
// grouped column counts as aggregated, and blank is the right answer here —
|
|
158
|
+
// groups expand into their rows, not into a panel.
|
|
159
|
+
};
|
|
160
|
+
}
|
|
@@ -18,3 +18,18 @@
|
|
|
18
18
|
align-items: flex-end;
|
|
19
19
|
gap: var(--mantine-spacing-xs);
|
|
20
20
|
}
|
|
21
|
+
|
|
22
|
+
.filterPanelHeader {
|
|
23
|
+
display: flex;
|
|
24
|
+
align-items: center;
|
|
25
|
+
justify-content: space-between;
|
|
26
|
+
gap: var(--mantine-spacing-md);
|
|
27
|
+
padding-bottom: var(--mantine-spacing-xs);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.filterPanelFooter {
|
|
31
|
+
display: flex;
|
|
32
|
+
align-items: center;
|
|
33
|
+
justify-content: space-between;
|
|
34
|
+
gap: var(--mantine-spacing-md);
|
|
35
|
+
}
|
|
@@ -1,16 +1,29 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
ActionIcon,
|
|
3
|
+
Button,
|
|
4
|
+
MultiSelect,
|
|
5
|
+
Select,
|
|
6
|
+
Stack,
|
|
7
|
+
Text,
|
|
8
|
+
TextInput,
|
|
9
|
+
} from "@mantine/core";
|
|
2
10
|
import { useSelector } from "@tanstack/react-store";
|
|
11
|
+
import { useEffect, useRef } from "react";
|
|
3
12
|
import classes from "./TMDataGridFilterPanel.module.css";
|
|
4
13
|
import { useTMDataGridContext } from "../TMDataGridContext";
|
|
14
|
+
import {
|
|
15
|
+
optionsToComboboxData,
|
|
16
|
+
resolveColumnOptions,
|
|
17
|
+
} from "../core/columnOptions";
|
|
5
18
|
import { getColumnLabel, getColumnType } from "../core/columnUtils";
|
|
6
19
|
import {
|
|
7
20
|
type TMDataGridFilterOperator,
|
|
8
21
|
type TMDataGridFilterValue,
|
|
9
|
-
FILTER_OPERATOR_LABELS,
|
|
10
22
|
getDefaultOperator,
|
|
11
23
|
getOperatorsForType,
|
|
12
24
|
isTMDataGridFilterValue,
|
|
13
25
|
operatorNeedsValue,
|
|
26
|
+
operatorTakesArrayValue,
|
|
14
27
|
} from "../core/filterOperators";
|
|
15
28
|
import { CloseIcon } from "./icons";
|
|
16
29
|
|
|
@@ -27,12 +40,33 @@ function asFilterValue(value: unknown): TMDataGridFilterValue {
|
|
|
27
40
|
* free — the state is forwarded to the server instead of a row model.
|
|
28
41
|
*/
|
|
29
42
|
export function TMDataGridFilterPanel() {
|
|
30
|
-
const { table, ui, controlSize } = useTMDataGridContext();
|
|
43
|
+
const { table, ui, labels, controlSize } = useTMDataGridContext();
|
|
31
44
|
const opened = useSelector(ui, (state) => state.filterPanelOpen);
|
|
32
45
|
const columnFilters = useSelector(
|
|
33
46
|
table.store,
|
|
34
47
|
(state) => state.columnFilters,
|
|
35
48
|
);
|
|
49
|
+
const panelRef = useRef<HTMLDivElement>(null);
|
|
50
|
+
|
|
51
|
+
// Clicking away hides the panel, the way any floating surface behaves. On
|
|
52
|
+
// pointerdown rather than click, so a press that starts outside dismisses it
|
|
53
|
+
// even when the pointer is released somewhere else.
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (!opened) return;
|
|
56
|
+
|
|
57
|
+
const handlePointerDown = (event: PointerEvent) => {
|
|
58
|
+
const target = event.target;
|
|
59
|
+
if (!(target instanceof Element)) return;
|
|
60
|
+
if (panelRef.current?.contains(target)) return;
|
|
61
|
+
// The toolbar button toggles the panel itself. Closing from here first
|
|
62
|
+
// would leave its click reopening what the user meant to close.
|
|
63
|
+
if (target.closest("[data-dg-filter-toggle]")) return;
|
|
64
|
+
ui.actions.closeFilterPanel();
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
document.addEventListener("pointerdown", handlePointerDown);
|
|
68
|
+
return () => document.removeEventListener("pointerdown", handlePointerDown);
|
|
69
|
+
}, [opened, ui]);
|
|
36
70
|
|
|
37
71
|
if (!opened) return null;
|
|
38
72
|
|
|
@@ -56,6 +90,18 @@ export function TMDataGridFilterPanel() {
|
|
|
56
90
|
columnFilters.find((filter) => filter.id === fromColumnId)?.value,
|
|
57
91
|
);
|
|
58
92
|
const target = table.getColumn(toColumnId);
|
|
93
|
+
const operator = getDefaultOperator(
|
|
94
|
+
target ? getColumnType(target) : "string",
|
|
95
|
+
);
|
|
96
|
+
// The typed value only survives the move while it still fits the new
|
|
97
|
+
// operator — a text needle has no meaning to an `isAnyOf` set, or a set
|
|
98
|
+
// to a text input.
|
|
99
|
+
const value =
|
|
100
|
+
operatorTakesArrayValue(operator) === Array.isArray(current.value)
|
|
101
|
+
? current.value
|
|
102
|
+
: operatorTakesArrayValue(operator)
|
|
103
|
+
? []
|
|
104
|
+
: "";
|
|
59
105
|
table.setColumnFilters(
|
|
60
106
|
columnFilters
|
|
61
107
|
.filter((filter) => filter.id !== toColumnId)
|
|
@@ -63,12 +109,7 @@ export function TMDataGridFilterPanel() {
|
|
|
63
109
|
filter.id === fromColumnId
|
|
64
110
|
? {
|
|
65
111
|
id: toColumnId,
|
|
66
|
-
value: {
|
|
67
|
-
operator: getDefaultOperator(
|
|
68
|
-
target ? getColumnType(target) : "string",
|
|
69
|
-
),
|
|
70
|
-
value: current.value,
|
|
71
|
-
} satisfies TMDataGridFilterValue,
|
|
112
|
+
value: { operator, value } satisfies TMDataGridFilterValue,
|
|
72
113
|
}
|
|
73
114
|
: filter,
|
|
74
115
|
),
|
|
@@ -86,6 +127,29 @@ export function TMDataGridFilterPanel() {
|
|
|
86
127
|
);
|
|
87
128
|
}
|
|
88
129
|
|
|
130
|
+
function changeOperator(columnId: string, operator: TMDataGridFilterOperator) {
|
|
131
|
+
const current = asFilterValue(
|
|
132
|
+
columnFilters.find((filter) => filter.id === columnId)?.value,
|
|
133
|
+
);
|
|
134
|
+
// Same arity rule as changing the column: keep the value across operators
|
|
135
|
+
// of the same shape, reset it across the array/scalar boundary.
|
|
136
|
+
const value =
|
|
137
|
+
operatorTakesArrayValue(operator) === Array.isArray(current.value)
|
|
138
|
+
? current.value
|
|
139
|
+
: operatorTakesArrayValue(operator)
|
|
140
|
+
? []
|
|
141
|
+
: "";
|
|
142
|
+
patchFilter(columnId, { operator, value });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Clears every filter, including half-typed ones the pills never showed. */
|
|
146
|
+
function clearAllFilters() {
|
|
147
|
+
table.setColumnFilters([]);
|
|
148
|
+
// Same exit as removing the last filter row by hand: an empty panel has
|
|
149
|
+
// nothing to show but its own buttons.
|
|
150
|
+
ui.actions.closeFilterPanel();
|
|
151
|
+
}
|
|
152
|
+
|
|
89
153
|
function addFilter() {
|
|
90
154
|
const nextColumn = filterableColumns.find(
|
|
91
155
|
(column) => !columnFilters.some((filter) => filter.id === column.id),
|
|
@@ -103,14 +167,47 @@ export function TMDataGridFilterPanel() {
|
|
|
103
167
|
]);
|
|
104
168
|
}
|
|
105
169
|
|
|
170
|
+
const canAddFilter = filterableColumns.some(
|
|
171
|
+
(column) => !columnFilters.some((filter) => filter.id === column.id),
|
|
172
|
+
);
|
|
173
|
+
|
|
106
174
|
return (
|
|
107
|
-
<div
|
|
175
|
+
<div
|
|
176
|
+
ref={panelRef}
|
|
177
|
+
role="group"
|
|
178
|
+
aria-label={labels.filters}
|
|
179
|
+
className={classes.filterPanel}
|
|
180
|
+
// Escape is what closes a floating surface, and every control that can
|
|
181
|
+
// hold focus in here sits inside this element.
|
|
182
|
+
onKeyDown={(event) => {
|
|
183
|
+
if (event.key !== "Escape") return;
|
|
184
|
+
event.stopPropagation();
|
|
185
|
+
ui.actions.closeFilterPanel();
|
|
186
|
+
}}
|
|
187
|
+
>
|
|
188
|
+
<div className={classes.filterPanelHeader}>
|
|
189
|
+
<Text size={controlSize} fw={600}>
|
|
190
|
+
{labels.filters}
|
|
191
|
+
</Text>
|
|
192
|
+
<ActionIcon
|
|
193
|
+
variant="subtle"
|
|
194
|
+
color="gray"
|
|
195
|
+
size="sm"
|
|
196
|
+
aria-label={labels.closeFilters}
|
|
197
|
+
onClick={ui.actions.closeFilterPanel}
|
|
198
|
+
>
|
|
199
|
+
<CloseIcon size={16} stroke={1.6} />
|
|
200
|
+
</ActionIcon>
|
|
201
|
+
</div>
|
|
202
|
+
|
|
108
203
|
<Stack gap="xs">
|
|
109
204
|
{columnFilters.map((filter) => {
|
|
110
205
|
const column = table.getColumn(filter.id);
|
|
111
206
|
const value = asFilterValue(filter.value);
|
|
112
207
|
const type = column ? getColumnType(column) : "string";
|
|
113
208
|
const needsValue = operatorNeedsValue(value.operator);
|
|
209
|
+
const takesArray = operatorTakesArrayValue(value.operator);
|
|
210
|
+
const scalarValue = typeof value.value === "string" ? value.value : "";
|
|
114
211
|
|
|
115
212
|
return (
|
|
116
213
|
<div key={filter.id} className={classes.filterRow}>
|
|
@@ -118,64 +215,122 @@ export function TMDataGridFilterPanel() {
|
|
|
118
215
|
variant="subtle"
|
|
119
216
|
color="gray"
|
|
120
217
|
size="lg"
|
|
121
|
-
aria-label=
|
|
218
|
+
aria-label={labels.removeFilter}
|
|
122
219
|
onClick={() => removeFilter(filter.id)}
|
|
123
220
|
>
|
|
124
221
|
<CloseIcon size={18} stroke={1.6} />
|
|
125
222
|
</ActionIcon>
|
|
126
223
|
|
|
127
224
|
<Select
|
|
128
|
-
label=
|
|
225
|
+
label={labels.filterColumn}
|
|
129
226
|
size={controlSize}
|
|
130
227
|
w={160}
|
|
131
228
|
allowDeselect={false}
|
|
229
|
+
// Both dropdowns render inside the panel: a portalled one is
|
|
230
|
+
// outside it in the DOM, and picking an option would read as a
|
|
231
|
+
// click away and close the panel under the user.
|
|
232
|
+
comboboxProps={{ withinPortal: false }}
|
|
132
233
|
data={columnOptions}
|
|
133
234
|
value={filter.id}
|
|
134
235
|
onChange={(next) => next && changeFilterColumn(filter.id, next)}
|
|
135
236
|
/>
|
|
136
237
|
|
|
137
238
|
<Select
|
|
138
|
-
label=
|
|
239
|
+
label={labels.filterOperator}
|
|
139
240
|
size={controlSize}
|
|
140
241
|
w={170}
|
|
141
242
|
allowDeselect={false}
|
|
243
|
+
comboboxProps={{ withinPortal: false }}
|
|
142
244
|
data={getOperatorsForType(type).map((operator) => ({
|
|
143
245
|
value: operator,
|
|
144
|
-
label:
|
|
246
|
+
label: labels.operators[operator],
|
|
145
247
|
}))}
|
|
146
248
|
value={value.operator}
|
|
147
249
|
onChange={(next) =>
|
|
148
250
|
next &&
|
|
149
|
-
|
|
150
|
-
operator: next as TMDataGridFilterOperator,
|
|
151
|
-
})
|
|
251
|
+
changeOperator(filter.id, next as TMDataGridFilterOperator)
|
|
152
252
|
}
|
|
153
253
|
/>
|
|
154
254
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
255
|
+
{takesArray && column ? (
|
|
256
|
+
// The set the cell is tested against. Options come from
|
|
257
|
+
// `meta.options`; a select column that declares none still
|
|
258
|
+
// gets the values present in the data, via the faceted index.
|
|
259
|
+
<MultiSelect
|
|
260
|
+
label={labels.filterValue}
|
|
261
|
+
size={controlSize}
|
|
262
|
+
w={180}
|
|
263
|
+
comboboxProps={{ withinPortal: false }}
|
|
264
|
+
searchable
|
|
265
|
+
data={optionsToComboboxData(
|
|
266
|
+
resolveColumnOptions({ table, column, fallback: "faceted" }),
|
|
267
|
+
)}
|
|
268
|
+
value={Array.isArray(value.value) ? [...value.value] : []}
|
|
269
|
+
onChange={(next) => patchFilter(filter.id, { value: next })}
|
|
270
|
+
/>
|
|
271
|
+
) : type === "boolean" ? (
|
|
272
|
+
<Select
|
|
273
|
+
label={labels.filterValue}
|
|
274
|
+
size={controlSize}
|
|
275
|
+
w={180}
|
|
276
|
+
comboboxProps={{ withinPortal: false }}
|
|
277
|
+
disabled={!needsValue}
|
|
278
|
+
clearable
|
|
279
|
+
placeholder={needsValue ? labels.filterValuePlaceholder : ""}
|
|
280
|
+
data={[
|
|
281
|
+
{ value: "true", label: labels.booleanTrue },
|
|
282
|
+
{ value: "false", label: labels.booleanFalse },
|
|
283
|
+
]}
|
|
284
|
+
value={needsValue && scalarValue !== "" ? scalarValue : null}
|
|
285
|
+
onChange={(next) =>
|
|
286
|
+
patchFilter(filter.id, { value: next ?? "" })
|
|
287
|
+
}
|
|
288
|
+
/>
|
|
289
|
+
) : (
|
|
290
|
+
<TextInput
|
|
291
|
+
label={labels.filterValue}
|
|
292
|
+
size={controlSize}
|
|
293
|
+
w={180}
|
|
294
|
+
type={
|
|
295
|
+
!needsValue
|
|
296
|
+
? "text"
|
|
297
|
+
: type === "number"
|
|
298
|
+
? "number"
|
|
299
|
+
: type === "date"
|
|
300
|
+
? "date"
|
|
301
|
+
: "text"
|
|
302
|
+
}
|
|
303
|
+
disabled={!needsValue}
|
|
304
|
+
placeholder={needsValue ? labels.filterValuePlaceholder : ""}
|
|
305
|
+
value={needsValue ? scalarValue : ""}
|
|
306
|
+
onChange={(event) =>
|
|
307
|
+
patchFilter(filter.id, { value: event.currentTarget.value })
|
|
308
|
+
}
|
|
309
|
+
/>
|
|
310
|
+
)}
|
|
167
311
|
</div>
|
|
168
312
|
);
|
|
169
313
|
})}
|
|
170
314
|
|
|
171
|
-
<
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
315
|
+
<div className={classes.filterPanelFooter}>
|
|
316
|
+
<Button
|
|
317
|
+
variant="subtle"
|
|
318
|
+
size="compact-sm"
|
|
319
|
+
disabled={!canAddFilter}
|
|
320
|
+
onClick={addFilter}
|
|
321
|
+
>
|
|
322
|
+
{labels.addFilter}
|
|
323
|
+
</Button>
|
|
324
|
+
<Button
|
|
325
|
+
variant="subtle"
|
|
326
|
+
color="gray"
|
|
327
|
+
size="compact-sm"
|
|
328
|
+
disabled={columnFilters.length === 0}
|
|
329
|
+
onClick={clearAllFilters}
|
|
330
|
+
>
|
|
331
|
+
{labels.clearAllFilters}
|
|
332
|
+
</Button>
|
|
333
|
+
</div>
|
|
179
334
|
</Stack>
|
|
180
335
|
</div>
|
|
181
336
|
);
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/* Wraps rather than scrolls: the pills are a summary, and a summary that goes
|
|
2
|
+
off-screen is not one. */
|
|
3
|
+
.filterPills {
|
|
4
|
+
display: flex;
|
|
5
|
+
align-items: center;
|
|
6
|
+
flex-wrap: wrap;
|
|
7
|
+
gap: var(--mantine-spacing-xs);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/* Mantine's dark-scheme pill is `dark-7` — the same colour as the body — so a
|
|
11
|
+
pill sitting on the page rather than inside a `PillsInput` disappears. Both
|
|
12
|
+
schemes are restated here so the two stay a pair, with a border to keep the
|
|
13
|
+
shape readable against any surface. Scoped through `.filterPills` for the
|
|
14
|
+
specificity to beat Mantine's own rule whichever stylesheet loads last. */
|
|
15
|
+
.filterPills .pill {
|
|
16
|
+
background-color: light-dark(
|
|
17
|
+
var(--mantine-color-gray-1),
|
|
18
|
+
var(--mantine-color-dark-5)
|
|
19
|
+
);
|
|
20
|
+
color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0));
|
|
21
|
+
border: 1px solid
|
|
22
|
+
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-3));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/* The label is a button so a pill can be clicked back open, but it must read as
|
|
26
|
+
part of the pill — Mantine's own label styles carry the font. */
|
|
27
|
+
.pillLabel {
|
|
28
|
+
font: inherit;
|
|
29
|
+
color: inherit;
|
|
30
|
+
cursor: pointer;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.pillLabel:hover {
|
|
34
|
+
text-decoration: underline;
|
|
35
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { Button, Pill, UnstyledButton } from "@mantine/core";
|
|
2
|
+
import type { RowData } from "@tanstack/react-table";
|
|
3
|
+
import { useSelector } from "@tanstack/react-store";
|
|
4
|
+
import classes from "./TMDataGridFilterPills.module.css";
|
|
5
|
+
import { getColumnLabel, getColumnType } from "../core/columnUtils";
|
|
6
|
+
import {
|
|
7
|
+
formatFilterLabel,
|
|
8
|
+
isFilterActive,
|
|
9
|
+
isTMDataGridFilterValue,
|
|
10
|
+
} from "../core/filterOperators";
|
|
11
|
+
import type { TMDataGridSize } from "../core/sizes";
|
|
12
|
+
import { openColumnFilter, type TMDataGridApi } from "../useTMDataGrid";
|
|
13
|
+
|
|
14
|
+
export type TMDataGridFilterPillsProps<TData extends RowData> = {
|
|
15
|
+
/** The object returned by `useTMDataGrid`. */
|
|
16
|
+
api: TMDataGridApi<TData>;
|
|
17
|
+
/** Mantine size of the pills. Defaults to `"sm"`. */
|
|
18
|
+
size?: TMDataGridSize;
|
|
19
|
+
/** "Clear all" button, shown once two filters are active. Defaults to `true`. */
|
|
20
|
+
showClearAll?: boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Replaces what a click on a pill's label does. The default opens the grid's
|
|
23
|
+
* filter panel on that column.
|
|
24
|
+
*/
|
|
25
|
+
onPillClick?: (columnId: string) => void;
|
|
26
|
+
className?: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* One pill per active filter — `First name: Sofia ✕` — with the ✕ clearing that
|
|
31
|
+
* filter and a click on the label opening the filter panel on its column.
|
|
32
|
+
*
|
|
33
|
+
* The one grid component that takes the api as a prop instead of reading
|
|
34
|
+
* context, so it can live anywhere: a page header, a card title, a breadcrumb
|
|
35
|
+
* row. Renders nothing while no filter is active.
|
|
36
|
+
*
|
|
37
|
+
* ```tsx
|
|
38
|
+
* const grid = useTMDataGrid({ data, columns });
|
|
39
|
+
*
|
|
40
|
+
* <Group>
|
|
41
|
+
* <Title order={3}>Employees</Title>
|
|
42
|
+
* <TMDataGridFilterPills api={grid} />
|
|
43
|
+
* </Group>
|
|
44
|
+
* ```
|
|
45
|
+
*
|
|
46
|
+
* Half-typed filters are left out: a filter that is not narrowing the rows yet
|
|
47
|
+
* has nothing to report, which is the same rule the funnel indicator uses.
|
|
48
|
+
*/
|
|
49
|
+
export function TMDataGridFilterPills<TData extends RowData>({
|
|
50
|
+
api,
|
|
51
|
+
size = "sm",
|
|
52
|
+
showClearAll = true,
|
|
53
|
+
onPillClick,
|
|
54
|
+
className,
|
|
55
|
+
}: TMDataGridFilterPillsProps<TData>) {
|
|
56
|
+
const { table, labels } = api;
|
|
57
|
+
const columnFilters = useSelector(
|
|
58
|
+
table.store,
|
|
59
|
+
(state) => state.columnFilters,
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const activeFilters = columnFilters.filter((filter) =>
|
|
63
|
+
isFilterActive(filter.value),
|
|
64
|
+
);
|
|
65
|
+
if (activeFilters.length === 0) return null;
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<div
|
|
69
|
+
role="group"
|
|
70
|
+
aria-label={labels.activeFilters}
|
|
71
|
+
className={[classes.filterPills, className].filter(Boolean).join(" ")}
|
|
72
|
+
>
|
|
73
|
+
<Pill.Group size={size}>
|
|
74
|
+
{activeFilters.map((filter) => {
|
|
75
|
+
const column = table.getColumn(filter.id);
|
|
76
|
+
const label = column ? getColumnLabel(column) : filter.id;
|
|
77
|
+
const value = filter.value;
|
|
78
|
+
// isFilterActive already vouched for the shape; this narrows it.
|
|
79
|
+
if (!isTMDataGridFilterValue(value)) return null;
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<Pill
|
|
83
|
+
key={filter.id}
|
|
84
|
+
size={size}
|
|
85
|
+
className={classes.pill}
|
|
86
|
+
withRemoveButton
|
|
87
|
+
onRemove={() =>
|
|
88
|
+
table.setColumnFilters(
|
|
89
|
+
columnFilters.filter((other) => other.id !== filter.id),
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
// Mantine hides its remove button from assistive tech and from
|
|
93
|
+
// the tab order, because inside a `PillsInput` Backspace is the
|
|
94
|
+
// way out. These pills stand on their own, so the ✕ has to be a
|
|
95
|
+
// real button.
|
|
96
|
+
removeButtonProps={{
|
|
97
|
+
"aria-label": labels.clearFilter(label),
|
|
98
|
+
"aria-hidden": false,
|
|
99
|
+
tabIndex: 0,
|
|
100
|
+
}}
|
|
101
|
+
>
|
|
102
|
+
<UnstyledButton
|
|
103
|
+
className={classes.pillLabel}
|
|
104
|
+
onClick={() =>
|
|
105
|
+
onPillClick
|
|
106
|
+
? onPillClick(filter.id)
|
|
107
|
+
: openColumnFilter(api, filter.id)
|
|
108
|
+
}
|
|
109
|
+
>
|
|
110
|
+
{formatFilterLabel({
|
|
111
|
+
label,
|
|
112
|
+
type: column ? getColumnType(column) : "string",
|
|
113
|
+
filter: value,
|
|
114
|
+
operatorLabels: labels.operators,
|
|
115
|
+
})}
|
|
116
|
+
</UnstyledButton>
|
|
117
|
+
</Pill>
|
|
118
|
+
);
|
|
119
|
+
})}
|
|
120
|
+
</Pill.Group>
|
|
121
|
+
|
|
122
|
+
{showClearAll && activeFilters.length > 1 && (
|
|
123
|
+
// Below two pills their own ✕ is the shorter path, so the button would
|
|
124
|
+
// only be a second way to do the same thing.
|
|
125
|
+
<Button
|
|
126
|
+
variant="subtle"
|
|
127
|
+
color="gray"
|
|
128
|
+
size="compact-xs"
|
|
129
|
+
onClick={() => table.setColumnFilters([])}
|
|
130
|
+
>
|
|
131
|
+
{labels.clearAllFilters}
|
|
132
|
+
</Button>
|
|
133
|
+
)}
|
|
134
|
+
</div>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
@@ -8,3 +8,11 @@
|
|
|
8
8
|
flex-shrink: 0;
|
|
9
9
|
border-top: 1px solid var(--mantine-color-default-border);
|
|
10
10
|
}
|
|
11
|
+
|
|
12
|
+
/* Grouping suspends paging. The pager stays put and fades rather than
|
|
13
|
+
disappearing, so the footer reads as switched off rather than as having
|
|
14
|
+
vanished — and the row it holds does not change height under the grid. Its
|
|
15
|
+
controls carry their own `disabled`; this is the label half. */
|
|
16
|
+
.footer[data-paging-suspended="true"] {
|
|
17
|
+
opacity: 0.55;
|
|
18
|
+
}
|