@jielga/tmdatagrid 2.0.0-beta.8 → 2.0.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 (155) hide show
  1. package/README.md +5 -212
  2. package/dist/index.d.ts +1323 -796
  3. package/dist/index.js +4719 -3193
  4. package/dist/index.js.map +1 -1
  5. package/dist/styles.css +1 -1
  6. package/docs/adding-rows.md +132 -0
  7. package/docs/anatomy.md +119 -0
  8. package/docs/card-view.md +108 -0
  9. package/docs/cell-selection.md +194 -0
  10. package/docs/column-layout.md +182 -0
  11. package/docs/column-menu.md +66 -0
  12. package/docs/columns.md +268 -0
  13. package/docs/components.md +311 -0
  14. package/docs/draft-store.md +242 -0
  15. package/docs/editing.md +303 -0
  16. package/docs/editors.md +250 -0
  17. package/docs/export.md +319 -0
  18. package/docs/filtering.md +362 -0
  19. package/docs/getting-started.md +123 -0
  20. package/docs/grouping.md +165 -0
  21. package/docs/loading-and-empty.md +92 -0
  22. package/docs/localization.md +79 -0
  23. package/docs/menu.md +143 -0
  24. package/docs/migrating-to-2.md +163 -0
  25. package/docs/pagination.md +144 -0
  26. package/docs/persistence.md +114 -0
  27. package/docs/portfolio-rebalancer.md +94 -0
  28. package/docs/query-builder.md +179 -0
  29. package/docs/quick-search.md +84 -0
  30. package/docs/row-details.md +115 -0
  31. package/docs/row-interaction.md +149 -0
  32. package/docs/row-pinning.md +132 -0
  33. package/docs/row-selection.md +136 -0
  34. package/docs/row-styling.md +133 -0
  35. package/docs/scrolling.md +112 -0
  36. package/docs/server-query.md +246 -0
  37. package/docs/server-side.md +206 -0
  38. package/docs/sorting.md +101 -0
  39. package/docs/styling.md +126 -0
  40. package/docs/summary-row.md +76 -0
  41. package/docs/testing.md +744 -0
  42. package/docs/toolbar.md +161 -0
  43. package/docs/use-tm-data-grid.md +361 -0
  44. package/package.json +22 -46
  45. package/skills/appearance/SKILL.md +72 -19
  46. package/skills/cell-selection/SKILL.md +69 -78
  47. package/skills/columns/SKILL.md +90 -34
  48. package/skills/data/SKILL.md +86 -16
  49. package/skills/editing/SKILL.md +83 -50
  50. package/skills/editing/references/common-mistakes.md +77 -69
  51. package/skills/editing/references/editing-api.md +31 -23
  52. package/skills/editing/references/editors-and-validation.md +24 -17
  53. package/skills/filtering/SKILL.md +148 -40
  54. package/skills/getting-started/SKILL.md +17 -15
  55. package/skills/grouping/SKILL.md +31 -16
  56. package/skills/options/SKILL.md +8 -8
  57. package/skills/rows/SKILL.md +22 -18
  58. package/skills/server-side/SKILL.md +170 -17
  59. package/skills/testing/SKILL.md +150 -32
  60. package/skills/testing-components/SKILL.md +230 -0
  61. package/skills/testing-editing/SKILL.md +240 -0
  62. package/src/{tmdatagrid/TMDataGridContext.ts → TMDataGridContext.ts} +7 -19
  63. package/src/{tmdatagrid/components → components}/TMDataGrid.module.css +7 -1
  64. package/src/{tmdatagrid/components → components}/TMDataGrid.tsx +38 -21
  65. package/src/{tmdatagrid/components → components}/TMDataGridCellEditor.tsx +73 -7
  66. package/src/{tmdatagrid/components → components}/TMDataGridColumnsPanel.module.css +5 -1
  67. package/src/{tmdatagrid/components → components}/TMDataGridColumnsPanel.tsx +47 -55
  68. package/src/{tmdatagrid/components → components}/TMDataGridDetailsColumn.tsx +9 -55
  69. package/src/{tmdatagrid/components → components}/TMDataGridDraftActions.tsx +41 -24
  70. package/src/{tmdatagrid/components → components}/TMDataGridEditColumn.tsx +23 -63
  71. package/src/components/TMDataGridEntryRows.tsx +354 -0
  72. package/src/components/TMDataGridExportPicker.module.css +77 -0
  73. package/src/components/TMDataGridExportPicker.tsx +234 -0
  74. package/src/components/TMDataGridFilterPanel.module.css +54 -0
  75. package/src/components/TMDataGridFilterPanel.tsx +348 -0
  76. package/src/{tmdatagrid/components → components}/TMDataGridFilterPills.tsx +15 -8
  77. package/src/components/TMDataGridFilterSurface.module.css +54 -0
  78. package/src/components/TMDataGridFilterSurface.tsx +167 -0
  79. package/src/{tmdatagrid/components → components}/TMDataGridFooter.tsx +53 -90
  80. package/src/{tmdatagrid/components → components}/TMDataGridGroupColumn.tsx +9 -72
  81. package/src/{tmdatagrid/components → components}/TMDataGridHeaderCell.module.css +12 -2
  82. package/src/{tmdatagrid/components → components}/TMDataGridHeaderCell.tsx +58 -21
  83. package/src/components/TMDataGridHeaderFilterRow.module.css +51 -0
  84. package/src/components/TMDataGridHeaderFilterRow.tsx +301 -0
  85. package/src/components/TMDataGridMenu.tsx +357 -0
  86. package/src/{tmdatagrid/components → components}/TMDataGridSelectColumn.tsx +15 -53
  87. package/src/{tmdatagrid/components → components}/TMDataGridTable.module.css +88 -65
  88. package/src/{tmdatagrid/components → components}/TMDataGridTable.tsx +579 -165
  89. package/src/{tmdatagrid/components → components}/TMDataGridToolbar.module.css +5 -0
  90. package/src/components/TMDataGridToolbar.tsx +181 -0
  91. package/src/{tmdatagrid/components → components}/editors/TMDataGridBooleanEditor.tsx +3 -3
  92. package/src/{tmdatagrid/components → components}/editors/TMDataGridDateEditor.tsx +3 -3
  93. package/src/{tmdatagrid/components → components}/editors/TMDataGridMultiSelectEditor.tsx +3 -3
  94. package/src/{tmdatagrid/components → components}/editors/TMDataGridNumberEditor.tsx +3 -3
  95. package/src/{tmdatagrid/components → components}/editors/TMDataGridSelectEditor.tsx +3 -3
  96. package/src/{tmdatagrid/components → components}/editors/TMDataGridStringEditor.tsx +3 -3
  97. package/src/{tmdatagrid/components → components}/editors/editorShared.ts +16 -2
  98. package/src/{tmdatagrid/components → components}/filters/DgAutocompleteFilter.tsx +5 -4
  99. package/src/{tmdatagrid/components → components}/filters/DgDateRangeFilter.tsx +22 -5
  100. package/src/{tmdatagrid/components → components}/filters/DgRangeSliderFilter.tsx +5 -1
  101. package/src/{tmdatagrid/components → components}/filters/DgTriStateFilter.tsx +5 -1
  102. package/src/{tmdatagrid/components → components}/filters/TMDataGridFilterValueInput.tsx +44 -28
  103. package/src/components/filters/controlLayout.ts +32 -0
  104. package/src/components/filters/filterControlFor.ts +65 -0
  105. package/src/components/generatedColumns.tsx +187 -0
  106. package/src/{tmdatagrid/components → components}/icons.ts +1 -0
  107. package/src/{tmdatagrid/components → components}/sticky.module.css +44 -0
  108. package/src/components/useHideableColumns.ts +52 -0
  109. package/src/{tmdatagrid/core → core}/autosize.ts +5 -2
  110. package/src/{tmdatagrid/core → core}/capabilities.ts +5 -5
  111. package/src/{tmdatagrid/core → core}/columnOptions.ts +60 -8
  112. package/src/{tmdatagrid/core → core}/columnOrdering.ts +33 -14
  113. package/src/{tmdatagrid/core → core}/columnUtils.ts +44 -5
  114. package/src/core/controlledStateSync.ts +108 -0
  115. package/src/core/deletedRows.ts +34 -0
  116. package/src/core/dom.ts +74 -0
  117. package/src/{tmdatagrid/core → core}/editEngine.ts +1172 -388
  118. package/src/{tmdatagrid/core → core}/editorFocus.ts +8 -4
  119. package/src/core/export.ts +704 -0
  120. package/src/{tmdatagrid/core → core}/filterControls.ts +38 -1
  121. package/src/{tmdatagrid/core → core}/filterOperators.ts +64 -1
  122. package/src/core/filterSurface.ts +99 -0
  123. package/src/{tmdatagrid/core → core}/grouping.ts +21 -0
  124. package/src/{tmdatagrid/core → core}/labels.ts +51 -6
  125. package/src/{tmdatagrid/core → core}/labelsSv.ts +27 -6
  126. package/src/core/pageReset.ts +120 -0
  127. package/src/core/pagination.ts +81 -0
  128. package/src/{tmdatagrid/core → core}/persistence.ts +22 -5
  129. package/src/{tmdatagrid/core → core}/summary.ts +20 -4
  130. package/src/{tmdatagrid/index.ts → index.ts} +70 -36
  131. package/src/{tmdatagrid/useTMDataGrid.tsx → useTMDataGrid.tsx} +534 -123
  132. package/src/useTMDataGridExport.ts +78 -0
  133. package/src/tmdatagrid/components/TMDataGridEntryRows.tsx +0 -298
  134. package/src/tmdatagrid/components/TMDataGridFilterPanel.module.css +0 -35
  135. package/src/tmdatagrid/components/TMDataGridFilterPanel.tsx +0 -354
  136. package/src/tmdatagrid/components/TMDataGridToolbar.tsx +0 -161
  137. package/src/tmdatagrid/core/cellExport.ts +0 -320
  138. /package/src/{tmdatagrid/components → components}/TMDataGridDetailsColumn.module.css +0 -0
  139. /package/src/{tmdatagrid/components → components}/TMDataGridFilterPills.module.css +0 -0
  140. /package/src/{tmdatagrid/components → components}/TMDataGridFooter.module.css +0 -0
  141. /package/src/{tmdatagrid/components → components}/TMDataGridGroupColumn.module.css +0 -0
  142. /package/src/{tmdatagrid/components → components}/TMDataGridRowNumberColumn.tsx +0 -0
  143. /package/src/{tmdatagrid/components → components}/TMDataGridSearch.tsx +0 -0
  144. /package/src/{tmdatagrid/core → core}/cellNavigation.ts +0 -0
  145. /package/src/{tmdatagrid/core → core}/cellRange.ts +0 -0
  146. /package/src/{tmdatagrid/core → core}/controlledState.ts +0 -0
  147. /package/src/{tmdatagrid/core → core}/draftCellContext.ts +0 -0
  148. /package/src/{tmdatagrid/core → core}/expanding.ts +0 -0
  149. /package/src/{tmdatagrid/core → core}/matchHighlight.ts +0 -0
  150. /package/src/{tmdatagrid/core → core}/quickSearch.ts +0 -0
  151. /package/src/{tmdatagrid/core → core}/resizePreview.ts +0 -0
  152. /package/src/{tmdatagrid/core → core}/rowPinning.ts +0 -0
  153. /package/src/{tmdatagrid/core → core}/rowSelection.ts +0 -0
  154. /package/src/{tmdatagrid/core → core}/sizes.ts +0 -0
  155. /package/src/{tmdatagrid/core → core}/useSettledTableState.ts +0 -0
@@ -12,12 +12,14 @@ import {
12
12
  import type { TMDataGridColumnLayout } from "./TMDataGridTable";
13
13
  import { getColumnCapabilities, getGridCapabilities } from "../core/capabilities";
14
14
  import { autosizeColumn } from "../core/autosize";
15
+ import { isNode } from "../core/dom";
15
16
  import {
16
17
  getColumnRegion,
17
18
  getStepTargetColumn,
18
19
  keepGeneratedColumnsOutermost,
19
20
  moveColumn,
20
21
  moveColumnByStep,
22
+ pinningPositionOfSide,
21
23
  type TMDataGridDropSide,
22
24
  } from "../core/columnOrdering";
23
25
  import {
@@ -46,6 +48,7 @@ import {
46
48
  UngroupIcon,
47
49
  } from "./icons";
48
50
  import { GROUP_COLUMN_ID } from "./TMDataGridGroupColumn";
51
+ import { TMDataGridMenuColumns } from "./TMDataGridMenu";
49
52
  import { type TMDataGridFeatures, openColumnFilter } from "../useTMDataGrid";
50
53
 
51
54
  /** The table as the header cell sees it, after the context type erasure. */
@@ -89,7 +92,7 @@ export function TMDataGridHeaderCell({
89
92
  renderColumnMenuItems?: TMDataGridColumnMenuItemsRenderer;
90
93
  }) {
91
94
  const api = useTMDataGridContext();
92
- const { table, features, labels } = api;
95
+ const { table, features, filters, labels } = api;
93
96
  const column = header.column;
94
97
  const [menuOpened, setMenuOpened] = useState(false);
95
98
  const [contextMenuOpened, setContextMenuOpened] = useState(false);
@@ -191,7 +194,7 @@ export function TMDataGridHeaderCell({
191
194
  function handleDragLeave(event: DragEvent<HTMLDivElement>) {
192
195
  // dragleave also fires when the pointer crosses into a child element.
193
196
  const next = event.relatedTarget;
194
- if (next instanceof Node && event.currentTarget.contains(next)) return;
197
+ if (isNode(next) && event.currentTarget.contains(next)) return;
195
198
  setDropSide(null);
196
199
  }
197
200
 
@@ -270,8 +273,8 @@ export function TMDataGridHeaderCell({
270
273
  */
271
274
  function setPinned(position: "left" | "right" | false) {
272
275
  if (position) freezeRenderedWidths();
273
- column.pin(position);
274
- // `pin("right")` appends, which would leave the column outside the edit
276
+ column.pin(pinningPositionOfSide(position));
277
+ // `pin("end")` appends, which would leave the column outside the edit
275
278
  // lane. Normalising after the pin rather than writing the lane by hand
276
279
  // keeps TanStack's own handling of a header group's leaves.
277
280
  table.setColumnPinning(keepGeneratedColumnsOutermost);
@@ -297,7 +300,9 @@ export function TMDataGridHeaderCell({
297
300
  <Menu.Divider key="sort-divider" />,
298
301
  );
299
302
  }
300
- if (canFilter) {
303
+ // Not under header filters: the item's only job is to reveal a control that
304
+ // is already on screen, one row below this menu.
305
+ if (canFilter && !filters.inHeader) {
301
306
  menuItems.push(
302
307
  <Menu.Item
303
308
  key="filter"
@@ -476,13 +481,19 @@ export function TMDataGridHeaderCell({
476
481
  }
477
482
  if (canManageColumns) {
478
483
  menuItems.push(
479
- <Menu.Item
480
- key="manage"
481
- leftSection={<ColumnsIcon size={16} stroke={1.6} />}
482
- onClick={() => api.ui.actions.setColumnsPanelOpen(true)}
483
- >
484
- {labels.manageColumns}
485
- </Menu.Item>,
484
+ <Menu.Sub key="manage">
485
+ <Menu.Sub.Target>
486
+ <Menu.Sub.Item leftSection={<ColumnsIcon size={16} stroke={1.6} />}>
487
+ {labels.manageColumns}
488
+ </Menu.Sub.Item>
489
+ </Menu.Sub.Target>
490
+ <Menu.Sub.Dropdown>
491
+ {/* No search box: `Menu.Search` registers on the root menu context,
492
+ which switches off type-ahead and the arrow-key handling of every
493
+ dropdown of this menu - the column menu's included. */}
494
+ <TMDataGridMenuColumns searchable={false} />
495
+ </Menu.Sub.Dropdown>
496
+ </Menu.Sub>,
486
497
  );
487
498
  }
488
499
  // A trailing divider reads as a dropped item - drop the divider instead.
@@ -529,11 +540,11 @@ export function TMDataGridHeaderCell({
529
540
  // The same coordinate the body, summary and entry cells carry, so one
530
541
  // selector reaches a column's header and its cells alike.
531
542
  data-column-id={column.id}
532
- data-active={isSorted || isFiltered}
543
+ data-active={isSorted || isFiltered || undefined}
533
544
  data-align={align}
534
545
  // A control lane is a fixed track, so it cannot take the cell padding the
535
546
  // scale grows for text. See isControlColumn.
536
- data-control-column={isControlColumn(column.id)}
547
+ data-control-column={isControlColumn(column.id) || undefined}
537
548
  // Only meaningful on a sortable column: "none" announces that this
538
549
  // header sorts, which is wrong on one that does not.
539
550
  aria-sort={
@@ -558,6 +569,11 @@ export function TMDataGridHeaderCell({
558
569
  onDragLeave={isDropTarget ? handleDragLeave : undefined}
559
570
  onDrop={isDropTarget ? handleDrop : undefined}
560
571
  style={{
572
+ // A group header covers the tracks of the leaves under it. Without the
573
+ // span it would sit in one track and leave the rest of its width with
574
+ // no cell at all - a gap the body rows scroll up through, since the
575
+ // header rows above the body are what hides them.
576
+ gridColumn: header.colSpan > 1 ? `span ${header.colSpan}` : undefined,
561
577
  left: pinnedAt === "left" ? layout.offset : undefined,
562
578
  right: pinnedAt === "right" ? layout.offset : undefined,
563
579
  position: pinnedAt ? "sticky" : undefined,
@@ -599,7 +615,9 @@ export function TMDataGridHeaderCell({
599
615
 
600
616
  {isLeaf && (
601
617
  <div className={classes.headerActions}>
602
- {isFiltered && (
618
+ {/* Dropped under header filters, where the filled-in control below
619
+ is both the indicator and the way in. */}
620
+ {isFiltered && !filters.inHeader && (
603
621
  <ActionIcon
604
622
  className={classes.headerAction}
605
623
  data-pinned-visible="true"
@@ -621,19 +639,24 @@ export function TMDataGridHeaderCell({
621
639
  {canSort && (
622
640
  <ActionIcon
623
641
  className={`${classes.headerAction} ${classes.sortAction}`}
624
- data-pinned-visible={isSorted}
642
+ data-pinned-visible={isSorted || undefined}
625
643
  // The arrow is the only sort indicator now that the title no
626
644
  // longer tints, so it takes the colour while the sort holds. On
627
645
  // an unsorted column it is only the hover affordance, and stays a
628
646
  // faded grey so the two are not confused.
629
- data-sorted={isSorted}
647
+ data-sorted={isSorted || undefined}
630
648
  variant="subtle"
631
649
  color={isSorted ? undefined : "gray"}
632
650
  size="xs"
633
651
  aria-label={labels.sortColumn(label)}
634
652
  data-dg-part="header-sort"
635
653
  data-column-id={column.id}
636
- onClick={column.getToggleSortingHandler()}
654
+ // Stopped here: the header cell around it sorts on click too, and
655
+ // letting the click reach it would advance the sort twice.
656
+ onClick={(event) => {
657
+ event.stopPropagation();
658
+ column.getToggleSortingHandler()?.(event);
659
+ }}
637
660
  >
638
661
  {sortDirection ? (
639
662
  <ArrowDownIcon size={14} stroke={1.6} />
@@ -667,7 +690,7 @@ export function TMDataGridHeaderCell({
667
690
  // Held visible for the right-click menu too: that one opens at
668
691
  // the pointer and is portaled, so the hover that revealed the
669
692
  // actions is lost the moment the pointer enters the dropdown.
670
- data-pinned-visible={menuOpened || contextMenuOpened}
693
+ data-pinned-visible={menuOpened || contextMenuOpened || undefined}
671
694
  variant="subtle"
672
695
  color="gray"
673
696
  size="xs"
@@ -702,18 +725,30 @@ export function TMDataGridHeaderCell({
702
725
  ]
703
726
  .filter(Boolean)
704
727
  .join(" ")}
728
+ // A part only where it is a handle: on a fixed-width column the
729
+ // separator is a plain divider, and a test that finds it would be
730
+ // dragging something that does nothing.
731
+ data-dg-part={canResize ? "header-resize" : undefined}
732
+ data-column-id={canResize ? column.id : undefined}
705
733
  // Keeps a drag that starts on the separator from being picked up by
706
734
  // the draggable header around it.
707
735
  draggable={false}
708
736
  // The drag starts from `column.getSize()`, so the rendered width has
709
737
  // to be in `columnSizing` before the handler reads it - otherwise
710
738
  // the first pointer move snaps a fluid column to its declared size.
739
+ //
740
+ // The handler listens for the moves and the release on the document
741
+ // it is given - the separator's own, not the global one, which is
742
+ // the opener's when the grid is rendered through a portal into a
743
+ // window opened with `window.open`.
711
744
  onMouseDown={
712
745
  canResize
713
746
  ? (event) => {
714
747
  suppressSortRef.current = true;
715
748
  freezeRenderedWidths();
716
- header.getResizeHandler()(event);
749
+ header.getResizeHandler(event.currentTarget.ownerDocument)(
750
+ event,
751
+ );
717
752
  }
718
753
  : undefined
719
754
  }
@@ -721,7 +756,9 @@ export function TMDataGridHeaderCell({
721
756
  canResize
722
757
  ? (event) => {
723
758
  freezeRenderedWidths();
724
- header.getResizeHandler()(event);
759
+ header.getResizeHandler(event.currentTarget.ownerDocument)(
760
+ event,
761
+ );
725
762
  }
726
763
  : undefined
727
764
  }
@@ -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,301 @@
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
+ getColumnOperators,
11
+ isControlColumn,
12
+ } from "../core/columnUtils";
13
+ import {
14
+ type TMDataGridFilterOperator,
15
+ type TMDataGridFilterValue,
16
+ emptyValueForOperator,
17
+ filterValueShape,
18
+ isTMDataGridFilterValue,
19
+ } from "../core/filterOperators";
20
+ import { FilterIcon } from "./icons";
21
+ import {
22
+ filterControlFor,
23
+ filterOptionsUseFacets,
24
+ } from "./filters/filterControlFor";
25
+ import type { TMDataGridColumnLayout } from "./TMDataGridTable";
26
+ import type { TMDataGridHeader } from "./TMDataGridHeaderCell";
27
+
28
+ /**
29
+ * Whether a value says nothing at all - untrimmed, unlike `isFilterActive`,
30
+ * which is about whether a filter *narrows* anything. A lone space narrows
31
+ * nothing but is very much something the user typed.
32
+ */
33
+ function isEmptyValue(value: string | ReadonlyArray<string>): boolean {
34
+ return Array.isArray(value)
35
+ ? value.every((entry) => entry === "")
36
+ : value === "";
37
+ }
38
+
39
+ /**
40
+ * One column's filter control, as it appears in the header row: the value
41
+ * control the column would get in the panel, plus a button for the operator
42
+ * when the column's type offers more than one.
43
+ *
44
+ * The column and operator dropdowns of a panel row have no place here - the
45
+ * column is the one the cell sits over, and a header cell has room for a value
46
+ * and not much else. Everything else is the same filter: the same operators,
47
+ * the same `meta.filter.control`, the same `columnFilters` state.
48
+ */
49
+ function HeaderFilterControl({ column }: { column: TMDataGridHeader["column"] }) {
50
+ const { table, labels, controlSize } = useTMDataGridContext();
51
+ const filterValue = useSelector(
52
+ table.store,
53
+ (state) => state.columnFilters.find((filter) => filter.id === column.id)?.value,
54
+ );
55
+
56
+ const defaultOperator = getColumnDefaultOperator(column);
57
+ const current = isTMDataGridFilterValue(filterValue) ? filterValue : undefined;
58
+ const operator = current?.operator ?? defaultOperator;
59
+ const value = current?.value ?? emptyValueForOperator(operator);
60
+ const label = getColumnLabel(column);
61
+
62
+ /**
63
+ * Writes the filter, or drops it entirely once nothing is left to say. A
64
+ * header control is always on screen, so an empty one has no row to keep
65
+ * alive the way a panel row does, and leaving the entry behind would put a
66
+ * filter in `columnFilters` that says nothing.
67
+ *
68
+ * Emptiness here is the literal one, not `isFilterActive`'s: that one trims,
69
+ * and dropping the filter on a value of `" "` would take the space back out
70
+ * of the input the moment it was typed. An operator the user picked is worth
71
+ * keeping even with an empty value; the type's own default is not.
72
+ */
73
+ function write(next: TMDataGridFilterValue) {
74
+ const worthKeeping =
75
+ !isEmptyValue(next.value) || next.operator !== defaultOperator;
76
+ column.setFilterValue(worthKeeping ? next : undefined);
77
+ }
78
+
79
+ function changeOperator(next: TMDataGridFilterOperator) {
80
+ // Same arity rule as the panel: keep the value across operators of the
81
+ // same shape, reset it across any shape boundary.
82
+ write({
83
+ operator: next,
84
+ value:
85
+ filterValueShape(next) === filterValueShape(operator)
86
+ ? value
87
+ : emptyValueForOperator(next),
88
+ });
89
+ }
90
+
91
+ // Memoized because this row re-renders with the table - on every scroll
92
+ // frame, for every column - while resolving faceted options walks the
93
+ // faceted map into a sorted set. TanStack swaps that map's identity whenever
94
+ // the values behind it change, so it is exactly the right key.
95
+ const facetKey = filterOptionsUseFacets(column)
96
+ ? column.getFacetedUniqueValues()
97
+ : undefined;
98
+ const { options, ValueControl } = useMemo(
99
+ () => filterControlFor(table, column),
100
+ // `facetKey` is a cache key, not something the body reads: the resolution
101
+ // reaches the faceted map through the column, so nothing here names it.
102
+ // eslint-disable-next-line react-hooks/exhaustive-deps
103
+ [table, column, facetKey],
104
+ );
105
+ const operators = getColumnOperators(column);
106
+
107
+ return (
108
+ <>
109
+ <div className={classes.headerFilterValue} data-dg-filter-value-slot>
110
+ <ValueControl
111
+ column={column}
112
+ table={table}
113
+ operator={operator}
114
+ value={value}
115
+ onChange={(next) => write({ operator, value: next })}
116
+ options={options}
117
+ size={controlSize}
118
+ labels={labels}
119
+ layout="header"
120
+ />
121
+ </div>
122
+
123
+ <OperatorMenu
124
+ columnId={column.id}
125
+ columnLabel={label}
126
+ operator={operator}
127
+ isDefault={operator === defaultOperator}
128
+ operators={operators}
129
+ onChange={changeOperator}
130
+ />
131
+ </>
132
+ );
133
+ }
134
+
135
+ /**
136
+ * The operator picker beside a header filter's value: which comparison the
137
+ * column is on, and the list to change it to.
138
+ *
139
+ * The tooltip is the only thing saying what the current operator is while the
140
+ * menu is shut, and the only thing in the way once it is open - so it is held
141
+ * back while the dropdown shows.
142
+ */
143
+ function OperatorMenu({
144
+ columnId,
145
+ columnLabel,
146
+ operator,
147
+ isDefault,
148
+ operators,
149
+ onChange,
150
+ }: {
151
+ columnId: string;
152
+ columnLabel: string;
153
+ operator: TMDataGridFilterOperator;
154
+ isDefault: boolean;
155
+ operators: ReadonlyArray<TMDataGridFilterOperator>;
156
+ onChange: (next: TMDataGridFilterOperator) => void;
157
+ }) {
158
+ const { labels } = useTMDataGridContext();
159
+ const [opened, setOpened] = useState(false);
160
+
161
+ return (
162
+ <Menu
163
+ opened={opened}
164
+ onChange={setOpened}
165
+ position="bottom-end"
166
+ shadow="md"
167
+ width={220}
168
+ withinPortal
169
+ >
170
+ <Menu.Target>
171
+ <Tooltip
172
+ label={labels.operators[operator]}
173
+ openDelay={400}
174
+ disabled={opened}
175
+ >
176
+ <ActionIcon
177
+ className={classes.headerFilterOperator}
178
+ variant="subtle"
179
+ // Tinted while the column is on anything but its default operator -
180
+ // the one part of the filter the value cannot show.
181
+ color={isDefault ? "gray" : undefined}
182
+ size="sm"
183
+ aria-label={labels.filterOperatorFor(columnLabel)}
184
+ data-dg-part="header-filter-operator"
185
+ data-column-id={columnId}
186
+ >
187
+ <FilterIcon size={14} stroke={1.6} />
188
+ </ActionIcon>
189
+ </Tooltip>
190
+ </Menu.Target>
191
+ <Menu.Dropdown>
192
+ {operators.map((candidate) => (
193
+ <Menu.Item
194
+ key={candidate}
195
+ className={
196
+ candidate === operator ? classes.operatorItemActive : undefined
197
+ }
198
+ onClick={() => onChange(candidate)}
199
+ >
200
+ {labels.operators[candidate]}
201
+ </Menu.Item>
202
+ ))}
203
+ </Menu.Dropdown>
204
+ </Menu>
205
+ );
206
+ }
207
+
208
+ /**
209
+ * `filters.inHeader` - a second header row holding one value control per
210
+ * filterable column, always visible.
211
+ *
212
+ * A header row like the group rows above it, on the same column tracks and
213
+ * with the same pinned lanes, so resizing, reordering and pinning move the
214
+ * controls with their columns. It sticks below the group rows rather than at
215
+ * the top, and carries `data-dg-header-row` so the body's scroll offset counts
216
+ * it in.
217
+ *
218
+ * @internal Rendered by `TMDataGrid.Table`.
219
+ */
220
+ export function TMDataGridHeaderFilterRow({
221
+ leafHeaders,
222
+ layoutFor,
223
+ ariaRowIndex,
224
+ }: {
225
+ leafHeaders: ReadonlyArray<TMDataGridHeader>;
226
+ layoutFor: (columnId: string) => TMDataGridColumnLayout;
227
+ ariaRowIndex: number;
228
+ }) {
229
+ const { ui, labels } = useTMDataGridContext();
230
+ const focusColumnId = useSelector(ui, (state) => state.headerFilterColumnId);
231
+ const rowRef = useRef<HTMLDivElement>(null);
232
+
233
+ // `openColumnFilter` under header filters means "put me in that column's
234
+ // control" - there is no panel to open. The column may be scrolled out
235
+ // sideways, so it is brought into view first.
236
+ useEffect(() => {
237
+ if (focusColumnId === null) return;
238
+ const cell = rowRef.current?.querySelector<HTMLElement>(
239
+ `[data-dg-part="header-filter-cell"][data-column-id="${CSS.escape(focusColumnId)}"]`,
240
+ );
241
+ cell?.scrollIntoView({ block: "nearest", inline: "nearest" });
242
+ cell
243
+ ?.querySelector<HTMLElement>(
244
+ "input:not([type='hidden']), textarea, select, [tabindex]:not([tabindex='-1'])",
245
+ )
246
+ ?.focus();
247
+ ui.actions.focusHeaderFilter(null);
248
+ }, [focusColumnId, ui]);
249
+
250
+ return (
251
+ <div
252
+ ref={rowRef}
253
+ role="row"
254
+ aria-rowindex={ariaRowIndex}
255
+ // Counted by the same measurement the group rows are: it is part of what
256
+ // the body scrolls under.
257
+ data-dg-header-row
258
+ data-dg-header-filter-row
259
+ data-dg-part="header-filter-row"
260
+ // The header/body boundary is this row once it exists, so it wears the
261
+ // scrolled-under shadow the last group row wears otherwise.
262
+ className={`${classes.headerFilterRow} ${sticky.headerBoundary}`}
263
+ >
264
+ {leafHeaders.map((header) => {
265
+ const column = header.column;
266
+ const layout = layoutFor(column.id);
267
+ return (
268
+ <div
269
+ key={header.id}
270
+ role="columnheader"
271
+ aria-label={labels.filterOn(getColumnLabel(column))}
272
+ data-dg-part="header-filter-cell"
273
+ data-column-id={column.id}
274
+ data-control-column={isControlColumn(column.id) || undefined}
275
+ className={[
276
+ classes.headerFilterCell,
277
+ layout.isBoundary && layout.pinnedAt === "left"
278
+ ? sticky.stickyLeft
279
+ : "",
280
+ layout.isBoundary && layout.pinnedAt === "right"
281
+ ? sticky.stickyRight
282
+ : "",
283
+ ]
284
+ .filter(Boolean)
285
+ .join(" ")}
286
+ style={{
287
+ left: layout.pinnedAt === "left" ? layout.offset : undefined,
288
+ right: layout.pinnedAt === "right" ? layout.offset : undefined,
289
+ position: layout.pinnedAt ? "sticky" : undefined,
290
+ zIndex: layout.pinnedAt
291
+ ? "var(--dg-z-header-pinned-cell, 7)"
292
+ : undefined,
293
+ }}
294
+ >
295
+ {column.getCanFilter() && <HeaderFilterControl column={column} />}
296
+ </div>
297
+ );
298
+ })}
299
+ </div>
300
+ );
301
+ }