@jielga/tmdatagrid 2.0.0-beta.11 → 2.0.0-beta.13
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 +285 -22
- package/dist/index.js +2857 -2376
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +1 -2
- package/skills/appearance/SKILL.md +1 -1
- package/skills/cell-selection/SKILL.md +1 -1
- package/skills/columns/SKILL.md +2 -2
- package/skills/data/SKILL.md +5 -2
- package/skills/editing/SKILL.md +1 -1
- package/skills/filtering/SKILL.md +112 -26
- package/skills/getting-started/SKILL.md +4 -4
- package/skills/grouping/SKILL.md +1 -1
- package/skills/options/SKILL.md +3 -1
- package/skills/rows/SKILL.md +1 -1
- package/skills/server-side/SKILL.md +44 -15
- package/skills/testing/SKILL.md +6 -3
- package/src/tmdatagrid/components/TMDataGrid.tsx +3 -0
- package/src/tmdatagrid/components/TMDataGridEditColumn.tsx +1 -1
- package/src/tmdatagrid/components/TMDataGridFilterPanel.module.css +37 -18
- package/src/tmdatagrid/components/TMDataGridFilterPanel.tsx +138 -160
- package/src/tmdatagrid/components/TMDataGridFilterSurface.module.css +54 -0
- package/src/tmdatagrid/components/TMDataGridFilterSurface.tsx +162 -0
- package/src/tmdatagrid/components/TMDataGridFooter.tsx +28 -1
- package/src/tmdatagrid/components/TMDataGridHeaderCell.module.css +10 -0
- package/src/tmdatagrid/components/TMDataGridHeaderCell.tsx +12 -3
- package/src/tmdatagrid/components/TMDataGridHeaderFilterRow.module.css +51 -0
- package/src/tmdatagrid/components/TMDataGridHeaderFilterRow.tsx +303 -0
- package/src/tmdatagrid/components/TMDataGridTable.module.css +9 -41
- package/src/tmdatagrid/components/TMDataGridTable.tsx +84 -15
- package/src/tmdatagrid/components/TMDataGridToolbar.tsx +37 -11
- package/src/tmdatagrid/components/filters/DgAutocompleteFilter.tsx +5 -4
- package/src/tmdatagrid/components/filters/DgDateRangeFilter.tsx +22 -5
- package/src/tmdatagrid/components/filters/DgRangeSliderFilter.tsx +5 -1
- package/src/tmdatagrid/components/filters/DgTriStateFilter.tsx +5 -1
- package/src/tmdatagrid/components/filters/TMDataGridFilterValueInput.tsx +44 -28
- package/src/tmdatagrid/components/filters/controlLayout.ts +32 -0
- package/src/tmdatagrid/components/filters/filterControlFor.ts +65 -0
- package/src/tmdatagrid/components/sticky.module.css +44 -0
- package/src/tmdatagrid/core/columnOptions.ts +46 -0
- package/src/tmdatagrid/core/controlledStateSync.ts +108 -0
- package/src/tmdatagrid/core/editEngine.ts +68 -12
- package/src/tmdatagrid/core/filterControls.ts +28 -0
- package/src/tmdatagrid/core/filterOperators.ts +64 -1
- package/src/tmdatagrid/core/filterSurface.ts +99 -0
- package/src/tmdatagrid/core/labels.ts +7 -0
- package/src/tmdatagrid/core/labelsSv.ts +3 -0
- package/src/tmdatagrid/core/pageReset.ts +115 -0
- package/src/tmdatagrid/index.ts +13 -0
- package/src/tmdatagrid/useTMDataGrid.tsx +191 -11
|
@@ -0,0 +1,162 @@
|
|
|
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 { CloseIcon } from "./icons";
|
|
7
|
+
import { TMDataGridFilterPanel } from "./TMDataGridFilterPanel";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The title row both automatic surfaces wear: the "Filters" heading and the
|
|
11
|
+
* close button that puts the surface away without touching the filters.
|
|
12
|
+
*/
|
|
13
|
+
function FilterSurfaceHeader() {
|
|
14
|
+
const { ui, labels, controlSize } = useTMDataGridContext();
|
|
15
|
+
return (
|
|
16
|
+
<div className={classes.surfaceHeader}>
|
|
17
|
+
<Text span size={controlSize} fw={600}>
|
|
18
|
+
{labels.filters}
|
|
19
|
+
</Text>
|
|
20
|
+
<ActionIcon
|
|
21
|
+
variant="subtle"
|
|
22
|
+
color="gray"
|
|
23
|
+
size="sm"
|
|
24
|
+
aria-label={labels.closeFilters}
|
|
25
|
+
data-dg-part="filter-panel-close"
|
|
26
|
+
onClick={ui.actions.closeFilterPanel}
|
|
27
|
+
>
|
|
28
|
+
<CloseIcon size={16} stroke={1.6} />
|
|
29
|
+
</ActionIcon>
|
|
30
|
+
</div>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Closes the popup once its last filter row goes, covering both ways that
|
|
36
|
+
* happens - removed by hand, or "Clear all" - without either of them having
|
|
37
|
+
* to know which surface is showing.
|
|
38
|
+
*
|
|
39
|
+
* On the transition to zero, not on being zero: a popup asked to start open
|
|
40
|
+
* with no filters yet would otherwise close itself before its first paint.
|
|
41
|
+
*/
|
|
42
|
+
function useCloseWhenEmptied(opened: boolean) {
|
|
43
|
+
const { table, ui } = useTMDataGridContext();
|
|
44
|
+
const filterCount = useSelector(
|
|
45
|
+
table.store,
|
|
46
|
+
(state) => state.columnFilters.length,
|
|
47
|
+
);
|
|
48
|
+
const previousCount = useRef(filterCount);
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
const emptied = previousCount.current > 0 && filterCount === 0;
|
|
51
|
+
previousCount.current = filterCount;
|
|
52
|
+
if (opened && emptied) ui.actions.closeFilterPanel();
|
|
53
|
+
}, [opened, filterCount, ui]);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* `filters.surface: "popup"` - the panel floating over the first body rows,
|
|
58
|
+
* anchored under the header. The default surface, and the only one before
|
|
59
|
+
* `filters` existed.
|
|
60
|
+
*
|
|
61
|
+
* Everything that makes it floating lives here rather than in the panel: a
|
|
62
|
+
* pointerdown outside closes it, Escape closes it, and emptying it closes it.
|
|
63
|
+
*
|
|
64
|
+
* @internal Rendered by `TMDataGrid.Table`.
|
|
65
|
+
*/
|
|
66
|
+
export function TMDataGridFilterPopup() {
|
|
67
|
+
const { ui, labels } = useTMDataGridContext();
|
|
68
|
+
const opened = useSelector(ui, (state) => state.filterPanelOpen);
|
|
69
|
+
const popupRef = useRef<HTMLDivElement>(null);
|
|
70
|
+
useCloseWhenEmptied(opened);
|
|
71
|
+
|
|
72
|
+
// Clicking away hides the popup, the way any floating surface behaves. On
|
|
73
|
+
// pointerdown rather than click, so a press that starts outside dismisses it
|
|
74
|
+
// even when the pointer is released somewhere else.
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
if (!opened) return;
|
|
77
|
+
|
|
78
|
+
const handlePointerDown = (event: PointerEvent) => {
|
|
79
|
+
const target = event.target;
|
|
80
|
+
if (!(target instanceof Element)) return;
|
|
81
|
+
if (popupRef.current?.contains(target)) return;
|
|
82
|
+
// The toolbar button toggles the popup itself. Closing from here first
|
|
83
|
+
// would leave its click reopening what the user meant to close.
|
|
84
|
+
if (target.closest('[data-dg-part="filter-button"]')) return;
|
|
85
|
+
// A portalled overlay - a header filter's dropdown, a custom control's
|
|
86
|
+
// portalled picker - is outside the popup in the DOM but not in meaning:
|
|
87
|
+
// a press in one is a press in a control, not a click away. Mantine
|
|
88
|
+
// stamps `data-portal` on every portal node it creates.
|
|
89
|
+
if (target.closest("[data-portal]")) return;
|
|
90
|
+
ui.actions.closeFilterPanel();
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
document.addEventListener("pointerdown", handlePointerDown);
|
|
94
|
+
return () => document.removeEventListener("pointerdown", handlePointerDown);
|
|
95
|
+
}, [opened, ui]);
|
|
96
|
+
|
|
97
|
+
if (!opened) return null;
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<div
|
|
101
|
+
ref={popupRef}
|
|
102
|
+
data-dg-part="filter-popup"
|
|
103
|
+
className={classes.filterPopup}
|
|
104
|
+
aria-label={labels.filters}
|
|
105
|
+
// Escape is what closes a floating surface, and every control that can
|
|
106
|
+
// hold focus in here sits inside this element.
|
|
107
|
+
onKeyDown={(event) => {
|
|
108
|
+
if (event.key !== "Escape") return;
|
|
109
|
+
event.stopPropagation();
|
|
110
|
+
ui.actions.closeFilterPanel();
|
|
111
|
+
}}
|
|
112
|
+
>
|
|
113
|
+
<FilterSurfaceHeader />
|
|
114
|
+
<TMDataGridFilterPanel />
|
|
115
|
+
</div>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* `filters.surface: "sidebar"` - the panel beside the table, inside the grid
|
|
121
|
+
* frame and under the toolbar, taking width from the rows rather than covering
|
|
122
|
+
* them.
|
|
123
|
+
*
|
|
124
|
+
* Not a floating surface: a click in the table is a click on the rows the
|
|
125
|
+
* sidebar is filtering, so nothing about it dismisses the panel, and clearing
|
|
126
|
+
* the filters leaves it standing with its "Add filter" button - a place to
|
|
127
|
+
* work from rather than a thing that appears and goes away. Escape closes it,
|
|
128
|
+
* because a keyboard user inside it needs a way back out to the grid.
|
|
129
|
+
*
|
|
130
|
+
* @internal Rendered by `TMDataGrid.Table`.
|
|
131
|
+
*/
|
|
132
|
+
export function TMDataGridFilterSidebar() {
|
|
133
|
+
const { ui, filters, labels } = useTMDataGridContext();
|
|
134
|
+
const opened = useSelector(ui, (state) => state.filterPanelOpen);
|
|
135
|
+
|
|
136
|
+
if (!opened) return null;
|
|
137
|
+
|
|
138
|
+
return (
|
|
139
|
+
<div
|
|
140
|
+
data-dg-part="filter-sidebar"
|
|
141
|
+
data-side={filters.sidebarSide}
|
|
142
|
+
className={classes.filterSidebar}
|
|
143
|
+
style={{ width: filters.sidebarWidth }}
|
|
144
|
+
aria-label={labels.filters}
|
|
145
|
+
onKeyDown={(event) => {
|
|
146
|
+
if (event.key !== "Escape") return;
|
|
147
|
+
event.stopPropagation();
|
|
148
|
+
ui.actions.closeFilterPanel();
|
|
149
|
+
}}
|
|
150
|
+
>
|
|
151
|
+
<FilterSurfaceHeader />
|
|
152
|
+
{/* The panel's rows are as tall as the filters put in them, and the
|
|
153
|
+
sidebar is as tall as the table beside it - so the overflow is the
|
|
154
|
+
sidebar's to scroll, not the page's. */}
|
|
155
|
+
<ScrollArea type="auto" className={classes.filterSidebarBody}>
|
|
156
|
+
{/* Stacked, not side by side: a filter row wants about 550px laid out
|
|
157
|
+
in a line, and the sidebar is 280. */}
|
|
158
|
+
<TMDataGridFilterPanel layout="stacked" />
|
|
159
|
+
</ScrollArea>
|
|
160
|
+
</div>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
@@ -63,6 +63,12 @@ export type TMDataGridPaginationControls = {
|
|
|
63
63
|
PageSize: () => ReactNode;
|
|
64
64
|
/** The "1–25 of 300" range label. */
|
|
65
65
|
Range: () => ReactNode;
|
|
66
|
+
/**
|
|
67
|
+
* The "Page 3 of 200" label - what a server-paged grid usually shows in
|
|
68
|
+
* place of a row range. Not in the default footer; put it in a
|
|
69
|
+
* `renderPagination` layout.
|
|
70
|
+
*/
|
|
71
|
+
PageNumber: () => ReactNode;
|
|
66
72
|
/** The previous/next buttons. */
|
|
67
73
|
Pager: () => ReactNode;
|
|
68
74
|
};
|
|
@@ -182,6 +188,26 @@ function PaginationRange() {
|
|
|
182
188
|
);
|
|
183
189
|
}
|
|
184
190
|
|
|
191
|
+
function PaginationPageNumber() {
|
|
192
|
+
const { table, features, labels, controlSize } = useTMDataGridContext();
|
|
193
|
+
useSettledTableState(table.store);
|
|
194
|
+
const paging = isPagingActive(table, features);
|
|
195
|
+
const { pageIndex } = table.store.state.pagination;
|
|
196
|
+
|
|
197
|
+
return (
|
|
198
|
+
<Text span size={controlSize} c="dimmed" data-dg-part="page-number">
|
|
199
|
+
{paging
|
|
200
|
+
? labels.pageNumber({
|
|
201
|
+
page: pageIndex + 1,
|
|
202
|
+
pageCount: table.getPageCount(),
|
|
203
|
+
})
|
|
204
|
+
: // Same reasoning as the range label: nothing is being sliced, so a
|
|
205
|
+
// page number would be a lie.
|
|
206
|
+
labels.groupedAllRows(table.getFilteredRowModel().rows.length)}
|
|
207
|
+
</Text>
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
185
211
|
function PaginationPager() {
|
|
186
212
|
const { table, features, labels } = useTMDataGridContext();
|
|
187
213
|
useSettledTableState(table.store);
|
|
@@ -223,7 +249,7 @@ export type TMDataGridFooterProps = {
|
|
|
223
249
|
* renderPagination={({ state, actions, Controls }) => (
|
|
224
250
|
* <Group>
|
|
225
251
|
* <Controls.PageSize />
|
|
226
|
-
* <
|
|
252
|
+
* <Controls.PageNumber />
|
|
227
253
|
* <Controls.Pager />
|
|
228
254
|
* </Group>
|
|
229
255
|
* )}
|
|
@@ -258,6 +284,7 @@ export function TMDataGridFooter({
|
|
|
258
284
|
() => ({
|
|
259
285
|
PageSize: () => <PaginationPageSize pageSizeOptions={pageSizeOptions} />,
|
|
260
286
|
Range: PaginationRange,
|
|
287
|
+
PageNumber: PaginationPageNumber,
|
|
261
288
|
Pager: PaginationPager,
|
|
262
289
|
}),
|
|
263
290
|
[pageSizeOptions],
|
|
@@ -149,6 +149,16 @@
|
|
|
149
149
|
z-index: 1;
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
+
/* The last column's divider straddles inward instead. Half of a 10px box
|
|
153
|
+
hanging past the last track is 5px of scrollable overflow, which is a
|
|
154
|
+
permanent horizontal scrollbar under a grid whose columns fit exactly. The
|
|
155
|
+
grab area keeps its width, wholly inside the cell, and flex-end keeps the
|
|
156
|
+
visible line on the column edge where every other divider draws it. */
|
|
157
|
+
.headerCell:last-child .columnSeparator {
|
|
158
|
+
transform: none;
|
|
159
|
+
justify-content: flex-end;
|
|
160
|
+
}
|
|
161
|
+
|
|
152
162
|
.columnSeparatorResizable {
|
|
153
163
|
cursor: col-resize;
|
|
154
164
|
touch-action: none;
|
|
@@ -90,7 +90,7 @@ export function TMDataGridHeaderCell({
|
|
|
90
90
|
renderColumnMenuItems?: TMDataGridColumnMenuItemsRenderer;
|
|
91
91
|
}) {
|
|
92
92
|
const api = useTMDataGridContext();
|
|
93
|
-
const { table, features, labels } = api;
|
|
93
|
+
const { table, features, filters, labels } = api;
|
|
94
94
|
const column = header.column;
|
|
95
95
|
const [menuOpened, setMenuOpened] = useState(false);
|
|
96
96
|
const [contextMenuOpened, setContextMenuOpened] = useState(false);
|
|
@@ -298,7 +298,9 @@ export function TMDataGridHeaderCell({
|
|
|
298
298
|
<Menu.Divider key="sort-divider" />,
|
|
299
299
|
);
|
|
300
300
|
}
|
|
301
|
-
|
|
301
|
+
// Not under header filters: the item's only job is to reveal a control that
|
|
302
|
+
// is already on screen, one row below this menu.
|
|
303
|
+
if (canFilter && !filters.inHeader) {
|
|
302
304
|
menuItems.push(
|
|
303
305
|
<Menu.Item
|
|
304
306
|
key="filter"
|
|
@@ -565,6 +567,11 @@ export function TMDataGridHeaderCell({
|
|
|
565
567
|
onDragLeave={isDropTarget ? handleDragLeave : undefined}
|
|
566
568
|
onDrop={isDropTarget ? handleDrop : undefined}
|
|
567
569
|
style={{
|
|
570
|
+
// A group header covers the tracks of the leaves under it. Without the
|
|
571
|
+
// span it would sit in one track and leave the rest of its width with
|
|
572
|
+
// no cell at all - a gap the body rows scroll up through, since the
|
|
573
|
+
// header rows above the body are what hides them.
|
|
574
|
+
gridColumn: header.colSpan > 1 ? `span ${header.colSpan}` : undefined,
|
|
568
575
|
left: pinnedAt === "left" ? layout.offset : undefined,
|
|
569
576
|
right: pinnedAt === "right" ? layout.offset : undefined,
|
|
570
577
|
position: pinnedAt ? "sticky" : undefined,
|
|
@@ -606,7 +613,9 @@ export function TMDataGridHeaderCell({
|
|
|
606
613
|
|
|
607
614
|
{isLeaf && (
|
|
608
615
|
<div className={classes.headerActions}>
|
|
609
|
-
{
|
|
616
|
+
{/* Dropped under header filters, where the filled-in control below
|
|
617
|
+
is both the indicator and the way in. */}
|
|
618
|
+
{isFiltered && !filters.inHeader && (
|
|
610
619
|
<ActionIcon
|
|
611
620
|
className={classes.headerAction}
|
|
612
621
|
data-pinned-visible="true"
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/* The second header row, under the group rows and above the body. Same column
|
|
2
|
+
tracks as every other row, through subgrid, so it follows resizing,
|
|
3
|
+
reordering and pinning without knowing about any of them.
|
|
4
|
+
|
|
5
|
+
Sticky like the header rows above it, and stacked under them by the same
|
|
6
|
+
measurement: the table writes every header row's `top` inline once it knows
|
|
7
|
+
how tall the rows before it are. This is the pre-measurement fallback, and
|
|
8
|
+
it is right for the common case of one header row. */
|
|
9
|
+
.headerFilterRow {
|
|
10
|
+
display: grid;
|
|
11
|
+
grid-column: 1 / -1;
|
|
12
|
+
grid-template-columns: subgrid;
|
|
13
|
+
position: sticky;
|
|
14
|
+
top: var(--dg-header-height);
|
|
15
|
+
z-index: var(--dg-z-header, 6);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.headerFilterCell {
|
|
19
|
+
display: flex;
|
|
20
|
+
align-items: center;
|
|
21
|
+
gap: 2px;
|
|
22
|
+
min-width: 0;
|
|
23
|
+
/* A 60px column has room for the input and nothing beside it. Clipping is
|
|
24
|
+
what keeps the control from spilling over its neighbour; the operator
|
|
25
|
+
button is the first thing to go. */
|
|
26
|
+
overflow: hidden;
|
|
27
|
+
padding: calc(var(--dg-padding) / 2);
|
|
28
|
+
/* Opaque: the body scrolls underneath it. */
|
|
29
|
+
background-color: var(--mantine-color-body);
|
|
30
|
+
border-bottom: 1px solid var(--mantine-color-default-border);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/* The generated lanes - checkbox, details, row number - hold no filter, so
|
|
34
|
+
they keep the header's own padding rather than a control's. */
|
|
35
|
+
.headerFilterCell[data-control-column="true"] {
|
|
36
|
+
padding: 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.headerFilterValue {
|
|
40
|
+
flex: 1;
|
|
41
|
+
min-width: 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.headerFilterOperator {
|
|
45
|
+
flex: none;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.operatorItemActive {
|
|
49
|
+
font-weight: 600;
|
|
50
|
+
color: var(--mantine-primary-color-filled);
|
|
51
|
+
}
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { ActionIcon, Menu, Tooltip } from "@mantine/core";
|
|
2
|
+
import { useSelector } from "@tanstack/react-store";
|
|
3
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
4
|
+
import classes from "./TMDataGridHeaderFilterRow.module.css";
|
|
5
|
+
import sticky from "./sticky.module.css";
|
|
6
|
+
import { useTMDataGridContext } from "../TMDataGridContext";
|
|
7
|
+
import {
|
|
8
|
+
getColumnDefaultOperator,
|
|
9
|
+
getColumnLabel,
|
|
10
|
+
getColumnType,
|
|
11
|
+
isControlColumn,
|
|
12
|
+
} from "../core/columnUtils";
|
|
13
|
+
import {
|
|
14
|
+
type TMDataGridFilterOperator,
|
|
15
|
+
type TMDataGridFilterValue,
|
|
16
|
+
emptyValueForOperator,
|
|
17
|
+
filterValueShape,
|
|
18
|
+
getOperatorsForType,
|
|
19
|
+
isTMDataGridFilterValue,
|
|
20
|
+
} from "../core/filterOperators";
|
|
21
|
+
import { FilterIcon } from "./icons";
|
|
22
|
+
import {
|
|
23
|
+
filterControlFor,
|
|
24
|
+
filterOptionsUseFacets,
|
|
25
|
+
} from "./filters/filterControlFor";
|
|
26
|
+
import type { TMDataGridColumnLayout } from "./TMDataGridTable";
|
|
27
|
+
import type { TMDataGridHeader } from "./TMDataGridHeaderCell";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Whether a value says nothing at all - untrimmed, unlike `isFilterActive`,
|
|
31
|
+
* which is about whether a filter *narrows* anything. A lone space narrows
|
|
32
|
+
* nothing but is very much something the user typed.
|
|
33
|
+
*/
|
|
34
|
+
function isEmptyValue(value: string | ReadonlyArray<string>): boolean {
|
|
35
|
+
return Array.isArray(value)
|
|
36
|
+
? value.every((entry) => entry === "")
|
|
37
|
+
: value === "";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* One column's filter control, as it appears in the header row: the value
|
|
42
|
+
* control the column would get in the panel, plus a button for the operator
|
|
43
|
+
* when the column's type offers more than one.
|
|
44
|
+
*
|
|
45
|
+
* The column and operator dropdowns of a panel row have no place here - the
|
|
46
|
+
* column is the one the cell sits over, and a header cell has room for a value
|
|
47
|
+
* and not much else. Everything else is the same filter: the same operators,
|
|
48
|
+
* the same `meta.filter.control`, the same `columnFilters` state.
|
|
49
|
+
*/
|
|
50
|
+
function HeaderFilterControl({ column }: { column: TMDataGridHeader["column"] }) {
|
|
51
|
+
const { table, labels, controlSize } = useTMDataGridContext();
|
|
52
|
+
const filterValue = useSelector(
|
|
53
|
+
table.store,
|
|
54
|
+
(state) => state.columnFilters.find((filter) => filter.id === column.id)?.value,
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const type = getColumnType(column);
|
|
58
|
+
const defaultOperator = getColumnDefaultOperator(column);
|
|
59
|
+
const current = isTMDataGridFilterValue(filterValue) ? filterValue : undefined;
|
|
60
|
+
const operator = current?.operator ?? defaultOperator;
|
|
61
|
+
const value = current?.value ?? emptyValueForOperator(operator);
|
|
62
|
+
const label = getColumnLabel(column);
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Writes the filter, or drops it entirely once nothing is left to say. A
|
|
66
|
+
* header control is always on screen, so an empty one has no row to keep
|
|
67
|
+
* alive the way a panel row does, and leaving the entry behind would put a
|
|
68
|
+
* filter in `columnFilters` that says nothing.
|
|
69
|
+
*
|
|
70
|
+
* Emptiness here is the literal one, not `isFilterActive`'s: that one trims,
|
|
71
|
+
* and dropping the filter on a value of `" "` would take the space back out
|
|
72
|
+
* of the input the moment it was typed. An operator the user picked is worth
|
|
73
|
+
* keeping even with an empty value; the type's own default is not.
|
|
74
|
+
*/
|
|
75
|
+
function write(next: TMDataGridFilterValue) {
|
|
76
|
+
const worthKeeping =
|
|
77
|
+
!isEmptyValue(next.value) || next.operator !== defaultOperator;
|
|
78
|
+
column.setFilterValue(worthKeeping ? next : undefined);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function changeOperator(next: TMDataGridFilterOperator) {
|
|
82
|
+
// Same arity rule as the panel: keep the value across operators of the
|
|
83
|
+
// same shape, reset it across any shape boundary.
|
|
84
|
+
write({
|
|
85
|
+
operator: next,
|
|
86
|
+
value:
|
|
87
|
+
filterValueShape(next) === filterValueShape(operator)
|
|
88
|
+
? value
|
|
89
|
+
: emptyValueForOperator(next),
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Memoized because this row re-renders with the table - on every scroll
|
|
94
|
+
// frame, for every column - while resolving faceted options walks the
|
|
95
|
+
// faceted map into a sorted set. TanStack swaps that map's identity whenever
|
|
96
|
+
// the values behind it change, so it is exactly the right key.
|
|
97
|
+
const facetKey = filterOptionsUseFacets(column)
|
|
98
|
+
? column.getFacetedUniqueValues()
|
|
99
|
+
: undefined;
|
|
100
|
+
const { options, ValueControl } = useMemo(
|
|
101
|
+
() => filterControlFor(table, column),
|
|
102
|
+
// `facetKey` is a cache key, not something the body reads: the resolution
|
|
103
|
+
// reaches the faceted map through the column, so nothing here names it.
|
|
104
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
105
|
+
[table, column, facetKey],
|
|
106
|
+
);
|
|
107
|
+
const operators = getOperatorsForType(type);
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<>
|
|
111
|
+
<div className={classes.headerFilterValue} data-dg-filter-value-slot>
|
|
112
|
+
<ValueControl
|
|
113
|
+
column={column}
|
|
114
|
+
table={table}
|
|
115
|
+
operator={operator}
|
|
116
|
+
value={value}
|
|
117
|
+
onChange={(next) => write({ operator, value: next })}
|
|
118
|
+
options={options}
|
|
119
|
+
size={controlSize}
|
|
120
|
+
labels={labels}
|
|
121
|
+
layout="header"
|
|
122
|
+
/>
|
|
123
|
+
</div>
|
|
124
|
+
|
|
125
|
+
<OperatorMenu
|
|
126
|
+
columnId={column.id}
|
|
127
|
+
columnLabel={label}
|
|
128
|
+
operator={operator}
|
|
129
|
+
isDefault={operator === defaultOperator}
|
|
130
|
+
operators={operators}
|
|
131
|
+
onChange={changeOperator}
|
|
132
|
+
/>
|
|
133
|
+
</>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The operator picker beside a header filter's value: which comparison the
|
|
139
|
+
* column is on, and the list to change it to.
|
|
140
|
+
*
|
|
141
|
+
* The tooltip is the only thing saying what the current operator is while the
|
|
142
|
+
* menu is shut, and the only thing in the way once it is open - so it is held
|
|
143
|
+
* back while the dropdown shows.
|
|
144
|
+
*/
|
|
145
|
+
function OperatorMenu({
|
|
146
|
+
columnId,
|
|
147
|
+
columnLabel,
|
|
148
|
+
operator,
|
|
149
|
+
isDefault,
|
|
150
|
+
operators,
|
|
151
|
+
onChange,
|
|
152
|
+
}: {
|
|
153
|
+
columnId: string;
|
|
154
|
+
columnLabel: string;
|
|
155
|
+
operator: TMDataGridFilterOperator;
|
|
156
|
+
isDefault: boolean;
|
|
157
|
+
operators: ReadonlyArray<TMDataGridFilterOperator>;
|
|
158
|
+
onChange: (next: TMDataGridFilterOperator) => void;
|
|
159
|
+
}) {
|
|
160
|
+
const { labels } = useTMDataGridContext();
|
|
161
|
+
const [opened, setOpened] = useState(false);
|
|
162
|
+
|
|
163
|
+
return (
|
|
164
|
+
<Menu
|
|
165
|
+
opened={opened}
|
|
166
|
+
onChange={setOpened}
|
|
167
|
+
position="bottom-end"
|
|
168
|
+
shadow="md"
|
|
169
|
+
width={220}
|
|
170
|
+
withinPortal
|
|
171
|
+
>
|
|
172
|
+
<Menu.Target>
|
|
173
|
+
<Tooltip
|
|
174
|
+
label={labels.operators[operator]}
|
|
175
|
+
openDelay={400}
|
|
176
|
+
disabled={opened}
|
|
177
|
+
>
|
|
178
|
+
<ActionIcon
|
|
179
|
+
className={classes.headerFilterOperator}
|
|
180
|
+
variant="subtle"
|
|
181
|
+
// Tinted while the column is on anything but its default operator -
|
|
182
|
+
// the one part of the filter the value cannot show.
|
|
183
|
+
color={isDefault ? "gray" : undefined}
|
|
184
|
+
size="sm"
|
|
185
|
+
aria-label={labels.filterOperatorFor(columnLabel)}
|
|
186
|
+
data-dg-part="header-filter-operator"
|
|
187
|
+
data-column-id={columnId}
|
|
188
|
+
>
|
|
189
|
+
<FilterIcon size={14} stroke={1.6} />
|
|
190
|
+
</ActionIcon>
|
|
191
|
+
</Tooltip>
|
|
192
|
+
</Menu.Target>
|
|
193
|
+
<Menu.Dropdown>
|
|
194
|
+
{operators.map((candidate) => (
|
|
195
|
+
<Menu.Item
|
|
196
|
+
key={candidate}
|
|
197
|
+
className={
|
|
198
|
+
candidate === operator ? classes.operatorItemActive : undefined
|
|
199
|
+
}
|
|
200
|
+
onClick={() => onChange(candidate)}
|
|
201
|
+
>
|
|
202
|
+
{labels.operators[candidate]}
|
|
203
|
+
</Menu.Item>
|
|
204
|
+
))}
|
|
205
|
+
</Menu.Dropdown>
|
|
206
|
+
</Menu>
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* `filters.inHeader` - a second header row holding one value control per
|
|
212
|
+
* filterable column, always visible.
|
|
213
|
+
*
|
|
214
|
+
* A header row like the group rows above it, on the same column tracks and
|
|
215
|
+
* with the same pinned lanes, so resizing, reordering and pinning move the
|
|
216
|
+
* controls with their columns. It sticks below the group rows rather than at
|
|
217
|
+
* the top, and carries `data-dg-header-row` so the body's scroll offset counts
|
|
218
|
+
* it in.
|
|
219
|
+
*
|
|
220
|
+
* @internal Rendered by `TMDataGrid.Table`.
|
|
221
|
+
*/
|
|
222
|
+
export function TMDataGridHeaderFilterRow({
|
|
223
|
+
leafHeaders,
|
|
224
|
+
layoutFor,
|
|
225
|
+
ariaRowIndex,
|
|
226
|
+
}: {
|
|
227
|
+
leafHeaders: ReadonlyArray<TMDataGridHeader>;
|
|
228
|
+
layoutFor: (columnId: string) => TMDataGridColumnLayout;
|
|
229
|
+
ariaRowIndex: number;
|
|
230
|
+
}) {
|
|
231
|
+
const { ui, labels } = useTMDataGridContext();
|
|
232
|
+
const focusColumnId = useSelector(ui, (state) => state.headerFilterColumnId);
|
|
233
|
+
const rowRef = useRef<HTMLDivElement>(null);
|
|
234
|
+
|
|
235
|
+
// `openColumnFilter` under header filters means "put me in that column's
|
|
236
|
+
// control" - there is no panel to open. The column may be scrolled out
|
|
237
|
+
// sideways, so it is brought into view first.
|
|
238
|
+
useEffect(() => {
|
|
239
|
+
if (focusColumnId === null) return;
|
|
240
|
+
const cell = rowRef.current?.querySelector<HTMLElement>(
|
|
241
|
+
`[data-dg-part="header-filter-cell"][data-column-id="${CSS.escape(focusColumnId)}"]`,
|
|
242
|
+
);
|
|
243
|
+
cell?.scrollIntoView({ block: "nearest", inline: "nearest" });
|
|
244
|
+
cell
|
|
245
|
+
?.querySelector<HTMLElement>(
|
|
246
|
+
"input:not([type='hidden']), textarea, select, [tabindex]:not([tabindex='-1'])",
|
|
247
|
+
)
|
|
248
|
+
?.focus();
|
|
249
|
+
ui.actions.focusHeaderFilter(null);
|
|
250
|
+
}, [focusColumnId, ui]);
|
|
251
|
+
|
|
252
|
+
return (
|
|
253
|
+
<div
|
|
254
|
+
ref={rowRef}
|
|
255
|
+
role="row"
|
|
256
|
+
aria-rowindex={ariaRowIndex}
|
|
257
|
+
// Counted by the same measurement the group rows are: it is part of what
|
|
258
|
+
// the body scrolls under.
|
|
259
|
+
data-dg-header-row
|
|
260
|
+
data-dg-header-filter-row
|
|
261
|
+
data-dg-part="header-filter-row"
|
|
262
|
+
// The header/body boundary is this row once it exists, so it wears the
|
|
263
|
+
// scrolled-under shadow the last group row wears otherwise.
|
|
264
|
+
className={`${classes.headerFilterRow} ${sticky.headerBoundary}`}
|
|
265
|
+
>
|
|
266
|
+
{leafHeaders.map((header) => {
|
|
267
|
+
const column = header.column;
|
|
268
|
+
const layout = layoutFor(column.id);
|
|
269
|
+
return (
|
|
270
|
+
<div
|
|
271
|
+
key={header.id}
|
|
272
|
+
role="columnheader"
|
|
273
|
+
aria-label={labels.filterOn(getColumnLabel(column))}
|
|
274
|
+
data-dg-part="header-filter-cell"
|
|
275
|
+
data-column-id={column.id}
|
|
276
|
+
data-control-column={isControlColumn(column.id)}
|
|
277
|
+
className={[
|
|
278
|
+
classes.headerFilterCell,
|
|
279
|
+
layout.isBoundary && layout.pinnedAt === "left"
|
|
280
|
+
? sticky.stickyLeft
|
|
281
|
+
: "",
|
|
282
|
+
layout.isBoundary && layout.pinnedAt === "right"
|
|
283
|
+
? sticky.stickyRight
|
|
284
|
+
: "",
|
|
285
|
+
]
|
|
286
|
+
.filter(Boolean)
|
|
287
|
+
.join(" ")}
|
|
288
|
+
style={{
|
|
289
|
+
left: layout.pinnedAt === "left" ? layout.offset : undefined,
|
|
290
|
+
right: layout.pinnedAt === "right" ? layout.offset : undefined,
|
|
291
|
+
position: layout.pinnedAt ? "sticky" : undefined,
|
|
292
|
+
zIndex: layout.pinnedAt
|
|
293
|
+
? "var(--dg-z-header-pinned-cell, 7)"
|
|
294
|
+
: undefined,
|
|
295
|
+
}}
|
|
296
|
+
>
|
|
297
|
+
{column.getCanFilter() && <HeaderFilterControl column={column} />}
|
|
298
|
+
</div>
|
|
299
|
+
);
|
|
300
|
+
})}
|
|
301
|
+
</div>
|
|
302
|
+
);
|
|
303
|
+
}
|