@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.
- package/LICENSE +21 -0
- package/README.md +162 -0
- package/dist/index.d.ts +592 -0
- package/dist/index.js +1590 -0
- package/dist/index.js.map +1 -0
- package/dist/styles.css +2 -0
- package/package.json +111 -0
- package/skills/columns/SKILL.md +195 -0
- package/skills/features/SKILL.md +214 -0
- package/skills/getting-started/SKILL.md +209 -0
- package/skills/options/SKILL.md +208 -0
- package/skills/server-side/SKILL.md +150 -0
- package/src/tmdatagrid/TMDataGrid.module.css +410 -0
- package/src/tmdatagrid/TMDataGrid.tsx +101 -0
- package/src/tmdatagrid/TMDataGridColumnsPanel.tsx +91 -0
- package/src/tmdatagrid/TMDataGridContext.ts +32 -0
- package/src/tmdatagrid/TMDataGridFilterPanel.tsx +182 -0
- package/src/tmdatagrid/TMDataGridFooter.tsx +74 -0
- package/src/tmdatagrid/TMDataGridHeaderCell.tsx +466 -0
- package/src/tmdatagrid/TMDataGridTable.tsx +297 -0
- package/src/tmdatagrid/TMDataGridToolbar.tsx +131 -0
- package/src/tmdatagrid/capabilities.ts +117 -0
- package/src/tmdatagrid/columnOrdering.ts +140 -0
- package/src/tmdatagrid/columnUtils.ts +43 -0
- package/src/tmdatagrid/filterOperators.ts +171 -0
- package/src/tmdatagrid/icons.ts +25 -0
- package/src/tmdatagrid/index.ts +68 -0
- package/src/tmdatagrid/persistence.ts +187 -0
- package/src/tmdatagrid/sizes.ts +29 -0
- package/src/tmdatagrid/useTMDataGrid.tsx +378 -0
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { Loader, Text } from "@mantine/core";
|
|
2
|
+
import {
|
|
3
|
+
type Cell,
|
|
4
|
+
flexRender,
|
|
5
|
+
type Row,
|
|
6
|
+
type RowData,
|
|
7
|
+
} from "@tanstack/react-table";
|
|
8
|
+
import { useSelector } from "@tanstack/react-store";
|
|
9
|
+
import { useVirtualizer } from "@tanstack/react-virtual";
|
|
10
|
+
import { useCallback, useRef } from "react";
|
|
11
|
+
import classes from "./TMDataGrid.module.css";
|
|
12
|
+
import { type TMDataGridRowData, useTMDataGridContext } from "./TMDataGridContext";
|
|
13
|
+
import { TMDataGridFilterPanel } from "./TMDataGridFilterPanel";
|
|
14
|
+
import { TMDataGridHeaderCell } from "./TMDataGridHeaderCell";
|
|
15
|
+
import { getColumnAlign } from "./columnUtils";
|
|
16
|
+
import { SearchIcon } from "./icons";
|
|
17
|
+
import type { TMDataGridFeatures } from "./useTMDataGrid";
|
|
18
|
+
|
|
19
|
+
/** Where a column sits relative to the pinned regions, resolved once per render. */
|
|
20
|
+
export type TMDataGridColumnLayout = {
|
|
21
|
+
pinnedAt: "left" | "right" | false;
|
|
22
|
+
/** Distance in px from the grid's left or right edge. */
|
|
23
|
+
offset: number;
|
|
24
|
+
/** Last left-pinned / first right-pinned column — the one that casts the edge. */
|
|
25
|
+
isBoundary: boolean;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const UNPINNED_LAYOUT: TMDataGridColumnLayout = {
|
|
29
|
+
pinnedAt: false,
|
|
30
|
+
offset: 0,
|
|
31
|
+
isBoundary: false,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function TMDataGridBodyCell({
|
|
35
|
+
cell,
|
|
36
|
+
rowHeight,
|
|
37
|
+
layout,
|
|
38
|
+
}: {
|
|
39
|
+
cell: Cell<TMDataGridFeatures, TMDataGridRowData, unknown>;
|
|
40
|
+
rowHeight: number;
|
|
41
|
+
layout: TMDataGridColumnLayout;
|
|
42
|
+
}) {
|
|
43
|
+
return (
|
|
44
|
+
<div
|
|
45
|
+
role="cell"
|
|
46
|
+
data-align={getColumnAlign(cell.column)}
|
|
47
|
+
className={[
|
|
48
|
+
classes.bodyCell,
|
|
49
|
+
layout.isBoundary && layout.pinnedAt === "left" ? classes.stickyLeft : "",
|
|
50
|
+
layout.isBoundary && layout.pinnedAt === "right"
|
|
51
|
+
? classes.stickyRight
|
|
52
|
+
: "",
|
|
53
|
+
]
|
|
54
|
+
.filter(Boolean)
|
|
55
|
+
.join(" ")}
|
|
56
|
+
style={{
|
|
57
|
+
minHeight: rowHeight,
|
|
58
|
+
left: layout.pinnedAt === "left" ? layout.offset : undefined,
|
|
59
|
+
right: layout.pinnedAt === "right" ? layout.offset : undefined,
|
|
60
|
+
position: layout.pinnedAt ? "sticky" : undefined,
|
|
61
|
+
zIndex: layout.pinnedAt ? 2 : undefined,
|
|
62
|
+
}}
|
|
63
|
+
>
|
|
64
|
+
<span className={classes.cellContent}>
|
|
65
|
+
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
66
|
+
</span>
|
|
67
|
+
</div>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export type TMDataGridTableProps<TData extends RowData> = {
|
|
72
|
+
/** Called when a body row is clicked. */
|
|
73
|
+
onRowClick?: (row: Row<TMDataGridFeatures, TData>) => void;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The scrollable grid surface. Always virtualized: only the rows inside the
|
|
78
|
+
* viewport (plus overscan) are mounted, regardless of page size.
|
|
79
|
+
*/
|
|
80
|
+
export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
|
|
81
|
+
onRowClick,
|
|
82
|
+
}: TMDataGridTableProps<TData>) {
|
|
83
|
+
const { table, rowHeight, controlSize } = useTMDataGridContext();
|
|
84
|
+
|
|
85
|
+
// The body depends on every state slice (sorting, filters, paging, sizing,
|
|
86
|
+
// visibility, selection), so it subscribes to the whole table store.
|
|
87
|
+
useSelector(table.store);
|
|
88
|
+
|
|
89
|
+
const { loading, noResultsLabel = "No rows match your filters" } =
|
|
90
|
+
table.options.meta ?? {};
|
|
91
|
+
|
|
92
|
+
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
|
93
|
+
// Respects `manualPagination`: server-paged grids get the rows as delivered.
|
|
94
|
+
const rows = table.getPaginatedRowModel().rows;
|
|
95
|
+
|
|
96
|
+
const virtualizer = useVirtualizer({
|
|
97
|
+
count: rows.length,
|
|
98
|
+
getScrollElement: () => scrollContainerRef.current,
|
|
99
|
+
estimateSize: useCallback(() => rowHeight, [rowHeight]),
|
|
100
|
+
getItemKey: useCallback((index: number) => rows[index]?.id ?? index, [rows]),
|
|
101
|
+
overscan: 6,
|
|
102
|
+
initialRect: { height: 600, width: 1200 },
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const virtualItems = virtualizer.getVirtualItems();
|
|
106
|
+
const paddingTop = virtualItems[0]?.start ?? 0;
|
|
107
|
+
const paddingBottom =
|
|
108
|
+
virtualizer.getTotalSize() - (virtualItems.at(-1)?.end ?? 0);
|
|
109
|
+
|
|
110
|
+
const leftLeafColumns = table.getLeftLeafColumns();
|
|
111
|
+
const rightLeafColumns = table.getRightLeafColumns();
|
|
112
|
+
const centerLeafColumns = table.getCenterLeafColumns();
|
|
113
|
+
|
|
114
|
+
const orderedColumns = [
|
|
115
|
+
...leftLeafColumns,
|
|
116
|
+
...centerLeafColumns,
|
|
117
|
+
...rightLeafColumns,
|
|
118
|
+
];
|
|
119
|
+
|
|
120
|
+
// Headers and cells must follow the same left → center → right order as the
|
|
121
|
+
// column tracks, otherwise pinning a column would shuffle it out of its lane.
|
|
122
|
+
const centerHeaderGroups = table.getCenterHeaderGroups();
|
|
123
|
+
const leftHeaderGroups = table.getLeftHeaderGroups();
|
|
124
|
+
const rightHeaderGroups = table.getRightHeaderGroups();
|
|
125
|
+
const headerGroups = centerHeaderGroups.map((group, index) => ({
|
|
126
|
+
id: group.id,
|
|
127
|
+
headers: [
|
|
128
|
+
...(leftHeaderGroups[index]?.headers ?? []),
|
|
129
|
+
...group.headers,
|
|
130
|
+
...(rightHeaderGroups[index]?.headers ?? []),
|
|
131
|
+
],
|
|
132
|
+
}));
|
|
133
|
+
// Unresized columns stay fluid (`minmax(min, flex fr)`) so the grid fills the
|
|
134
|
+
// viewport; dragging a resize handle pins that column to an exact pixel width.
|
|
135
|
+
// minWidth is the sum of those floors — anything wider and the grid would
|
|
136
|
+
// force a horizontal scrollbar it doesn't need.
|
|
137
|
+
//
|
|
138
|
+
// Pinned columns are always exact: their sticky offsets come from
|
|
139
|
+
// `getStart()` / `getAfter()`, which sum `getSize()` and cannot see an `fr`.
|
|
140
|
+
const columnSizing = table.store.state.columnSizing;
|
|
141
|
+
const columnTracks = orderedColumns.map((column) => {
|
|
142
|
+
const isFixed = column.columnDef.minSize === column.columnDef.maxSize;
|
|
143
|
+
const isResized = column.id in columnSizing;
|
|
144
|
+
const isPinned = column.getIsPinned() !== false;
|
|
145
|
+
const minWidth = column.columnDef.minSize ?? 80;
|
|
146
|
+
if (isFixed || isResized || isPinned) {
|
|
147
|
+
return { track: `${column.getSize()}px`, minWidth: column.getSize() };
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
track: `minmax(${minWidth}px, ${column.columnDef.meta?.flex ?? 1}fr)`,
|
|
151
|
+
minWidth,
|
|
152
|
+
};
|
|
153
|
+
});
|
|
154
|
+
const gridTemplateColumns = columnTracks.map((c) => c.track).join(" ");
|
|
155
|
+
const gridMinWidth = columnTracks.reduce((sum, c) => sum + c.minWidth, 0);
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Sticky offsets are derived here, from state this component subscribes to,
|
|
159
|
+
* and handed down as props.
|
|
160
|
+
*
|
|
161
|
+
* Deriving them inside the cell via `column.getIsPinned()` /
|
|
162
|
+
* `column.getStart()` looks equivalent but isn't: those are plain calls on a
|
|
163
|
+
* `column` object whose identity survives a pin, so the React Compiler caches
|
|
164
|
+
* the result and the cell keeps rendering with stale pinning. Building the
|
|
165
|
+
* offsets from the same widths as the grid tracks also guarantees the two
|
|
166
|
+
* agree to the pixel.
|
|
167
|
+
*/
|
|
168
|
+
const columnLayout = new Map<string, TMDataGridColumnLayout>();
|
|
169
|
+
const widthOf = (columnId: string) =>
|
|
170
|
+
columnTracks[orderedColumns.findIndex((c) => c.id === columnId)]?.minWidth ??
|
|
171
|
+
0;
|
|
172
|
+
|
|
173
|
+
let leftOffset = 0;
|
|
174
|
+
for (const column of leftLeafColumns) {
|
|
175
|
+
columnLayout.set(column.id, {
|
|
176
|
+
pinnedAt: "left",
|
|
177
|
+
offset: leftOffset,
|
|
178
|
+
isBoundary: column.id === leftLeafColumns.at(-1)?.id,
|
|
179
|
+
});
|
|
180
|
+
leftOffset += widthOf(column.id);
|
|
181
|
+
}
|
|
182
|
+
let rightOffset = 0;
|
|
183
|
+
for (const column of [...rightLeafColumns].reverse()) {
|
|
184
|
+
columnLayout.set(column.id, {
|
|
185
|
+
pinnedAt: "right",
|
|
186
|
+
offset: rightOffset,
|
|
187
|
+
isBoundary: column.id === rightLeafColumns[0]?.id,
|
|
188
|
+
});
|
|
189
|
+
rightOffset += widthOf(column.id);
|
|
190
|
+
}
|
|
191
|
+
for (const column of centerLeafColumns) {
|
|
192
|
+
columnLayout.set(column.id, {
|
|
193
|
+
pinnedAt: false,
|
|
194
|
+
offset: 0,
|
|
195
|
+
isBoundary: false,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
const layoutFor = (columnId: string) =>
|
|
199
|
+
columnLayout.get(columnId) ?? UNPINNED_LAYOUT;
|
|
200
|
+
|
|
201
|
+
const isEmpty = rows.length === 0;
|
|
202
|
+
|
|
203
|
+
return (
|
|
204
|
+
<div className={classes.tableWrapper}>
|
|
205
|
+
<div ref={scrollContainerRef} className={classes.scrollContainer}>
|
|
206
|
+
<div
|
|
207
|
+
role="table"
|
|
208
|
+
className={classes.grid}
|
|
209
|
+
style={{ gridTemplateColumns, minWidth: gridMinWidth }}
|
|
210
|
+
>
|
|
211
|
+
<div role="rowgroup" style={{ display: "contents" }}>
|
|
212
|
+
{headerGroups.map((headerGroup) => (
|
|
213
|
+
<div key={headerGroup.id} role="row" className={classes.headerRow}>
|
|
214
|
+
{headerGroup.headers.map((header) => (
|
|
215
|
+
<TMDataGridHeaderCell
|
|
216
|
+
key={header.id}
|
|
217
|
+
header={header}
|
|
218
|
+
layout={layoutFor(header.column.id)}
|
|
219
|
+
/>
|
|
220
|
+
))}
|
|
221
|
+
</div>
|
|
222
|
+
))}
|
|
223
|
+
</div>
|
|
224
|
+
|
|
225
|
+
{paddingTop > 0 && (
|
|
226
|
+
<div aria-hidden style={{ gridColumn: "1/-1", height: paddingTop }} />
|
|
227
|
+
)}
|
|
228
|
+
|
|
229
|
+
<div role="rowgroup" style={{ display: "contents" }}>
|
|
230
|
+
{virtualItems.map((virtualItem) => {
|
|
231
|
+
const row = rows[virtualItem.index];
|
|
232
|
+
if (!row) return null;
|
|
233
|
+
return (
|
|
234
|
+
<div
|
|
235
|
+
key={virtualItem.key}
|
|
236
|
+
role="row"
|
|
237
|
+
data-testid={`dg-row-${row.id}`}
|
|
238
|
+
data-selected={row.getIsSelected()}
|
|
239
|
+
className={classes.bodyRow}
|
|
240
|
+
style={{
|
|
241
|
+
minHeight: rowHeight,
|
|
242
|
+
cursor: onRowClick ? "pointer" : undefined,
|
|
243
|
+
}}
|
|
244
|
+
onClick={
|
|
245
|
+
onRowClick
|
|
246
|
+
? () =>
|
|
247
|
+
onRowClick(
|
|
248
|
+
row as unknown as Row<TMDataGridFeatures, TData>,
|
|
249
|
+
)
|
|
250
|
+
: undefined
|
|
251
|
+
}
|
|
252
|
+
>
|
|
253
|
+
{[
|
|
254
|
+
...row.getLeftVisibleCells(),
|
|
255
|
+
...row.getCenterVisibleCells(),
|
|
256
|
+
...row.getRightVisibleCells(),
|
|
257
|
+
].map((cell) => (
|
|
258
|
+
<TMDataGridBodyCell
|
|
259
|
+
key={cell.id}
|
|
260
|
+
cell={cell}
|
|
261
|
+
rowHeight={rowHeight}
|
|
262
|
+
layout={layoutFor(cell.column.id)}
|
|
263
|
+
/>
|
|
264
|
+
))}
|
|
265
|
+
</div>
|
|
266
|
+
);
|
|
267
|
+
})}
|
|
268
|
+
</div>
|
|
269
|
+
|
|
270
|
+
{isEmpty && loading === true && (
|
|
271
|
+
<div role="row" className={classes.messageRow}>
|
|
272
|
+
<Loader size="lg" />
|
|
273
|
+
</div>
|
|
274
|
+
)}
|
|
275
|
+
|
|
276
|
+
{isEmpty && loading !== true && (
|
|
277
|
+
<div role="row" className={classes.messageRow}>
|
|
278
|
+
<SearchIcon size={40} stroke={1.4} opacity={0.4} />
|
|
279
|
+
<Text size={controlSize} c="dimmed">
|
|
280
|
+
{noResultsLabel}
|
|
281
|
+
</Text>
|
|
282
|
+
</div>
|
|
283
|
+
)}
|
|
284
|
+
|
|
285
|
+
{paddingBottom > 0 && (
|
|
286
|
+
<div
|
|
287
|
+
aria-hidden
|
|
288
|
+
style={{ gridColumn: "1/-1", height: paddingBottom }}
|
|
289
|
+
/>
|
|
290
|
+
)}
|
|
291
|
+
</div>
|
|
292
|
+
</div>
|
|
293
|
+
|
|
294
|
+
<TMDataGridFilterPanel />
|
|
295
|
+
</div>
|
|
296
|
+
);
|
|
297
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { ActionIcon, Popover, Text, Tooltip } from "@mantine/core";
|
|
2
|
+
import { useSelector } from "@tanstack/react-store";
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
import classes from "./TMDataGrid.module.css";
|
|
5
|
+
import { TMDataGridColumnsPanel } from "./TMDataGridColumnsPanel";
|
|
6
|
+
import { useTMDataGridContext } from "./TMDataGridContext";
|
|
7
|
+
import { getGridCapabilities } from "./capabilities";
|
|
8
|
+
import { isFilterActive } from "./filterOperators";
|
|
9
|
+
import { BurgerIcon, FilterIcon } from "./icons";
|
|
10
|
+
import { openColumnFilter } from "./useTMDataGrid";
|
|
11
|
+
|
|
12
|
+
/** Row above the grid. Compose it from the pieces below, or anything else. */
|
|
13
|
+
export function TMDataGridToolbar({ children }: { children?: ReactNode }) {
|
|
14
|
+
return <div className={classes.toolbar}>{children}</div>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Pushes the following toolbar items to the right. */
|
|
18
|
+
export function TMDataGridToolbarSpacer() {
|
|
19
|
+
return <div className={classes.toolbarSpacer} />;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Visible rows over total rows. On a server-driven grid, set
|
|
24
|
+
* `meta.totalRowCount` for the denominator — the client never sees every row.
|
|
25
|
+
*/
|
|
26
|
+
export function TMDataGridSummaryCount({ children }: { children?: ReactNode }) {
|
|
27
|
+
const { table, controlSize } = useTMDataGridContext();
|
|
28
|
+
useSelector(table.store);
|
|
29
|
+
|
|
30
|
+
if (children !== undefined) {
|
|
31
|
+
return (
|
|
32
|
+
<Text size={controlSize} c="dimmed">
|
|
33
|
+
{children}
|
|
34
|
+
</Text>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const shown = table.getRowCount();
|
|
39
|
+
const total =
|
|
40
|
+
table.options.meta?.totalRowCount ??
|
|
41
|
+
table.getPreFilteredRowModel().rows.length;
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<Text size={controlSize} c="dimmed">
|
|
45
|
+
{shown} / {total}
|
|
46
|
+
</Text>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Burger menu in the grid's top-right corner — opens "Manage columns".
|
|
52
|
+
* Renders nothing when no column can be hidden (`enableHiding: false`).
|
|
53
|
+
*/
|
|
54
|
+
export function TMDataGridColumnsButton() {
|
|
55
|
+
const { table, ui, features, controlSize } = useTMDataGridContext();
|
|
56
|
+
const opened = useSelector(ui, (state) => state.columnsPanelOpen);
|
|
57
|
+
|
|
58
|
+
if (!getGridCapabilities(table, features).canHideAny) return null;
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<Popover
|
|
62
|
+
opened={opened}
|
|
63
|
+
onChange={ui.actions.setColumnsPanelOpen}
|
|
64
|
+
position="bottom-end"
|
|
65
|
+
shadow="md"
|
|
66
|
+
radius="md"
|
|
67
|
+
withinPortal
|
|
68
|
+
trapFocus
|
|
69
|
+
>
|
|
70
|
+
<Popover.Target>
|
|
71
|
+
<Tooltip label="Manage columns" openDelay={400}>
|
|
72
|
+
<ActionIcon
|
|
73
|
+
variant="subtle"
|
|
74
|
+
color="gray"
|
|
75
|
+
size={controlSize}
|
|
76
|
+
aria-label="Manage columns"
|
|
77
|
+
onClick={ui.actions.toggleColumnsPanel}
|
|
78
|
+
>
|
|
79
|
+
<BurgerIcon size={18} stroke={1.6} />
|
|
80
|
+
</ActionIcon>
|
|
81
|
+
</Tooltip>
|
|
82
|
+
</Popover.Target>
|
|
83
|
+
<Popover.Dropdown p={0}>
|
|
84
|
+
<TMDataGridColumnsPanel />
|
|
85
|
+
</Popover.Dropdown>
|
|
86
|
+
</Popover>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Toggles the filter panel, seeding a filter row on the first filterable column.
|
|
92
|
+
* Renders nothing when no column can be filtered (`enableColumnFilters: false`).
|
|
93
|
+
*/
|
|
94
|
+
export function TMDataGridFilterButton() {
|
|
95
|
+
const api = useTMDataGridContext();
|
|
96
|
+
const { table, ui, features, controlSize } = api;
|
|
97
|
+
const opened = useSelector(ui, (state) => state.filterPanelOpen);
|
|
98
|
+
const columnFilters = useSelector(
|
|
99
|
+
table.store,
|
|
100
|
+
(state) => state.columnFilters,
|
|
101
|
+
);
|
|
102
|
+
const activeCount = columnFilters.filter((filter) =>
|
|
103
|
+
isFilterActive(filter.value),
|
|
104
|
+
).length;
|
|
105
|
+
|
|
106
|
+
if (!getGridCapabilities(table, features).canFilterAny) return null;
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<Tooltip label="Filters" openDelay={400}>
|
|
110
|
+
<ActionIcon
|
|
111
|
+
variant={opened || activeCount > 0 ? "light" : "subtle"}
|
|
112
|
+
color={activeCount > 0 ? undefined : "gray"}
|
|
113
|
+
size={controlSize}
|
|
114
|
+
aria-label="Filters"
|
|
115
|
+
onClick={() => {
|
|
116
|
+
if (opened) {
|
|
117
|
+
ui.actions.closeFilterPanel();
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const firstFilterable = table
|
|
121
|
+
.getAllLeafColumns()
|
|
122
|
+
.find((column) => column.getCanFilter());
|
|
123
|
+
if (firstFilterable) openColumnFilter(api, firstFilterable.id);
|
|
124
|
+
else ui.actions.openFilterPanel();
|
|
125
|
+
}}
|
|
126
|
+
>
|
|
127
|
+
<FilterIcon size={16} stroke={1.6} />
|
|
128
|
+
</ActionIcon>
|
|
129
|
+
</Tooltip>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { Column, RowData } from "@tanstack/react-table";
|
|
2
|
+
import type { TMDataGridRowData } from "./TMDataGridContext";
|
|
3
|
+
import { isColumnReorderable } from "./columnUtils";
|
|
4
|
+
import type {
|
|
5
|
+
TMDataGridFeatures,
|
|
6
|
+
TMDataGridTable,
|
|
7
|
+
UseTMDataGridOptions,
|
|
8
|
+
} from "./useTMDataGrid";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Table-level feature switches, read straight off the options object.
|
|
12
|
+
*
|
|
13
|
+
* Why not just call `column.getCanSort()` everywhere? Because that is a method
|
|
14
|
+
* call on a `column` whose identity survives an options change, so the React
|
|
15
|
+
* Compiler caches the result and the chrome keeps rendering for a feature that
|
|
16
|
+
* was switched off. Reading the options into a fresh object each render gives
|
|
17
|
+
* every derived value something that actually changes to invalidate on.
|
|
18
|
+
*
|
|
19
|
+
* TanStack still decides: these flags are combined with `getCanX()`, which is
|
|
20
|
+
* what applies the per-column overrides.
|
|
21
|
+
*/
|
|
22
|
+
export type TMDataGridFeatureFlags = {
|
|
23
|
+
sorting: boolean;
|
|
24
|
+
filtering: boolean;
|
|
25
|
+
hiding: boolean;
|
|
26
|
+
pinning: boolean;
|
|
27
|
+
resizing: boolean;
|
|
28
|
+
ordering: boolean;
|
|
29
|
+
rowSelection: boolean;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export function readFeatureFlags<TData extends RowData>(
|
|
33
|
+
options: Pick<
|
|
34
|
+
UseTMDataGridOptions<TData>,
|
|
35
|
+
| "enableSorting"
|
|
36
|
+
| "enableColumnFilters"
|
|
37
|
+
| "enableHiding"
|
|
38
|
+
| "enableColumnPinning"
|
|
39
|
+
| "enableColumnResizing"
|
|
40
|
+
| "enableColumnOrdering"
|
|
41
|
+
| "enableRowSelection"
|
|
42
|
+
>,
|
|
43
|
+
): TMDataGridFeatureFlags {
|
|
44
|
+
return {
|
|
45
|
+
sorting: options.enableSorting !== false,
|
|
46
|
+
filtering: options.enableColumnFilters !== false,
|
|
47
|
+
hiding: options.enableHiding !== false,
|
|
48
|
+
pinning: options.enableColumnPinning !== false,
|
|
49
|
+
resizing: options.enableColumnResizing !== false,
|
|
50
|
+
ordering: options.enableColumnOrdering !== false,
|
|
51
|
+
rowSelection: options.enableRowSelection !== false,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* What one column's header may offer.
|
|
57
|
+
*
|
|
58
|
+
* | Affordance | Turned off by |
|
|
59
|
+
* | ---------- | ------------- |
|
|
60
|
+
* | Sort arrow, Sort ASC/DESC | `enableSorting` (table or column) |
|
|
61
|
+
* | Filter item, panel entry | `enableColumnFilters` / `enableColumnFilter` |
|
|
62
|
+
* | Hide column | `enableHiding` (table or column) |
|
|
63
|
+
* | Pin to left / right | `enableColumnPinning` / `enablePinning` |
|
|
64
|
+
* | Resize dragging | `enableColumnResizing` / `enableResizing` |
|
|
65
|
+
* | Header dragging, Move left / right | `enableColumnOrdering` / `meta.enableOrdering` |
|
|
66
|
+
*/
|
|
67
|
+
export type TMDataGridColumnCapabilities = {
|
|
68
|
+
canSort: boolean;
|
|
69
|
+
canFilter: boolean;
|
|
70
|
+
canHide: boolean;
|
|
71
|
+
canPin: boolean;
|
|
72
|
+
canResize: boolean;
|
|
73
|
+
canReorder: boolean;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export function getColumnCapabilities(
|
|
77
|
+
column: Column<TMDataGridFeatures, TMDataGridRowData, unknown>,
|
|
78
|
+
features: TMDataGridFeatureFlags,
|
|
79
|
+
): TMDataGridColumnCapabilities {
|
|
80
|
+
return {
|
|
81
|
+
canSort: features.sorting && column.getCanSort(),
|
|
82
|
+
canFilter: features.filtering && column.getCanFilter(),
|
|
83
|
+
canHide: features.hiding && column.getCanHide(),
|
|
84
|
+
canPin: features.pinning && column.getCanPin(),
|
|
85
|
+
canResize: features.resizing && column.getCanResize(),
|
|
86
|
+
// Ordering has no TanStack capability method — see isColumnReorderable.
|
|
87
|
+
canReorder: features.ordering && isColumnReorderable(column),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The same questions, asked of the whole grid — used to hide toolbar buttons. */
|
|
92
|
+
export type TMDataGridCapabilities = {
|
|
93
|
+
canSortAny: boolean;
|
|
94
|
+
canFilterAny: boolean;
|
|
95
|
+
canHideAny: boolean;
|
|
96
|
+
canPinAny: boolean;
|
|
97
|
+
canReorderAny: boolean;
|
|
98
|
+
canSelectRows: boolean;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export function getGridCapabilities(
|
|
102
|
+
table: TMDataGridTable<TMDataGridRowData>,
|
|
103
|
+
features: TMDataGridFeatureFlags,
|
|
104
|
+
): TMDataGridCapabilities {
|
|
105
|
+
const columns = table.getAllLeafColumns();
|
|
106
|
+
const any = (predicate: (capabilities: TMDataGridColumnCapabilities) => boolean) =>
|
|
107
|
+
columns.some((column) => predicate(getColumnCapabilities(column, features)));
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
canSortAny: any((c) => c.canSort),
|
|
111
|
+
canFilterAny: any((c) => c.canFilter),
|
|
112
|
+
canHideAny: any((c) => c.canHide),
|
|
113
|
+
canPinAny: any((c) => c.canPin),
|
|
114
|
+
canReorderAny: any((c) => c.canReorder),
|
|
115
|
+
canSelectRows: features.rowSelection,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import type { Column, ColumnPinningState } from "@tanstack/react-table";
|
|
2
|
+
import type { TMDataGridRowData } from "./TMDataGridContext";
|
|
3
|
+
import { isColumnReorderable } from "./columnUtils";
|
|
4
|
+
import type { TMDataGridFeatures, TMDataGridTable } from "./useTMDataGrid";
|
|
5
|
+
|
|
6
|
+
type GridColumn = Column<TMDataGridFeatures, TMDataGridRowData, unknown>;
|
|
7
|
+
type GridTable = TMDataGridTable<TMDataGridRowData>;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The lane a column renders in.
|
|
11
|
+
*
|
|
12
|
+
* Pinning splits the grid into three, and TanStack sequences them from two
|
|
13
|
+
* different state slices: `columnPinning.left` / `.right` order the pinned
|
|
14
|
+
* lanes, `columnOrder` orders the centre. A move therefore always stays inside
|
|
15
|
+
* one lane — moving a column into another one would be a pin, not a reorder.
|
|
16
|
+
*/
|
|
17
|
+
export type TMDataGridColumnRegion = "left" | "center" | "right";
|
|
18
|
+
|
|
19
|
+
/** Which edge of the column under the pointer a dragged column lands on. */
|
|
20
|
+
export type TMDataGridDropSide = "before" | "after";
|
|
21
|
+
|
|
22
|
+
export function getColumnRegion(
|
|
23
|
+
columnPinning: ColumnPinningState,
|
|
24
|
+
columnId: string,
|
|
25
|
+
): TMDataGridColumnRegion {
|
|
26
|
+
if (columnPinning.left.includes(columnId)) return "left";
|
|
27
|
+
if (columnPinning.right.includes(columnId)) return "right";
|
|
28
|
+
return "center";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Moves one id next to another, leaving every other id in place. */
|
|
32
|
+
function moveInList(
|
|
33
|
+
ids: ReadonlyArray<string>,
|
|
34
|
+
movedId: string,
|
|
35
|
+
targetId: string,
|
|
36
|
+
side: TMDataGridDropSide,
|
|
37
|
+
): Array<string> {
|
|
38
|
+
const remaining = ids.filter((id) => id !== movedId);
|
|
39
|
+
const targetIndex = remaining.indexOf(targetId);
|
|
40
|
+
if (targetIndex === -1) return [...ids];
|
|
41
|
+
remaining.splice(side === "before" ? targetIndex : targetIndex + 1, 0, movedId);
|
|
42
|
+
return remaining;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type MoveColumnArgs = {
|
|
46
|
+
table: GridTable;
|
|
47
|
+
/** Column being moved. */
|
|
48
|
+
columnId: string;
|
|
49
|
+
/** Column it was dropped on. */
|
|
50
|
+
targetId: string;
|
|
51
|
+
side: TMDataGridDropSide;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Moves a column next to another one. A no-op when the two are in different
|
|
56
|
+
* pinned regions, or when either id is unknown.
|
|
57
|
+
*
|
|
58
|
+
* `columnOrder` is always written as the full leaf order, including hidden and
|
|
59
|
+
* pinned columns, so a column keeps its place when it is later shown or
|
|
60
|
+
* unpinned. Pinned lanes are read from `columnPinning`, so a move inside one
|
|
61
|
+
* updates that array as well.
|
|
62
|
+
*/
|
|
63
|
+
export function moveColumn({
|
|
64
|
+
table,
|
|
65
|
+
columnId,
|
|
66
|
+
targetId,
|
|
67
|
+
side,
|
|
68
|
+
}: MoveColumnArgs): void {
|
|
69
|
+
if (columnId === targetId) return;
|
|
70
|
+
|
|
71
|
+
const columnPinning = table.store.state.columnPinning;
|
|
72
|
+
const region = getColumnRegion(columnPinning, columnId);
|
|
73
|
+
if (region !== getColumnRegion(columnPinning, targetId)) return;
|
|
74
|
+
|
|
75
|
+
const leafIds = table.getAllLeafColumns().map((column) => column.id);
|
|
76
|
+
table.setColumnOrder(moveInList(leafIds, columnId, targetId, side));
|
|
77
|
+
|
|
78
|
+
if (region !== "center") {
|
|
79
|
+
table.setColumnPinning((previous) => ({
|
|
80
|
+
...previous,
|
|
81
|
+
[region]: moveInList(previous[region], columnId, targetId, side),
|
|
82
|
+
}));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Visible leaf columns of one lane, in the order they render. */
|
|
87
|
+
function getRegionColumns(
|
|
88
|
+
table: GridTable,
|
|
89
|
+
region: TMDataGridColumnRegion,
|
|
90
|
+
): Array<GridColumn> {
|
|
91
|
+
if (region === "left") return table.getLeftVisibleLeafColumns();
|
|
92
|
+
if (region === "right") return table.getRightVisibleLeafColumns();
|
|
93
|
+
return table.getCenterVisibleLeafColumns();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export type ColumnStepArgs = {
|
|
97
|
+
table: GridTable;
|
|
98
|
+
columnId: string;
|
|
99
|
+
/** `-1` moves towards the start of the lane, `1` towards its end. */
|
|
100
|
+
direction: -1 | 1;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The column a single step would swap with, or `null` when there is none.
|
|
105
|
+
*
|
|
106
|
+
* A neighbour that cannot be reordered acts as a wall rather than being skipped
|
|
107
|
+
* — that is what keeps the checkbox column anchored at the start of the left
|
|
108
|
+
* lane. Use it to enable or disable a "move" control.
|
|
109
|
+
*/
|
|
110
|
+
export function getStepTargetColumn({
|
|
111
|
+
table,
|
|
112
|
+
columnId,
|
|
113
|
+
direction,
|
|
114
|
+
}: ColumnStepArgs): GridColumn | null {
|
|
115
|
+
const region = getColumnRegion(table.store.state.columnPinning, columnId);
|
|
116
|
+
const columns = getRegionColumns(table, region);
|
|
117
|
+
const index = columns.findIndex((column) => column.id === columnId);
|
|
118
|
+
if (index === -1) return null;
|
|
119
|
+
|
|
120
|
+
const target = columns[index + direction];
|
|
121
|
+
if (!target || !isColumnReorderable(target)) return null;
|
|
122
|
+
return target;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Moves a column one position within its lane. Hidden columns are stepped over. */
|
|
126
|
+
export function moveColumnByStep({
|
|
127
|
+
table,
|
|
128
|
+
columnId,
|
|
129
|
+
direction,
|
|
130
|
+
}: ColumnStepArgs): void {
|
|
131
|
+
const target = getStepTargetColumn({ table, columnId, direction });
|
|
132
|
+
if (!target) return;
|
|
133
|
+
|
|
134
|
+
moveColumn({
|
|
135
|
+
table,
|
|
136
|
+
columnId,
|
|
137
|
+
targetId: target.id,
|
|
138
|
+
side: direction === -1 ? "before" : "after",
|
|
139
|
+
});
|
|
140
|
+
}
|