@jielga/tmdatagrid 0.3.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 (43) hide show
  1. package/dist/index.d.ts +947 -64
  2. package/dist/index.js +2474 -1423
  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 +1 -1
  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 +10 -0
  13. package/src/tmdatagrid/components/TMDataGrid.tsx +23 -1
  14. package/src/tmdatagrid/components/TMDataGridColumnsPanel.tsx +5 -5
  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.tsx +111 -33
  18. package/src/tmdatagrid/components/TMDataGridFilterPills.tsx +5 -4
  19. package/src/tmdatagrid/components/TMDataGridFooter.tsx +11 -19
  20. package/src/tmdatagrid/components/TMDataGridGroupColumn.tsx +23 -11
  21. package/src/tmdatagrid/components/TMDataGridHeaderCell.module.css +15 -3
  22. package/src/tmdatagrid/components/TMDataGridHeaderCell.tsx +181 -52
  23. package/src/tmdatagrid/components/TMDataGridSearch.tsx +122 -0
  24. package/src/tmdatagrid/components/TMDataGridSelectColumn.tsx +23 -13
  25. package/src/tmdatagrid/components/TMDataGridTable.module.css +127 -6
  26. package/src/tmdatagrid/components/TMDataGridTable.tsx +1003 -30
  27. package/src/tmdatagrid/components/TMDataGridToolbar.tsx +30 -7
  28. package/src/tmdatagrid/components/icons.ts +1 -0
  29. package/src/tmdatagrid/components/sticky.module.css +77 -16
  30. package/src/tmdatagrid/core/autosize.ts +125 -0
  31. package/src/tmdatagrid/core/capabilities.ts +41 -0
  32. package/src/tmdatagrid/core/cellExport.ts +321 -0
  33. package/src/tmdatagrid/core/cellNavigation.ts +103 -0
  34. package/src/tmdatagrid/core/cellRange.ts +118 -0
  35. package/src/tmdatagrid/core/columnOptions.ts +123 -0
  36. package/src/tmdatagrid/core/columnUtils.ts +18 -0
  37. package/src/tmdatagrid/core/expanding.ts +93 -0
  38. package/src/tmdatagrid/core/filterOperators.ts +176 -15
  39. package/src/tmdatagrid/core/labels.ts +211 -0
  40. package/src/tmdatagrid/core/labelsSv.ts +108 -0
  41. package/src/tmdatagrid/core/summary.ts +35 -0
  42. package/src/tmdatagrid/index.ts +70 -0
  43. package/src/tmdatagrid/useTMDataGrid.tsx +328 -12
@@ -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,11 +19,17 @@ 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,
26
33
  CollapseAllIcon,
27
34
  ColumnsIcon,
28
35
  DotsVerticalIcon,
@@ -38,7 +45,6 @@ import {
38
45
  UngroupIcon,
39
46
  } from "./icons";
40
47
  import { GROUP_COLUMN_ID } from "./TMDataGridGroupColumn";
41
- import { SELECT_COLUMN_ID } from "./TMDataGridSelectColumn";
42
48
  import { type TMDataGridFeatures, openColumnFilter } from "../useTMDataGrid";
43
49
 
44
50
  export type TMDataGridHeader = Header<
@@ -55,9 +61,10 @@ export function TMDataGridHeaderCell({
55
61
  layout: TMDataGridColumnLayout;
56
62
  }) {
57
63
  const api = useTMDataGridContext();
58
- const { table, features } = api;
64
+ const { table, features, labels } = api;
59
65
  const column = header.column;
60
66
  const [menuOpened, setMenuOpened] = useState(false);
67
+ const [contextMenuOpened, setContextMenuOpened] = useState(false);
61
68
  const [dropSide, setDropSide] = useState<TMDataGridDropSide | null>(null);
62
69
  const cellRef = useRef<HTMLDivElement>(null);
63
70
  // A resize drag ends with a click that bubbles to the header — without this
@@ -88,6 +95,12 @@ export function TMDataGridHeaderCell({
88
95
  const align = getColumnAlign(column);
89
96
  const sortDirection = sorting.find((sort) => sort.id === column.id)?.desc;
90
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;
91
104
  const isFiltered = columnFilters.some(
92
105
  (filter) => filter.id === column.id && isFilterActive(filter.value),
93
106
  );
@@ -169,6 +182,18 @@ export function TMDataGridHeaderCell({
169
182
  api.ui.actions.endColumnDrag();
170
183
  }
171
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
+
172
197
  /**
173
198
  * Pinned columns are positioned with `getStart()` / `getAfter()`, which sum
174
199
  * `column.getSize()`. Fluid (`fr`) columns don't report their rendered width
@@ -196,14 +221,14 @@ export function TMDataGridHeaderCell({
196
221
  leftSection={<ArrowUpIcon size={16} stroke={1.6} />}
197
222
  onClick={() => column.toggleSorting(false)}
198
223
  >
199
- Sort by ASC
224
+ {labels.sortAsc}
200
225
  </Menu.Item>,
201
226
  <Menu.Item
202
227
  key="sort-desc"
203
228
  leftSection={<ArrowDownIcon size={16} stroke={1.6} />}
204
229
  onClick={() => column.toggleSorting(true)}
205
230
  >
206
- Sort by DESC
231
+ {labels.sortDesc}
207
232
  </Menu.Item>,
208
233
  <Menu.Divider key="sort-divider" />,
209
234
  );
@@ -215,7 +240,7 @@ export function TMDataGridHeaderCell({
215
240
  leftSection={<FilterIcon size={16} stroke={1.6} />}
216
241
  onClick={() => openColumnFilter(api, column.id)}
217
242
  >
218
- Filter
243
+ {labels.filterMenuItem}
219
244
  </Menu.Item>,
220
245
  <Menu.Divider key="filter-divider" />,
221
246
  );
@@ -234,7 +259,7 @@ export function TMDataGridHeaderCell({
234
259
  }
235
260
  onClick={column.getToggleGroupingHandler()}
236
261
  >
237
- {isGrouped ? `Ungroup ${label}` : `Group by ${label}`}
262
+ {isGrouped ? labels.ungroup(label) : labels.groupBy(label)}
238
263
  </Menu.Item>,
239
264
  );
240
265
  }
@@ -253,25 +278,38 @@ export function TMDataGridHeaderCell({
253
278
  leftSection={<UngroupIcon size={16} stroke={1.6} />}
254
279
  onClick={() => groupedColumn.toggleGrouping()}
255
280
  >
256
- {`Ungroup ${getColumnLabel(groupedColumn)}`}
281
+ {labels.ungroup(getColumnLabel(groupedColumn))}
257
282
  </Menu.Item>,
258
283
  );
259
284
  }
260
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
+
261
299
  menuItems.push(
262
300
  <Menu.Item
263
301
  key="expand-all"
264
302
  leftSection={<ExpandAllIcon size={16} stroke={1.6} />}
265
- onClick={() => table.toggleAllRowsExpanded(true)}
303
+ onClick={() => expandAllGroups(true)}
266
304
  >
267
- Expand all groups
305
+ {labels.expandAllGroups}
268
306
  </Menu.Item>,
269
307
  <Menu.Item
270
308
  key="collapse-all"
271
309
  leftSection={<CollapseAllIcon size={16} stroke={1.6} />}
272
- onClick={() => table.toggleAllRowsExpanded(false)}
310
+ onClick={() => expandAllGroups(false)}
273
311
  >
274
- Collapse all groups
312
+ {labels.collapseAllGroups}
275
313
  </Menu.Item>,
276
314
  );
277
315
  }
@@ -286,7 +324,7 @@ export function TMDataGridHeaderCell({
286
324
  leftSection={<PinLeftIcon size={16} stroke={1.6} />}
287
325
  onClick={() => setPinned(pinnedAt === "left" ? false : "left")}
288
326
  >
289
- Pin to left
327
+ {labels.pinLeft}
290
328
  </Menu.Item>,
291
329
  <Menu.Item
292
330
  key="pin-right"
@@ -294,7 +332,7 @@ export function TMDataGridHeaderCell({
294
332
  leftSection={<PinRightIcon size={16} stroke={1.6} />}
295
333
  onClick={() => setPinned(pinnedAt === "right" ? false : "right")}
296
334
  >
297
- Pin to right
335
+ {labels.pinRight}
298
336
  </Menu.Item>,
299
337
  );
300
338
  if (pinnedAt) {
@@ -304,7 +342,7 @@ export function TMDataGridHeaderCell({
304
342
  leftSection={<PinOffIcon size={16} stroke={1.6} />}
305
343
  onClick={() => setPinned(false)}
306
344
  >
307
- Unpin
345
+ {labels.unpin}
308
346
  </Menu.Item>,
309
347
  );
310
348
  }
@@ -333,7 +371,7 @@ export function TMDataGridHeaderCell({
333
371
  moveColumnByStep({ table, columnId: column.id, direction: -1 })
334
372
  }
335
373
  >
336
- Move left
374
+ {labels.moveLeft}
337
375
  </Menu.Item>,
338
376
  <Menu.Item
339
377
  key="move-right"
@@ -343,12 +381,24 @@ export function TMDataGridHeaderCell({
343
381
  moveColumnByStep({ table, columnId: column.id, direction: 1 })
344
382
  }
345
383
  >
346
- Move right
384
+ {labels.moveRight}
347
385
  </Menu.Item>,
348
386
  <Menu.Divider key="move-divider" />,
349
387
  );
350
388
  }
351
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
+ }
352
402
  if (canHide) {
353
403
  menuItems.push(
354
404
  <Menu.Item
@@ -356,7 +406,7 @@ export function TMDataGridHeaderCell({
356
406
  leftSection={<EyeOffIcon size={16} stroke={1.6} />}
357
407
  onClick={() => column.toggleVisibility(false)}
358
408
  >
359
- Hide column
409
+ {labels.hideColumn}
360
410
  </Menu.Item>,
361
411
  );
362
412
  }
@@ -367,7 +417,7 @@ export function TMDataGridHeaderCell({
367
417
  leftSection={<ColumnsIcon size={16} stroke={1.6} />}
368
418
  onClick={() => api.ui.actions.setColumnsPanelOpen(true)}
369
419
  >
370
- Manage columns
420
+ {labels.manageColumns}
371
421
  </Menu.Item>,
372
422
  );
373
423
  }
@@ -376,6 +426,12 @@ export function TMDataGridHeaderCell({
376
426
  menuItems.pop();
377
427
  }
378
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
+
379
435
  const cellClass = [
380
436
  classes.headerCell,
381
437
  canSort ? classes.headerCellSortable : "",
@@ -388,16 +444,16 @@ export function TMDataGridHeaderCell({
388
444
  .filter(Boolean)
389
445
  .join(" ");
390
446
 
391
- return (
447
+ const cell = (
392
448
  <div
393
449
  ref={cellRef}
394
450
  role="columnheader"
395
451
  data-testid={`dg-header-${column.id}`}
396
452
  data-active={isSorted || isFiltered}
397
453
  data-align={align}
398
- // The checkbox lane is a fixed track, so it cannot take the cell padding
399
- // the scale grows for text. See TMDataGridHeaderCell.module.css.
400
- data-select-column={column.id === SELECT_COLUMN_ID}
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)}
401
457
  // Only meaningful on a sortable column: "none" advertises that this
402
458
  // header sorts, which is a lie on one that doesn't.
403
459
  aria-sort={
@@ -423,8 +479,19 @@ export function TMDataGridHeaderCell({
423
479
  left: pinnedAt === "left" ? layout.offset : undefined,
424
480
  right: pinnedAt === "right" ? layout.offset : undefined,
425
481
  position: pinnedAt ? "sticky" : undefined,
426
- zIndex: pinnedAt ? 4 : undefined,
482
+ // From the stacking ladder in TMDataGrid.module.css.
483
+ zIndex: pinnedAt ? "var(--dg-z-header-pinned-cell, 7)" : undefined,
427
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
+ }
428
495
  onClick={
429
496
  canSort
430
497
  ? (event) => {
@@ -432,6 +499,8 @@ export function TMDataGridHeaderCell({
432
499
  suppressSortRef.current = false;
433
500
  return;
434
501
  }
502
+ // TanStack's handler reads the modifier itself: a plain click
503
+ // replaces the sort, Shift+click appends (`isMultiSortEvent`).
435
504
  column.getToggleSortingHandler()?.(event);
436
505
  }
437
506
  : undefined
@@ -439,7 +508,8 @@ export function TMDataGridHeaderCell({
439
508
  >
440
509
  {header.isPlaceholder ? null : (
441
510
  <Tooltip label={label} openDelay={600} withinPortal disabled={!isLeaf}>
442
- <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>
443
513
  {flexRender(column.columnDef.header, header.getContext())}
444
514
  </span>
445
515
  </Tooltip>
@@ -454,7 +524,7 @@ export function TMDataGridHeaderCell({
454
524
  variant="subtle"
455
525
  color="gray"
456
526
  size="xs"
457
- aria-label={`Filter on ${label}`}
527
+ aria-label={labels.filterOn(label)}
458
528
  onClick={(event) => {
459
529
  event.stopPropagation();
460
530
  openColumnFilter(api, column.id);
@@ -471,7 +541,7 @@ export function TMDataGridHeaderCell({
471
541
  variant="subtle"
472
542
  color="gray"
473
543
  size="xs"
474
- aria-label={`Sort ${label}`}
544
+ aria-label={labels.sortColumn(label)}
475
545
  onClick={column.getToggleSortingHandler()}
476
546
  >
477
547
  {sortDirection ? (
@@ -482,7 +552,16 @@ export function TMDataGridHeaderCell({
482
552
  </ActionIcon>
483
553
  )}
484
554
 
485
- {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 && (
486
565
  <Menu
487
566
  opened={menuOpened}
488
567
  onChange={setMenuOpened}
@@ -494,11 +573,14 @@ export function TMDataGridHeaderCell({
494
573
  <Menu.Target>
495
574
  <ActionIcon
496
575
  className={classes.headerAction}
497
- 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}
498
580
  variant="subtle"
499
581
  color="gray"
500
582
  size="xs"
501
- aria-label={`${label} column menu`}
583
+ aria-label={labels.columnMenu(label)}
502
584
  onClick={(event) => {
503
585
  event.stopPropagation();
504
586
  setMenuOpened((opened) => !opened);
@@ -515,30 +597,77 @@ export function TMDataGridHeaderCell({
515
597
  </div>
516
598
  )}
517
599
 
518
- <div
519
- className={[
520
- classes.columnSeparator,
521
- canResize ? classes.columnSeparatorResizable : "",
522
- isResizing ? classes.columnSeparatorActive : "",
523
- ]
524
- .filter(Boolean)
525
- .join(" ")}
526
- // Keeps a drag that starts on the separator from being picked up by the
527
- // draggable header around it.
528
- draggable={false}
529
- onMouseDown={
530
- canResize
531
- ? (event) => {
532
- suppressSortRef.current = true;
533
- header.getResizeHandler()(event);
534
- }
535
- : undefined
536
- }
537
- onTouchStart={canResize ? header.getResizeHandler() : undefined}
538
- onClick={(event) => event.stopPropagation()}
539
- />
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
+ )}
540
637
  </div>
541
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
+ );
542
671
  }
543
672
 
544
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
+ }
@@ -1,7 +1,10 @@
1
1
  import { Checkbox } from "@mantine/core";
2
2
  import { useSelector } from "@tanstack/react-store";
3
3
  import type { ColumnDef, Row, RowData } from "@tanstack/react-table";
4
- import { useTMDataGridContext } from "../TMDataGridContext";
4
+ import {
5
+ useCellControlTabIndex,
6
+ useTMDataGridContext,
7
+ } from "../TMDataGridContext";
5
8
  import {
6
9
  getDisplayedRows,
7
10
  getSelectableRowIds,
@@ -31,6 +34,7 @@ function SelectAllCheckbox<TData extends RowData>({
31
34
  }: {
32
35
  table: TMDataGridTable<TData>;
33
36
  }) {
37
+ const { labels } = useTMDataGridContext();
34
38
  const allSelected = useSelector(table.store, () =>
35
39
  table.getIsAllRowsSelected(),
36
40
  );
@@ -41,7 +45,7 @@ function SelectAllCheckbox<TData extends RowData>({
41
45
  return (
42
46
  <Checkbox
43
47
  size="xs"
44
- aria-label="Select all rows"
48
+ aria-label={labels.selectAllRows}
45
49
  checked={allSelected}
46
50
  indeterminate={someSelected && !allSelected}
47
51
  onChange={table.getToggleAllRowsSelectedHandler()}
@@ -75,7 +79,11 @@ function SelectRowCheckbox<TData extends RowData>({
75
79
  // Cells render inside the grid's provider, so the checkbox can reach the
76
80
  // chrome store — it needs the shift-click pivot, and the feature flags to know
77
81
  // which row model a range is measured over.
78
- const { ui, features } = useTMDataGridContext();
82
+ const { ui, features, labels } = useTMDataGridContext();
83
+ // Out of the tab order once the grid has a cell cursor — see
84
+ // useCellControlTabIndex. Enter on the lane still steps in, and Space on any
85
+ // cell of the row ticks it.
86
+ const tabIndex = useCellControlTabIndex();
79
87
  const isGroupRow = row.subRows.length > 0;
80
88
 
81
89
  // A group row is never selected by id: `rowSelection` only ever holds the
@@ -101,7 +109,8 @@ function SelectRowCheckbox<TData extends RowData>({
101
109
  return (
102
110
  <Checkbox
103
111
  size="xs"
104
- aria-label={isGroupRow ? "Select group" : "Select row"}
112
+ tabIndex={tabIndex}
113
+ aria-label={isGroupRow ? labels.selectGroup : labels.selectRow}
105
114
  checked={selected}
106
115
  disabled={selectableIds.length === 0}
107
116
  indeterminate={someSelected && !selected}
@@ -143,23 +152,24 @@ function SelectRowCheckbox<TData extends RowData>({
143
152
  * The generated checkbox column, prepended under
144
153
  * `selectionMode: "checkbox"` (the default) or `"checkboxAndHighlight"`.
145
154
  */
146
- export function createSelectColumn<TData extends RowData>(): ColumnDef<
147
- TMDataGridFeatures,
148
- TData,
149
- unknown
150
- > {
155
+ export function createSelectColumn<TData extends RowData>(
156
+ label = "Checkbox selection",
157
+ ): ColumnDef<TMDataGridFeatures, TData, unknown> {
151
158
  return {
152
159
  id: SELECT_COLUMN_ID,
153
160
  meta: {
154
- label: "Checkbox selection",
161
+ label,
155
162
  align: "center",
156
163
  // Structurally the first column; it also anchors the left pinned lane, so
157
164
  // no other column can be moved in front of it.
158
165
  enableOrdering: false,
159
166
  },
160
- size: 48,
161
- minSize: 48,
162
- maxSize: 48,
167
+ // A system lane: as wide as the control it holds and no wider. Fixed at
168
+ // every scale — the control does not grow with the font size, so neither
169
+ // should its track.
170
+ size: 36,
171
+ minSize: 36,
172
+ maxSize: 36,
163
173
  enableResizing: false,
164
174
  enableSorting: false,
165
175
  enableColumnFilter: false,