@jielga/tmdatagrid 0.1.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.
@@ -0,0 +1,182 @@
1
+ import { ActionIcon, Button, Select, Stack, TextInput } from "@mantine/core";
2
+ import { useSelector } from "@tanstack/react-store";
3
+ import classes from "./TMDataGrid.module.css";
4
+ import { useTMDataGridContext } from "./TMDataGridContext";
5
+ import { getColumnLabel, getColumnType } from "./columnUtils";
6
+ import {
7
+ type TMDataGridFilterOperator,
8
+ type TMDataGridFilterValue,
9
+ FILTER_OPERATOR_LABELS,
10
+ getDefaultOperator,
11
+ getOperatorsForType,
12
+ isTMDataGridFilterValue,
13
+ operatorNeedsValue,
14
+ } from "./filterOperators";
15
+ import { CloseIcon } from "./icons";
16
+
17
+ const FALLBACK_FILTER: TMDataGridFilterValue = { operator: "contains", value: "" };
18
+
19
+ function asFilterValue(value: unknown): TMDataGridFilterValue {
20
+ return isTMDataGridFilterValue(value) ? value : FALLBACK_FILTER;
21
+ }
22
+
23
+ /**
24
+ * The MUI-style filter surface: one row per active column filter, each row a
25
+ * column / operator / value triple. It only ever reads and writes the table's
26
+ * `columnFilters` state, so a `manualFiltering` grid gets the same panel for
27
+ * free — the state is forwarded to the server instead of a row model.
28
+ */
29
+ export function TMDataGridFilterPanel() {
30
+ const { table, ui, controlSize } = useTMDataGridContext();
31
+ const opened = useSelector(ui, (state) => state.filterPanelOpen);
32
+ const columnFilters = useSelector(
33
+ table.store,
34
+ (state) => state.columnFilters,
35
+ );
36
+
37
+ if (!opened) return null;
38
+
39
+ const filterableColumns = table
40
+ .getAllLeafColumns()
41
+ .filter((column) => column.getCanFilter());
42
+
43
+ const columnOptions = filterableColumns.map((column) => ({
44
+ value: column.id,
45
+ label: getColumnLabel(column),
46
+ }));
47
+
48
+ function removeFilter(columnId: string) {
49
+ const remaining = columnFilters.filter((filter) => filter.id !== columnId);
50
+ table.setColumnFilters(remaining);
51
+ if (remaining.length === 0) ui.actions.closeFilterPanel();
52
+ }
53
+
54
+ function changeFilterColumn(fromColumnId: string, toColumnId: string) {
55
+ const current = asFilterValue(
56
+ columnFilters.find((filter) => filter.id === fromColumnId)?.value,
57
+ );
58
+ const target = table.getColumn(toColumnId);
59
+ table.setColumnFilters(
60
+ columnFilters
61
+ .filter((filter) => filter.id !== toColumnId)
62
+ .map((filter) =>
63
+ filter.id === fromColumnId
64
+ ? {
65
+ id: toColumnId,
66
+ value: {
67
+ operator: getDefaultOperator(
68
+ target ? getColumnType(target) : "string",
69
+ ),
70
+ value: current.value,
71
+ } satisfies TMDataGridFilterValue,
72
+ }
73
+ : filter,
74
+ ),
75
+ );
76
+ ui.actions.openFilterPanel(toColumnId);
77
+ }
78
+
79
+ function patchFilter(columnId: string, patch: Partial<TMDataGridFilterValue>) {
80
+ table.setColumnFilters(
81
+ columnFilters.map((filter) =>
82
+ filter.id === columnId
83
+ ? { ...filter, value: { ...asFilterValue(filter.value), ...patch } }
84
+ : filter,
85
+ ),
86
+ );
87
+ }
88
+
89
+ function addFilter() {
90
+ const nextColumn = filterableColumns.find(
91
+ (column) => !columnFilters.some((filter) => filter.id === column.id),
92
+ );
93
+ if (!nextColumn) return;
94
+ table.setColumnFilters([
95
+ ...columnFilters,
96
+ {
97
+ id: nextColumn.id,
98
+ value: {
99
+ operator: getDefaultOperator(getColumnType(nextColumn)),
100
+ value: "",
101
+ } satisfies TMDataGridFilterValue,
102
+ },
103
+ ]);
104
+ }
105
+
106
+ return (
107
+ <div className={classes.filterPanel}>
108
+ <Stack gap="xs">
109
+ {columnFilters.map((filter) => {
110
+ const column = table.getColumn(filter.id);
111
+ const value = asFilterValue(filter.value);
112
+ const type = column ? getColumnType(column) : "string";
113
+ const needsValue = operatorNeedsValue(value.operator);
114
+
115
+ return (
116
+ <div key={filter.id} className={classes.filterRow}>
117
+ <ActionIcon
118
+ variant="subtle"
119
+ color="gray"
120
+ size="lg"
121
+ aria-label="Remove filter"
122
+ onClick={() => removeFilter(filter.id)}
123
+ >
124
+ <CloseIcon size={18} stroke={1.6} />
125
+ </ActionIcon>
126
+
127
+ <Select
128
+ label="Column"
129
+ size={controlSize}
130
+ w={160}
131
+ allowDeselect={false}
132
+ data={columnOptions}
133
+ value={filter.id}
134
+ onChange={(next) => next && changeFilterColumn(filter.id, next)}
135
+ />
136
+
137
+ <Select
138
+ label="Operator"
139
+ size={controlSize}
140
+ w={170}
141
+ allowDeselect={false}
142
+ data={getOperatorsForType(type).map((operator) => ({
143
+ value: operator,
144
+ label: FILTER_OPERATOR_LABELS[operator],
145
+ }))}
146
+ value={value.operator}
147
+ onChange={(next) =>
148
+ next &&
149
+ patchFilter(filter.id, {
150
+ operator: next as TMDataGridFilterOperator,
151
+ })
152
+ }
153
+ />
154
+
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
+ />
167
+ </div>
168
+ );
169
+ })}
170
+
171
+ <Button
172
+ variant="subtle"
173
+ size="compact-sm"
174
+ style={{ alignSelf: "flex-start" }}
175
+ onClick={addFilter}
176
+ >
177
+ Add filter
178
+ </Button>
179
+ </Stack>
180
+ </div>
181
+ );
182
+ }
@@ -0,0 +1,74 @@
1
+ import { ActionIcon, Group, Select, Text } from "@mantine/core";
2
+ import { useSelector } from "@tanstack/react-store";
3
+ import classes from "./TMDataGrid.module.css";
4
+ import { useTMDataGridContext } from "./TMDataGridContext";
5
+ import { ChevronLeftIcon, ChevronRightIcon } from "./icons";
6
+
7
+ export type TMDataGridFooterProps = {
8
+ pageSizeOptions?: ReadonlyArray<number>;
9
+ };
10
+
11
+ /**
12
+ * MUI-style pager: "Rows per page · from–to of total · ‹ ›".
13
+ *
14
+ * Row totals come from `table.getRowCount()`, which prefers `options.rowCount`
15
+ * — so a server-paged grid shows the server's total without changes here.
16
+ */
17
+ export function TMDataGridFooter({
18
+ pageSizeOptions = [10, 25, 50, 100],
19
+ }: TMDataGridFooterProps) {
20
+ const { table, controlSize } = useTMDataGridContext();
21
+ useSelector(table.store);
22
+
23
+ const { pageIndex, pageSize } = table.store.state.pagination;
24
+ const total = table.getRowCount();
25
+ const from = total === 0 ? 0 : pageIndex * pageSize + 1;
26
+ const to = Math.min(total, (pageIndex + 1) * pageSize);
27
+
28
+ return (
29
+ <div className={classes.footer}>
30
+ <Group gap="xs" wrap="nowrap">
31
+ <Text size={controlSize} c="dimmed">
32
+ Rows per page:
33
+ </Text>
34
+ <Select
35
+ size={controlSize}
36
+ w={78}
37
+ allowDeselect={false}
38
+ variant="unstyled"
39
+ data={pageSizeOptions.map(String)}
40
+ value={String(pageSize)}
41
+ onChange={(value) => {
42
+ table.setPageSize(Number(value) || pageSizeOptions[0]);
43
+ table.setPageIndex(0);
44
+ }}
45
+ />
46
+ </Group>
47
+
48
+ <Text size={controlSize} c="dimmed">
49
+ {from}–{to} of {total}
50
+ </Text>
51
+
52
+ <Group gap={4} wrap="nowrap">
53
+ <ActionIcon
54
+ variant="subtle"
55
+ color="gray"
56
+ aria-label="Previous page"
57
+ disabled={!table.getCanPreviousPage()}
58
+ onClick={() => table.previousPage()}
59
+ >
60
+ <ChevronLeftIcon size={18} stroke={1.6} />
61
+ </ActionIcon>
62
+ <ActionIcon
63
+ variant="subtle"
64
+ color="gray"
65
+ aria-label="Next page"
66
+ disabled={!table.getCanNextPage()}
67
+ onClick={() => table.nextPage()}
68
+ >
69
+ <ChevronRightIcon size={18} stroke={1.6} />
70
+ </ActionIcon>
71
+ </Group>
72
+ </div>
73
+ );
74
+ }