@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
@@ -0,0 +1,65 @@
1
+ import type { Column } from "@tanstack/react-table";
2
+ import { resolveColumnOptions, type TMDataGridOption } from "../../core/columnOptions";
3
+ import { getColumnFilterControl, getColumnType } from "../../core/columnUtils";
4
+ import type { TMDataGridFilterControlComponent } from "../../core/filterControls";
5
+ import type { TMDataGridRowData } from "../../TMDataGridContext";
6
+ import type { TMDataGridFeatures, TMDataGridTable } from "../../useTMDataGrid";
7
+ import { TMDataGridFilterValueInput } from "./TMDataGridFilterValueInput";
8
+
9
+ type FilterColumn = Column<TMDataGridFeatures, TMDataGridRowData, unknown>;
10
+
11
+ /** One array, so "this column has no options" never changes identity. */
12
+ const NO_OPTIONS: ReadonlyArray<TMDataGridOption> = [];
13
+
14
+ /**
15
+ * Whether resolving this column's options would read the faceted index -
16
+ * which is what makes the resolution worth memoizing, and what it goes stale
17
+ * against.
18
+ */
19
+ export function filterOptionsUseFacets(column: FilterColumn): boolean {
20
+ const declared = column.columnDef.meta?.options;
21
+ return (
22
+ columnNeedsFilterOptions(column) &&
23
+ (declared === undefined || declared === "faceted")
24
+ );
25
+ }
26
+
27
+ /**
28
+ * Whether a column's filter control is offered a list of options at all.
29
+ *
30
+ * Only where options mean something out of the box - a declared set, or a
31
+ * select-shaped column's faceted values. A custom control wanting faceted
32
+ * values on some other column resolves them itself; resolving here would build
33
+ * the faceted index for every filtered column.
34
+ */
35
+ function columnNeedsFilterOptions(column: FilterColumn): boolean {
36
+ const type = getColumnType(column);
37
+ return (
38
+ column.columnDef.meta?.options !== undefined ||
39
+ type === "select" ||
40
+ type === "multiSelect"
41
+ );
42
+ }
43
+
44
+ /**
45
+ * What a column's filter control is, and what options it is handed - the one
46
+ * decision the panel row and the header cell make identically.
47
+ *
48
+ * Not a hook: the panel resolves this inside a `map` over its rows, where a
49
+ * hook cannot go. The header row memoizes the call itself, because it
50
+ * re-renders with the table on every scroll frame.
51
+ */
52
+ export function filterControlFor(
53
+ table: TMDataGridTable<TMDataGridRowData>,
54
+ column: FilterColumn,
55
+ ): {
56
+ options: ReadonlyArray<TMDataGridOption>;
57
+ ValueControl: TMDataGridFilterControlComponent;
58
+ } {
59
+ return {
60
+ options: columnNeedsFilterOptions(column)
61
+ ? resolveColumnOptions({ table, column, fallback: "faceted" })
62
+ : NO_OPTIONS,
63
+ ValueControl: getColumnFilterControl(column) ?? TMDataGridFilterValueInput,
64
+ };
65
+ }
@@ -104,3 +104,47 @@
104
104
  opacity: 0;
105
105
  }
106
106
  }
107
+
108
+ /* The scrolled-under shadow: a soft band under the header, only while body
109
+ rows are actually beneath it. Worn by the last header row - the header/body
110
+ boundary - which is the filter row under `filters.inHeader` and the last
111
+ group row otherwise. Lives here rather than in either row's own module
112
+ because the two are in different modules and the boundary moves between
113
+ them.
114
+
115
+ A scroll-driven animation, like the pinned-lane gradients: the shadow tracks
116
+ the scroll on the compositor with no listener and no render, and an inactive
117
+ timeline (nothing to scroll) leaves `opacity: 0` standing, so a grid that
118
+ fits shows nothing. Where unsupported there is simply no shadow - the
119
+ header's border already draws the boundary. */
120
+ .headerBoundary::after {
121
+ content: "";
122
+ position: absolute;
123
+ inset: 100% 0 auto 0;
124
+ height: 6px;
125
+ pointer-events: none;
126
+ background: linear-gradient(
127
+ to bottom,
128
+ var(--dg-header-shadow-color, rgba(0, 0, 0, 0.14)),
129
+ transparent
130
+ );
131
+ opacity: 0;
132
+ }
133
+
134
+ @supports (animation-timeline: scroll()) {
135
+ .headerBoundary::after {
136
+ animation: dgHeaderShadow linear both;
137
+ animation-timeline: scroll(nearest block);
138
+ /* Arrives over the first rows leaving, reads as depth not as a fade. */
139
+ animation-range: 0px 24px;
140
+ }
141
+ }
142
+
143
+ @keyframes dgHeaderShadow {
144
+ from {
145
+ opacity: 0;
146
+ }
147
+ to {
148
+ opacity: 1;
149
+ }
150
+ }
@@ -45,6 +45,51 @@ export type TMDataGridOptionsSource =
45
45
  | "faceted"
46
46
  | ((args: TMDataGridOptionsArgs) => ReadonlyArray<TMDataGridOption | string>);
47
47
 
48
+ /**
49
+ * Columns already warned about, per grid - so a warning fires once and a
50
+ * test's grid is not silenced by another test's. Keyed on `table.store`, not
51
+ * on the table: `useTable` returns a fresh table object every render, while
52
+ * the store is created once and shared by every render's copy.
53
+ */
54
+ const warnedFaceted = new WeakMap<object, Set<string>>();
55
+
56
+ /**
57
+ * Faceted options read the distinct values in `data`, which under
58
+ * `manualFiltering` or `manualPagination` is whatever the server sent for the
59
+ * current page. The dropdown then offers the values that happen to be on the
60
+ * page the user is looking at, and looks correct while being wrong - so it is
61
+ * said out loud, once per column.
62
+ *
63
+ * Fires from render, unlike the library's other warnings: the fallback form
64
+ * of `"faceted"` only exists at resolve time, which is render. The guard
65
+ * makes it once per grid regardless - a StrictMode double render or a
66
+ * discarded concurrent render marks the set the same way a committed one
67
+ * does. H3 in the backlog folds it into the diagnostics mechanism with the
68
+ * rest.
69
+ */
70
+ function warnFacetedUnderManualMode(
71
+ table: TMDataGridTable<TMDataGridRowData>,
72
+ columnId: string,
73
+ ): void {
74
+ if (
75
+ table.options.manualFiltering !== true &&
76
+ table.options.manualPagination !== true
77
+ ) {
78
+ return;
79
+ }
80
+ const store = table.store as object;
81
+ let warned = warnedFaceted.get(store);
82
+ if (warned === undefined) {
83
+ warned = new Set();
84
+ warnedFaceted.set(store, warned);
85
+ }
86
+ if (warned.has(columnId)) return;
87
+ warned.add(columnId);
88
+ console.warn(
89
+ `TMDataGrid: column "${columnId}" resolves faceted options while the server owns the rows - the distinct values of one page are not the distinct values of the result set. Pass meta.options as a list or a function instead.`,
90
+ );
91
+ }
92
+
48
93
  function addFacetValue(target: Set<string>, value: unknown): void {
49
94
  if (value === null || value === undefined || value === "") return;
50
95
  target.add(String(value));
@@ -68,6 +113,7 @@ export function resolveColumnOptions({
68
113
  if (!source) return [];
69
114
 
70
115
  if (source === "faceted") {
116
+ warnFacetedUnderManualMode(table, column.id);
71
117
  const values = new Set<string>();
72
118
  for (const key of column.getFacetedUniqueValues().keys()) {
73
119
  if (Array.isArray(key)) {
@@ -0,0 +1,108 @@
1
+ import type { ReadonlyStore, Store } from "@tanstack/store";
2
+
3
+ /**
4
+ * Timing repair for TanStack's controlled-state sync.
5
+ *
6
+ * `useTable` calls `table.setOptions` from its render body, and that syncs
7
+ * `options.state` into the table's atoms. An atom write publishes the store
8
+ * synchronously, so every component subscribed to `table.store` schedules a
9
+ * setState while the consumer's component is still rendering - which React
10
+ * reports as "Cannot update a component (X) while rendering a different
11
+ * component (Y)", pointing at the consumer's component rather than at the
12
+ * grid.
13
+ *
14
+ * The write has to stay where it is: the atoms must hold the controlled value
15
+ * before the table builds its row models for that render. Only the
16
+ * notification is early, so it moves to a microtask. `getSnapshot` already
17
+ * returns the new value, so a component reads the same state either way; the
18
+ * subscription is only what schedules a re-render for the components the
19
+ * consumer's own render does not reach.
20
+ *
21
+ * Scope and assumptions. Only `table.store` is patched: a subscription made
22
+ * on `table.atoms.*` or `table.baseAtoms.*` keeps the synchronous timing.
23
+ * The patch leans on three table-core 9.0.0-beta.21 internals - the store is
24
+ * created once in `constructTable`, `useTable` returns a shallow copy that
25
+ * shares it, and the sync runs inside `setOptions` during render. The
26
+ * publish test in controlledState.test.tsx fails loudly if any of them
27
+ * moves; it is the guard for this module, not a redundant check. The
28
+ * intended end state - owning the controlled slices through `options.atoms`,
29
+ * which removes the render-time sync entirely - is in the backlog.
30
+ */
31
+
32
+ type Observer = ((value: unknown) => void) | { next?: (value: unknown) => void };
33
+
34
+ type Subscribe = (observer: Observer) => { unsubscribe: () => void };
35
+
36
+ /**
37
+ * True while `useTable` is syncing controlled state inside a render pass.
38
+ * Process-global, so while one grid is inside its `useTable` call a publish
39
+ * on any patched store is deferred too - harmless, since nothing else runs
40
+ * during a synchronous render pass.
41
+ */
42
+ let syncing = false;
43
+
44
+ /**
45
+ * Called immediately before the `useTable` call that performs the sync.
46
+ *
47
+ * The microtask is the backstop that bounds the flag to the current task: if
48
+ * `useTable` throws and an error boundary unmounts the grid, nothing after
49
+ * the call runs, and without it every patched store would keep deferring its
50
+ * notifications for the life of the page.
51
+ */
52
+ export function beginControlledStateSync(): void {
53
+ syncing = true;
54
+ queueMicrotask(() => {
55
+ syncing = false;
56
+ });
57
+ }
58
+
59
+ /**
60
+ * Called immediately after it - the prompt clear; correctness rests on the
61
+ * backstop above. Not a `finally` around the call, because hooks inside
62
+ * `try` opt the whole hook out of the React Compiler.
63
+ */
64
+ export function endControlledStateSync(): void {
65
+ syncing = false;
66
+ }
67
+
68
+ const patched = new WeakSet<object>();
69
+
70
+ /**
71
+ * Wraps a table store's `subscribe` so notifications raised during the sync
72
+ * are delivered in a microtask. Idempotent, and installed on the store object
73
+ * itself rather than on a copy of the table - rows hold the original, so
74
+ * `row.table.store` has to be the patched one.
75
+ */
76
+ export function deferControlledStateSyncPublishes<TState>(
77
+ store: Store<TState> | ReadonlyStore<TState>,
78
+ ): void {
79
+ if (patched.has(store)) return;
80
+ patched.add(store);
81
+
82
+ const subscribe = store.subscribe.bind(store) as Subscribe;
83
+
84
+ (store as unknown as { subscribe: Subscribe }).subscribe = (observer) => {
85
+ const next =
86
+ typeof observer === "function" ? observer : observer.next?.bind(observer);
87
+ if (next === undefined) return subscribe(observer);
88
+
89
+ let live = true;
90
+ const subscription = subscribe((value) => {
91
+ if (!syncing) {
92
+ next(value);
93
+ return;
94
+ }
95
+ queueMicrotask(() => {
96
+ // The subscriber may have unmounted between the write and the flush.
97
+ if (live) next(value);
98
+ });
99
+ });
100
+
101
+ return {
102
+ unsubscribe: () => {
103
+ live = false;
104
+ subscription.unsubscribe();
105
+ },
106
+ };
107
+ };
108
+ }
@@ -707,11 +707,26 @@ export type TMDataGridEditApi<
707
707
  ) => Promise<TMDataGridAddRowsResult>;
708
708
  /**
709
709
  * Deletes a row: `onRowDelete` straight away, or under `editing.draft` a
710
- * toggle of the id in `deletedRowIds` - the row renders struck through
711
- * until `saveDrafts` reports it. On an uncommitted entry row it just
712
- * discards the entry.
710
+ * mark in `deletedRowIds` - the row renders struck through until
711
+ * `saveDrafts` reports it. Idempotent: deleting a marked row again leaves
712
+ * it marked, and {@link restoreRow} is the undo. On an entry row,
713
+ * committed or not, it just discards the entry; an id the grid does not
714
+ * know is a no-op.
713
715
  */
714
716
  deleteRow: (rowId: string) => void;
717
+ /**
718
+ * {@link deleteRow} for several rows in one call - one notification for
719
+ * the batch, for a bulk action over a selection. Because `deleteRow` is
720
+ * idempotent and ignores unknown ids, the list may be passed exactly as
721
+ * the selection stands - already-marked rows stay marked, duplicates and
722
+ * stale ids do nothing.
723
+ */
724
+ deleteRows: (rowIds: ReadonlyArray<string>) => void;
725
+ /**
726
+ * Removes a row's deletion mark - the lane's Restore. A no-op on a row
727
+ * that is not marked, and outside `editing.draft`, where no marks exist.
728
+ */
729
+ restoreRow: (rowId: string) => void;
715
730
  /** Whether delete chrome makes sense - the lane's trash gate. */
716
731
  canDeleteRows: () => boolean;
717
732
  };
@@ -875,6 +890,7 @@ export function createEditEngine(
875
890
  };
876
891
  const forms = new Map<string, FormEntry>();
877
892
  let newRowCounter = 0;
893
+ const NEW_ROW_ID_PREFIX = "__new__";
878
894
  /** Lets `commit` tell a `saveDrafts` flush apart from a lone commit. */
879
895
  let savingDrafts = false;
880
896
 
@@ -1428,6 +1444,11 @@ export function createEditEngine(
1428
1444
  } finally {
1429
1445
  entry.pendingCommit = null;
1430
1446
  }
1447
+ // Dropped while the submit was in flight - cancel or deleteRow won the
1448
+ // race. The form is gone, so there is nothing to park or drop; marking
1449
+ // the row committed now would plant an id in `committedRowIds` that no
1450
+ // save or discard could ever clear.
1451
+ if (forms.get(rowId) !== entry) return true;
1431
1452
  if (!entry.lastSubmitOk) {
1432
1453
  // Snapshot before the caller closes the editor: the field errors go
1433
1454
  // with it, and the row is about to be left carrying them.
@@ -1532,7 +1553,7 @@ export function createEditEngine(
1532
1553
 
1533
1554
  const addRow = (values?: TMDataGridRowData): string => {
1534
1555
  newRowCounter += 1;
1535
- const tempId = `__new__${newRowCounter}`;
1556
+ const tempId = `${NEW_ROW_ID_PREFIX}${newRowCounter}`;
1536
1557
  createForm(tempId, seedNewRow(values), true);
1537
1558
  store.setState((prev) => ({
1538
1559
  ...prev,
@@ -1564,7 +1585,7 @@ export function createEditEngine(
1564
1585
  batch(() => {
1565
1586
  for (const values of rows) {
1566
1587
  newRowCounter += 1;
1567
- const tempId = `__new__${newRowCounter}`;
1588
+ const tempId = `${NEW_ROW_ID_PREFIX}${newRowCounter}`;
1568
1589
  createForm(tempId, seedNewRow(values), true);
1569
1590
  tempIds.push(tempId);
1570
1591
  }
@@ -1599,13 +1620,26 @@ export function createEditEngine(
1599
1620
  }
1600
1621
  const context = getContext();
1601
1622
  if (context.draft) {
1602
- // A toggle: the second press unmarks - the mark is a draft too.
1603
- store.setState((prev) => ({
1604
- ...prev,
1605
- deletedRowIds: prev.deletedRowIds.includes(rowId)
1606
- ? prev.deletedRowIds.filter((id) => id !== rowId)
1607
- : [...prev.deletedRowIds, rowId],
1608
- }));
1623
+ // Idempotent: a marked row stays marked - `restoreRow` is the undo.
1624
+ store.setState((prev) => {
1625
+ if (prev.deletedRowIds.includes(rowId)) return prev;
1626
+ // Only a consumer row can be marked. A deletion mark is what
1627
+ // `saveDrafts` reports to the server, so an id it cannot act on -
1628
+ // an engine temp id, a record gone from `data`, an id the grid
1629
+ // never knew - must not live on as a mark inflating the draft
1630
+ // count. Entry rows are dropped above, never marked; the prefix
1631
+ // check also catches one already dropped that a stale selection or
1632
+ // a double-fired handler names again, while the table's data still
1633
+ // shows it for one render. The core model, so a filtered-out row
1634
+ // still takes its mark.
1635
+ if (
1636
+ rowId.startsWith(NEW_ROW_ID_PREFIX) ||
1637
+ !(rowId in context.table.getCoreRowModel().rowsById)
1638
+ ) {
1639
+ return prev;
1640
+ }
1641
+ return { ...prev, deletedRowIds: [...prev.deletedRowIds, rowId] };
1642
+ });
1609
1643
  return;
1610
1644
  }
1611
1645
  const row = getRow(rowId);
@@ -1614,6 +1648,26 @@ export function createEditEngine(
1614
1648
  void context.onRowDelete?.({ rowId, row });
1615
1649
  };
1616
1650
 
1651
+ const deleteRows = (rowIds: ReadonlyArray<string>) => {
1652
+ // One notification for the batch - each id still goes through
1653
+ // `deleteRow`, so entry rows drop and everything else marks or no-ops
1654
+ // by the same rules.
1655
+ batch(() => {
1656
+ for (const rowId of rowIds) deleteRow(rowId);
1657
+ });
1658
+ };
1659
+
1660
+ const restoreRow = (rowId: string) => {
1661
+ store.setState((prev) =>
1662
+ prev.deletedRowIds.includes(rowId)
1663
+ ? {
1664
+ ...prev,
1665
+ deletedRowIds: prev.deletedRowIds.filter((id) => id !== rowId),
1666
+ }
1667
+ : prev,
1668
+ );
1669
+ };
1670
+
1617
1671
  const canDeleteRows = (): boolean => {
1618
1672
  const context = getContext();
1619
1673
  if (context.draft) {
@@ -1903,6 +1957,8 @@ export function createEditEngine(
1903
1957
  addRow,
1904
1958
  addRows,
1905
1959
  deleteRow,
1960
+ deleteRows,
1961
+ restoreRow,
1906
1962
  canDeleteRows,
1907
1963
  };
1908
1964
  }
@@ -34,8 +34,36 @@ export type TMDataGridFilterControlArgs = {
34
34
  options: ReadonlyArray<TMDataGridOption>;
35
35
  size: TMDataGridSize;
36
36
  labels: TMDataGridLabels;
37
+ /**
38
+ * How much room the control has, and whether it names itself. The same
39
+ * vocabulary as `TMDataGrid.FilterPanel`'s own `layout` prop, plus the one
40
+ * value only a header cell can be in.
41
+ *
42
+ * | Layout | Where | Field |
43
+ * | --- | --- | --- |
44
+ * | `"row"` | A filter row laid out side by side | Labelled, fixed width |
45
+ * | `"stacked"` | A filter row in a narrow host - the sidebar | Labelled, full width |
46
+ * | `"header"` | One header cell, under `filters.inHeader` | `aria-label`, full width |
47
+ *
48
+ * Every built-in control honours it. A custom control that ignores it still
49
+ * works - it will simply look the same everywhere.
50
+ */
51
+ layout: TMDataGridFilterControlLayout;
37
52
  };
38
53
 
54
+ /** How much room a filter control has. See `layout`. */
55
+ export type TMDataGridFilterControlLayout = "row" | "stacked" | "header";
56
+
57
+ /**
58
+ * The two a filter *panel* can be in - {@link TMDataGridFilterControlLayout}
59
+ * without the header cell, which is not a panel. `TMDataGrid.FilterPanel`'s
60
+ * `layout` prop.
61
+ */
62
+ export type TMDataGridFilterPanelLayout = Exclude<
63
+ TMDataGridFilterControlLayout,
64
+ "header"
65
+ >;
66
+
39
67
  /**
40
68
  * `meta.filter.control` - replaces the built-in value control for this column.
41
69
  * Rendered as JSX, never invoked as a bare function, so hooks are legal
@@ -1,4 +1,10 @@
1
- import type { Row, RowData, TableFeatures } from "@tanstack/react-table";
1
+ import type {
2
+ ColumnFiltersState,
3
+ Row,
4
+ RowData,
5
+ TableFeatures,
6
+ } from "@tanstack/react-table";
7
+ import type { TMDataGridTable } from "../useTMDataGrid";
2
8
 
3
9
  /**
4
10
  * The value shape stored in `columnFilters` for every TMDataGrid column.
@@ -210,6 +216,25 @@ export function isTMDataGridFilterValue(
210
216
  );
211
217
  }
212
218
 
219
+ /**
220
+ * The three value shapes an operator can take. A set is not a range, even
221
+ * though both are arrays.
222
+ */
223
+ export type TMDataGridFilterValueShape = "scalar" | "set" | "range";
224
+
225
+ /**
226
+ * Which shape an operator's value takes. A typed value survives an operator or
227
+ * column change only within its shape, which is the rule both the panel and
228
+ * the header controls use when the operator changes.
229
+ */
230
+ export function filterValueShape(
231
+ operator: TMDataGridFilterOperator,
232
+ ): TMDataGridFilterValueShape {
233
+ if (operatorTakesArrayValue(operator)) return "set";
234
+ if (operatorTakesRangeValue(operator)) return "range";
235
+ return "scalar";
236
+ }
237
+
213
238
  /**
214
239
  * A filter only narrows the row set once it has something to compare against.
215
240
  * Half-typed filters stay in state (so the panel keeps rendering their row) but
@@ -225,6 +250,44 @@ export function isFilterActive(value: unknown): boolean {
225
250
  : typeof value.value === "string" && value.value.trim() !== "";
226
251
  }
227
252
 
253
+ /** One column's filter, typed - what `columnFilters` holds per entry. */
254
+ export type TMDataGridColumnFilter = {
255
+ id: string;
256
+ value: TMDataGridFilterValue;
257
+ };
258
+
259
+ /**
260
+ * The column filters that are actually narrowing the grid, typed.
261
+ *
262
+ * `ColumnFiltersState` types `value` as `unknown`, so the first line of a
263
+ * server-side mapping layer is otherwise a cast back to the shape the grid
264
+ * itself wrote, wrapped in the same "drop the half-typed ones" filter every
265
+ * consumer writes:
266
+ *
267
+ * ```ts
268
+ * const predicates = activeColumnFilters(table).map((filter) =>
269
+ * toPredicate(filter.id, filter.value),
270
+ * );
271
+ * ```
272
+ *
273
+ * Takes the table, or a `columnFilters` array where the consumer owns the
274
+ * slice. Reading it from the table reads the current value and does not
275
+ * subscribe; inside a component, subscribe to `columnFilters` the way the
276
+ * grid's own chrome does.
277
+ */
278
+ export function activeColumnFilters<TData extends RowData>(
279
+ source: TMDataGridTable<TData> | ColumnFiltersState,
280
+ ): Array<TMDataGridColumnFilter> {
281
+ const columnFilters = Array.isArray(source)
282
+ ? source
283
+ : source.store.state.columnFilters;
284
+ return columnFilters.flatMap((entry) =>
285
+ isTMDataGridFilterValue(entry.value) && isFilterActive(entry.value)
286
+ ? [{ id: entry.id, value: entry.value }]
287
+ : [],
288
+ );
289
+ }
290
+
228
291
  /**
229
292
  * One-line description of a single filter, as shown on a filter pill.
230
293
  *
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Where the grid puts its filter controls.
3
+ *
4
+ * | Surface | Where it renders |
5
+ * | --- | --- |
6
+ * | `"popup"` | Floating over the first body rows, under the header |
7
+ * | `"sidebar"` | Beside the table, inside the grid frame |
8
+ * | `"none"` | Nowhere - the grid renders no panel of its own |
9
+ *
10
+ * Header filters are not one of these: they are a second row of controls in
11
+ * the header, always visible, and they coexist with any of the three. See
12
+ * {@link TMDataGridFiltersOptions.inHeader}.
13
+ */
14
+ export type TMDataGridFilterSurface = "popup" | "sidebar" | "none";
15
+
16
+ /** Which side of the table the sidebar surface renders on. */
17
+ export type TMDataGridFilterSidebarSide = "left" | "right";
18
+
19
+ /**
20
+ * `filters` on `useTMDataGrid` - everything about where the filter controls
21
+ * are, as opposed to what they do.
22
+ *
23
+ * Named for the option key, the way `editing` has `TMDataGridEditingOptions`.
24
+ * Not to be confused with `TMDataGridColumnFilterOptions`, which is one
25
+ * column's `meta.filter`.
26
+ *
27
+ * ```tsx
28
+ * useTMDataGrid({ data, columns, filters: { surface: "sidebar" } });
29
+ * ```
30
+ */
31
+ export type TMDataGridFiltersOptions = {
32
+ /**
33
+ * Which surface `TMDataGrid.Table` renders and `TMDataGrid.FilterButton`
34
+ * toggles. Defaults to `"popup"`.
35
+ *
36
+ * Under `"none"` the table renders no panel and the filter button renders
37
+ * nothing. That is what a grid running header filters alone wants, and it is
38
+ * also what frees a hand-placed `<TMDataGrid.FilterPanel />` to be the only
39
+ * panel on the page - mounted, it is always visible, so drive it off
40
+ * `ui.state.filterPanelOpen` if it belongs behind a control of your own.
41
+ */
42
+ surface?: TMDataGridFilterSurface;
43
+ /** Which side the `"sidebar"` surface sits on. Defaults to `"right"`. */
44
+ sidebarSide?: TMDataGridFilterSidebarSide;
45
+ /** Width of the `"sidebar"` surface, any CSS length. Defaults to `"280px"`. */
46
+ sidebarWidth?: string;
47
+ /**
48
+ * Whether the popup or the sidebar starts open. Read once, at mount, like
49
+ * `initialState`.
50
+ *
51
+ * Defaults to `true` under `"sidebar"` and `false` everywhere else: a
52
+ * sidebar is a layout choice, so asking for one and getting an empty strip
53
+ * until the funnel is clicked is not what it reads like, while a popup that
54
+ * greets you open is in the way.
55
+ *
56
+ * Under `"none"` it is simply the starting value of
57
+ * `ui.state.filterPanelOpen`, which a control of your own can read.
58
+ */
59
+ defaultOpen?: boolean;
60
+ /**
61
+ * A second header row holding one value control per filterable column,
62
+ * always visible. Off by default.
63
+ *
64
+ * Independent of `surface` - a grid may have header filters and a popup at
65
+ * once. What it does change is the column chrome: the header's funnel
66
+ * indicator and the column menu's "Filter" item both come off, because
67
+ * their only job was to reveal a control that is now already on screen.
68
+ *
69
+ * A header cell has room for a value and an operator button, not for the
70
+ * panel's column / operator / value triple. Everything else about a filter
71
+ * is unchanged - the same operators, the same `meta.filter.control`, the
72
+ * same `columnFilters` state.
73
+ */
74
+ inHeader?: boolean;
75
+ };
76
+
77
+ /** {@link TMDataGridFiltersOptions} with every default filled in. */
78
+ export type TMDataGridFiltersSettings = Required<TMDataGridFiltersOptions>;
79
+
80
+ /**
81
+ * Fills the defaults in. Field by field rather than by spreading, so an
82
+ * explicit `undefined` - which is what destructuring an absent option group
83
+ * hands over - reads as "not set" rather than overwriting the default with it.
84
+ *
85
+ * `defaultOpen` is the one default that is not a constant: it follows the
86
+ * surface, so there is no flat table of defaults to export.
87
+ */
88
+ export function resolveFilterOptions(
89
+ options: TMDataGridFiltersOptions = {},
90
+ ): TMDataGridFiltersSettings {
91
+ const surface = options.surface ?? "popup";
92
+ return {
93
+ surface,
94
+ sidebarSide: options.sidebarSide ?? "right",
95
+ sidebarWidth: options.sidebarWidth ?? "280px",
96
+ defaultOpen: options.defaultOpen ?? surface === "sidebar",
97
+ inHeader: options.inHeader ?? false,
98
+ };
99
+ }
@@ -62,6 +62,8 @@ export type TMDataGridLabels = {
62
62
  clearAllFilters: string;
63
63
  closeFilters: string;
64
64
  removeFilter: string;
65
+ /** Names the operator button in a column's header filter control. */
66
+ filterOperatorFor: (column: string) => string;
65
67
 
66
68
  // Filter pills
67
69
  activeFilters: string;
@@ -89,6 +91,8 @@ export type TMDataGridLabels = {
89
91
  // Footer / pager
90
92
  rowsPerPage: string;
91
93
  pageRange: (args: { from: number; to: number; total: number }) => string;
94
+ /** `pageCount` is `-1` when a manual grid declares an unknown total. */
95
+ pageNumber: (args: { page: number; pageCount: number }) => string;
92
96
  groupedAllRows: (total: number) => string;
93
97
  pagingSuspendedHint: string;
94
98
  previousPage: string;
@@ -211,6 +215,7 @@ export const TMDATAGRID_LABELS_EN: TMDataGridLabels = {
211
215
  clearAllFilters: "Clear all",
212
216
  closeFilters: "Close filters",
213
217
  removeFilter: "Remove filter",
218
+ filterOperatorFor: (column) => `${column} filter operator`,
214
219
 
215
220
  activeFilters: "Active filters",
216
221
  clearFilter: (column) => `Clear ${column} filter`,
@@ -235,6 +240,8 @@ export const TMDATAGRID_LABELS_EN: TMDataGridLabels = {
235
240
 
236
241
  rowsPerPage: "Rows per page:",
237
242
  pageRange: ({ from, to, total }) => `${from}–${to} of ${total}`,
243
+ pageNumber: ({ page, pageCount }) =>
244
+ pageCount < 0 ? `Page ${page}` : `Page ${page} of ${pageCount}`,
238
245
  groupedAllRows: (total) => `Grouped · all ${total} rows`,
239
246
  pagingSuspendedHint:
240
247
  "Paging is off while the rows are grouped: the whole tree is rendered and virtualized. Ungroup to page again.",