@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.
Files changed (50) hide show
  1. package/dist/index.d.ts +285 -22
  2. package/dist/index.js +2857 -2376
  3. package/dist/index.js.map +1 -1
  4. package/dist/styles.css +1 -1
  5. package/package.json +1 -2
  6. package/skills/appearance/SKILL.md +1 -1
  7. package/skills/cell-selection/SKILL.md +1 -1
  8. package/skills/columns/SKILL.md +2 -2
  9. package/skills/data/SKILL.md +5 -2
  10. package/skills/editing/SKILL.md +1 -1
  11. package/skills/filtering/SKILL.md +112 -26
  12. package/skills/getting-started/SKILL.md +4 -4
  13. package/skills/grouping/SKILL.md +1 -1
  14. package/skills/options/SKILL.md +3 -1
  15. package/skills/rows/SKILL.md +1 -1
  16. package/skills/server-side/SKILL.md +44 -15
  17. package/skills/testing/SKILL.md +6 -3
  18. package/src/tmdatagrid/components/TMDataGrid.tsx +3 -0
  19. package/src/tmdatagrid/components/TMDataGridEditColumn.tsx +1 -1
  20. package/src/tmdatagrid/components/TMDataGridFilterPanel.module.css +37 -18
  21. package/src/tmdatagrid/components/TMDataGridFilterPanel.tsx +138 -160
  22. package/src/tmdatagrid/components/TMDataGridFilterSurface.module.css +54 -0
  23. package/src/tmdatagrid/components/TMDataGridFilterSurface.tsx +162 -0
  24. package/src/tmdatagrid/components/TMDataGridFooter.tsx +28 -1
  25. package/src/tmdatagrid/components/TMDataGridHeaderCell.module.css +10 -0
  26. package/src/tmdatagrid/components/TMDataGridHeaderCell.tsx +12 -3
  27. package/src/tmdatagrid/components/TMDataGridHeaderFilterRow.module.css +51 -0
  28. package/src/tmdatagrid/components/TMDataGridHeaderFilterRow.tsx +303 -0
  29. package/src/tmdatagrid/components/TMDataGridTable.module.css +9 -41
  30. package/src/tmdatagrid/components/TMDataGridTable.tsx +84 -15
  31. package/src/tmdatagrid/components/TMDataGridToolbar.tsx +37 -11
  32. package/src/tmdatagrid/components/filters/DgAutocompleteFilter.tsx +5 -4
  33. package/src/tmdatagrid/components/filters/DgDateRangeFilter.tsx +22 -5
  34. package/src/tmdatagrid/components/filters/DgRangeSliderFilter.tsx +5 -1
  35. package/src/tmdatagrid/components/filters/DgTriStateFilter.tsx +5 -1
  36. package/src/tmdatagrid/components/filters/TMDataGridFilterValueInput.tsx +44 -28
  37. package/src/tmdatagrid/components/filters/controlLayout.ts +32 -0
  38. package/src/tmdatagrid/components/filters/filterControlFor.ts +65 -0
  39. package/src/tmdatagrid/components/sticky.module.css +44 -0
  40. package/src/tmdatagrid/core/columnOptions.ts +46 -0
  41. package/src/tmdatagrid/core/controlledStateSync.ts +108 -0
  42. package/src/tmdatagrid/core/editEngine.ts +68 -12
  43. package/src/tmdatagrid/core/filterControls.ts +28 -0
  44. package/src/tmdatagrid/core/filterOperators.ts +64 -1
  45. package/src/tmdatagrid/core/filterSurface.ts +99 -0
  46. package/src/tmdatagrid/core/labels.ts +7 -0
  47. package/src/tmdatagrid/core/labelsSv.ts +3 -0
  48. package/src/tmdatagrid/core/pageReset.ts +115 -0
  49. package/src/tmdatagrid/index.ts +13 -0
  50. 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
- * <MyJumpToPage page={state.pageIndex} onJump={actions.setPageIndex} />
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
- if (canFilter) {
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
- {isFiltered && (
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
+ }