@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,466 @@
1
+ import { ActionIcon, Menu, Tooltip } from "@mantine/core";
2
+ import { flexRender, type Header } from "@tanstack/react-table";
3
+ import { useSelector } from "@tanstack/react-store";
4
+ import { shallow } from "@tanstack/store";
5
+ import { type DragEvent, type ReactNode, useRef, useState } from "react";
6
+ import classes from "./TMDataGrid.module.css";
7
+ import {
8
+ type TMDataGridRowData,
9
+ useTMDataGridContext,
10
+ } from "./TMDataGridContext";
11
+ import type { TMDataGridColumnLayout } from "./TMDataGridTable";
12
+ import { getColumnCapabilities, getGridCapabilities } from "./capabilities";
13
+ import {
14
+ getColumnRegion,
15
+ getStepTargetColumn,
16
+ moveColumn,
17
+ moveColumnByStep,
18
+ type TMDataGridDropSide,
19
+ } from "./columnOrdering";
20
+ import { getColumnAlign, getColumnLabel } from "./columnUtils";
21
+ import { isFilterActive } from "./filterOperators";
22
+ import {
23
+ ArrowDownIcon,
24
+ ArrowUpIcon,
25
+ ColumnsIcon,
26
+ DotsVerticalIcon,
27
+ EyeOffIcon,
28
+ FilterIcon,
29
+ MoveLeftIcon,
30
+ MoveRightIcon,
31
+ PinLeftIcon,
32
+ PinOffIcon,
33
+ PinRightIcon,
34
+ } from "./icons";
35
+ import {
36
+ type TMDataGridFeatures,
37
+ openColumnFilter,
38
+ SELECT_COLUMN_ID,
39
+ } from "./useTMDataGrid";
40
+
41
+ export type TMDataGridHeader = Header<
42
+ TMDataGridFeatures,
43
+ TMDataGridRowData,
44
+ unknown
45
+ >;
46
+
47
+ export function TMDataGridHeaderCell({
48
+ header,
49
+ layout,
50
+ }: {
51
+ header: TMDataGridHeader;
52
+ layout: TMDataGridColumnLayout;
53
+ }) {
54
+ const api = useTMDataGridContext();
55
+ const { table, features } = api;
56
+ const column = header.column;
57
+ const [menuOpened, setMenuOpened] = useState(false);
58
+ const [dropSide, setDropSide] = useState<TMDataGridDropSide | null>(null);
59
+ const cellRef = useRef<HTMLDivElement>(null);
60
+ // A resize drag ends with a click that bubbles to the header — without this
61
+ // every resize would also toggle the column's sort order.
62
+ const suppressSortRef = useRef(false);
63
+
64
+ // Subscribing to the slices this cell renders from keeps it live even when the
65
+ // surrounding tree is memoized (React Compiler) or the table lives elsewhere.
66
+ // `columnOrder` is in the list because the move menu items are derived from
67
+ // the column's neighbours, which is exactly what an order change invalidates.
68
+ const { sorting, columnFilters, resizingColumnId, columnPinning } =
69
+ useSelector(
70
+ table.store,
71
+ (state) => ({
72
+ sorting: state.sorting,
73
+ columnFilters: state.columnFilters,
74
+ resizingColumnId: state.columnResizing.isResizingColumn,
75
+ columnPinning: state.columnPinning,
76
+ columnOrder: state.columnOrder,
77
+ }),
78
+ { compare: shallow },
79
+ );
80
+ const draggedColumnId = useSelector(api.ui, (state) => state.draggedColumnId);
81
+
82
+ const isLeaf = header.subHeaders.length === 0;
83
+ const label = getColumnLabel(column);
84
+ const align = getColumnAlign(column);
85
+ const sortDirection = sorting.find((sort) => sort.id === column.id)?.desc;
86
+ const isSorted = sortDirection !== undefined;
87
+ const isFiltered = columnFilters.some(
88
+ (filter) => filter.id === column.id && isFilterActive(filter.value),
89
+ );
90
+ const isResizing = resizingColumnId === column.id;
91
+ const pinnedAt = layout.pinnedAt;
92
+
93
+ // Every affordance below mirrors a TanStack capability check, so switching a
94
+ // feature off through the table options removes the markup with it.
95
+ const capabilities = getColumnCapabilities(column, features);
96
+ const canSort = isLeaf && capabilities.canSort;
97
+ const canFilter = isLeaf && capabilities.canFilter;
98
+ const canHide = isLeaf && capabilities.canHide;
99
+ const canPin = isLeaf && capabilities.canPin;
100
+ const canResize = capabilities.canResize;
101
+ const canReorder = isLeaf && capabilities.canReorder;
102
+ const canManageColumns =
103
+ isLeaf && getGridCapabilities(table, features).canHideAny;
104
+
105
+ // A column only moves inside its own pinned lane, so a header in another one
106
+ // never lights up as a target. See TMDataGridColumnRegion.
107
+ const isDragged = draggedColumnId === column.id;
108
+ const isDropTarget =
109
+ canReorder &&
110
+ draggedColumnId !== null &&
111
+ !isDragged &&
112
+ getColumnRegion(columnPinning, draggedColumnId) ===
113
+ getColumnRegion(columnPinning, column.id);
114
+
115
+ function handleDragStart(event: DragEvent<HTMLDivElement>) {
116
+ event.dataTransfer.effectAllowed = "move";
117
+ // Some browsers refuse to start a drag with an empty payload.
118
+ event.dataTransfer.setData("text/plain", column.id);
119
+ api.ui.actions.startColumnDrag(column.id);
120
+ }
121
+
122
+ function handleDragOver(event: DragEvent<HTMLDivElement>) {
123
+ if (!isDropTarget) return;
124
+ // Only a prevented dragover marks an element as a drop target.
125
+ event.preventDefault();
126
+ event.dataTransfer.dropEffect = "move";
127
+ const bounds = cellRef.current?.getBoundingClientRect();
128
+ if (!bounds) return;
129
+ setDropSide(
130
+ event.clientX < bounds.left + bounds.width / 2 ? "before" : "after",
131
+ );
132
+ }
133
+
134
+ function handleDragLeave(event: DragEvent<HTMLDivElement>) {
135
+ // dragleave also fires when the pointer crosses into a child element.
136
+ const next = event.relatedTarget;
137
+ if (next instanceof Node && event.currentTarget.contains(next)) return;
138
+ setDropSide(null);
139
+ }
140
+
141
+ function handleDrop(event: DragEvent<HTMLDivElement>) {
142
+ event.preventDefault();
143
+ setDropSide(null);
144
+ api.ui.actions.endColumnDrag();
145
+ if (!isDropTarget || !dropSide || !draggedColumnId) return;
146
+ moveColumn({
147
+ table,
148
+ columnId: draggedColumnId,
149
+ targetId: column.id,
150
+ side: dropSide,
151
+ });
152
+ }
153
+
154
+ function handleDragEnd() {
155
+ setDropSide(null);
156
+ api.ui.actions.endColumnDrag();
157
+ }
158
+
159
+ /**
160
+ * Pinned columns are positioned with `getStart()` / `getAfter()`, which sum
161
+ * `column.getSize()`. Fluid (`fr`) columns don't report their rendered width
162
+ * there, so freeze the measured width into `columnSizing` as we pin — that
163
+ * keeps the column looking the same and keeps the offsets exact.
164
+ */
165
+ function setPinned(position: "left" | "right" | false) {
166
+ if (position && !(column.id in table.store.state.columnSizing)) {
167
+ const width = cellRef.current?.getBoundingClientRect().width;
168
+ if (width) {
169
+ table.setColumnSizing((prev) => ({
170
+ ...prev,
171
+ [column.id]: Math.round(width),
172
+ }));
173
+ }
174
+ }
175
+ column.pin(position);
176
+ }
177
+
178
+ const menuItems: ReactNode[] = [];
179
+ if (canSort) {
180
+ menuItems.push(
181
+ <Menu.Item
182
+ key="sort-asc"
183
+ leftSection={<ArrowUpIcon size={16} stroke={1.6} />}
184
+ onClick={() => column.toggleSorting(false)}
185
+ >
186
+ Sort by ASC
187
+ </Menu.Item>,
188
+ <Menu.Item
189
+ key="sort-desc"
190
+ leftSection={<ArrowDownIcon size={16} stroke={1.6} />}
191
+ onClick={() => column.toggleSorting(true)}
192
+ >
193
+ Sort by DESC
194
+ </Menu.Item>,
195
+ <Menu.Divider key="sort-divider" />,
196
+ );
197
+ }
198
+ if (canFilter) {
199
+ menuItems.push(
200
+ <Menu.Item
201
+ key="filter"
202
+ leftSection={<FilterIcon size={16} stroke={1.6} />}
203
+ onClick={() => openColumnFilter(api, column.id)}
204
+ >
205
+ Filter
206
+ </Menu.Item>,
207
+ <Menu.Divider key="filter-divider" />,
208
+ );
209
+ }
210
+ if (canPin) {
211
+ menuItems.push(
212
+ <Menu.Item
213
+ key="pin-left"
214
+ className={pinnedAt === "left" ? classes.menuItemActive : undefined}
215
+ leftSection={<PinLeftIcon size={16} stroke={1.6} />}
216
+ onClick={() => setPinned(pinnedAt === "left" ? false : "left")}
217
+ >
218
+ Pin to left
219
+ </Menu.Item>,
220
+ <Menu.Item
221
+ key="pin-right"
222
+ className={pinnedAt === "right" ? classes.menuItemActive : undefined}
223
+ leftSection={<PinRightIcon size={16} stroke={1.6} />}
224
+ onClick={() => setPinned(pinnedAt === "right" ? false : "right")}
225
+ >
226
+ Pin to right
227
+ </Menu.Item>,
228
+ );
229
+ if (pinnedAt) {
230
+ menuItems.push(
231
+ <Menu.Item
232
+ key="unpin"
233
+ leftSection={<PinOffIcon size={16} stroke={1.6} />}
234
+ onClick={() => setPinned(false)}
235
+ >
236
+ Unpin
237
+ </Menu.Item>,
238
+ );
239
+ }
240
+ menuItems.push(<Menu.Divider key="pin-divider" />);
241
+ }
242
+ if (canReorder) {
243
+ // The keyboard path to reordering, and the discoverable one — a header you
244
+ // can drag looks no different from one you cannot.
245
+ const previous = getStepTargetColumn({
246
+ table,
247
+ columnId: column.id,
248
+ direction: -1,
249
+ });
250
+ const next = getStepTargetColumn({
251
+ table,
252
+ columnId: column.id,
253
+ direction: 1,
254
+ });
255
+ if (previous || next) {
256
+ menuItems.push(
257
+ <Menu.Item
258
+ key="move-left"
259
+ disabled={!previous}
260
+ leftSection={<MoveLeftIcon size={16} stroke={1.6} />}
261
+ onClick={() =>
262
+ moveColumnByStep({ table, columnId: column.id, direction: -1 })
263
+ }
264
+ >
265
+ Move left
266
+ </Menu.Item>,
267
+ <Menu.Item
268
+ key="move-right"
269
+ disabled={!next}
270
+ leftSection={<MoveRightIcon size={16} stroke={1.6} />}
271
+ onClick={() =>
272
+ moveColumnByStep({ table, columnId: column.id, direction: 1 })
273
+ }
274
+ >
275
+ Move right
276
+ </Menu.Item>,
277
+ <Menu.Divider key="move-divider" />,
278
+ );
279
+ }
280
+ }
281
+ if (canHide) {
282
+ menuItems.push(
283
+ <Menu.Item
284
+ key="hide"
285
+ leftSection={<EyeOffIcon size={16} stroke={1.6} />}
286
+ onClick={() => column.toggleVisibility(false)}
287
+ >
288
+ Hide column
289
+ </Menu.Item>,
290
+ );
291
+ }
292
+ if (canManageColumns) {
293
+ menuItems.push(
294
+ <Menu.Item
295
+ key="manage"
296
+ leftSection={<ColumnsIcon size={16} stroke={1.6} />}
297
+ onClick={() => api.ui.actions.setColumnsPanelOpen(true)}
298
+ >
299
+ Manage columns
300
+ </Menu.Item>,
301
+ );
302
+ }
303
+ // A trailing divider reads as a dropped item — drop the divider instead.
304
+ while (menuItems.length > 0 && isDivider(menuItems.at(-1))) {
305
+ menuItems.pop();
306
+ }
307
+
308
+ const cellClass = [
309
+ classes.headerCell,
310
+ canSort ? classes.headerCellSortable : "",
311
+ isDragged ? classes.headerCellDragging : "",
312
+ dropSide === "before" ? classes.headerCellDropBefore : "",
313
+ dropSide === "after" ? classes.headerCellDropAfter : "",
314
+ layout.isBoundary && pinnedAt === "left" ? classes.stickyLeft : "",
315
+ layout.isBoundary && pinnedAt === "right" ? classes.stickyRight : "",
316
+ ]
317
+ .filter(Boolean)
318
+ .join(" ");
319
+
320
+ return (
321
+ <div
322
+ ref={cellRef}
323
+ role="columnheader"
324
+ data-testid={`dg-header-${column.id}`}
325
+ data-active={isSorted || isFiltered}
326
+ data-align={align}
327
+ className={cellClass}
328
+ // Dropped while a resize is running, so that dragging the separator
329
+ // resizes the column instead of starting to move it. The resize handler
330
+ // records the column on mousedown, before the first pointer move.
331
+ draggable={canReorder && !isResizing}
332
+ onDragStart={canReorder ? handleDragStart : undefined}
333
+ onDragEnd={canReorder ? handleDragEnd : undefined}
334
+ onDragOver={isDropTarget ? handleDragOver : undefined}
335
+ onDragLeave={isDropTarget ? handleDragLeave : undefined}
336
+ onDrop={isDropTarget ? handleDrop : undefined}
337
+ style={{
338
+ left: pinnedAt === "left" ? layout.offset : undefined,
339
+ right: pinnedAt === "right" ? layout.offset : undefined,
340
+ position: pinnedAt ? "sticky" : undefined,
341
+ zIndex: pinnedAt ? 4 : undefined,
342
+ }}
343
+ onClick={
344
+ canSort
345
+ ? (event) => {
346
+ if (suppressSortRef.current) {
347
+ suppressSortRef.current = false;
348
+ return;
349
+ }
350
+ column.getToggleSortingHandler()?.(event);
351
+ }
352
+ : undefined
353
+ }
354
+ >
355
+ {header.isPlaceholder ? null : (
356
+ <Tooltip label={label} openDelay={600} withinPortal disabled={!isLeaf}>
357
+ <span className={classes.headerTitle}>
358
+ {flexRender(column.columnDef.header, header.getContext())}
359
+ </span>
360
+ </Tooltip>
361
+ )}
362
+
363
+ {isLeaf && (
364
+ <div className={classes.headerActions}>
365
+ {isFiltered && (
366
+ <ActionIcon
367
+ className={classes.headerAction}
368
+ data-pinned-visible="true"
369
+ variant="subtle"
370
+ color="gray"
371
+ size="xs"
372
+ aria-label={`Filter on ${label}`}
373
+ onClick={(event) => {
374
+ event.stopPropagation();
375
+ openColumnFilter(api, column.id);
376
+ }}
377
+ >
378
+ <FilterIcon size={14} stroke={1.6} />
379
+ </ActionIcon>
380
+ )}
381
+
382
+ {canSort && (
383
+ <ActionIcon
384
+ className={classes.headerAction}
385
+ data-pinned-visible={isSorted}
386
+ variant="subtle"
387
+ color="gray"
388
+ size="xs"
389
+ aria-label={`Sort ${label}`}
390
+ onClick={column.getToggleSortingHandler()}
391
+ >
392
+ {sortDirection ? (
393
+ <ArrowDownIcon size={14} stroke={1.6} />
394
+ ) : (
395
+ <ArrowUpIcon size={14} stroke={1.6} />
396
+ )}
397
+ </ActionIcon>
398
+ )}
399
+
400
+ {menuItems.length > 0 && column.id !== SELECT_COLUMN_ID && (
401
+ <Menu
402
+ opened={menuOpened}
403
+ onChange={setMenuOpened}
404
+ position="bottom-start"
405
+ shadow="md"
406
+ width={220}
407
+ withinPortal
408
+ >
409
+ <Menu.Target>
410
+ <ActionIcon
411
+ className={classes.headerAction}
412
+ data-pinned-visible={menuOpened}
413
+ variant="subtle"
414
+ color="gray"
415
+ size="xs"
416
+ aria-label={`${label} column menu`}
417
+ onClick={(event) => {
418
+ event.stopPropagation();
419
+ setMenuOpened((opened) => !opened);
420
+ }}
421
+ >
422
+ <DotsVerticalIcon size={14} stroke={1.6} />
423
+ </ActionIcon>
424
+ </Menu.Target>
425
+ <Menu.Dropdown onClick={(event) => event.stopPropagation()}>
426
+ {menuItems}
427
+ </Menu.Dropdown>
428
+ </Menu>
429
+ )}
430
+ </div>
431
+ )}
432
+
433
+ <div
434
+ className={[
435
+ classes.columnSeparator,
436
+ canResize ? classes.columnSeparatorResizable : "",
437
+ isResizing ? classes.columnSeparatorActive : "",
438
+ ]
439
+ .filter(Boolean)
440
+ .join(" ")}
441
+ // Keeps a drag that starts on the separator from being picked up by the
442
+ // draggable header around it.
443
+ draggable={false}
444
+ onMouseDown={
445
+ canResize
446
+ ? (event) => {
447
+ suppressSortRef.current = true;
448
+ header.getResizeHandler()(event);
449
+ }
450
+ : undefined
451
+ }
452
+ onTouchStart={canResize ? header.getResizeHandler() : undefined}
453
+ onClick={(event) => event.stopPropagation()}
454
+ />
455
+ </div>
456
+ );
457
+ }
458
+
459
+ function isDivider(node: ReactNode): boolean {
460
+ return (
461
+ typeof node === "object" &&
462
+ node !== null &&
463
+ "type" in node &&
464
+ node.type === Menu.Divider
465
+ );
466
+ }