@jielga/tmdatagrid 0.5.0 → 1.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 (62) hide show
  1. package/README.md +25 -2
  2. package/dist/index.d.ts +856 -14
  3. package/dist/index.js +3370 -1546
  4. package/dist/index.js.map +1 -1
  5. package/dist/styles.css +1 -1
  6. package/dist/styles.layer.css +1 -0
  7. package/package.json +12 -2
  8. package/skills/columns/SKILL.md +1 -1
  9. package/skills/features/SKILL.md +1 -1
  10. package/skills/getting-started/SKILL.md +1 -1
  11. package/skills/options/SKILL.md +1 -1
  12. package/skills/server-side/SKILL.md +1 -1
  13. package/skills/testing/SKILL.md +198 -0
  14. package/src/tmdatagrid/components/TMDataGrid.module.css +11 -0
  15. package/src/tmdatagrid/components/TMDataGrid.tsx +34 -0
  16. package/src/tmdatagrid/components/TMDataGridCellEditor.tsx +256 -0
  17. package/src/tmdatagrid/components/TMDataGridColumnsPanel.tsx +35 -12
  18. package/src/tmdatagrid/components/TMDataGridDetailsColumn.tsx +3 -0
  19. package/src/tmdatagrid/components/TMDataGridEditActions.tsx +62 -0
  20. package/src/tmdatagrid/components/TMDataGridEditColumn.tsx +237 -0
  21. package/src/tmdatagrid/components/TMDataGridEntryRows.tsx +164 -0
  22. package/src/tmdatagrid/components/TMDataGridFilterPanel.tsx +77 -62
  23. package/src/tmdatagrid/components/TMDataGridFilterPills.tsx +6 -0
  24. package/src/tmdatagrid/components/TMDataGridFooter.tsx +10 -3
  25. package/src/tmdatagrid/components/TMDataGridGroupColumn.tsx +2 -0
  26. package/src/tmdatagrid/components/TMDataGridHeaderCell.module.css +15 -2
  27. package/src/tmdatagrid/components/TMDataGridHeaderCell.tsx +21 -4
  28. package/src/tmdatagrid/components/TMDataGridRowNumberColumn.tsx +44 -0
  29. package/src/tmdatagrid/components/TMDataGridSearch.tsx +2 -0
  30. package/src/tmdatagrid/components/TMDataGridSelectColumn.tsx +3 -0
  31. package/src/tmdatagrid/components/TMDataGridTable.module.css +206 -0
  32. package/src/tmdatagrid/components/TMDataGridTable.tsx +841 -57
  33. package/src/tmdatagrid/components/TMDataGridToolbar.tsx +12 -7
  34. package/src/tmdatagrid/components/editors/TMDataGridBooleanEditor.tsx +29 -0
  35. package/src/tmdatagrid/components/editors/TMDataGridDateEditor.tsx +57 -0
  36. package/src/tmdatagrid/components/editors/TMDataGridMultiSelectEditor.tsx +45 -0
  37. package/src/tmdatagrid/components/editors/TMDataGridNumberEditor.tsx +38 -0
  38. package/src/tmdatagrid/components/editors/TMDataGridSelectEditor.tsx +53 -0
  39. package/src/tmdatagrid/components/editors/TMDataGridStringEditor.tsx +31 -0
  40. package/src/tmdatagrid/components/editors/editorShared.ts +48 -0
  41. package/src/tmdatagrid/components/filters/DgAutocompleteFilter.tsx +46 -0
  42. package/src/tmdatagrid/components/filters/DgDateRangeFilter.tsx +43 -0
  43. package/src/tmdatagrid/components/filters/DgRangeSliderFilter.tsx +52 -0
  44. package/src/tmdatagrid/components/filters/DgTriStateFilter.tsx +38 -0
  45. package/src/tmdatagrid/components/filters/TMDataGridFilterValueInput.tsx +122 -0
  46. package/src/tmdatagrid/components/icons.ts +4 -0
  47. package/src/tmdatagrid/core/autosize.ts +1 -1
  48. package/src/tmdatagrid/core/capabilities.ts +37 -1
  49. package/src/tmdatagrid/core/cellNavigation.ts +41 -0
  50. package/src/tmdatagrid/core/columnUtils.ts +31 -6
  51. package/src/tmdatagrid/core/editEngine.ts +857 -0
  52. package/src/tmdatagrid/core/filterControls.ts +45 -0
  53. package/src/tmdatagrid/core/filterOperators.ts +72 -4
  54. package/src/tmdatagrid/core/labels.ts +68 -1
  55. package/src/tmdatagrid/core/labelsSv.ts +24 -1
  56. package/src/tmdatagrid/core/matchHighlight.ts +96 -0
  57. package/src/tmdatagrid/core/persistence.ts +97 -3
  58. package/src/tmdatagrid/core/quickSearch.ts +144 -0
  59. package/src/tmdatagrid/core/rowPinning.ts +37 -0
  60. package/src/tmdatagrid/core/rowSelection.ts +10 -1
  61. package/src/tmdatagrid/index.ts +51 -0
  62. package/src/tmdatagrid/useTMDataGrid.tsx +456 -15
@@ -11,7 +11,11 @@ import { openColumnFilter } from "../useTMDataGrid";
11
11
 
12
12
  /** Row above the grid. Compose it from the pieces below, or anything else. */
13
13
  export function TMDataGridToolbar({ children }: { children?: ReactNode }) {
14
- return <div className={classes.toolbar}>{children}</div>;
14
+ return (
15
+ <div data-dg-part="toolbar" className={classes.toolbar}>
16
+ {children}
17
+ </div>
18
+ );
15
19
  }
16
20
 
17
21
  /** Pushes the following toolbar items to the right. */
@@ -39,7 +43,7 @@ export function TMDataGridToolbarSpacer() {
39
43
  export function TMDataGridLoadingIndicator() {
40
44
  const { table, labels } = useTMDataGridContext();
41
45
  if (table.options.meta?.loading !== true) return null;
42
- return <Loader size="xs" aria-label={labels.loading} />;
46
+ return <Loader size="xs" data-dg-part="loading" aria-label={labels.loading} />;
43
47
  }
44
48
 
45
49
  /**
@@ -52,7 +56,7 @@ export function TMDataGridSummaryCount({ children }: { children?: ReactNode }) {
52
56
 
53
57
  if (children !== undefined) {
54
58
  return (
55
- <Text size={controlSize} c="dimmed">
59
+ <Text size={controlSize} c="dimmed" data-dg-part="summary-count">
56
60
  {children}
57
61
  </Text>
58
62
  );
@@ -64,7 +68,7 @@ export function TMDataGridSummaryCount({ children }: { children?: ReactNode }) {
64
68
  table.getPreFilteredRowModel().rows.length;
65
69
 
66
70
  return (
67
- <Text size={controlSize} c="dimmed">
71
+ <Text size={controlSize} c="dimmed" data-dg-part="summary-count">
68
72
  {shown} / {total}
69
73
  </Text>
70
74
  );
@@ -97,6 +101,7 @@ export function TMDataGridColumnsButton() {
97
101
  color="gray"
98
102
  size={controlSize}
99
103
  aria-label={labels.manageColumns}
104
+ data-dg-part="columns-button"
100
105
  onClick={ui.actions.toggleColumnsPanel}
101
106
  >
102
107
  <BurgerIcon size={18} stroke={1.6} />
@@ -135,9 +140,9 @@ export function TMDataGridFilterButton() {
135
140
  color={activeCount > 0 ? undefined : "gray"}
136
141
  size={controlSize}
137
142
  aria-label={labels.filters}
138
- // Marks this as the panel's own toggle, so the panel's click-away
139
- // handler leaves it alone and the button stays a toggle.
140
- data-dg-filter-toggle
143
+ // Also what the panel's click-away handler tests for, so that a click
144
+ // here reads as a toggle rather than as a click outside.
145
+ data-dg-part="filter-button"
141
146
  onClick={() => {
142
147
  if (opened) {
143
148
  ui.actions.closeFilterPanel();
@@ -0,0 +1,29 @@
1
+ import { Checkbox } from "@mantine/core";
2
+ import type { TMDataGridEditorArgs } from "../../core/editEngine";
3
+ import { getColumnLabel } from "../../core/columnUtils";
4
+ import { useTMDataGridContext } from "../../TMDataGridContext";
5
+ import { useFieldError, useFieldValue } from "./editorShared";
6
+
7
+ /** The built-in editor for `meta.type: "boolean"` — a focused checkbox. */
8
+ export function TMDataGridBooleanEditor({
9
+ field,
10
+ autoFocus,
11
+ column,
12
+ size,
13
+ }: TMDataGridEditorArgs) {
14
+ const { labels } = useTMDataGridContext();
15
+ const value = useFieldValue(field);
16
+ const error = useFieldError(field);
17
+ return (
18
+ <Checkbox
19
+ size={size}
20
+ autoFocus={autoFocus}
21
+ aria-label={labels.editCell(getColumnLabel(column))}
22
+ data-dg-part="editor-input"
23
+ checked={value === true}
24
+ onChange={(event) => field.handleChange(event.currentTarget.checked)}
25
+ onBlur={field.handleBlur}
26
+ error={error}
27
+ />
28
+ );
29
+ }
@@ -0,0 +1,57 @@
1
+ import { TextInput } from "@mantine/core";
2
+ import { useRef } from "react";
3
+ import type { TMDataGridEditorArgs } from "../../core/editEngine";
4
+ import { getColumnLabel } from "../../core/columnUtils";
5
+ import { useTMDataGridContext } from "../../TMDataGridContext";
6
+ import { useFieldError, useFieldValue } from "./editorShared";
7
+
8
+ /**
9
+ * The built-in editor for `meta.type: "date"` — the native
10
+ * `<input type="date">` styled by Mantine, no `@mantine/dates` involved. A
11
+ * consumer wanting a real picker plugs one in through `meta.editor`.
12
+ */
13
+ export function TMDataGridDateEditor({
14
+ field,
15
+ autoFocus,
16
+ column,
17
+ size,
18
+ }: TMDataGridEditorArgs) {
19
+ const { labels } = useTMDataGridContext();
20
+ const value = useFieldValue(field);
21
+ const error = useFieldError(field);
22
+ // The cell's original shape decides what the editor writes back: a `Date`
23
+ // cell keeps getting Dates, an ISO-string cell keeps getting strings —
24
+ // decided once at open, so clearing and retyping cannot flip the type.
25
+ const writesDates = useRef(value instanceof Date);
26
+
27
+ const text =
28
+ value instanceof Date
29
+ ? value.toLocaleDateString("sv-SE")
30
+ : typeof value === "string"
31
+ ? value.slice(0, 10)
32
+ : "";
33
+
34
+ return (
35
+ <TextInput
36
+ size={size}
37
+ w="100%"
38
+ autoFocus={autoFocus}
39
+ type="date"
40
+ aria-label={labels.editCell(getColumnLabel(column))}
41
+ data-dg-part="editor-input"
42
+ value={text}
43
+ onChange={(event) => {
44
+ const next = event.currentTarget.value;
45
+ if (next === "") {
46
+ field.handleChange(null);
47
+ return;
48
+ }
49
+ field.handleChange(
50
+ writesDates.current ? new Date(`${next}T00:00:00`) : next,
51
+ );
52
+ }}
53
+ onBlur={field.handleBlur}
54
+ error={error}
55
+ />
56
+ );
57
+ }
@@ -0,0 +1,45 @@
1
+ import { MultiSelect } from "@mantine/core";
2
+ import type { TMDataGridEditorArgs } from "../../core/editEngine";
3
+ import {
4
+ optionsToComboboxData,
5
+ resolveColumnOptions,
6
+ } from "../../core/columnOptions";
7
+ import { getColumnLabel } from "../../core/columnUtils";
8
+ import { useTMDataGridContext } from "../../TMDataGridContext";
9
+ import { useFieldError, useFieldValue } from "./editorShared";
10
+
11
+ /**
12
+ * The built-in editor for `meta.type: "multiSelect"` — cells holding string
13
+ * arrays. Never commits on pick: building a set takes several, so the commit
14
+ * stays on Enter/blur like a text edit.
15
+ */
16
+ export function TMDataGridMultiSelectEditor({
17
+ field,
18
+ autoFocus,
19
+ column,
20
+ row,
21
+ table,
22
+ size,
23
+ }: TMDataGridEditorArgs) {
24
+ const { labels } = useTMDataGridContext();
25
+ const value = useFieldValue(field);
26
+ const error = useFieldError(field);
27
+ return (
28
+ <MultiSelect
29
+ size={size}
30
+ w="100%"
31
+ autoFocus={autoFocus}
32
+ searchable
33
+ comboboxProps={{ withinPortal: false }}
34
+ aria-label={labels.editCell(getColumnLabel(column))}
35
+ data-dg-part="editor-input"
36
+ data={optionsToComboboxData(
37
+ resolveColumnOptions({ table, column, row, fallback: "faceted" }),
38
+ )}
39
+ value={Array.isArray(value) ? value.map(String) : []}
40
+ onChange={(next) => field.handleChange(next)}
41
+ onBlur={field.handleBlur}
42
+ error={error}
43
+ />
44
+ );
45
+ }
@@ -0,0 +1,38 @@
1
+ import { NumberInput } from "@mantine/core";
2
+ import type { TMDataGridEditorArgs } from "../../core/editEngine";
3
+ import { getColumnLabel } from "../../core/columnUtils";
4
+ import { useTMDataGridContext } from "../../TMDataGridContext";
5
+ import { selectAllOnFocus, useFieldError, useFieldValue } from "./editorShared";
6
+
7
+ /** The built-in editor for `meta.type: "number"`. */
8
+ export function TMDataGridNumberEditor({
9
+ field,
10
+ autoFocus,
11
+ column,
12
+ size,
13
+ }: TMDataGridEditorArgs) {
14
+ const { labels } = useTMDataGridContext();
15
+ const value = useFieldValue(field);
16
+ const error = useFieldError(field);
17
+ return (
18
+ <NumberInput
19
+ size={size}
20
+ w="100%"
21
+ autoFocus={autoFocus}
22
+ onFocus={selectAllOnFocus}
23
+ hideControls
24
+ aria-label={labels.editCell(getColumnLabel(column))}
25
+ data-dg-part="editor-input"
26
+ value={typeof value === "number" ? value : ""}
27
+ onChange={(next) =>
28
+ // Mantine reports a number once one can be parsed, and a string while
29
+ // it cannot. An empty cell is `null` — the honest "no value".
30
+ field.handleChange(
31
+ typeof next === "number" ? next : next === "" ? null : Number(next),
32
+ )
33
+ }
34
+ onBlur={field.handleBlur}
35
+ error={error}
36
+ />
37
+ );
38
+ }
@@ -0,0 +1,53 @@
1
+ import { Select } from "@mantine/core";
2
+ import type { TMDataGridEditorArgs } from "../../core/editEngine";
3
+ import {
4
+ optionsToComboboxData,
5
+ resolveColumnOptions,
6
+ } from "../../core/columnOptions";
7
+ import { getColumnLabel } from "../../core/columnUtils";
8
+ import { useTMDataGridContext } from "../../TMDataGridContext";
9
+ import { useFieldError, useFieldValue } from "./editorShared";
10
+
11
+ /**
12
+ * The built-in editor for `meta.type: "select"`. Options come from the same
13
+ * `meta.options` the filter panel reads — with the row in hand, so a
14
+ * function source can depend on the record being edited.
15
+ */
16
+ export function TMDataGridSelectEditor({
17
+ field,
18
+ autoFocus,
19
+ column,
20
+ row,
21
+ table,
22
+ size,
23
+ commit,
24
+ }: TMDataGridEditorArgs) {
25
+ const { labels, features } = useTMDataGridContext();
26
+ const value = useFieldValue(field);
27
+ const error = useFieldError(field);
28
+ return (
29
+ <Select
30
+ size={size}
31
+ w="100%"
32
+ autoFocus={autoFocus}
33
+ searchable
34
+ // Inside the cell, so picking an option is not a click-away — the same
35
+ // reason the filter panel keeps its dropdowns unportalled.
36
+ comboboxProps={{ withinPortal: false }}
37
+ aria-label={labels.editCell(getColumnLabel(column))}
38
+ data-dg-part="editor-input"
39
+ data={optionsToComboboxData(
40
+ resolveColumnOptions({ table, column, row, fallback: "faceted" }),
41
+ )}
42
+ value={typeof value === "string" ? value : null}
43
+ onChange={(next) => {
44
+ field.handleChange(next);
45
+ // Sheets semantics under `"cell"`: a pick is the edit, so it commits.
46
+ // The confirming modes leave the draft for their explicit commit.
47
+ if (next !== null && features.editMode === "cell") void commit();
48
+ }}
49
+ onBlur={field.handleBlur}
50
+ error={error}
51
+ />
52
+ );
53
+ }
@@ -0,0 +1,31 @@
1
+ import { TextInput } from "@mantine/core";
2
+ import type { TMDataGridEditorArgs } from "../../core/editEngine";
3
+ import { getColumnLabel } from "../../core/columnUtils";
4
+ import { useTMDataGridContext } from "../../TMDataGridContext";
5
+ import { selectAllOnFocus, useFieldError, useFieldValue } from "./editorShared";
6
+
7
+ /** The built-in editor for `meta.type: "string"` (and the default). */
8
+ export function TMDataGridStringEditor({
9
+ field,
10
+ autoFocus,
11
+ column,
12
+ size,
13
+ }: TMDataGridEditorArgs) {
14
+ const { labels } = useTMDataGridContext();
15
+ const value = useFieldValue(field);
16
+ const error = useFieldError(field);
17
+ return (
18
+ <TextInput
19
+ size={size}
20
+ w="100%"
21
+ autoFocus={autoFocus}
22
+ onFocus={selectAllOnFocus}
23
+ aria-label={labels.editCell(getColumnLabel(column))}
24
+ data-dg-part="editor-input"
25
+ value={typeof value === "string" ? value : ""}
26
+ onChange={(event) => field.handleChange(event.currentTarget.value)}
27
+ onBlur={field.handleBlur}
28
+ error={error}
29
+ />
30
+ );
31
+ }
@@ -0,0 +1,48 @@
1
+ import { useStore } from "@tanstack/react-form";
2
+ import type { TMDataGridEditField } from "../../core/editEngine";
3
+
4
+ /**
5
+ * The first error as text, whatever shape the validator produced — TanStack
6
+ * Form keeps errors verbatim, so a Zod issue arrives as `{ message }`, a
7
+ * plain function's return arrives as it was, and a form-level schema's
8
+ * pathless issues arrive keyed under an empty path (`{ "": [issues] }`).
9
+ * This digs until it finds a message.
10
+ */
11
+ export function firstErrorText(error: unknown): string | undefined {
12
+ if (error === null || error === undefined) return undefined;
13
+ if (typeof error === "string") return error;
14
+ if (Array.isArray(error)) {
15
+ for (const entry of error) {
16
+ const text = firstErrorText(entry);
17
+ if (text !== undefined) return text;
18
+ }
19
+ return undefined;
20
+ }
21
+ if (typeof error === "object") {
22
+ const message = (error as { message?: unknown }).message;
23
+ if (typeof message === "string") return message;
24
+ for (const value of Object.values(error)) {
25
+ const text = firstErrorText(value);
26
+ if (text !== undefined) return text;
27
+ }
28
+ }
29
+ return undefined;
30
+ }
31
+
32
+ /** The field's live value — one subscription, on the field's own store. */
33
+ export function useFieldValue(field: TMDataGridEditField): unknown {
34
+ return useStore(field.store, (state) => state.value as unknown);
35
+ }
36
+
37
+ /** The field's first error, for the Mantine input's `error` prop. */
38
+ export function useFieldError(field: TMDataGridEditField): string | undefined {
39
+ return useStore(field.store, (state) =>
40
+ firstErrorText(state.meta.errors as ReadonlyArray<unknown>),
41
+ );
42
+ }
43
+
44
+ /** Select-all on focus — F2/Enter open with the value ready to replace. */
45
+ export function selectAllOnFocus(event: { currentTarget: unknown }): void {
46
+ const target = event.currentTarget;
47
+ if (target instanceof HTMLInputElement) target.select();
48
+ }
@@ -0,0 +1,46 @@
1
+ import { Autocomplete } from "@mantine/core";
2
+ import { resolveColumnOptions } from "../../core/columnOptions";
3
+ import type { TMDataGridFilterControlArgs } from "../../core/filterControls";
4
+ import {
5
+ operatorNeedsValue,
6
+ operatorTakesArrayValue,
7
+ operatorTakesRangeValue,
8
+ } from "../../core/filterOperators";
9
+ import { TMDataGridFilterValueInput } from "./TMDataGridFilterValueInput";
10
+
11
+ /**
12
+ * A built-in `meta.filterControl` for text-shaped columns: a free-text input
13
+ * that also offers the values present in the data (or the column's declared
14
+ * `meta.options`) as suggestions. Set or range operators fall back to the
15
+ * standard input.
16
+ */
17
+ export function DgAutocompleteFilter(args: TMDataGridFilterControlArgs) {
18
+ const { column, table, operator, value, onChange, options, size, labels } =
19
+ args;
20
+ if (operatorTakesArrayValue(operator) || operatorTakesRangeValue(operator)) {
21
+ return <TMDataGridFilterValueInput {...args} />;
22
+ }
23
+ const needsValue = operatorNeedsValue(operator);
24
+ // `options` arrives pre-resolved only for declared or select-shaped
25
+ // columns; on a plain string column the suggestions come from the faceted
26
+ // values, resolved here.
27
+ const suggestions =
28
+ options.length > 0
29
+ ? options
30
+ : resolveColumnOptions({ table, column, fallback: "faceted" });
31
+
32
+ return (
33
+ <Autocomplete
34
+ label={labels.filterValue}
35
+ size={size}
36
+ w={180}
37
+ comboboxProps={{ withinPortal: false }}
38
+ disabled={!needsValue}
39
+ placeholder={needsValue ? labels.filterValuePlaceholder : ""}
40
+ data={suggestions.map((option) => option.value)}
41
+ limit={20}
42
+ value={needsValue && typeof value === "string" ? value : ""}
43
+ onChange={onChange}
44
+ />
45
+ );
46
+ }
@@ -0,0 +1,43 @@
1
+ import { Group, TextInput } from "@mantine/core";
2
+ import type { TMDataGridFilterControlArgs } from "../../core/filterControls";
3
+ import { operatorTakesRangeValue } from "../../core/filterOperators";
4
+ import { TMDataGridFilterValueInput } from "./TMDataGridFilterValueInput";
5
+
6
+ /**
7
+ * A built-in `meta.filterControl` for `date` columns: a From/To pair of
8
+ * native date inputs writing the `between` pair — no `@mantine/dates`
9
+ * involved, like the built-in date editor. Pair it with
10
+ * `meta.defaultFilterOperator: "between"`; any non-range operator falls back
11
+ * to the standard input.
12
+ */
13
+ export function DgDateRangeFilter(args: TMDataGridFilterControlArgs) {
14
+ const { operator, value, onChange, size, labels } = args;
15
+ if (!operatorTakesRangeValue(operator)) {
16
+ return <TMDataGridFilterValueInput {...args} />;
17
+ }
18
+
19
+ const pair: [string, string] = Array.isArray(value)
20
+ ? [String(value[0] ?? ""), String(value[1] ?? "")]
21
+ : ["", ""];
22
+
23
+ return (
24
+ <Group gap={4} wrap="nowrap" align="flex-start">
25
+ <TextInput
26
+ label={labels.filterFrom}
27
+ size={size}
28
+ w={88}
29
+ type="date"
30
+ value={pair[0]}
31
+ onChange={(event) => onChange([event.currentTarget.value, pair[1]])}
32
+ />
33
+ <TextInput
34
+ label={labels.filterTo}
35
+ size={size}
36
+ w={88}
37
+ type="date"
38
+ value={pair[1]}
39
+ onChange={(event) => onChange([pair[0], event.currentTarget.value])}
40
+ />
41
+ </Group>
42
+ );
43
+ }
@@ -0,0 +1,52 @@
1
+ import { Input, RangeSlider } from "@mantine/core";
2
+ import type { TMDataGridFilterControlArgs } from "../../core/filterControls";
3
+ import { operatorTakesRangeValue } from "../../core/filterOperators";
4
+ import { TMDataGridFilterValueInput } from "./TMDataGridFilterValueInput";
5
+
6
+ /**
7
+ * A built-in `meta.filterControl` for `number` columns: a range slider whose
8
+ * bounds are seeded from the values present in the data, writing the
9
+ * `between` pair. Pair it with `meta.defaultFilterOperator: "between"` so
10
+ * the filter opens on it; any non-range operator falls back to the standard
11
+ * input.
12
+ */
13
+ export function DgRangeSliderFilter(args: TMDataGridFilterControlArgs) {
14
+ const { column, operator, value, onChange, size, labels } = args;
15
+ if (!operatorTakesRangeValue(operator)) {
16
+ return <TMDataGridFilterValueInput {...args} />;
17
+ }
18
+
19
+ // Bounds from the faceted index — the min and max actually in the data.
20
+ const numbers: number[] = [];
21
+ for (const key of column.getFacetedUniqueValues().keys()) {
22
+ const numeric = Number(key);
23
+ if (Number.isFinite(numeric)) numbers.push(numeric);
24
+ }
25
+ const min = numbers.length > 0 ? Math.min(...numbers) : 0;
26
+ const max = numbers.length > 0 ? Math.max(...numbers) : 100;
27
+
28
+ const pair = Array.isArray(value) ? value : ["", ""];
29
+ const lower = pair[0] === undefined || pair[0] === "" ? min : Number(pair[0]);
30
+ const upper = pair[1] === undefined || pair[1] === "" ? max : Number(pair[1]);
31
+
32
+ return (
33
+ <Input.Wrapper label={labels.filterValue} size={size} w={180}>
34
+ <RangeSlider
35
+ size={size}
36
+ mt={6}
37
+ min={min}
38
+ max={max}
39
+ minRange={0}
40
+ value={[
41
+ Math.min(Math.max(lower, min), max),
42
+ Math.min(Math.max(upper, min), max),
43
+ ]}
44
+ thumbFromLabel={labels.filterFrom}
45
+ thumbToLabel={labels.filterTo}
46
+ onChange={([nextLower, nextUpper]) =>
47
+ onChange([String(nextLower), String(nextUpper)])
48
+ }
49
+ />
50
+ </Input.Wrapper>
51
+ );
52
+ }
@@ -0,0 +1,38 @@
1
+ import { Input, SegmentedControl } from "@mantine/core";
2
+ import type { TMDataGridFilterControlArgs } from "../../core/filterControls";
3
+ import {
4
+ operatorNeedsValue,
5
+ operatorTakesArrayValue,
6
+ operatorTakesRangeValue,
7
+ } from "../../core/filterOperators";
8
+ import { TMDataGridFilterValueInput } from "./TMDataGridFilterValueInput";
9
+
10
+ /**
11
+ * A built-in `meta.filterControl` for `boolean` columns: an All / Yes / No
12
+ * segmented control — All clears the value, which deactivates the filter.
13
+ * Set or range operators fall back to the standard input.
14
+ */
15
+ export function DgTriStateFilter(args: TMDataGridFilterControlArgs) {
16
+ const { operator, value, onChange, size, labels } = args;
17
+ if (operatorTakesArrayValue(operator) || operatorTakesRangeValue(operator)) {
18
+ return <TMDataGridFilterValueInput {...args} />;
19
+ }
20
+ const needsValue = operatorNeedsValue(operator);
21
+ const scalar = typeof value === "string" ? value : "";
22
+
23
+ return (
24
+ <Input.Wrapper label={labels.filterValue} size={size}>
25
+ <SegmentedControl
26
+ size={size}
27
+ disabled={!needsValue}
28
+ data={[
29
+ { value: "all", label: labels.filterAll },
30
+ { value: "true", label: labels.booleanTrue },
31
+ { value: "false", label: labels.booleanFalse },
32
+ ]}
33
+ value={scalar === "true" || scalar === "false" ? scalar : "all"}
34
+ onChange={(next) => onChange(next === "all" ? "" : next)}
35
+ />
36
+ </Input.Wrapper>
37
+ );
38
+ }
@@ -0,0 +1,122 @@
1
+ import { Group, MultiSelect, Select, TextInput } from "@mantine/core";
2
+ import { optionsToComboboxData } from "../../core/columnOptions";
3
+ import { getColumnType } from "../../core/columnUtils";
4
+ import type { TMDataGridFilterControlArgs } from "../../core/filterControls";
5
+ import {
6
+ operatorNeedsValue,
7
+ operatorTakesArrayValue,
8
+ operatorTakesRangeValue,
9
+ } from "../../core/filterOperators";
10
+
11
+ /**
12
+ * The built-in value control of a filter-panel row — what renders when a
13
+ * column declares no `meta.filterControl`. Shaped by the operator: a
14
+ * multi-select for the set operators, a From/To pair for `between`, a Yes/No
15
+ * dropdown for booleans, a typed input otherwise.
16
+ *
17
+ * Exported so a custom control can fall back to it for the operators it does
18
+ * not care about, instead of rebuilding them.
19
+ */
20
+ export function TMDataGridFilterValueInput({
21
+ column,
22
+ operator,
23
+ value,
24
+ onChange,
25
+ options,
26
+ size,
27
+ labels,
28
+ }: TMDataGridFilterControlArgs) {
29
+ const type = getColumnType(column);
30
+ const needsValue = operatorNeedsValue(operator);
31
+ const scalarValue = typeof value === "string" ? value : "";
32
+ const inputType =
33
+ type === "number" ? "number" : type === "date" ? "date" : "text";
34
+
35
+ if (operatorTakesRangeValue(operator)) {
36
+ const rangeValue: [string, string] = Array.isArray(value)
37
+ ? [String(value[0] ?? ""), String(value[1] ?? "")]
38
+ : ["", ""];
39
+ return (
40
+ // The interval's two ends. Either may stay empty — an open end — and
41
+ // each writes its slot of the `[min, max]` pair.
42
+ <Group gap={4} wrap="nowrap" align="flex-start">
43
+ <TextInput
44
+ label={labels.filterFrom}
45
+ size={size}
46
+ w={88}
47
+ type={inputType}
48
+ data-dg-part="filter-value-from"
49
+ value={rangeValue[0]}
50
+ onChange={(event) =>
51
+ onChange([event.currentTarget.value, rangeValue[1]])
52
+ }
53
+ />
54
+ <TextInput
55
+ label={labels.filterTo}
56
+ size={size}
57
+ w={88}
58
+ type={inputType}
59
+ data-dg-part="filter-value-to"
60
+ value={rangeValue[1]}
61
+ onChange={(event) =>
62
+ onChange([rangeValue[0], event.currentTarget.value])
63
+ }
64
+ />
65
+ </Group>
66
+ );
67
+ }
68
+
69
+ if (operatorTakesArrayValue(operator)) {
70
+ // The set the cell is tested against. Options come from `meta.options`;
71
+ // a select column that declares none still gets the values present in
72
+ // the data, via the faceted index.
73
+ return (
74
+ <MultiSelect
75
+ label={labels.filterValue}
76
+ size={size}
77
+ w={180}
78
+ comboboxProps={{ withinPortal: false }}
79
+ searchable
80
+ data-dg-part="filter-value"
81
+ data={optionsToComboboxData(options)}
82
+ value={Array.isArray(value) ? [...value] : []}
83
+ onChange={onChange}
84
+ />
85
+ );
86
+ }
87
+
88
+ if (type === "boolean") {
89
+ return (
90
+ <Select
91
+ label={labels.filterValue}
92
+ size={size}
93
+ w={180}
94
+ comboboxProps={{ withinPortal: false }}
95
+ data-dg-part="filter-value"
96
+ disabled={!needsValue}
97
+ clearable
98
+ placeholder={needsValue ? labels.filterValuePlaceholder : ""}
99
+ data={[
100
+ { value: "true", label: labels.booleanTrue },
101
+ { value: "false", label: labels.booleanFalse },
102
+ ]}
103
+ value={needsValue && scalarValue !== "" ? scalarValue : null}
104
+ onChange={(next) => onChange(next ?? "")}
105
+ />
106
+ );
107
+ }
108
+
109
+ return (
110
+ <TextInput
111
+ label={labels.filterValue}
112
+ size={size}
113
+ w={180}
114
+ type={needsValue ? inputType : "text"}
115
+ data-dg-part="filter-value"
116
+ disabled={!needsValue}
117
+ placeholder={needsValue ? labels.filterValuePlaceholder : ""}
118
+ value={needsValue ? scalarValue : ""}
119
+ onChange={(event) => onChange(event.currentTarget.value)}
120
+ />
121
+ );
122
+ }