@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.
Files changed (50) hide show
  1. package/dist/index.d.ts +1337 -94
  2. package/dist/index.js +2812 -1189
  3. package/dist/index.js.map +1 -1
  4. package/dist/styles.css +1 -1
  5. package/package.json +2 -2
  6. package/skills/columns/SKILL.md +1 -1
  7. package/skills/features/SKILL.md +61 -11
  8. package/skills/getting-started/SKILL.md +41 -6
  9. package/skills/options/SKILL.md +12 -2
  10. package/skills/server-side/SKILL.md +1 -1
  11. package/src/tmdatagrid/TMDataGridContext.ts +26 -0
  12. package/src/tmdatagrid/components/TMDataGrid.module.css +21 -1
  13. package/src/tmdatagrid/components/TMDataGrid.tsx +29 -1
  14. package/src/tmdatagrid/components/TMDataGridColumnsPanel.tsx +12 -6
  15. package/src/tmdatagrid/components/TMDataGridDetailsColumn.module.css +33 -0
  16. package/src/tmdatagrid/components/TMDataGridDetailsColumn.tsx +160 -0
  17. package/src/tmdatagrid/components/TMDataGridFilterPanel.module.css +15 -0
  18. package/src/tmdatagrid/components/TMDataGridFilterPanel.tsx +192 -37
  19. package/src/tmdatagrid/components/TMDataGridFilterPills.module.css +35 -0
  20. package/src/tmdatagrid/components/TMDataGridFilterPills.tsx +136 -0
  21. package/src/tmdatagrid/components/TMDataGridFooter.module.css +8 -0
  22. package/src/tmdatagrid/components/TMDataGridFooter.tsx +63 -43
  23. package/src/tmdatagrid/components/TMDataGridGroupColumn.module.css +48 -0
  24. package/src/tmdatagrid/components/TMDataGridGroupColumn.tsx +159 -0
  25. package/src/tmdatagrid/components/TMDataGridHeaderCell.module.css +18 -0
  26. package/src/tmdatagrid/components/TMDataGridHeaderCell.tsx +249 -44
  27. package/src/tmdatagrid/components/TMDataGridSearch.tsx +122 -0
  28. package/src/tmdatagrid/components/TMDataGridSelectColumn.tsx +103 -16
  29. package/src/tmdatagrid/components/TMDataGridTable.module.css +160 -5
  30. package/src/tmdatagrid/components/TMDataGridTable.tsx +1380 -43
  31. package/src/tmdatagrid/components/TMDataGridToolbar.tsx +33 -7
  32. package/src/tmdatagrid/components/icons.ts +6 -0
  33. package/src/tmdatagrid/components/sticky.module.css +77 -16
  34. package/src/tmdatagrid/core/autosize.ts +125 -0
  35. package/src/tmdatagrid/core/capabilities.ts +122 -11
  36. package/src/tmdatagrid/core/cellExport.ts +321 -0
  37. package/src/tmdatagrid/core/cellNavigation.ts +103 -0
  38. package/src/tmdatagrid/core/cellRange.ts +118 -0
  39. package/src/tmdatagrid/core/columnOptions.ts +123 -0
  40. package/src/tmdatagrid/core/columnUtils.ts +18 -0
  41. package/src/tmdatagrid/core/expanding.ts +93 -0
  42. package/src/tmdatagrid/core/filterOperators.ts +198 -12
  43. package/src/tmdatagrid/core/grouping.ts +21 -0
  44. package/src/tmdatagrid/core/labels.ts +211 -0
  45. package/src/tmdatagrid/core/labelsSv.ts +108 -0
  46. package/src/tmdatagrid/core/persistence.ts +12 -0
  47. package/src/tmdatagrid/core/rowSelection.ts +202 -0
  48. package/src/tmdatagrid/core/summary.ts +35 -0
  49. package/src/tmdatagrid/index.ts +96 -2
  50. 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 { getColumnAlign, getColumnLabel } from "../core/columnUtils";
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 { SELECT_COLUMN_ID } from "./TMDataGridSelectColumn";
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
- Sort by ASC
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
- Sort by DESC
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
- Filter
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
- Pin to left
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
- Pin to right
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
- Unpin
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
- Move left
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
- Move right
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
- Hide column
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
- Manage columns
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
- return (
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
- zIndex: pinnedAt ? 4 : undefined,
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
- <span className={classes.headerTitle}>
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={`Filter on ${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={`Sort ${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
- {menuItems.length > 0 && column.id !== SELECT_COLUMN_ID && (
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
- data-pinned-visible={menuOpened}
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={`${label} column menu`}
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
- <div
443
- className={[
444
- classes.columnSeparator,
445
- canResize ? classes.columnSeparatorResizable : "",
446
- isResizing ? classes.columnSeparatorActive : "",
447
- ]
448
- .filter(Boolean)
449
- .join(" ")}
450
- // Keeps a drag that starts on the separator from being picked up by the
451
- // draggable header around it.
452
- draggable={false}
453
- onMouseDown={
454
- canResize
455
- ? (event) => {
456
- suppressSortRef.current = true;
457
- header.getResizeHandler()(event);
458
- }
459
- : undefined
460
- }
461
- onTouchStart={canResize ? header.getResizeHandler() : undefined}
462
- onClick={(event) => event.stopPropagation()}
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
+ }