@jielga/tmdatagrid 0.2.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist/index.d.ts +1337 -94
  2. package/dist/index.js +2812 -1189
  3. package/dist/index.js.map +1 -1
  4. package/dist/styles.css +1 -1
  5. package/package.json +2 -2
  6. package/skills/columns/SKILL.md +1 -1
  7. package/skills/features/SKILL.md +61 -11
  8. package/skills/getting-started/SKILL.md +41 -6
  9. package/skills/options/SKILL.md +12 -2
  10. package/skills/server-side/SKILL.md +1 -1
  11. package/src/tmdatagrid/TMDataGridContext.ts +26 -0
  12. package/src/tmdatagrid/components/TMDataGrid.module.css +21 -1
  13. package/src/tmdatagrid/components/TMDataGrid.tsx +29 -1
  14. package/src/tmdatagrid/components/TMDataGridColumnsPanel.tsx +12 -6
  15. package/src/tmdatagrid/components/TMDataGridDetailsColumn.module.css +33 -0
  16. package/src/tmdatagrid/components/TMDataGridDetailsColumn.tsx +160 -0
  17. package/src/tmdatagrid/components/TMDataGridFilterPanel.module.css +15 -0
  18. package/src/tmdatagrid/components/TMDataGridFilterPanel.tsx +192 -37
  19. package/src/tmdatagrid/components/TMDataGridFilterPills.module.css +35 -0
  20. package/src/tmdatagrid/components/TMDataGridFilterPills.tsx +136 -0
  21. package/src/tmdatagrid/components/TMDataGridFooter.module.css +8 -0
  22. package/src/tmdatagrid/components/TMDataGridFooter.tsx +63 -43
  23. package/src/tmdatagrid/components/TMDataGridGroupColumn.module.css +48 -0
  24. package/src/tmdatagrid/components/TMDataGridGroupColumn.tsx +159 -0
  25. package/src/tmdatagrid/components/TMDataGridHeaderCell.module.css +18 -0
  26. package/src/tmdatagrid/components/TMDataGridHeaderCell.tsx +249 -44
  27. package/src/tmdatagrid/components/TMDataGridSearch.tsx +122 -0
  28. package/src/tmdatagrid/components/TMDataGridSelectColumn.tsx +103 -16
  29. package/src/tmdatagrid/components/TMDataGridTable.module.css +160 -5
  30. package/src/tmdatagrid/components/TMDataGridTable.tsx +1380 -43
  31. package/src/tmdatagrid/components/TMDataGridToolbar.tsx +33 -7
  32. package/src/tmdatagrid/components/icons.ts +6 -0
  33. package/src/tmdatagrid/components/sticky.module.css +77 -16
  34. package/src/tmdatagrid/core/autosize.ts +125 -0
  35. package/src/tmdatagrid/core/capabilities.ts +122 -11
  36. package/src/tmdatagrid/core/cellExport.ts +321 -0
  37. package/src/tmdatagrid/core/cellNavigation.ts +103 -0
  38. package/src/tmdatagrid/core/cellRange.ts +118 -0
  39. package/src/tmdatagrid/core/columnOptions.ts +123 -0
  40. package/src/tmdatagrid/core/columnUtils.ts +18 -0
  41. package/src/tmdatagrid/core/expanding.ts +93 -0
  42. package/src/tmdatagrid/core/filterOperators.ts +198 -12
  43. package/src/tmdatagrid/core/grouping.ts +21 -0
  44. package/src/tmdatagrid/core/labels.ts +211 -0
  45. package/src/tmdatagrid/core/labelsSv.ts +108 -0
  46. package/src/tmdatagrid/core/persistence.ts +12 -0
  47. package/src/tmdatagrid/core/rowSelection.ts +202 -0
  48. package/src/tmdatagrid/core/summary.ts +35 -0
  49. package/src/tmdatagrid/index.ts +96 -2
  50. package/src/tmdatagrid/useTMDataGrid.tsx +558 -37
@@ -0,0 +1,160 @@
1
+ import { UnstyledButton } from "@mantine/core";
2
+ import {
3
+ useCellControlTabIndex,
4
+ useTMDataGridContext,
5
+ } from "../TMDataGridContext";
6
+ import type { ColumnDef, Row, RowData } from "@tanstack/react-table";
7
+ import { useSelector } from "@tanstack/react-store";
8
+ import classes from "./TMDataGridDetailsColumn.module.css";
9
+ import { ChevronRightIcon } from "./icons";
10
+ import { areAllRowsExpanded, resolveExpandAll } from "../core/expanding";
11
+ import type { TMDataGridFeatures, TMDataGridTable } from "../useTMDataGrid";
12
+
13
+ export const DETAILS_COLUMN_ID = "__details__";
14
+
15
+ /**
16
+ * The chevron that opens a row's detail panel.
17
+ *
18
+ * Expansion is read through a subscription rather than from
19
+ * `row.getIsExpanded()` in the component body: the `row` identity survives an
20
+ * expand, so the React Compiler would cache the call along with it and the
21
+ * chevron would never turn. Same reason the select checkbox subscribes.
22
+ */
23
+ function DetailsCell<TData extends RowData>({
24
+ row,
25
+ }: {
26
+ row: Row<TMDataGridFeatures, TData>;
27
+ }) {
28
+ const { labels } = useTMDataGridContext();
29
+ const expanded = useSelector(row.table.store, () => row.getIsExpanded());
30
+ // See useCellControlTabIndex: a body control is reached by stepping into its
31
+ // cell, not by Tab, or the grid would have one tab stop per mounted row.
32
+ const tabIndex = useCellControlTabIndex();
33
+
34
+ // Group rows open into their children, not into a panel — see the body's
35
+ // `showsDetails`. Their cells are aggregated, so this lane is already blank
36
+ // on them; the guard is what makes that a decision rather than a side effect.
37
+ if (row.getIsGrouped()) return null;
38
+
39
+ return (
40
+ <UnstyledButton
41
+ className={classes.detailsToggle}
42
+ tabIndex={tabIndex}
43
+ aria-expanded={expanded}
44
+ aria-label={expanded ? labels.hideDetails : labels.showDetails}
45
+ // The row underneath may select or highlight on click; opening a panel is
46
+ // its own gesture and must not also trigger those.
47
+ onClick={(event) => {
48
+ event.stopPropagation();
49
+ row.toggleExpanded();
50
+ }}
51
+ >
52
+ <span className={classes.chevron} data-expanded={expanded}>
53
+ <ChevronRightIcon size={16} stroke={1.6} />
54
+ </span>
55
+ </UnstyledButton>
56
+ );
57
+ }
58
+
59
+ /**
60
+ * Expand-all / collapse-all for the lane, the way the checkbox column's header
61
+ * selects and clears every row.
62
+ *
63
+ * Deliberately not `table.toggleAllRowsExpanded()`: that writes the `expanded`
64
+ * state's whole-table form, and one state holds both the tree and the panels —
65
+ * so it would unfold every group as well. Only the data rows are touched here;
66
+ * whatever the tree was showing, it goes on showing. See resolveExpandAll.
67
+ */
68
+ function DetailsHeader<TData extends RowData>({
69
+ table,
70
+ }: {
71
+ table: TMDataGridTable<TData>;
72
+ }) {
73
+ const { labels } = useTMDataGridContext();
74
+ // Pre-paginated: expand-all means every row the filters left, not the page
75
+ // that happens to be on screen. Same model `getCanSomeRowsExpand` reads.
76
+ const detailRows = () => table.getPrePaginatedRowModel().flatRows;
77
+
78
+ const allExpanded = useSelector(table.store, (state) =>
79
+ areAllRowsExpanded({
80
+ rows: detailRows(),
81
+ expanded: state.expanded,
82
+ target: "details",
83
+ }),
84
+ );
85
+
86
+ return (
87
+ <UnstyledButton
88
+ className={classes.detailsToggle}
89
+ aria-expanded={allExpanded}
90
+ aria-label={
91
+ allExpanded ? labels.collapseAllDetails : labels.expandAllDetails
92
+ }
93
+ // Partly expanded opens the rest rather than closing what is already
94
+ // open — the same reading as an indeterminate select-all box.
95
+ onClick={() =>
96
+ table.setExpanded(
97
+ resolveExpandAll({
98
+ rows: detailRows(),
99
+ expanded: table.store.state.expanded,
100
+ target: "details",
101
+ expand: !allExpanded,
102
+ }),
103
+ )
104
+ }
105
+ >
106
+ <span className={classes.chevron} data-expanded={allExpanded}>
107
+ <ChevronRightIcon size={16} stroke={1.6} />
108
+ </span>
109
+ </UnstyledButton>
110
+ );
111
+ }
112
+
113
+ /**
114
+ * The generated details lane, prepended whenever `renderDetails` is set.
115
+ *
116
+ * Structural, like the checkbox and tree columns: fixed width, pinned to the
117
+ * left after both of them, not hideable, not movable and not resizable. A
118
+ * toggle that wandered off to the right of the grid — or hid itself — would
119
+ * leave rows with panels no one can open.
120
+ *
121
+ * Last of the three because it acts on one record: the checkbox picks rows out
122
+ * and the tree says which group they are in, and only then is there a row to
123
+ * open.
124
+ *
125
+ * Nothing stops a second toggle elsewhere: `row.toggleExpanded()` is the whole
126
+ * interface, and this lane is only the one the grid ships.
127
+ */
128
+ export function createDetailsColumn<TData extends RowData>(
129
+ label = "Details",
130
+ ): ColumnDef<TMDataGridFeatures, TData, unknown> {
131
+ return {
132
+ id: DETAILS_COLUMN_ID,
133
+ meta: {
134
+ label,
135
+ align: "center",
136
+ // Structurally the last of the generated lanes.
137
+ enableOrdering: false,
138
+ },
139
+ // A system lane: as wide as the control it holds and no wider. Fixed at
140
+ // every scale — the control does not grow with the font size, so neither
141
+ // should its track.
142
+ size: 36,
143
+ minSize: 36,
144
+ maxSize: 36,
145
+ enableResizing: false,
146
+ enableSorting: false,
147
+ enableColumnFilter: false,
148
+ enableGlobalFilter: false,
149
+ // Its visibility is not the user's to set: hiding it would strand every
150
+ // panel behind a control that is no longer there.
151
+ enableHiding: false,
152
+ // Structurally pinned to the left; users shouldn't be able to move it.
153
+ enablePinning: false,
154
+ header: ({ table }) => <DetailsHeader table={table} />,
155
+ cell: ({ row }) => <DetailsCell row={row} />,
156
+ // Deliberately no `aggregatedCell`: on a group row every cell outside the
157
+ // grouped column counts as aggregated, and blank is the right answer here —
158
+ // groups expand into their rows, not into a panel.
159
+ };
160
+ }
@@ -18,3 +18,18 @@
18
18
  align-items: flex-end;
19
19
  gap: var(--mantine-spacing-xs);
20
20
  }
21
+
22
+ .filterPanelHeader {
23
+ display: flex;
24
+ align-items: center;
25
+ justify-content: space-between;
26
+ gap: var(--mantine-spacing-md);
27
+ padding-bottom: var(--mantine-spacing-xs);
28
+ }
29
+
30
+ .filterPanelFooter {
31
+ display: flex;
32
+ align-items: center;
33
+ justify-content: space-between;
34
+ gap: var(--mantine-spacing-md);
35
+ }
@@ -1,16 +1,29 @@
1
- import { ActionIcon, Button, Select, Stack, TextInput } from "@mantine/core";
1
+ import {
2
+ ActionIcon,
3
+ Button,
4
+ MultiSelect,
5
+ Select,
6
+ Stack,
7
+ Text,
8
+ TextInput,
9
+ } from "@mantine/core";
2
10
  import { useSelector } from "@tanstack/react-store";
11
+ import { useEffect, useRef } from "react";
3
12
  import classes from "./TMDataGridFilterPanel.module.css";
4
13
  import { useTMDataGridContext } from "../TMDataGridContext";
14
+ import {
15
+ optionsToComboboxData,
16
+ resolveColumnOptions,
17
+ } from "../core/columnOptions";
5
18
  import { getColumnLabel, getColumnType } from "../core/columnUtils";
6
19
  import {
7
20
  type TMDataGridFilterOperator,
8
21
  type TMDataGridFilterValue,
9
- FILTER_OPERATOR_LABELS,
10
22
  getDefaultOperator,
11
23
  getOperatorsForType,
12
24
  isTMDataGridFilterValue,
13
25
  operatorNeedsValue,
26
+ operatorTakesArrayValue,
14
27
  } from "../core/filterOperators";
15
28
  import { CloseIcon } from "./icons";
16
29
 
@@ -27,12 +40,33 @@ function asFilterValue(value: unknown): TMDataGridFilterValue {
27
40
  * free — the state is forwarded to the server instead of a row model.
28
41
  */
29
42
  export function TMDataGridFilterPanel() {
30
- const { table, ui, controlSize } = useTMDataGridContext();
43
+ const { table, ui, labels, controlSize } = useTMDataGridContext();
31
44
  const opened = useSelector(ui, (state) => state.filterPanelOpen);
32
45
  const columnFilters = useSelector(
33
46
  table.store,
34
47
  (state) => state.columnFilters,
35
48
  );
49
+ const panelRef = useRef<HTMLDivElement>(null);
50
+
51
+ // Clicking away hides the panel, the way any floating surface behaves. On
52
+ // pointerdown rather than click, so a press that starts outside dismisses it
53
+ // even when the pointer is released somewhere else.
54
+ useEffect(() => {
55
+ if (!opened) return;
56
+
57
+ const handlePointerDown = (event: PointerEvent) => {
58
+ const target = event.target;
59
+ if (!(target instanceof Element)) return;
60
+ if (panelRef.current?.contains(target)) return;
61
+ // The toolbar button toggles the panel itself. Closing from here first
62
+ // would leave its click reopening what the user meant to close.
63
+ if (target.closest("[data-dg-filter-toggle]")) return;
64
+ ui.actions.closeFilterPanel();
65
+ };
66
+
67
+ document.addEventListener("pointerdown", handlePointerDown);
68
+ return () => document.removeEventListener("pointerdown", handlePointerDown);
69
+ }, [opened, ui]);
36
70
 
37
71
  if (!opened) return null;
38
72
 
@@ -56,6 +90,18 @@ export function TMDataGridFilterPanel() {
56
90
  columnFilters.find((filter) => filter.id === fromColumnId)?.value,
57
91
  );
58
92
  const target = table.getColumn(toColumnId);
93
+ const operator = getDefaultOperator(
94
+ target ? getColumnType(target) : "string",
95
+ );
96
+ // The typed value only survives the move while it still fits the new
97
+ // operator — a text needle has no meaning to an `isAnyOf` set, or a set
98
+ // to a text input.
99
+ const value =
100
+ operatorTakesArrayValue(operator) === Array.isArray(current.value)
101
+ ? current.value
102
+ : operatorTakesArrayValue(operator)
103
+ ? []
104
+ : "";
59
105
  table.setColumnFilters(
60
106
  columnFilters
61
107
  .filter((filter) => filter.id !== toColumnId)
@@ -63,12 +109,7 @@ export function TMDataGridFilterPanel() {
63
109
  filter.id === fromColumnId
64
110
  ? {
65
111
  id: toColumnId,
66
- value: {
67
- operator: getDefaultOperator(
68
- target ? getColumnType(target) : "string",
69
- ),
70
- value: current.value,
71
- } satisfies TMDataGridFilterValue,
112
+ value: { operator, value } satisfies TMDataGridFilterValue,
72
113
  }
73
114
  : filter,
74
115
  ),
@@ -86,6 +127,29 @@ export function TMDataGridFilterPanel() {
86
127
  );
87
128
  }
88
129
 
130
+ function changeOperator(columnId: string, operator: TMDataGridFilterOperator) {
131
+ const current = asFilterValue(
132
+ columnFilters.find((filter) => filter.id === columnId)?.value,
133
+ );
134
+ // Same arity rule as changing the column: keep the value across operators
135
+ // of the same shape, reset it across the array/scalar boundary.
136
+ const value =
137
+ operatorTakesArrayValue(operator) === Array.isArray(current.value)
138
+ ? current.value
139
+ : operatorTakesArrayValue(operator)
140
+ ? []
141
+ : "";
142
+ patchFilter(columnId, { operator, value });
143
+ }
144
+
145
+ /** Clears every filter, including half-typed ones the pills never showed. */
146
+ function clearAllFilters() {
147
+ table.setColumnFilters([]);
148
+ // Same exit as removing the last filter row by hand: an empty panel has
149
+ // nothing to show but its own buttons.
150
+ ui.actions.closeFilterPanel();
151
+ }
152
+
89
153
  function addFilter() {
90
154
  const nextColumn = filterableColumns.find(
91
155
  (column) => !columnFilters.some((filter) => filter.id === column.id),
@@ -103,14 +167,47 @@ export function TMDataGridFilterPanel() {
103
167
  ]);
104
168
  }
105
169
 
170
+ const canAddFilter = filterableColumns.some(
171
+ (column) => !columnFilters.some((filter) => filter.id === column.id),
172
+ );
173
+
106
174
  return (
107
- <div className={classes.filterPanel}>
175
+ <div
176
+ ref={panelRef}
177
+ role="group"
178
+ aria-label={labels.filters}
179
+ className={classes.filterPanel}
180
+ // Escape is what closes a floating surface, and every control that can
181
+ // hold focus in here sits inside this element.
182
+ onKeyDown={(event) => {
183
+ if (event.key !== "Escape") return;
184
+ event.stopPropagation();
185
+ ui.actions.closeFilterPanel();
186
+ }}
187
+ >
188
+ <div className={classes.filterPanelHeader}>
189
+ <Text size={controlSize} fw={600}>
190
+ {labels.filters}
191
+ </Text>
192
+ <ActionIcon
193
+ variant="subtle"
194
+ color="gray"
195
+ size="sm"
196
+ aria-label={labels.closeFilters}
197
+ onClick={ui.actions.closeFilterPanel}
198
+ >
199
+ <CloseIcon size={16} stroke={1.6} />
200
+ </ActionIcon>
201
+ </div>
202
+
108
203
  <Stack gap="xs">
109
204
  {columnFilters.map((filter) => {
110
205
  const column = table.getColumn(filter.id);
111
206
  const value = asFilterValue(filter.value);
112
207
  const type = column ? getColumnType(column) : "string";
113
208
  const needsValue = operatorNeedsValue(value.operator);
209
+ const takesArray = operatorTakesArrayValue(value.operator);
210
+ const scalarValue = typeof value.value === "string" ? value.value : "";
114
211
 
115
212
  return (
116
213
  <div key={filter.id} className={classes.filterRow}>
@@ -118,64 +215,122 @@ export function TMDataGridFilterPanel() {
118
215
  variant="subtle"
119
216
  color="gray"
120
217
  size="lg"
121
- aria-label="Remove filter"
218
+ aria-label={labels.removeFilter}
122
219
  onClick={() => removeFilter(filter.id)}
123
220
  >
124
221
  <CloseIcon size={18} stroke={1.6} />
125
222
  </ActionIcon>
126
223
 
127
224
  <Select
128
- label="Column"
225
+ label={labels.filterColumn}
129
226
  size={controlSize}
130
227
  w={160}
131
228
  allowDeselect={false}
229
+ // Both dropdowns render inside the panel: a portalled one is
230
+ // outside it in the DOM, and picking an option would read as a
231
+ // click away and close the panel under the user.
232
+ comboboxProps={{ withinPortal: false }}
132
233
  data={columnOptions}
133
234
  value={filter.id}
134
235
  onChange={(next) => next && changeFilterColumn(filter.id, next)}
135
236
  />
136
237
 
137
238
  <Select
138
- label="Operator"
239
+ label={labels.filterOperator}
139
240
  size={controlSize}
140
241
  w={170}
141
242
  allowDeselect={false}
243
+ comboboxProps={{ withinPortal: false }}
142
244
  data={getOperatorsForType(type).map((operator) => ({
143
245
  value: operator,
144
- label: FILTER_OPERATOR_LABELS[operator],
246
+ label: labels.operators[operator],
145
247
  }))}
146
248
  value={value.operator}
147
249
  onChange={(next) =>
148
250
  next &&
149
- patchFilter(filter.id, {
150
- operator: next as TMDataGridFilterOperator,
151
- })
251
+ changeOperator(filter.id, next as TMDataGridFilterOperator)
152
252
  }
153
253
  />
154
254
 
155
- <TextInput
156
- label="Value"
157
- size={controlSize}
158
- w={180}
159
- type={type === "number" && needsValue ? "number" : "text"}
160
- disabled={!needsValue}
161
- placeholder={needsValue ? "Filter value" : ""}
162
- value={needsValue ? value.value : ""}
163
- onChange={(event) =>
164
- patchFilter(filter.id, { value: event.currentTarget.value })
165
- }
166
- />
255
+ {takesArray && column ? (
256
+ // The set the cell is tested against. Options come from
257
+ // `meta.options`; a select column that declares none still
258
+ // gets the values present in the data, via the faceted index.
259
+ <MultiSelect
260
+ label={labels.filterValue}
261
+ size={controlSize}
262
+ w={180}
263
+ comboboxProps={{ withinPortal: false }}
264
+ searchable
265
+ data={optionsToComboboxData(
266
+ resolveColumnOptions({ table, column, fallback: "faceted" }),
267
+ )}
268
+ value={Array.isArray(value.value) ? [...value.value] : []}
269
+ onChange={(next) => patchFilter(filter.id, { value: next })}
270
+ />
271
+ ) : type === "boolean" ? (
272
+ <Select
273
+ label={labels.filterValue}
274
+ size={controlSize}
275
+ w={180}
276
+ comboboxProps={{ withinPortal: false }}
277
+ disabled={!needsValue}
278
+ clearable
279
+ placeholder={needsValue ? labels.filterValuePlaceholder : ""}
280
+ data={[
281
+ { value: "true", label: labels.booleanTrue },
282
+ { value: "false", label: labels.booleanFalse },
283
+ ]}
284
+ value={needsValue && scalarValue !== "" ? scalarValue : null}
285
+ onChange={(next) =>
286
+ patchFilter(filter.id, { value: next ?? "" })
287
+ }
288
+ />
289
+ ) : (
290
+ <TextInput
291
+ label={labels.filterValue}
292
+ size={controlSize}
293
+ w={180}
294
+ type={
295
+ !needsValue
296
+ ? "text"
297
+ : type === "number"
298
+ ? "number"
299
+ : type === "date"
300
+ ? "date"
301
+ : "text"
302
+ }
303
+ disabled={!needsValue}
304
+ placeholder={needsValue ? labels.filterValuePlaceholder : ""}
305
+ value={needsValue ? scalarValue : ""}
306
+ onChange={(event) =>
307
+ patchFilter(filter.id, { value: event.currentTarget.value })
308
+ }
309
+ />
310
+ )}
167
311
  </div>
168
312
  );
169
313
  })}
170
314
 
171
- <Button
172
- variant="subtle"
173
- size="compact-sm"
174
- style={{ alignSelf: "flex-start" }}
175
- onClick={addFilter}
176
- >
177
- Add filter
178
- </Button>
315
+ <div className={classes.filterPanelFooter}>
316
+ <Button
317
+ variant="subtle"
318
+ size="compact-sm"
319
+ disabled={!canAddFilter}
320
+ onClick={addFilter}
321
+ >
322
+ {labels.addFilter}
323
+ </Button>
324
+ <Button
325
+ variant="subtle"
326
+ color="gray"
327
+ size="compact-sm"
328
+ disabled={columnFilters.length === 0}
329
+ onClick={clearAllFilters}
330
+ >
331
+ {labels.clearAllFilters}
332
+ </Button>
333
+ </div>
179
334
  </Stack>
180
335
  </div>
181
336
  );
@@ -0,0 +1,35 @@
1
+ /* Wraps rather than scrolls: the pills are a summary, and a summary that goes
2
+ off-screen is not one. */
3
+ .filterPills {
4
+ display: flex;
5
+ align-items: center;
6
+ flex-wrap: wrap;
7
+ gap: var(--mantine-spacing-xs);
8
+ }
9
+
10
+ /* Mantine's dark-scheme pill is `dark-7` — the same colour as the body — so a
11
+ pill sitting on the page rather than inside a `PillsInput` disappears. Both
12
+ schemes are restated here so the two stay a pair, with a border to keep the
13
+ shape readable against any surface. Scoped through `.filterPills` for the
14
+ specificity to beat Mantine's own rule whichever stylesheet loads last. */
15
+ .filterPills .pill {
16
+ background-color: light-dark(
17
+ var(--mantine-color-gray-1),
18
+ var(--mantine-color-dark-5)
19
+ );
20
+ color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0));
21
+ border: 1px solid
22
+ light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-3));
23
+ }
24
+
25
+ /* The label is a button so a pill can be clicked back open, but it must read as
26
+ part of the pill — Mantine's own label styles carry the font. */
27
+ .pillLabel {
28
+ font: inherit;
29
+ color: inherit;
30
+ cursor: pointer;
31
+ }
32
+
33
+ .pillLabel:hover {
34
+ text-decoration: underline;
35
+ }
@@ -0,0 +1,136 @@
1
+ import { Button, Pill, UnstyledButton } from "@mantine/core";
2
+ import type { RowData } from "@tanstack/react-table";
3
+ import { useSelector } from "@tanstack/react-store";
4
+ import classes from "./TMDataGridFilterPills.module.css";
5
+ import { getColumnLabel, getColumnType } from "../core/columnUtils";
6
+ import {
7
+ formatFilterLabel,
8
+ isFilterActive,
9
+ isTMDataGridFilterValue,
10
+ } from "../core/filterOperators";
11
+ import type { TMDataGridSize } from "../core/sizes";
12
+ import { openColumnFilter, type TMDataGridApi } from "../useTMDataGrid";
13
+
14
+ export type TMDataGridFilterPillsProps<TData extends RowData> = {
15
+ /** The object returned by `useTMDataGrid`. */
16
+ api: TMDataGridApi<TData>;
17
+ /** Mantine size of the pills. Defaults to `"sm"`. */
18
+ size?: TMDataGridSize;
19
+ /** "Clear all" button, shown once two filters are active. Defaults to `true`. */
20
+ showClearAll?: boolean;
21
+ /**
22
+ * Replaces what a click on a pill's label does. The default opens the grid's
23
+ * filter panel on that column.
24
+ */
25
+ onPillClick?: (columnId: string) => void;
26
+ className?: string;
27
+ };
28
+
29
+ /**
30
+ * One pill per active filter — `First name: Sofia ✕` — with the ✕ clearing that
31
+ * filter and a click on the label opening the filter panel on its column.
32
+ *
33
+ * The one grid component that takes the api as a prop instead of reading
34
+ * context, so it can live anywhere: a page header, a card title, a breadcrumb
35
+ * row. Renders nothing while no filter is active.
36
+ *
37
+ * ```tsx
38
+ * const grid = useTMDataGrid({ data, columns });
39
+ *
40
+ * <Group>
41
+ * <Title order={3}>Employees</Title>
42
+ * <TMDataGridFilterPills api={grid} />
43
+ * </Group>
44
+ * ```
45
+ *
46
+ * Half-typed filters are left out: a filter that is not narrowing the rows yet
47
+ * has nothing to report, which is the same rule the funnel indicator uses.
48
+ */
49
+ export function TMDataGridFilterPills<TData extends RowData>({
50
+ api,
51
+ size = "sm",
52
+ showClearAll = true,
53
+ onPillClick,
54
+ className,
55
+ }: TMDataGridFilterPillsProps<TData>) {
56
+ const { table, labels } = api;
57
+ const columnFilters = useSelector(
58
+ table.store,
59
+ (state) => state.columnFilters,
60
+ );
61
+
62
+ const activeFilters = columnFilters.filter((filter) =>
63
+ isFilterActive(filter.value),
64
+ );
65
+ if (activeFilters.length === 0) return null;
66
+
67
+ return (
68
+ <div
69
+ role="group"
70
+ aria-label={labels.activeFilters}
71
+ className={[classes.filterPills, className].filter(Boolean).join(" ")}
72
+ >
73
+ <Pill.Group size={size}>
74
+ {activeFilters.map((filter) => {
75
+ const column = table.getColumn(filter.id);
76
+ const label = column ? getColumnLabel(column) : filter.id;
77
+ const value = filter.value;
78
+ // isFilterActive already vouched for the shape; this narrows it.
79
+ if (!isTMDataGridFilterValue(value)) return null;
80
+
81
+ return (
82
+ <Pill
83
+ key={filter.id}
84
+ size={size}
85
+ className={classes.pill}
86
+ withRemoveButton
87
+ onRemove={() =>
88
+ table.setColumnFilters(
89
+ columnFilters.filter((other) => other.id !== filter.id),
90
+ )
91
+ }
92
+ // Mantine hides its remove button from assistive tech and from
93
+ // the tab order, because inside a `PillsInput` Backspace is the
94
+ // way out. These pills stand on their own, so the ✕ has to be a
95
+ // real button.
96
+ removeButtonProps={{
97
+ "aria-label": labels.clearFilter(label),
98
+ "aria-hidden": false,
99
+ tabIndex: 0,
100
+ }}
101
+ >
102
+ <UnstyledButton
103
+ className={classes.pillLabel}
104
+ onClick={() =>
105
+ onPillClick
106
+ ? onPillClick(filter.id)
107
+ : openColumnFilter(api, filter.id)
108
+ }
109
+ >
110
+ {formatFilterLabel({
111
+ label,
112
+ type: column ? getColumnType(column) : "string",
113
+ filter: value,
114
+ operatorLabels: labels.operators,
115
+ })}
116
+ </UnstyledButton>
117
+ </Pill>
118
+ );
119
+ })}
120
+ </Pill.Group>
121
+
122
+ {showClearAll && activeFilters.length > 1 && (
123
+ // Below two pills their own ✕ is the shorter path, so the button would
124
+ // only be a second way to do the same thing.
125
+ <Button
126
+ variant="subtle"
127
+ color="gray"
128
+ size="compact-xs"
129
+ onClick={() => table.setColumnFilters([])}
130
+ >
131
+ {labels.clearAllFilters}
132
+ </Button>
133
+ )}
134
+ </div>
135
+ );
136
+ }
@@ -8,3 +8,11 @@
8
8
  flex-shrink: 0;
9
9
  border-top: 1px solid var(--mantine-color-default-border);
10
10
  }
11
+
12
+ /* Grouping suspends paging. The pager stays put and fades rather than
13
+ disappearing, so the footer reads as switched off rather than as having
14
+ vanished — and the row it holds does not change height under the grid. Its
15
+ controls carry their own `disabled`; this is the label half. */
16
+ .footer[data-paging-suspended="true"] {
17
+ opacity: 0.55;
18
+ }