@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
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from "../TMDataGridContext";
|
|
12
12
|
import type { TMDataGridColumnLayout } from "./TMDataGridTable";
|
|
13
13
|
import { getColumnCapabilities, getGridCapabilities } from "../core/capabilities";
|
|
14
|
+
import { autosizeColumn } from "../core/autosize";
|
|
14
15
|
import {
|
|
15
16
|
getColumnRegion,
|
|
16
17
|
getStepTargetColumn,
|
|
@@ -18,22 +19,32 @@ import {
|
|
|
18
19
|
moveColumnByStep,
|
|
19
20
|
type TMDataGridDropSide,
|
|
20
21
|
} from "../core/columnOrdering";
|
|
21
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
getColumnAlign,
|
|
24
|
+
getColumnLabel,
|
|
25
|
+
isControlColumn,
|
|
26
|
+
} from "../core/columnUtils";
|
|
27
|
+
import { resolveExpandAll } from "../core/expanding";
|
|
22
28
|
import { isFilterActive } from "../core/filterOperators";
|
|
23
29
|
import {
|
|
24
30
|
ArrowDownIcon,
|
|
25
31
|
ArrowUpIcon,
|
|
32
|
+
AutosizeIcon,
|
|
33
|
+
CollapseAllIcon,
|
|
26
34
|
ColumnsIcon,
|
|
27
35
|
DotsVerticalIcon,
|
|
36
|
+
ExpandAllIcon,
|
|
28
37
|
EyeOffIcon,
|
|
29
38
|
FilterIcon,
|
|
39
|
+
GroupIcon,
|
|
30
40
|
MoveLeftIcon,
|
|
31
41
|
MoveRightIcon,
|
|
32
42
|
PinLeftIcon,
|
|
33
43
|
PinOffIcon,
|
|
34
44
|
PinRightIcon,
|
|
45
|
+
UngroupIcon,
|
|
35
46
|
} from "./icons";
|
|
36
|
-
import {
|
|
47
|
+
import { GROUP_COLUMN_ID } from "./TMDataGridGroupColumn";
|
|
37
48
|
import { type TMDataGridFeatures, openColumnFilter } from "../useTMDataGrid";
|
|
38
49
|
|
|
39
50
|
export type TMDataGridHeader = Header<
|
|
@@ -50,9 +61,10 @@ export function TMDataGridHeaderCell({
|
|
|
50
61
|
layout: TMDataGridColumnLayout;
|
|
51
62
|
}) {
|
|
52
63
|
const api = useTMDataGridContext();
|
|
53
|
-
const { table, features } = api;
|
|
64
|
+
const { table, features, labels } = api;
|
|
54
65
|
const column = header.column;
|
|
55
66
|
const [menuOpened, setMenuOpened] = useState(false);
|
|
67
|
+
const [contextMenuOpened, setContextMenuOpened] = useState(false);
|
|
56
68
|
const [dropSide, setDropSide] = useState<TMDataGridDropSide | null>(null);
|
|
57
69
|
const cellRef = useRef<HTMLDivElement>(null);
|
|
58
70
|
// A resize drag ends with a click that bubbles to the header — without this
|
|
@@ -63,7 +75,7 @@ export function TMDataGridHeaderCell({
|
|
|
63
75
|
// surrounding tree is memoized (React Compiler) or the table lives elsewhere.
|
|
64
76
|
// `columnOrder` is in the list because the move menu items are derived from
|
|
65
77
|
// the column's neighbours, which is exactly what an order change invalidates.
|
|
66
|
-
const { sorting, columnFilters, resizingColumnId, columnPinning } =
|
|
78
|
+
const { sorting, columnFilters, resizingColumnId, columnPinning, grouping } =
|
|
67
79
|
useSelector(
|
|
68
80
|
table.store,
|
|
69
81
|
(state) => ({
|
|
@@ -72,6 +84,7 @@ export function TMDataGridHeaderCell({
|
|
|
72
84
|
resizingColumnId: state.columnResizing.isResizingColumn,
|
|
73
85
|
columnPinning: state.columnPinning,
|
|
74
86
|
columnOrder: state.columnOrder,
|
|
87
|
+
grouping: state.grouping,
|
|
75
88
|
}),
|
|
76
89
|
{ compare: shallow },
|
|
77
90
|
);
|
|
@@ -82,6 +95,12 @@ export function TMDataGridHeaderCell({
|
|
|
82
95
|
const align = getColumnAlign(column);
|
|
83
96
|
const sortDirection = sorting.find((sort) => sort.id === column.id)?.desc;
|
|
84
97
|
const isSorted = sortDirection !== undefined;
|
|
98
|
+
// 1-based position in a multi-sort, shown only while more than one column
|
|
99
|
+
// sorts — under a single sort the arrow already says everything.
|
|
100
|
+
const sortIndex =
|
|
101
|
+
isSorted && sorting.length > 1
|
|
102
|
+
? sorting.findIndex((sort) => sort.id === column.id) + 1
|
|
103
|
+
: null;
|
|
85
104
|
const isFiltered = columnFilters.some(
|
|
86
105
|
(filter) => filter.id === column.id && isFilterActive(filter.value),
|
|
87
106
|
);
|
|
@@ -97,9 +116,18 @@ export function TMDataGridHeaderCell({
|
|
|
97
116
|
const canPin = isLeaf && capabilities.canPin;
|
|
98
117
|
const canResize = capabilities.canResize;
|
|
99
118
|
const canReorder = isLeaf && capabilities.canReorder;
|
|
119
|
+
const canGroup = isLeaf && capabilities.canGroup;
|
|
100
120
|
const canManageColumns =
|
|
101
121
|
isLeaf && getGridCapabilities(table, features).canHideAny;
|
|
102
122
|
|
|
123
|
+
// Read off the subscribed slice, not from `column.getIsGrouped()`: that is a
|
|
124
|
+
// call on a `column` whose identity survives a grouping change, so the React
|
|
125
|
+
// Compiler would cache the answer and the menu would keep offering to group
|
|
126
|
+
// a column that already is. Same reason the sort and filter flags above are
|
|
127
|
+
// derived from state rather than from `getIsSorted()`.
|
|
128
|
+
const isGrouped = grouping.includes(column.id);
|
|
129
|
+
const isGroupingActive = grouping.length > 0;
|
|
130
|
+
|
|
103
131
|
// A column only moves inside its own pinned lane, so a header in another one
|
|
104
132
|
// never lights up as a target. See TMDataGridColumnRegion.
|
|
105
133
|
const isDragged = draggedColumnId === column.id;
|
|
@@ -154,6 +182,18 @@ export function TMDataGridHeaderCell({
|
|
|
154
182
|
api.ui.actions.endColumnDrag();
|
|
155
183
|
}
|
|
156
184
|
|
|
185
|
+
/**
|
|
186
|
+
* Sizes the column to its widest mounted content. The container is found
|
|
187
|
+
* from the cell rather than passed down — the header always renders inside
|
|
188
|
+
* the grid's scroll container, and that is the subtree autosize measures.
|
|
189
|
+
*/
|
|
190
|
+
function autosize() {
|
|
191
|
+
const container = cellRef.current?.closest<HTMLElement>(
|
|
192
|
+
"[data-dg-scroll-container]",
|
|
193
|
+
);
|
|
194
|
+
if (container) autosizeColumn({ table, columnId: column.id, container });
|
|
195
|
+
}
|
|
196
|
+
|
|
157
197
|
/**
|
|
158
198
|
* Pinned columns are positioned with `getStart()` / `getAfter()`, which sum
|
|
159
199
|
* `column.getSize()`. Fluid (`fr`) columns don't report their rendered width
|
|
@@ -181,14 +221,14 @@ export function TMDataGridHeaderCell({
|
|
|
181
221
|
leftSection={<ArrowUpIcon size={16} stroke={1.6} />}
|
|
182
222
|
onClick={() => column.toggleSorting(false)}
|
|
183
223
|
>
|
|
184
|
-
|
|
224
|
+
{labels.sortAsc}
|
|
185
225
|
</Menu.Item>,
|
|
186
226
|
<Menu.Item
|
|
187
227
|
key="sort-desc"
|
|
188
228
|
leftSection={<ArrowDownIcon size={16} stroke={1.6} />}
|
|
189
229
|
onClick={() => column.toggleSorting(true)}
|
|
190
230
|
>
|
|
191
|
-
|
|
231
|
+
{labels.sortDesc}
|
|
192
232
|
</Menu.Item>,
|
|
193
233
|
<Menu.Divider key="sort-divider" />,
|
|
194
234
|
);
|
|
@@ -200,11 +240,82 @@ export function TMDataGridHeaderCell({
|
|
|
200
240
|
leftSection={<FilterIcon size={16} stroke={1.6} />}
|
|
201
241
|
onClick={() => openColumnFilter(api, column.id)}
|
|
202
242
|
>
|
|
203
|
-
|
|
243
|
+
{labels.filterMenuItem}
|
|
204
244
|
</Menu.Item>,
|
|
205
245
|
<Menu.Divider key="filter-divider" />,
|
|
206
246
|
);
|
|
207
247
|
}
|
|
248
|
+
if (canGroup) {
|
|
249
|
+
menuItems.push(
|
|
250
|
+
<Menu.Item
|
|
251
|
+
key="group"
|
|
252
|
+
className={isGrouped ? classes.menuItemActive : undefined}
|
|
253
|
+
leftSection={
|
|
254
|
+
isGrouped ? (
|
|
255
|
+
<UngroupIcon size={16} stroke={1.6} />
|
|
256
|
+
) : (
|
|
257
|
+
<GroupIcon size={16} stroke={1.6} />
|
|
258
|
+
)
|
|
259
|
+
}
|
|
260
|
+
onClick={column.getToggleGroupingHandler()}
|
|
261
|
+
>
|
|
262
|
+
{isGrouped ? labels.ungroup(label) : labels.groupBy(label)}
|
|
263
|
+
</Menu.Item>,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
if (isGroupingActive) {
|
|
267
|
+
// Under the default `groupedColumnMode: "remove"` a grouped column is taken
|
|
268
|
+
// out of the grid, so its own header — and the Ungroup item on it — is no
|
|
269
|
+
// longer reachable. The tree column that replaced it carries them instead,
|
|
270
|
+
// which is also where someone would look for them.
|
|
271
|
+
if (column.id === GROUP_COLUMN_ID) {
|
|
272
|
+
for (const groupedId of grouping) {
|
|
273
|
+
const groupedColumn = table.getColumn(groupedId);
|
|
274
|
+
if (!groupedColumn) continue;
|
|
275
|
+
menuItems.push(
|
|
276
|
+
<Menu.Item
|
|
277
|
+
key={`ungroup-${groupedId}`}
|
|
278
|
+
leftSection={<UngroupIcon size={16} stroke={1.6} />}
|
|
279
|
+
onClick={() => groupedColumn.toggleGrouping()}
|
|
280
|
+
>
|
|
281
|
+
{labels.ungroup(getColumnLabel(groupedColumn))}
|
|
282
|
+
</Menu.Item>,
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
// Groups only, though `expanded` also holds which rows have their detail
|
|
287
|
+
// panel open — `toggleAllRowsExpanded` would open every one of them from an
|
|
288
|
+
// item that says "groups". See resolveExpandAll.
|
|
289
|
+
const expandAllGroups = (expand: boolean) =>
|
|
290
|
+
table.setExpanded(
|
|
291
|
+
resolveExpandAll({
|
|
292
|
+
rows: table.getPrePaginatedRowModel().flatRows,
|
|
293
|
+
expanded: table.store.state.expanded,
|
|
294
|
+
target: "groups",
|
|
295
|
+
expand,
|
|
296
|
+
}),
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
menuItems.push(
|
|
300
|
+
<Menu.Item
|
|
301
|
+
key="expand-all"
|
|
302
|
+
leftSection={<ExpandAllIcon size={16} stroke={1.6} />}
|
|
303
|
+
onClick={() => expandAllGroups(true)}
|
|
304
|
+
>
|
|
305
|
+
{labels.expandAllGroups}
|
|
306
|
+
</Menu.Item>,
|
|
307
|
+
<Menu.Item
|
|
308
|
+
key="collapse-all"
|
|
309
|
+
leftSection={<CollapseAllIcon size={16} stroke={1.6} />}
|
|
310
|
+
onClick={() => expandAllGroups(false)}
|
|
311
|
+
>
|
|
312
|
+
{labels.collapseAllGroups}
|
|
313
|
+
</Menu.Item>,
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
if (canGroup || isGroupingActive) {
|
|
317
|
+
menuItems.push(<Menu.Divider key="group-divider" />);
|
|
318
|
+
}
|
|
208
319
|
if (canPin) {
|
|
209
320
|
menuItems.push(
|
|
210
321
|
<Menu.Item
|
|
@@ -213,7 +324,7 @@ export function TMDataGridHeaderCell({
|
|
|
213
324
|
leftSection={<PinLeftIcon size={16} stroke={1.6} />}
|
|
214
325
|
onClick={() => setPinned(pinnedAt === "left" ? false : "left")}
|
|
215
326
|
>
|
|
216
|
-
|
|
327
|
+
{labels.pinLeft}
|
|
217
328
|
</Menu.Item>,
|
|
218
329
|
<Menu.Item
|
|
219
330
|
key="pin-right"
|
|
@@ -221,7 +332,7 @@ export function TMDataGridHeaderCell({
|
|
|
221
332
|
leftSection={<PinRightIcon size={16} stroke={1.6} />}
|
|
222
333
|
onClick={() => setPinned(pinnedAt === "right" ? false : "right")}
|
|
223
334
|
>
|
|
224
|
-
|
|
335
|
+
{labels.pinRight}
|
|
225
336
|
</Menu.Item>,
|
|
226
337
|
);
|
|
227
338
|
if (pinnedAt) {
|
|
@@ -231,7 +342,7 @@ export function TMDataGridHeaderCell({
|
|
|
231
342
|
leftSection={<PinOffIcon size={16} stroke={1.6} />}
|
|
232
343
|
onClick={() => setPinned(false)}
|
|
233
344
|
>
|
|
234
|
-
|
|
345
|
+
{labels.unpin}
|
|
235
346
|
</Menu.Item>,
|
|
236
347
|
);
|
|
237
348
|
}
|
|
@@ -260,7 +371,7 @@ export function TMDataGridHeaderCell({
|
|
|
260
371
|
moveColumnByStep({ table, columnId: column.id, direction: -1 })
|
|
261
372
|
}
|
|
262
373
|
>
|
|
263
|
-
|
|
374
|
+
{labels.moveLeft}
|
|
264
375
|
</Menu.Item>,
|
|
265
376
|
<Menu.Item
|
|
266
377
|
key="move-right"
|
|
@@ -270,12 +381,24 @@ export function TMDataGridHeaderCell({
|
|
|
270
381
|
moveColumnByStep({ table, columnId: column.id, direction: 1 })
|
|
271
382
|
}
|
|
272
383
|
>
|
|
273
|
-
|
|
384
|
+
{labels.moveRight}
|
|
274
385
|
</Menu.Item>,
|
|
275
386
|
<Menu.Divider key="move-divider" />,
|
|
276
387
|
);
|
|
277
388
|
}
|
|
278
389
|
}
|
|
390
|
+
if (canResize && isLeaf) {
|
|
391
|
+
menuItems.push(
|
|
392
|
+
<Menu.Item
|
|
393
|
+
key="autosize"
|
|
394
|
+
leftSection={<AutosizeIcon size={16} stroke={1.6} />}
|
|
395
|
+
onClick={autosize}
|
|
396
|
+
>
|
|
397
|
+
{labels.autosizeColumn}
|
|
398
|
+
</Menu.Item>,
|
|
399
|
+
<Menu.Divider key="autosize-divider" />,
|
|
400
|
+
);
|
|
401
|
+
}
|
|
279
402
|
if (canHide) {
|
|
280
403
|
menuItems.push(
|
|
281
404
|
<Menu.Item
|
|
@@ -283,7 +406,7 @@ export function TMDataGridHeaderCell({
|
|
|
283
406
|
leftSection={<EyeOffIcon size={16} stroke={1.6} />}
|
|
284
407
|
onClick={() => column.toggleVisibility(false)}
|
|
285
408
|
>
|
|
286
|
-
|
|
409
|
+
{labels.hideColumn}
|
|
287
410
|
</Menu.Item>,
|
|
288
411
|
);
|
|
289
412
|
}
|
|
@@ -294,7 +417,7 @@ export function TMDataGridHeaderCell({
|
|
|
294
417
|
leftSection={<ColumnsIcon size={16} stroke={1.6} />}
|
|
295
418
|
onClick={() => api.ui.actions.setColumnsPanelOpen(true)}
|
|
296
419
|
>
|
|
297
|
-
|
|
420
|
+
{labels.manageColumns}
|
|
298
421
|
</Menu.Item>,
|
|
299
422
|
);
|
|
300
423
|
}
|
|
@@ -303,6 +426,12 @@ export function TMDataGridHeaderCell({
|
|
|
303
426
|
menuItems.pop();
|
|
304
427
|
}
|
|
305
428
|
|
|
429
|
+
// One gate for both ways into the menu — the button and the right-click —
|
|
430
|
+
// so the two can never disagree about which headers have one. A control
|
|
431
|
+
// lane's header is itself a control (select-all, expand-all), so it carries
|
|
432
|
+
// no column menu, and a right-click there falls through to the browser's.
|
|
433
|
+
const hasMenu = isLeaf && menuItems.length > 0 && !isControlColumn(column.id);
|
|
434
|
+
|
|
306
435
|
const cellClass = [
|
|
307
436
|
classes.headerCell,
|
|
308
437
|
canSort ? classes.headerCellSortable : "",
|
|
@@ -315,13 +444,16 @@ export function TMDataGridHeaderCell({
|
|
|
315
444
|
.filter(Boolean)
|
|
316
445
|
.join(" ");
|
|
317
446
|
|
|
318
|
-
|
|
447
|
+
const cell = (
|
|
319
448
|
<div
|
|
320
449
|
ref={cellRef}
|
|
321
450
|
role="columnheader"
|
|
322
451
|
data-testid={`dg-header-${column.id}`}
|
|
323
452
|
data-active={isSorted || isFiltered}
|
|
324
453
|
data-align={align}
|
|
454
|
+
// A control lane is a fixed track, so it cannot take the cell padding the
|
|
455
|
+
// scale grows for text. See isControlColumn.
|
|
456
|
+
data-control-column={isControlColumn(column.id)}
|
|
325
457
|
// Only meaningful on a sortable column: "none" advertises that this
|
|
326
458
|
// header sorts, which is a lie on one that doesn't.
|
|
327
459
|
aria-sort={
|
|
@@ -347,8 +479,19 @@ export function TMDataGridHeaderCell({
|
|
|
347
479
|
left: pinnedAt === "left" ? layout.offset : undefined,
|
|
348
480
|
right: pinnedAt === "right" ? layout.offset : undefined,
|
|
349
481
|
position: pinnedAt ? "sticky" : undefined,
|
|
350
|
-
|
|
482
|
+
// From the stacking ladder in TMDataGrid.module.css.
|
|
483
|
+
zIndex: pinnedAt ? "var(--dg-z-header-pinned-cell, 7)" : undefined,
|
|
351
484
|
}}
|
|
485
|
+
// Shift+click is "add to sort", and the browser would smear a text
|
|
486
|
+
// selection across the headers it passes. Stopped at the one gesture,
|
|
487
|
+
// same as the body rows do for shift-select.
|
|
488
|
+
onMouseDown={
|
|
489
|
+
canSort
|
|
490
|
+
? (event) => {
|
|
491
|
+
if (event.shiftKey) event.preventDefault();
|
|
492
|
+
}
|
|
493
|
+
: undefined
|
|
494
|
+
}
|
|
352
495
|
onClick={
|
|
353
496
|
canSort
|
|
354
497
|
? (event) => {
|
|
@@ -356,6 +499,8 @@ export function TMDataGridHeaderCell({
|
|
|
356
499
|
suppressSortRef.current = false;
|
|
357
500
|
return;
|
|
358
501
|
}
|
|
502
|
+
// TanStack's handler reads the modifier itself: a plain click
|
|
503
|
+
// replaces the sort, Shift+click appends (`isMultiSortEvent`).
|
|
359
504
|
column.getToggleSortingHandler()?.(event);
|
|
360
505
|
}
|
|
361
506
|
: undefined
|
|
@@ -363,7 +508,8 @@ export function TMDataGridHeaderCell({
|
|
|
363
508
|
>
|
|
364
509
|
{header.isPlaceholder ? null : (
|
|
365
510
|
<Tooltip label={label} openDelay={600} withinPortal disabled={!isLeaf}>
|
|
366
|
-
|
|
511
|
+
{/* `data-dg-header-title` is how autosize measures the title text. */}
|
|
512
|
+
<span className={classes.headerTitle} data-dg-header-title>
|
|
367
513
|
{flexRender(column.columnDef.header, header.getContext())}
|
|
368
514
|
</span>
|
|
369
515
|
</Tooltip>
|
|
@@ -378,7 +524,7 @@ export function TMDataGridHeaderCell({
|
|
|
378
524
|
variant="subtle"
|
|
379
525
|
color="gray"
|
|
380
526
|
size="xs"
|
|
381
|
-
aria-label={
|
|
527
|
+
aria-label={labels.filterOn(label)}
|
|
382
528
|
onClick={(event) => {
|
|
383
529
|
event.stopPropagation();
|
|
384
530
|
openColumnFilter(api, column.id);
|
|
@@ -395,7 +541,7 @@ export function TMDataGridHeaderCell({
|
|
|
395
541
|
variant="subtle"
|
|
396
542
|
color="gray"
|
|
397
543
|
size="xs"
|
|
398
|
-
aria-label={
|
|
544
|
+
aria-label={labels.sortColumn(label)}
|
|
399
545
|
onClick={column.getToggleSortingHandler()}
|
|
400
546
|
>
|
|
401
547
|
{sortDirection ? (
|
|
@@ -406,7 +552,16 @@ export function TMDataGridHeaderCell({
|
|
|
406
552
|
</ActionIcon>
|
|
407
553
|
)}
|
|
408
554
|
|
|
409
|
-
{
|
|
555
|
+
{/* Shift+click adds a column to the sort (TanStack's own
|
|
556
|
+
`isMultiSortEvent`); the number is its priority. Outside the
|
|
557
|
+
hover-revealed action so an active multi-sort stays readable. */}
|
|
558
|
+
{sortIndex !== null && (
|
|
559
|
+
<span className={classes.sortIndex} data-testid="dg-sort-index">
|
|
560
|
+
{sortIndex}
|
|
561
|
+
</span>
|
|
562
|
+
)}
|
|
563
|
+
|
|
564
|
+
{hasMenu && (
|
|
410
565
|
<Menu
|
|
411
566
|
opened={menuOpened}
|
|
412
567
|
onChange={setMenuOpened}
|
|
@@ -418,11 +573,14 @@ export function TMDataGridHeaderCell({
|
|
|
418
573
|
<Menu.Target>
|
|
419
574
|
<ActionIcon
|
|
420
575
|
className={classes.headerAction}
|
|
421
|
-
|
|
576
|
+
// Held visible for the right-click menu too: that one opens at
|
|
577
|
+
// the pointer and is portaled, so the hover that revealed the
|
|
578
|
+
// actions is lost the moment the pointer enters the dropdown.
|
|
579
|
+
data-pinned-visible={menuOpened || contextMenuOpened}
|
|
422
580
|
variant="subtle"
|
|
423
581
|
color="gray"
|
|
424
582
|
size="xs"
|
|
425
|
-
aria-label={
|
|
583
|
+
aria-label={labels.columnMenu(label)}
|
|
426
584
|
onClick={(event) => {
|
|
427
585
|
event.stopPropagation();
|
|
428
586
|
setMenuOpened((opened) => !opened);
|
|
@@ -439,30 +597,77 @@ export function TMDataGridHeaderCell({
|
|
|
439
597
|
</div>
|
|
440
598
|
)}
|
|
441
599
|
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
600
|
+
{/* A system lane has no separator at all: it cannot be resized, and the
|
|
601
|
+
hover line reads as a handle that does nothing — the one place the
|
|
602
|
+
separator stops being a divider and starts being a lie. */}
|
|
603
|
+
{!isControlColumn(column.id) && (
|
|
604
|
+
<div
|
|
605
|
+
className={[
|
|
606
|
+
classes.columnSeparator,
|
|
607
|
+
canResize ? classes.columnSeparatorResizable : "",
|
|
608
|
+
isResizing ? classes.columnSeparatorActive : "",
|
|
609
|
+
]
|
|
610
|
+
.filter(Boolean)
|
|
611
|
+
.join(" ")}
|
|
612
|
+
// Keeps a drag that starts on the separator from being picked up by
|
|
613
|
+
// the draggable header around it.
|
|
614
|
+
draggable={false}
|
|
615
|
+
onMouseDown={
|
|
616
|
+
canResize
|
|
617
|
+
? (event) => {
|
|
618
|
+
suppressSortRef.current = true;
|
|
619
|
+
header.getResizeHandler()(event);
|
|
620
|
+
}
|
|
621
|
+
: undefined
|
|
622
|
+
}
|
|
623
|
+
onTouchStart={canResize ? header.getResizeHandler() : undefined}
|
|
624
|
+
onClick={(event) => event.stopPropagation()}
|
|
625
|
+
// The spreadsheet gesture: double-click the divider, the column
|
|
626
|
+
// fits its content. Same measurement as the menu item.
|
|
627
|
+
onDoubleClick={
|
|
628
|
+
canResize
|
|
629
|
+
? (event) => {
|
|
630
|
+
event.stopPropagation();
|
|
631
|
+
autosize();
|
|
632
|
+
}
|
|
633
|
+
: undefined
|
|
634
|
+
}
|
|
635
|
+
/>
|
|
636
|
+
)}
|
|
464
637
|
</div>
|
|
465
638
|
);
|
|
639
|
+
|
|
640
|
+
if (!hasMenu) return cell;
|
|
641
|
+
|
|
642
|
+
// A second Menu for the same items, rather than one Menu serving both ways
|
|
643
|
+
// in: the button's dropdown hangs off the button, while a context menu
|
|
644
|
+
// belongs at the pointer, and a Mantine Menu has one target. `menuItems` is
|
|
645
|
+
// built once and rendered by whichever dropdown is open — never both — so
|
|
646
|
+
// the two can't drift apart.
|
|
647
|
+
//
|
|
648
|
+
// The per-header Popover this adds is not the cost the body pays for one per
|
|
649
|
+
// row (see TMDataGridBodyRowGroup): there are as many headers as columns, and
|
|
650
|
+
// they don't re-render on every scroll frame.
|
|
651
|
+
return (
|
|
652
|
+
<Menu
|
|
653
|
+
opened={contextMenuOpened}
|
|
654
|
+
onChange={(opened) => {
|
|
655
|
+
setContextMenuOpened(opened);
|
|
656
|
+
// Right-clicking a header whose button menu is still open would
|
|
657
|
+
// otherwise leave two dropdowns of the same items on screen.
|
|
658
|
+
if (opened) setMenuOpened(false);
|
|
659
|
+
}}
|
|
660
|
+
position="bottom-start"
|
|
661
|
+
shadow="md"
|
|
662
|
+
width={220}
|
|
663
|
+
withinPortal
|
|
664
|
+
>
|
|
665
|
+
<Menu.ContextMenu>{cell}</Menu.ContextMenu>
|
|
666
|
+
<Menu.Dropdown onClick={(event) => event.stopPropagation()}>
|
|
667
|
+
{menuItems}
|
|
668
|
+
</Menu.Dropdown>
|
|
669
|
+
</Menu>
|
|
670
|
+
);
|
|
466
671
|
}
|
|
467
672
|
|
|
468
673
|
function isDivider(node: ReactNode): boolean {
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { ActionIcon, TextInput } from "@mantine/core";
|
|
2
|
+
import { useSelector } from "@tanstack/react-store";
|
|
3
|
+
import { useEffect, useRef, useState } from "react";
|
|
4
|
+
import { useTMDataGridContext } from "../TMDataGridContext";
|
|
5
|
+
import { getGridCapabilities } from "../core/capabilities";
|
|
6
|
+
import { CloseIcon, SearchIcon } from "./icons";
|
|
7
|
+
|
|
8
|
+
export type TMDataGridSearchProps = {
|
|
9
|
+
/** Defaults to `labels.searchPlaceholder`. */
|
|
10
|
+
placeholder?: string;
|
|
11
|
+
/**
|
|
12
|
+
* How long typing pauses before the filter is applied, in ms. Defaults to
|
|
13
|
+
* 250. `0` writes on every keystroke — what the tests use, and fine for
|
|
14
|
+
* small client-side data sets.
|
|
15
|
+
*/
|
|
16
|
+
debounce?: number;
|
|
17
|
+
/** Width of the input. Defaults to 220. */
|
|
18
|
+
w?: number | string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Quick search over every column — a debounced input writing the table's
|
|
23
|
+
* `globalFilter` state through the `"includesString"` filter the grid
|
|
24
|
+
* configures by default.
|
|
25
|
+
*
|
|
26
|
+
* ```tsx
|
|
27
|
+
* <TMDataGrid.Toolbar>
|
|
28
|
+
* <TMDataGrid.Search />
|
|
29
|
+
* <TMDataGrid.Spacer />
|
|
30
|
+
* <TMDataGrid.FilterButton />
|
|
31
|
+
* </TMDataGrid.Toolbar>
|
|
32
|
+
* ```
|
|
33
|
+
*
|
|
34
|
+
* Renders nothing under `enableGlobalFilter: false`. Columns opt out with
|
|
35
|
+
* their own `enableGlobalFilter: false` — the generated lanes already do.
|
|
36
|
+
*
|
|
37
|
+
* The state is TanStack's `globalFilter`, so everything around it comes free:
|
|
38
|
+
* `manualFiltering` grids forward it to the server, and it is one of the
|
|
39
|
+
* persisted `data` slices. A grid that wants its own input entirely writes
|
|
40
|
+
* `table.setGlobalFilter` itself — this component is only the built-in one.
|
|
41
|
+
*/
|
|
42
|
+
export function TMDataGridSearch({
|
|
43
|
+
placeholder,
|
|
44
|
+
debounce = 250,
|
|
45
|
+
w = 220,
|
|
46
|
+
}: TMDataGridSearchProps) {
|
|
47
|
+
const { table, features, labels, controlSize } = useTMDataGridContext();
|
|
48
|
+
|
|
49
|
+
// A consumer can put anything in `globalFilter`; the input only mirrors the
|
|
50
|
+
// string form and treats the rest as empty.
|
|
51
|
+
const globalFilter = useSelector(table.store, (state) =>
|
|
52
|
+
typeof state.globalFilter === "string" ? state.globalFilter : "",
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
const [draft, setDraft] = useState(globalFilter);
|
|
56
|
+
const timeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(
|
|
57
|
+
undefined,
|
|
58
|
+
);
|
|
59
|
+
// What this input last wrote, so an echo of its own write is not mistaken
|
|
60
|
+
// for an external change that should overwrite what is being typed.
|
|
61
|
+
const lastWrittenRef = useRef(globalFilter);
|
|
62
|
+
|
|
63
|
+
// External writes — a consumer's `setGlobalFilter`, a persistence restore —
|
|
64
|
+
// win over the draft; the input is a mirror, not an owner.
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (globalFilter === lastWrittenRef.current) return;
|
|
67
|
+
lastWrittenRef.current = globalFilter;
|
|
68
|
+
setDraft(globalFilter);
|
|
69
|
+
}, [globalFilter]);
|
|
70
|
+
|
|
71
|
+
// An unmount mid-debounce drops the pending write rather than filtering a
|
|
72
|
+
// grid whose input no longer exists.
|
|
73
|
+
useEffect(() => () => clearTimeout(timeoutRef.current), []);
|
|
74
|
+
|
|
75
|
+
if (!getGridCapabilities(table, features).canSearch) return null;
|
|
76
|
+
|
|
77
|
+
const commit = (value: string) => {
|
|
78
|
+
lastWrittenRef.current = value;
|
|
79
|
+
// `undefined` rather than `""`: an empty filter is no filter, and this is
|
|
80
|
+
// what keeps the persisted slice and the funnel logic clean.
|
|
81
|
+
table.setGlobalFilter(value === "" ? undefined : value);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const handleChange = (value: string) => {
|
|
85
|
+
setDraft(value);
|
|
86
|
+
clearTimeout(timeoutRef.current);
|
|
87
|
+
if (debounce <= 0) {
|
|
88
|
+
commit(value);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
timeoutRef.current = setTimeout(() => commit(value), debounce);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<TextInput
|
|
96
|
+
size={controlSize}
|
|
97
|
+
w={w}
|
|
98
|
+
value={draft}
|
|
99
|
+
onChange={(event) => handleChange(event.currentTarget.value)}
|
|
100
|
+
placeholder={placeholder ?? labels.searchPlaceholder}
|
|
101
|
+
aria-label={labels.searchLabel}
|
|
102
|
+
leftSection={<SearchIcon size={16} stroke={1.6} />}
|
|
103
|
+
rightSection={
|
|
104
|
+
draft !== "" ? (
|
|
105
|
+
<ActionIcon
|
|
106
|
+
variant="subtle"
|
|
107
|
+
color="gray"
|
|
108
|
+
size="sm"
|
|
109
|
+
aria-label={labels.clearSearch}
|
|
110
|
+
onClick={() => {
|
|
111
|
+
clearTimeout(timeoutRef.current);
|
|
112
|
+
setDraft("");
|
|
113
|
+
commit("");
|
|
114
|
+
}}
|
|
115
|
+
>
|
|
116
|
+
<CloseIcon size={14} stroke={1.6} />
|
|
117
|
+
</ActionIcon>
|
|
118
|
+
) : undefined
|
|
119
|
+
}
|
|
120
|
+
/>
|
|
121
|
+
);
|
|
122
|
+
}
|