@jielga/tmdatagrid 2.0.0-beta.11 → 2.0.0-beta.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist/index.d.ts +285 -22
  2. package/dist/index.js +2857 -2376
  3. package/dist/index.js.map +1 -1
  4. package/dist/styles.css +1 -1
  5. package/package.json +1 -2
  6. package/skills/appearance/SKILL.md +1 -1
  7. package/skills/cell-selection/SKILL.md +1 -1
  8. package/skills/columns/SKILL.md +2 -2
  9. package/skills/data/SKILL.md +5 -2
  10. package/skills/editing/SKILL.md +1 -1
  11. package/skills/filtering/SKILL.md +112 -26
  12. package/skills/getting-started/SKILL.md +4 -4
  13. package/skills/grouping/SKILL.md +1 -1
  14. package/skills/options/SKILL.md +3 -1
  15. package/skills/rows/SKILL.md +1 -1
  16. package/skills/server-side/SKILL.md +44 -15
  17. package/skills/testing/SKILL.md +6 -3
  18. package/src/tmdatagrid/components/TMDataGrid.tsx +3 -0
  19. package/src/tmdatagrid/components/TMDataGridEditColumn.tsx +1 -1
  20. package/src/tmdatagrid/components/TMDataGridFilterPanel.module.css +37 -18
  21. package/src/tmdatagrid/components/TMDataGridFilterPanel.tsx +138 -160
  22. package/src/tmdatagrid/components/TMDataGridFilterSurface.module.css +54 -0
  23. package/src/tmdatagrid/components/TMDataGridFilterSurface.tsx +162 -0
  24. package/src/tmdatagrid/components/TMDataGridFooter.tsx +28 -1
  25. package/src/tmdatagrid/components/TMDataGridHeaderCell.module.css +10 -0
  26. package/src/tmdatagrid/components/TMDataGridHeaderCell.tsx +12 -3
  27. package/src/tmdatagrid/components/TMDataGridHeaderFilterRow.module.css +51 -0
  28. package/src/tmdatagrid/components/TMDataGridHeaderFilterRow.tsx +303 -0
  29. package/src/tmdatagrid/components/TMDataGridTable.module.css +9 -41
  30. package/src/tmdatagrid/components/TMDataGridTable.tsx +84 -15
  31. package/src/tmdatagrid/components/TMDataGridToolbar.tsx +37 -11
  32. package/src/tmdatagrid/components/filters/DgAutocompleteFilter.tsx +5 -4
  33. package/src/tmdatagrid/components/filters/DgDateRangeFilter.tsx +22 -5
  34. package/src/tmdatagrid/components/filters/DgRangeSliderFilter.tsx +5 -1
  35. package/src/tmdatagrid/components/filters/DgTriStateFilter.tsx +5 -1
  36. package/src/tmdatagrid/components/filters/TMDataGridFilterValueInput.tsx +44 -28
  37. package/src/tmdatagrid/components/filters/controlLayout.ts +32 -0
  38. package/src/tmdatagrid/components/filters/filterControlFor.ts +65 -0
  39. package/src/tmdatagrid/components/sticky.module.css +44 -0
  40. package/src/tmdatagrid/core/columnOptions.ts +46 -0
  41. package/src/tmdatagrid/core/controlledStateSync.ts +108 -0
  42. package/src/tmdatagrid/core/editEngine.ts +68 -12
  43. package/src/tmdatagrid/core/filterControls.ts +28 -0
  44. package/src/tmdatagrid/core/filterOperators.ts +64 -1
  45. package/src/tmdatagrid/core/filterSurface.ts +99 -0
  46. package/src/tmdatagrid/core/labels.ts +7 -0
  47. package/src/tmdatagrid/core/labelsSv.ts +3 -0
  48. package/src/tmdatagrid/core/pageReset.ts +115 -0
  49. package/src/tmdatagrid/index.ts +13 -0
  50. package/src/tmdatagrid/useTMDataGrid.tsx +191 -11
@@ -62,6 +62,7 @@ export const TMDATAGRID_LABELS_SV: TMDataGridLabels = {
62
62
  clearAllFilters: "Rensa alla",
63
63
  closeFilters: "Stäng filter",
64
64
  removeFilter: "Ta bort filter",
65
+ filterOperatorFor: (column) => `Filteroperator för ${column}`,
65
66
 
66
67
  activeFilters: "Aktiva filter",
67
68
  clearFilter: (column) => `Rensa filtret för ${column}`,
@@ -86,6 +87,8 @@ export const TMDATAGRID_LABELS_SV: TMDataGridLabels = {
86
87
 
87
88
  rowsPerPage: "Rader per sida:",
88
89
  pageRange: ({ from, to, total }) => `${from}–${to} av ${total}`,
90
+ pageNumber: ({ page, pageCount }) =>
91
+ pageCount < 0 ? `Sida ${page}` : `Sida ${page} av ${pageCount}`,
89
92
  groupedAllRows: (total) => `Grupperad · alla ${total} rader`,
90
93
  pagingSuspendedHint:
91
94
  "Sidindelningen är avstängd medan raderna är grupperade: hela trädet renderas och virtualiseras. Avgruppera för att bläddra igen.",
@@ -0,0 +1,115 @@
1
+ import {
2
+ functionalUpdate,
3
+ makeStateUpdater,
4
+ type TableState,
5
+ type Updater,
6
+ } from "@tanstack/react-table";
7
+ import type { TMDataGridFeatures } from "../useTMDataGrid";
8
+ import { sameStateValue } from "./controlledState";
9
+ import { isFilterActive, isTMDataGridFilterValue } from "./filterOperators";
10
+
11
+ /**
12
+ * Sends a server-paged grid back to the first page when the query changes.
13
+ *
14
+ * Under `manualPagination` the grid does not own the result set, so a column
15
+ * filter, the quick search or a sort leaves `pageIndex` pointing into a page
16
+ * range that no longer exists: the next request asks the server for page 8 of
17
+ * a result set that now has three, and the consumer gets an empty grid with
18
+ * no error.
19
+ *
20
+ * TanStack's `autoResetPageIndex` does not cover it. It defaults to
21
+ * `!manualPagination`, so it is off exactly when this is needed, and it fires
22
+ * on a data change - which server-side is the response landing, one request
23
+ * too late.
24
+ *
25
+ * The reset rides on the slice's own change callback rather than on an effect
26
+ * over table state, so both writes land in the same render and the query goes
27
+ * out once. From an effect the request for the stale page would leave first.
28
+ */
29
+
30
+ /** The state a server-side query is built from. */
31
+ export type TMDataGridQuerySlice =
32
+ | "columnFilters"
33
+ | "globalFilter"
34
+ | "sorting";
35
+
36
+ /** What the reset reads and writes - the table, narrowed to that. */
37
+ export type TMDataGridQueryTable = {
38
+ store: { state: TableState<TMDataGridFeatures> };
39
+ setPageIndex: (index: number) => void;
40
+ };
41
+
42
+ type QueryHandler = (updater: never) => void;
43
+
44
+ type ColumnFilters = TableState<TMDataGridFeatures>["columnFilters"];
45
+
46
+ /**
47
+ * The entries the server could answer differently. Only the grid's own
48
+ * `{ operator, value }` shape can be recognised as "still empty" - anything
49
+ * else, a custom filter control's raw value or state restored from a URL,
50
+ * narrows the grid as far as this module can know, so it always counts.
51
+ */
52
+ function querySignificantFilters(filters: ColumnFilters): ColumnFilters {
53
+ return filters.filter(
54
+ (entry) =>
55
+ !isTMDataGridFilterValue(entry.value) || isFilterActive(entry.value),
56
+ );
57
+ }
58
+
59
+ /**
60
+ * Whether the server would answer the next state differently.
61
+ *
62
+ * A column filter with an empty value matches every row, so it is not part of
63
+ * the query: opening the filter panel seeds a row on the first filterable
64
+ * column, and the user then types into it. Neither moves the result set, and
65
+ * neither may throw away the page the user is on.
66
+ */
67
+ function changesTheQuery(
68
+ slice: TMDataGridQuerySlice,
69
+ previous: unknown,
70
+ next: unknown,
71
+ ): boolean {
72
+ if (slice === "columnFilters") {
73
+ return !sameStateValue(
74
+ querySignificantFilters(previous as ColumnFilters),
75
+ querySignificantFilters(next as ColumnFilters),
76
+ );
77
+ }
78
+ return !sameStateValue(previous, next);
79
+ }
80
+
81
+ /**
82
+ * Wraps one slice's change callback. `handler` is the consumer's, where they
83
+ * control the slice; without one the write is the same `makeStateUpdater`
84
+ * TanStack would have defaulted to, which this option replaces.
85
+ */
86
+ export function withPageReset(
87
+ slice: TMDataGridQuerySlice,
88
+ handler: QueryHandler | undefined,
89
+ getTable: () => TMDataGridQueryTable,
90
+ ): QueryHandler {
91
+ return (updater) => {
92
+ const table = getTable();
93
+ const previous = table.store.state[slice];
94
+ // Resolved here rather than read back off the table afterwards: where the
95
+ // consumer owns the slice the write is their `setState`, so the table
96
+ // still holds the old value when this returns. The updater therefore runs
97
+ // twice per change - once here, once in the write path - which is safe
98
+ // for every updater the grid and table-core produce, and a requirement on
99
+ // the consumer's: an updater has to be a pure derivation of the previous
100
+ // value. The decision is also best-effort under batching: two writes to
101
+ // one slice in the same tick both read the same `previous`, so the second
102
+ // decision is computed from a stale base. A wrong reset decision at
103
+ // worst, never a wrong write.
104
+ const next = functionalUpdate(updater as Updater<unknown>, previous);
105
+
106
+ (handler ?? (makeStateUpdater(slice, table as never) as QueryHandler))(
107
+ updater,
108
+ );
109
+
110
+ if (!changesTheQuery(slice, previous, next)) return;
111
+ // Already on the first page: writing anyway would publish the store and
112
+ // re-render the grid for a value that did not move.
113
+ if (table.store.state.pagination.pageIndex !== 0) table.setPageIndex(0);
114
+ };
115
+ }
@@ -45,11 +45,15 @@ export type {
45
45
  TMDataGridMenuProps,
46
46
  } from "./components/TMDataGridMenu";
47
47
  export {
48
+ type TMDataGridColumnFilter,
48
49
  type TMDataGridColumnType,
49
50
  type TMDataGridFilterOperator,
50
51
  type TMDataGridFilterValue,
52
+ type TMDataGridFilterValueShape,
53
+ activeColumnFilters,
51
54
  emptyValueForOperator,
52
55
  FILTER_OPERATOR_LABELS,
56
+ filterValueShape,
53
57
  formatFilterLabel,
54
58
  getDefaultOperator,
55
59
  getOperatorsForType,
@@ -112,7 +116,16 @@ export type {
112
116
  TMDataGridColumnFilterOptions,
113
117
  TMDataGridFilterControlArgs,
114
118
  TMDataGridFilterControlComponent,
119
+ TMDataGridFilterControlLayout,
120
+ TMDataGridFilterPanelLayout,
115
121
  } from "./core/filterControls";
122
+ export type {
123
+ TMDataGridFiltersOptions,
124
+ TMDataGridFiltersSettings,
125
+ TMDataGridFilterSidebarSide,
126
+ TMDataGridFilterSurface,
127
+ } from "./core/filterSurface";
128
+ export type { TMDataGridFilterPanelProps } from "./components/TMDataGridFilterPanel";
116
129
  export { TMDataGridFilterValueInput } from "./components/filters/TMDataGridFilterValueInput";
117
130
  export { DgRangeSliderFilter } from "./components/filters/DgRangeSliderFilter";
118
131
  export { DgDateRangeFilter } from "./components/filters/DgDateRangeFilter";
@@ -69,6 +69,11 @@ import {
69
69
  } from "./core/filterOperators";
70
70
  import { getColumnDefaultOperator, isControlColumn } from "./core/columnUtils";
71
71
  import type { TMDataGridColumnFilterOptions } from "./core/filterControls";
72
+ import {
73
+ resolveFilterOptions,
74
+ type TMDataGridFiltersOptions,
75
+ type TMDataGridFiltersSettings,
76
+ } from "./core/filterSurface";
72
77
  import {
73
78
  createFuzzyRankedSortedRowModel,
74
79
  fuzzyGlobalFilterFn,
@@ -101,6 +106,15 @@ import {
101
106
  stabilizeControlledState,
102
107
  withoutUndefinedSlices,
103
108
  } from "./core/controlledState";
109
+ import {
110
+ beginControlledStateSync,
111
+ deferControlledStateSyncPublishes,
112
+ endControlledStateSync,
113
+ } from "./core/controlledStateSync";
114
+ import {
115
+ withPageReset,
116
+ type TMDataGridQueryTable,
117
+ } from "./core/pageReset";
104
118
  import type { TMDataGridCellRange } from "./core/cellRange";
105
119
  import {
106
120
  createSelectColumn,
@@ -305,8 +319,20 @@ const EMPTY_IDS: ReadonlyArray<string> = [];
305
319
  */
306
320
  export type TMDataGridUiState = {
307
321
  filterPanelOpen: boolean;
308
- /** Column whose filter row should be focused when the panel opens. */
322
+ /**
323
+ * Column whose *panel* row should take the focus. Cleared once the row has
324
+ * taken it, so pointing at the same column twice focuses twice.
325
+ */
309
326
  filterPanelColumnId: string | null;
327
+ /**
328
+ * Column whose *header filter* control should take the focus, under
329
+ * `filters.inHeader`. Cleared once taken, like the one above.
330
+ *
331
+ * Its own slot rather than a second reader of `filterPanelColumnId`: a grid
332
+ * can have header filters and a panel at once, and two controls racing to
333
+ * answer one id means whichever mounted last wins the caret.
334
+ */
335
+ headerFilterColumnId: string | null;
310
336
  /**
311
337
  * Column being dragged by its header, if any. Held here rather than read from
312
338
  * `dataTransfer`, which browsers keep unreadable until the drop.
@@ -350,6 +376,18 @@ export type TMDataGridUiState = {
350
376
  export type TMDataGridUiActions = {
351
377
  openFilterPanel: (columnId?: string | null) => void;
352
378
  closeFilterPanel: () => void;
379
+ /**
380
+ * Points at a column's row in the filter panel without opening anything.
381
+ * `openFilterPanel` does this as well as opening; this is the half a panel
382
+ * that is already showing needs.
383
+ */
384
+ focusPanelFilter: (columnId: string | null) => void;
385
+ /**
386
+ * Points at a column's header filter control - what `openColumnFilter` does
387
+ * under `filters.inHeader`, where there is no panel to open. The header row
388
+ * scrolls the column into view and focuses it.
389
+ */
390
+ focusHeaderFilter: (columnId: string | null) => void;
353
391
  startColumnDrag: (columnId: string) => void;
354
392
  endColumnDrag: () => void;
355
393
  /**
@@ -401,6 +439,13 @@ export type TMDataGridApi<TData extends RowData> = {
401
439
  edit: TMDataGridEditApi<TData>;
402
440
  /** Table-level feature switches, re-read from options on every render. */
403
441
  features: TMDataGridFeatureFlags;
442
+ /**
443
+ * Where the filter controls live, the `filters` option with its defaults
444
+ * filled in. On the api rather than in a component's props because the
445
+ * pills, the column menu and `openColumnFilter` all have to agree with the
446
+ * table about which surface is on.
447
+ */
448
+ filters: TMDataGridFiltersSettings;
404
449
  /** Every string the chrome renders, `labels` merged over the English defaults. */
405
450
  labels: TMDataGridLabels;
406
451
  /** The detail renderer, when row details are on. See `renderDetails`. */
@@ -690,12 +735,40 @@ export type UseTMDataGridOptions<TData extends RowData> = Omit<
690
735
  * no extra flag.
691
736
  */
692
737
  enablePagination?: boolean;
738
+ /**
739
+ * Sends the grid back to the first page whenever the query changes - a
740
+ * column filter, the quick search or the sort. Defaults to `true` under
741
+ * `manualPagination` and `false` otherwise, where TanStack's own
742
+ * `autoResetPageIndex` already does it.
743
+ *
744
+ * Server-side, `pageIndex` is a position in a result set the grid does not
745
+ * own: narrowing the query leaves it pointing past the last page, and the
746
+ * next request comes back empty. The reset is applied in the same event as
747
+ * the change, so one request goes out, for the first page of the new query.
748
+ */
749
+ resetPageOnQueryChange?: boolean;
693
750
  /**
694
751
  * The row-number gutter: a generated lane, outermost left, numbering the
695
752
  * rows of the current view - sorted, filtered, continuing across pages,
696
753
  * with group rows unnumbered. Off by default.
697
754
  */
698
755
  enableRowNumbers?: boolean;
756
+ /**
757
+ * Where the grid puts its filter controls - a popup over the rows, a sidebar
758
+ * beside them, controls in the header row, or nowhere at all so you place
759
+ * `TMDataGrid.FilterPanel` yourself.
760
+ *
761
+ * ```tsx
762
+ * useTMDataGrid({ data, columns, filters: { surface: "sidebar", inHeader: true } });
763
+ * ```
764
+ *
765
+ * Defaults to `{ surface: "popup" }` - the floating panel the grid has
766
+ * always shown. See {@link TMDataGridFiltersOptions}.
767
+ *
768
+ * Read field by field, so a literal is fine here - unlike `labels` or
769
+ * `persist`, this one does not have to be referentially stable.
770
+ */
771
+ filters?: TMDataGridFiltersOptions;
699
772
  /**
700
773
  * How the quick search (`TMDataGrid.Search`) matches. `"fuzzy"` - the
701
774
  * default - forgives typos and skipped characters, and while it is the
@@ -959,6 +1032,7 @@ export function useTMDataGrid<TData extends RowData>({
959
1032
  labels: labelsOverride,
960
1033
  enableColumnOrdering,
961
1034
  enablePagination,
1035
+ resetPageOnQueryChange,
962
1036
  enableRowNumbers,
963
1037
  selectionMode,
964
1038
  showSelectedBackground,
@@ -966,6 +1040,7 @@ export function useTMDataGrid<TData extends RowData>({
966
1040
  onHighlightedRowChange,
967
1041
  cellSelection,
968
1042
  onFocusedCellChange,
1043
+ filters: filterOptions,
969
1044
  editing,
970
1045
  renderDetails,
971
1046
  renderDetailsEstHeight = DEFAULT_DETAILS_EST_HEIGHT,
@@ -997,6 +1072,33 @@ export function useTMDataGrid<TData extends RowData>({
997
1072
  // Resolved on the override's identity, so a module-scope dictionary costs one
998
1073
  // merge for the lifetime of the grid.
999
1074
  const labels = useMemo(() => mergeLabels(labelsOverride), [labelsOverride]);
1075
+ // Unpacked before the memo, so the api is keyed on the five fields rather
1076
+ // than on the object's identity - which is what lets `filters` be written as
1077
+ // a literal, the way it reads best, without republishing every render.
1078
+ const {
1079
+ surface: filterSurface,
1080
+ sidebarSide: filterSidebarSide,
1081
+ sidebarWidth: filterSidebarWidth,
1082
+ defaultOpen: filtersDefaultOpen,
1083
+ inHeader: filtersInHeader,
1084
+ } = filterOptions ?? {};
1085
+ const filters = useMemo(
1086
+ () =>
1087
+ resolveFilterOptions({
1088
+ surface: filterSurface,
1089
+ sidebarSide: filterSidebarSide,
1090
+ sidebarWidth: filterSidebarWidth,
1091
+ defaultOpen: filtersDefaultOpen,
1092
+ inHeader: filtersInHeader,
1093
+ }),
1094
+ [
1095
+ filterSurface,
1096
+ filterSidebarSide,
1097
+ filterSidebarWidth,
1098
+ filtersDefaultOpen,
1099
+ filtersInHeader,
1100
+ ],
1101
+ );
1000
1102
 
1001
1103
  const pinningEnabled = options.enableColumnPinning !== false;
1002
1104
  const selectColumnEnabled = features.selectColumn;
@@ -1266,6 +1368,23 @@ export function useTMDataGrid<TData extends RowData>({
1266
1368
  [],
1267
1369
  );
1268
1370
 
1371
+ // Filled immediately after the call below. The query-change wrappers close
1372
+ // over it rather than over `table`, since they are built as part of the
1373
+ // options the table is constructed from.
1374
+ const tableRef = useRef<TMDataGridTable<TData>>(null as never);
1375
+ // Server-side, a narrower query invalidates the page the grid is on - see
1376
+ // pageReset.ts. On by default only where the grid does not own the result
1377
+ // set; TanStack's `autoResetPageIndex` covers the client-side case.
1378
+ const resetPage = resetPageOnQueryChange ?? options.manualPagination === true;
1379
+ const getQueryTable = useCallback(
1380
+ () => tableRef.current as unknown as TMDataGridQueryTable,
1381
+ [],
1382
+ );
1383
+
1384
+ // The sync of `state` into the table's atoms happens inside this call, in
1385
+ // the render body, and publishing from there makes React warn about the
1386
+ // consumer's component - see controlledStateSync.ts.
1387
+ beginControlledStateSync();
1269
1388
  const table = useTable({
1270
1389
  // The grid paints a running drag itself, one style write per frame - see
1271
1390
  // the resize preview in TMDataGridTable - and takes the width into state
@@ -1288,6 +1407,32 @@ export function useTMDataGrid<TData extends RowData>({
1288
1407
  // `"reorder"` to keep the column and have it moved to the front instead.
1289
1408
  groupedColumnMode: "remove",
1290
1409
  ...options,
1410
+ // The query slices, wrapped so a change also takes the grid back to the
1411
+ // first page - see pageReset.ts. Spread conditionally: the keys carry a
1412
+ // `makeStateUpdater` default, and an explicit `undefined` would overwrite
1413
+ // it and leave the slice unwritable.
1414
+ ...(resetPage
1415
+ ? {
1416
+ onColumnFiltersChange: withPageReset(
1417
+ "columnFilters",
1418
+ options.onColumnFiltersChange as never,
1419
+ getQueryTable,
1420
+ ) as TableOptions<
1421
+ TMDataGridFeatures,
1422
+ TData
1423
+ >["onColumnFiltersChange"],
1424
+ onGlobalFilterChange: withPageReset(
1425
+ "globalFilter",
1426
+ options.onGlobalFilterChange as never,
1427
+ getQueryTable,
1428
+ ) as TableOptions<TMDataGridFeatures, TData>["onGlobalFilterChange"],
1429
+ onSortingChange: withPageReset(
1430
+ "sorting",
1431
+ options.onSortingChange as never,
1432
+ getQueryTable,
1433
+ ) as TableOptions<TMDataGridFeatures, TData>["onSortingChange"],
1434
+ }
1435
+ : {}),
1291
1436
  // The rows as shown - see `shown` above. The consumer's own array passes
1292
1437
  // through untouched while nothing is committed.
1293
1438
  data: shown.rows,
@@ -1367,6 +1512,9 @@ export function useTMDataGrid<TData extends RowData>({
1367
1512
  },
1368
1513
  },
1369
1514
  }, selectSettledState);
1515
+ endControlledStateSync();
1516
+ tableRef.current = table as unknown as TMDataGridTable<TData>;
1517
+ deferControlledStateSyncPublishes(table.store);
1370
1518
 
1371
1519
  // The engine's view of this render - see the engine's creation above.
1372
1520
  editContextRef.current = {
@@ -1553,8 +1701,11 @@ export function useTMDataGrid<TData extends RowData>({
1553
1701
 
1554
1702
  const ui = useCreateStore<TMDataGridUiState, TMDataGridUiActions>(
1555
1703
  {
1556
- filterPanelOpen: false,
1704
+ // `useCreateStore` builds the store once per mount, so `defaultOpen` is
1705
+ // read the way `initialState` is - a starting point, not a controller.
1706
+ filterPanelOpen: filters.defaultOpen,
1557
1707
  filterPanelColumnId: null,
1708
+ headerFilterColumnId: null,
1558
1709
  draggedColumnId: null,
1559
1710
  // `useCreateStore` builds the store once per mount, so this is a genuine
1560
1711
  // default rather than a value that would fight later clicks.
@@ -1576,6 +1727,10 @@ export function useTMDataGrid<TData extends RowData>({
1576
1727
  filterPanelOpen: false,
1577
1728
  filterPanelColumnId: null,
1578
1729
  })),
1730
+ focusPanelFilter: (columnId) =>
1731
+ setState((prev) => ({ ...prev, filterPanelColumnId: columnId })),
1732
+ focusHeaderFilter: (columnId) =>
1733
+ setState((prev) => ({ ...prev, headerFilterColumnId: columnId })),
1579
1734
  startColumnDrag: (columnId) =>
1580
1735
  setState((prev) => ({ ...prev, draggedColumnId: columnId })),
1581
1736
  endColumnDrag: () =>
@@ -1701,6 +1856,7 @@ export function useTMDataGrid<TData extends RowData>({
1701
1856
  ui,
1702
1857
  edit,
1703
1858
  features,
1859
+ filters,
1704
1860
  labels,
1705
1861
  renderDetails,
1706
1862
  renderDetailsEstHeight,
@@ -1712,20 +1868,44 @@ export function useTMDataGrid<TData extends RowData>({
1712
1868
  }
1713
1869
 
1714
1870
  /**
1715
- * Opens the filter panel for a column, seeding an empty filter row when the
1716
- * column has none yet - mirrors "Filter" in the column header menu.
1871
+ * Gives a column an empty filter of its default operator, unless it already
1872
+ * has one - which is what makes a surface open on a row rather than on
1873
+ * nothing.
1874
+ *
1875
+ * @internal Shared by `openColumnFilter` and the toolbar's filter button.
1876
+ */
1877
+ export function seedColumnFilter<TData extends RowData>(
1878
+ api: TMDataGridApi<TData>,
1879
+ columnId: string,
1880
+ ): void {
1881
+ const column = api.table.getColumn(columnId);
1882
+ if (column === undefined || column.getFilterValue() !== undefined) return;
1883
+ const operator = getColumnDefaultOperator(column);
1884
+ column.setFilterValue({ operator, value: emptyValueForOperator(operator) });
1885
+ }
1886
+
1887
+ /**
1888
+ * Sends the user to a column's filter control, seeding an empty filter when
1889
+ * the column has none yet - what "Filter" in the column menu and a click on a
1890
+ * filter pill both do.
1891
+ *
1892
+ * Which control that is follows the grid's `filters` option. Under
1893
+ * `inHeader` it is the column's header control, which is already on screen, so
1894
+ * the call focuses it and leaves the popup or sidebar closed. Otherwise it is
1895
+ * the panel's row for that column, and the call opens the surface on it.
1717
1896
  */
1718
1897
  export function openColumnFilter<TData extends RowData>(
1719
1898
  api: TMDataGridApi<TData>,
1720
1899
  columnId: string,
1721
1900
  ): void {
1722
- const column = api.table.getColumn(columnId);
1723
- if (column && column.getFilterValue() === undefined) {
1724
- const operator = getColumnDefaultOperator(column);
1725
- column.setFilterValue({
1726
- operator,
1727
- value: emptyValueForOperator(operator),
1728
- });
1901
+ seedColumnFilter(api, columnId);
1902
+ if (api.filters.inHeader) {
1903
+ // The header control is always visible, so there is nothing to open -
1904
+ // only a column to point at. The header row watching this focuses it and
1905
+ // scrolls it into view. The panel, if one is also showing, is left alone:
1906
+ // it reads the other slot.
1907
+ api.ui.actions.focusHeaderFilter(columnId);
1908
+ return;
1729
1909
  }
1730
1910
  api.ui.actions.openFilterPanel(columnId);
1731
1911
  }