@jielga/tmdatagrid 0.1.0 → 0.2.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/README.md +35 -2
- package/dist/index.d.ts +442 -337
- package/dist/index.js +811 -694
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +15 -7
- package/skills/columns/SKILL.md +8 -7
- package/skills/features/SKILL.md +104 -16
- package/skills/getting-started/SKILL.md +39 -16
- package/skills/options/SKILL.md +12 -7
- package/skills/server-side/SKILL.md +5 -2
- package/src/tmdatagrid/TMDataGridContext.ts +1 -1
- package/src/tmdatagrid/components/TMDataGrid.module.css +63 -0
- package/src/tmdatagrid/{TMDataGrid.tsx → components/TMDataGrid.tsx} +9 -4
- package/src/tmdatagrid/components/TMDataGridColumnsPanel.module.css +25 -0
- package/src/tmdatagrid/{TMDataGridColumnsPanel.tsx → components/TMDataGridColumnsPanel.tsx} +3 -3
- package/src/tmdatagrid/components/TMDataGridFilterPanel.module.css +20 -0
- package/src/tmdatagrid/{TMDataGridFilterPanel.tsx → components/TMDataGridFilterPanel.tsx} +4 -4
- package/src/tmdatagrid/components/TMDataGridFooter.module.css +10 -0
- package/src/tmdatagrid/components/TMDataGridFooter.tsx +143 -0
- package/src/tmdatagrid/components/TMDataGridHeaderCell.module.css +153 -0
- package/src/tmdatagrid/{TMDataGridHeaderCell.tsx → components/TMDataGridHeaderCell.tsx} +22 -13
- package/src/tmdatagrid/components/TMDataGridSelectColumn.tsx +100 -0
- package/src/tmdatagrid/components/TMDataGridTable.module.css +103 -0
- package/src/tmdatagrid/{TMDataGridTable.tsx → components/TMDataGridTable.tsx} +79 -20
- package/src/tmdatagrid/components/TMDataGridToolbar.module.css +12 -0
- package/src/tmdatagrid/{TMDataGridToolbar.tsx → components/TMDataGridToolbar.tsx} +5 -5
- package/src/tmdatagrid/components/sticky.module.css +45 -0
- package/src/tmdatagrid/{capabilities.ts → core/capabilities.ts} +30 -2
- package/src/tmdatagrid/{columnOrdering.ts → core/columnOrdering.ts} +2 -2
- package/src/tmdatagrid/{columnUtils.ts → core/columnUtils.ts} +1 -1
- package/src/tmdatagrid/{persistence.ts → core/persistence.ts} +80 -2
- package/src/tmdatagrid/index.ts +15 -9
- package/src/tmdatagrid/useTMDataGrid.tsx +102 -55
- package/src/tmdatagrid/TMDataGrid.module.css +0 -410
- package/src/tmdatagrid/TMDataGridFooter.tsx +0 -74
- /package/src/tmdatagrid/{icons.ts → components/icons.ts} +0 -0
- /package/src/tmdatagrid/{filterOperators.ts → core/filterOperators.ts} +0 -0
- /package/src/tmdatagrid/{sizes.ts → core/sizes.ts} +0 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/* Floats over the first body rows, anchored to `.tableWrapper`. Sits just
|
|
2
|
+
below the header row, so its offset follows the size scale rather than a
|
|
3
|
+
fixed pixel count. */
|
|
4
|
+
.filterPanel {
|
|
5
|
+
position: absolute;
|
|
6
|
+
top: calc(var(--dg-header-height) + 2px);
|
|
7
|
+
left: var(--mantine-spacing-md);
|
|
8
|
+
z-index: 5;
|
|
9
|
+
padding: var(--mantine-spacing-sm);
|
|
10
|
+
border-radius: var(--mantine-radius-md);
|
|
11
|
+
border: 1px solid var(--mantine-color-default-border);
|
|
12
|
+
background-color: var(--mantine-color-body);
|
|
13
|
+
box-shadow: var(--mantine-shadow-md);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.filterRow {
|
|
17
|
+
display: flex;
|
|
18
|
+
align-items: flex-end;
|
|
19
|
+
gap: var(--mantine-spacing-xs);
|
|
20
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { ActionIcon, Button, Select, Stack, TextInput } from "@mantine/core";
|
|
2
2
|
import { useSelector } from "@tanstack/react-store";
|
|
3
|
-
import classes from "./
|
|
4
|
-
import { useTMDataGridContext } from "
|
|
5
|
-
import { getColumnLabel, getColumnType } from "
|
|
3
|
+
import classes from "./TMDataGridFilterPanel.module.css";
|
|
4
|
+
import { useTMDataGridContext } from "../TMDataGridContext";
|
|
5
|
+
import { getColumnLabel, getColumnType } from "../core/columnUtils";
|
|
6
6
|
import {
|
|
7
7
|
type TMDataGridFilterOperator,
|
|
8
8
|
type TMDataGridFilterValue,
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
getOperatorsForType,
|
|
12
12
|
isTMDataGridFilterValue,
|
|
13
13
|
operatorNeedsValue,
|
|
14
|
-
} from "
|
|
14
|
+
} from "../core/filterOperators";
|
|
15
15
|
import { CloseIcon } from "./icons";
|
|
16
16
|
|
|
17
17
|
const FALLBACK_FILTER: TMDataGridFilterValue = { operator: "contains", value: "" };
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { ActionIcon, Group, Select, Text } from "@mantine/core";
|
|
2
|
+
import type { RowData } from "@tanstack/react-table";
|
|
3
|
+
import { useSelector } from "@tanstack/react-store";
|
|
4
|
+
import type { ReactNode } from "react";
|
|
5
|
+
import classes from "./TMDataGridFooter.module.css";
|
|
6
|
+
import { useTMDataGridContext } from "../TMDataGridContext";
|
|
7
|
+
import { getGridCapabilities } from "../core/capabilities";
|
|
8
|
+
import { ChevronLeftIcon, ChevronRightIcon } from "./icons";
|
|
9
|
+
import type { TMDataGridTable } from "../useTMDataGrid";
|
|
10
|
+
|
|
11
|
+
/** Distilled pagination state and actions for building a custom pager. */
|
|
12
|
+
export type TMDataGridPaginationApi = {
|
|
13
|
+
pageIndex: number;
|
|
14
|
+
pageSize: number;
|
|
15
|
+
/** `-1` when a manual grid declares `pageCount: -1` (unknown total). */
|
|
16
|
+
pageCount: number;
|
|
17
|
+
rowCount: number;
|
|
18
|
+
canPreviousPage: boolean;
|
|
19
|
+
canNextPage: boolean;
|
|
20
|
+
setPageIndex: (pageIndex: number) => void;
|
|
21
|
+
setPageSize: (pageSize: number) => void;
|
|
22
|
+
previousPage: () => void;
|
|
23
|
+
nextPage: () => void;
|
|
24
|
+
firstPage: () => void;
|
|
25
|
+
lastPage: () => void;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Reads {@link TMDataGridPaginationApi} off a table. The Footer feeds it to the
|
|
30
|
+
* `pagination` render prop; a pager living outside the Footer can call it with
|
|
31
|
+
* the table from `useTMDataGrid` directly.
|
|
32
|
+
*/
|
|
33
|
+
export function getTMDataGridPaginationApi<TData extends RowData>(
|
|
34
|
+
table: TMDataGridTable<TData>,
|
|
35
|
+
): TMDataGridPaginationApi {
|
|
36
|
+
const { pageIndex, pageSize } = table.store.state.pagination;
|
|
37
|
+
return {
|
|
38
|
+
pageIndex,
|
|
39
|
+
pageSize,
|
|
40
|
+
pageCount: table.getPageCount(),
|
|
41
|
+
rowCount: table.getRowCount(),
|
|
42
|
+
canPreviousPage: table.getCanPreviousPage(),
|
|
43
|
+
canNextPage: table.getCanNextPage(),
|
|
44
|
+
setPageIndex: (index) => table.setPageIndex(index),
|
|
45
|
+
setPageSize: (size) => table.setPageSize(size),
|
|
46
|
+
previousPage: () => table.previousPage(),
|
|
47
|
+
nextPage: () => table.nextPage(),
|
|
48
|
+
firstPage: () => table.firstPage(),
|
|
49
|
+
lastPage: () => table.lastPage(),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type TMDataGridFooterProps = {
|
|
54
|
+
pageSizeOptions?: ReadonlyArray<number>;
|
|
55
|
+
/** Replaces the built-in pager. Receives the distilled pagination API. */
|
|
56
|
+
pagination?: (api: TMDataGridPaginationApi) => ReactNode;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* MUI-style pager: "Rows per page · from–to of total · ‹ ›".
|
|
61
|
+
*
|
|
62
|
+
* Renders nothing unless pagination is enabled (`enablePagination` or
|
|
63
|
+
* `manualPagination`) — the footer is a pager; extra footer content belongs in
|
|
64
|
+
* your own layout.
|
|
65
|
+
*
|
|
66
|
+
* Row totals come from `table.getRowCount()`, which prefers `options.rowCount`
|
|
67
|
+
* — so a server-paged grid shows the server's total without changes here.
|
|
68
|
+
*/
|
|
69
|
+
export function TMDataGridFooter({
|
|
70
|
+
pageSizeOptions = [10, 25, 50, 100],
|
|
71
|
+
pagination,
|
|
72
|
+
}: TMDataGridFooterProps) {
|
|
73
|
+
const { table, features, controlSize } = useTMDataGridContext();
|
|
74
|
+
useSelector(table.store);
|
|
75
|
+
|
|
76
|
+
if (!getGridCapabilities(table, features).canPaginate) return null;
|
|
77
|
+
|
|
78
|
+
if (pagination) {
|
|
79
|
+
return (
|
|
80
|
+
<div className={classes.footer}>
|
|
81
|
+
{pagination(getTMDataGridPaginationApi(table))}
|
|
82
|
+
</div>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const { pageIndex, pageSize } = table.store.state.pagination;
|
|
87
|
+
const total = table.getRowCount();
|
|
88
|
+
const from = total === 0 ? 0 : pageIndex * pageSize + 1;
|
|
89
|
+
const to = Math.min(total, (pageIndex + 1) * pageSize);
|
|
90
|
+
// A page size restored from storage, or set through `initialState`, need not
|
|
91
|
+
// be one of the offered sizes. Without it in the list the Select renders
|
|
92
|
+
// blank, so the current size is folded in rather than shown as nothing.
|
|
93
|
+
const sizeOptions = pageSizeOptions.includes(pageSize)
|
|
94
|
+
? pageSizeOptions
|
|
95
|
+
: [...pageSizeOptions, pageSize].sort((a, b) => a - b);
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<div className={classes.footer}>
|
|
99
|
+
<Group gap="xs" wrap="nowrap">
|
|
100
|
+
<Text size={controlSize} c="dimmed">
|
|
101
|
+
Rows per page:
|
|
102
|
+
</Text>
|
|
103
|
+
<Select
|
|
104
|
+
size={controlSize}
|
|
105
|
+
w={78}
|
|
106
|
+
allowDeselect={false}
|
|
107
|
+
variant="unstyled"
|
|
108
|
+
data={sizeOptions.map(String)}
|
|
109
|
+
value={String(pageSize)}
|
|
110
|
+
onChange={(value) => {
|
|
111
|
+
table.setPageSize(Number(value) || pageSizeOptions[0]);
|
|
112
|
+
table.setPageIndex(0);
|
|
113
|
+
}}
|
|
114
|
+
/>
|
|
115
|
+
</Group>
|
|
116
|
+
|
|
117
|
+
<Text size={controlSize} c="dimmed">
|
|
118
|
+
{from}–{to} of {total}
|
|
119
|
+
</Text>
|
|
120
|
+
|
|
121
|
+
<Group gap={4} wrap="nowrap">
|
|
122
|
+
<ActionIcon
|
|
123
|
+
variant="subtle"
|
|
124
|
+
color="gray"
|
|
125
|
+
aria-label="Previous page"
|
|
126
|
+
disabled={!table.getCanPreviousPage()}
|
|
127
|
+
onClick={() => table.previousPage()}
|
|
128
|
+
>
|
|
129
|
+
<ChevronLeftIcon size={18} stroke={1.6} />
|
|
130
|
+
</ActionIcon>
|
|
131
|
+
<ActionIcon
|
|
132
|
+
variant="subtle"
|
|
133
|
+
color="gray"
|
|
134
|
+
aria-label="Next page"
|
|
135
|
+
disabled={!table.getCanNextPage()}
|
|
136
|
+
onClick={() => table.nextPage()}
|
|
137
|
+
>
|
|
138
|
+
<ChevronRightIcon size={18} stroke={1.6} />
|
|
139
|
+
</ActionIcon>
|
|
140
|
+
</Group>
|
|
141
|
+
</div>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
.headerCell {
|
|
2
|
+
position: relative;
|
|
3
|
+
display: flex;
|
|
4
|
+
align-items: center;
|
|
5
|
+
gap: 2px;
|
|
6
|
+
padding: 0 var(--dg-padding);
|
|
7
|
+
min-width: 0;
|
|
8
|
+
min-height: var(--dg-header-height);
|
|
9
|
+
font-weight: 600;
|
|
10
|
+
font-size: var(--dg-font-size);
|
|
11
|
+
background-color: var(--mantine-color-body);
|
|
12
|
+
border-bottom: 1px solid var(--mantine-color-default-border);
|
|
13
|
+
user-select: none;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.headerCell[data-align="right"] {
|
|
17
|
+
justify-content: flex-end;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.headerCell[data-align="center"] {
|
|
21
|
+
justify-content: center;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/* Sorted or filtered columns read as active, like the MUI data grid. */
|
|
25
|
+
.headerCell[data-active="true"] .headerTitle {
|
|
26
|
+
color: var(--mantine-primary-color-filled);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.headerTitle {
|
|
30
|
+
flex: 1 1 auto;
|
|
31
|
+
min-width: 0;
|
|
32
|
+
overflow: hidden;
|
|
33
|
+
text-overflow: ellipsis;
|
|
34
|
+
white-space: nowrap;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/* Right-aligned headers stay text so the ellipsis still works; centered ones
|
|
38
|
+
hold a control (the select-all checkbox), so they become a flex box. */
|
|
39
|
+
.headerCell[data-align="right"] .headerTitle {
|
|
40
|
+
text-align: right;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.headerCell[data-align="center"] .headerTitle {
|
|
44
|
+
display: flex;
|
|
45
|
+
justify-content: center;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.headerActions {
|
|
49
|
+
display: flex;
|
|
50
|
+
align-items: center;
|
|
51
|
+
gap: 0;
|
|
52
|
+
flex-shrink: 0;
|
|
53
|
+
margin-left: auto;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/* Actions always reserve their width. On right-aligned columns they move to the
|
|
57
|
+
leading edge so the header text stays flush with the cell values below it. */
|
|
58
|
+
.headerCell[data-align="right"] .headerActions {
|
|
59
|
+
order: -1;
|
|
60
|
+
margin-left: 0;
|
|
61
|
+
margin-right: auto;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/* Idle actions are removed from layout, not just faded — reserving their width
|
|
65
|
+
would truncate header text that otherwise fits. Indicators that must stay
|
|
66
|
+
visible (active sort, active filter, open menu) set data-pinned-visible. */
|
|
67
|
+
.headerAction {
|
|
68
|
+
display: none;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.headerCell:hover .headerAction,
|
|
72
|
+
.headerCell:focus-within .headerAction,
|
|
73
|
+
.headerAction[data-pinned-visible="true"] {
|
|
74
|
+
display: flex;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.headerCellSortable {
|
|
78
|
+
cursor: pointer;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/* Column reordering. The dragged header stays in place and dims — the browser
|
|
82
|
+
already carries a snapshot of it under the pointer — while the header being
|
|
83
|
+
dragged over draws a bar on the edge the column will land against. */
|
|
84
|
+
.headerCellDragging {
|
|
85
|
+
opacity: 0.4;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.headerCellDropBefore::before,
|
|
89
|
+
.headerCellDropAfter::before {
|
|
90
|
+
content: "";
|
|
91
|
+
position: absolute;
|
|
92
|
+
top: 0;
|
|
93
|
+
bottom: 0;
|
|
94
|
+
width: 2px;
|
|
95
|
+
background-color: var(--mantine-primary-color-filled);
|
|
96
|
+
z-index: 5;
|
|
97
|
+
pointer-events: none;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
.headerCellDropBefore::before {
|
|
101
|
+
left: 0;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.headerCellDropAfter::before {
|
|
105
|
+
right: 0;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/* Doubles as the column divider seen between every header cell. */
|
|
109
|
+
.columnSeparator {
|
|
110
|
+
position: absolute;
|
|
111
|
+
top: 25%;
|
|
112
|
+
right: 0;
|
|
113
|
+
height: 50%;
|
|
114
|
+
width: 10px;
|
|
115
|
+
transform: translateX(50%);
|
|
116
|
+
display: flex;
|
|
117
|
+
justify-content: center;
|
|
118
|
+
z-index: 1;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.columnSeparatorResizable {
|
|
122
|
+
cursor: col-resize;
|
|
123
|
+
touch-action: none;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/* The divider is a hover affordance, not permanent chrome — it only appears on
|
|
127
|
+
the header cell you are pointing at, or on the column being resized. */
|
|
128
|
+
.columnSeparator::after {
|
|
129
|
+
content: "";
|
|
130
|
+
width: 1px;
|
|
131
|
+
height: 100%;
|
|
132
|
+
background-color: transparent;
|
|
133
|
+
transition:
|
|
134
|
+
background-color 120ms ease,
|
|
135
|
+
width 120ms ease;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
.headerCell:hover .columnSeparator::after,
|
|
139
|
+
.headerCell:focus-within .columnSeparator::after {
|
|
140
|
+
background-color: var(--mantine-color-default-border);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
.columnSeparatorResizable:hover::after,
|
|
144
|
+
.columnSeparatorActive::after {
|
|
145
|
+
width: 2px;
|
|
146
|
+
background-color: var(--mantine-primary-color-filled);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/* Marks the pin position a column is currently in. */
|
|
150
|
+
.menuItemActive {
|
|
151
|
+
background-color: var(--mantine-color-default-hover);
|
|
152
|
+
font-weight: 600;
|
|
153
|
+
}
|
|
@@ -3,22 +3,23 @@ import { flexRender, type Header } from "@tanstack/react-table";
|
|
|
3
3
|
import { useSelector } from "@tanstack/react-store";
|
|
4
4
|
import { shallow } from "@tanstack/store";
|
|
5
5
|
import { type DragEvent, type ReactNode, useRef, useState } from "react";
|
|
6
|
-
import classes from "./
|
|
6
|
+
import classes from "./TMDataGridHeaderCell.module.css";
|
|
7
|
+
import sticky from "./sticky.module.css";
|
|
7
8
|
import {
|
|
8
9
|
type TMDataGridRowData,
|
|
9
10
|
useTMDataGridContext,
|
|
10
|
-
} from "
|
|
11
|
+
} from "../TMDataGridContext";
|
|
11
12
|
import type { TMDataGridColumnLayout } from "./TMDataGridTable";
|
|
12
|
-
import { getColumnCapabilities, getGridCapabilities } from "
|
|
13
|
+
import { getColumnCapabilities, getGridCapabilities } from "../core/capabilities";
|
|
13
14
|
import {
|
|
14
15
|
getColumnRegion,
|
|
15
16
|
getStepTargetColumn,
|
|
16
17
|
moveColumn,
|
|
17
18
|
moveColumnByStep,
|
|
18
19
|
type TMDataGridDropSide,
|
|
19
|
-
} from "
|
|
20
|
-
import { getColumnAlign, getColumnLabel } from "
|
|
21
|
-
import { isFilterActive } from "
|
|
20
|
+
} from "../core/columnOrdering";
|
|
21
|
+
import { getColumnAlign, getColumnLabel } from "../core/columnUtils";
|
|
22
|
+
import { isFilterActive } from "../core/filterOperators";
|
|
22
23
|
import {
|
|
23
24
|
ArrowDownIcon,
|
|
24
25
|
ArrowUpIcon,
|
|
@@ -32,11 +33,8 @@ import {
|
|
|
32
33
|
PinOffIcon,
|
|
33
34
|
PinRightIcon,
|
|
34
35
|
} from "./icons";
|
|
35
|
-
import {
|
|
36
|
-
|
|
37
|
-
openColumnFilter,
|
|
38
|
-
SELECT_COLUMN_ID,
|
|
39
|
-
} from "./useTMDataGrid";
|
|
36
|
+
import { SELECT_COLUMN_ID } from "./TMDataGridSelectColumn";
|
|
37
|
+
import { type TMDataGridFeatures, openColumnFilter } from "../useTMDataGrid";
|
|
40
38
|
|
|
41
39
|
export type TMDataGridHeader = Header<
|
|
42
40
|
TMDataGridFeatures,
|
|
@@ -311,8 +309,8 @@ export function TMDataGridHeaderCell({
|
|
|
311
309
|
isDragged ? classes.headerCellDragging : "",
|
|
312
310
|
dropSide === "before" ? classes.headerCellDropBefore : "",
|
|
313
311
|
dropSide === "after" ? classes.headerCellDropAfter : "",
|
|
314
|
-
layout.isBoundary && pinnedAt === "left" ?
|
|
315
|
-
layout.isBoundary && pinnedAt === "right" ?
|
|
312
|
+
layout.isBoundary && pinnedAt === "left" ? sticky.stickyLeft : "",
|
|
313
|
+
layout.isBoundary && pinnedAt === "right" ? sticky.stickyRight : "",
|
|
316
314
|
]
|
|
317
315
|
.filter(Boolean)
|
|
318
316
|
.join(" ");
|
|
@@ -324,6 +322,17 @@ export function TMDataGridHeaderCell({
|
|
|
324
322
|
data-testid={`dg-header-${column.id}`}
|
|
325
323
|
data-active={isSorted || isFiltered}
|
|
326
324
|
data-align={align}
|
|
325
|
+
// Only meaningful on a sortable column: "none" advertises that this
|
|
326
|
+
// header sorts, which is a lie on one that doesn't.
|
|
327
|
+
aria-sort={
|
|
328
|
+
canSort
|
|
329
|
+
? isSorted
|
|
330
|
+
? sortDirection
|
|
331
|
+
? "descending"
|
|
332
|
+
: "ascending"
|
|
333
|
+
: "none"
|
|
334
|
+
: undefined
|
|
335
|
+
}
|
|
327
336
|
className={cellClass}
|
|
328
337
|
// Dropped while a resize is running, so that dragging the separator
|
|
329
338
|
// resizes the column instead of starting to move it. The resize handler
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { Checkbox } from "@mantine/core";
|
|
2
|
+
import { useSelector } from "@tanstack/react-store";
|
|
3
|
+
import type { ColumnDef, Row, RowData } from "@tanstack/react-table";
|
|
4
|
+
import type { TMDataGridFeatures, TMDataGridTable } from "../useTMDataGrid";
|
|
5
|
+
|
|
6
|
+
export const SELECT_COLUMN_ID = "__select__";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Both checkboxes subscribe to the table store themselves rather than reading
|
|
10
|
+
* the selection off the render they were handed.
|
|
11
|
+
*
|
|
12
|
+
* A cell is rendered by `flexRender`, which mounts the cell function as its own
|
|
13
|
+
* component — but the body cell above it keeps the same props across a
|
|
14
|
+
* selection change (row, column and layout identities all survive it), so the
|
|
15
|
+
* React Compiler reuses the memoized element and the subtree never re-renders.
|
|
16
|
+
* The row highlight would update, from state `TMDataGridTable` subscribes to,
|
|
17
|
+
* while the box below it stayed unchecked.
|
|
18
|
+
*
|
|
19
|
+
* Selection is derived inside the selector, not from a method call in the
|
|
20
|
+
* component body, for the same reason: `table` and `row` identities survive the
|
|
21
|
+
* change, so a call in the body would be cached along with them.
|
|
22
|
+
*/
|
|
23
|
+
function SelectAllCheckbox<TData extends RowData>({
|
|
24
|
+
table,
|
|
25
|
+
}: {
|
|
26
|
+
table: TMDataGridTable<TData>;
|
|
27
|
+
}) {
|
|
28
|
+
const allSelected = useSelector(table.store, () =>
|
|
29
|
+
table.getIsAllRowsSelected(),
|
|
30
|
+
);
|
|
31
|
+
const someSelected = useSelector(table.store, () =>
|
|
32
|
+
table.getIsSomeRowsSelected(),
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<Checkbox
|
|
37
|
+
size="xs"
|
|
38
|
+
aria-label="Select all rows"
|
|
39
|
+
checked={allSelected}
|
|
40
|
+
indeterminate={someSelected && !allSelected}
|
|
41
|
+
onChange={table.getToggleAllRowsSelectedHandler()}
|
|
42
|
+
/>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function SelectRowCheckbox<TData extends RowData>({
|
|
47
|
+
row,
|
|
48
|
+
}: {
|
|
49
|
+
row: Row<TMDataGridFeatures, TData>;
|
|
50
|
+
}) {
|
|
51
|
+
const selected = useSelector(row.table.store, () => row.getIsSelected());
|
|
52
|
+
const someSelected = useSelector(row.table.store, () =>
|
|
53
|
+
row.getIsSomeSelected(),
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<Checkbox
|
|
58
|
+
size="xs"
|
|
59
|
+
aria-label="Select row"
|
|
60
|
+
checked={selected}
|
|
61
|
+
disabled={!row.getCanSelect()}
|
|
62
|
+
indeterminate={someSelected && !selected}
|
|
63
|
+
onChange={row.getToggleSelectedHandler()}
|
|
64
|
+
// A row can select on click too; the checkbox must not toggle twice.
|
|
65
|
+
onClick={(event) => event.stopPropagation()}
|
|
66
|
+
/>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The generated checkbox column, prepended under
|
|
72
|
+
* `rowSelectionMode: "checkbox"` — the default whenever row selection is on.
|
|
73
|
+
*/
|
|
74
|
+
export function createSelectColumn<TData extends RowData>(): ColumnDef<
|
|
75
|
+
TMDataGridFeatures,
|
|
76
|
+
TData,
|
|
77
|
+
unknown
|
|
78
|
+
> {
|
|
79
|
+
return {
|
|
80
|
+
id: SELECT_COLUMN_ID,
|
|
81
|
+
meta: {
|
|
82
|
+
label: "Checkbox selection",
|
|
83
|
+
align: "center",
|
|
84
|
+
// Structurally the first column; it also anchors the left pinned lane, so
|
|
85
|
+
// no other column can be moved in front of it.
|
|
86
|
+
enableOrdering: false,
|
|
87
|
+
},
|
|
88
|
+
size: 48,
|
|
89
|
+
minSize: 48,
|
|
90
|
+
maxSize: 48,
|
|
91
|
+
enableResizing: false,
|
|
92
|
+
enableSorting: false,
|
|
93
|
+
enableColumnFilter: false,
|
|
94
|
+
enableGlobalFilter: false,
|
|
95
|
+
// Structurally pinned to the left; users shouldn't be able to move it.
|
|
96
|
+
enablePinning: false,
|
|
97
|
+
header: ({ table }) => <SelectAllCheckbox table={table} />,
|
|
98
|
+
cell: ({ row }) => <SelectRowCheckbox row={row} />,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/* Anchors the filter panel, which floats over the first body rows. */
|
|
2
|
+
.tableWrapper {
|
|
3
|
+
position: relative;
|
|
4
|
+
flex: 1;
|
|
5
|
+
min-height: 0;
|
|
6
|
+
display: flex;
|
|
7
|
+
flex-direction: column;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
.scrollContainer {
|
|
11
|
+
position: relative;
|
|
12
|
+
flex: 1;
|
|
13
|
+
min-height: 0;
|
|
14
|
+
overflow: auto;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/* Column tracks are set inline via gridTemplateColumns. */
|
|
18
|
+
.grid {
|
|
19
|
+
display: grid;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/* Rows are real layout boxes (not display: contents) so position: sticky works,
|
|
23
|
+
and subgrid keeps their cells on the parent's column tracks. */
|
|
24
|
+
.headerRow {
|
|
25
|
+
display: grid;
|
|
26
|
+
grid-column: 1 / -1;
|
|
27
|
+
grid-template-columns: subgrid;
|
|
28
|
+
position: sticky;
|
|
29
|
+
top: 0;
|
|
30
|
+
z-index: 3;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.bodyRow {
|
|
34
|
+
--row-bg: var(--mantine-color-body);
|
|
35
|
+
display: grid;
|
|
36
|
+
grid-column: 1 / -1;
|
|
37
|
+
grid-template-columns: subgrid;
|
|
38
|
+
align-items: stretch;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.bodyRow:hover {
|
|
42
|
+
--row-bg: var(--mantine-color-default-hover);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/* Selection and highlight are separate: a row is always `data-selected`, but it
|
|
46
|
+
only takes the highlight colour when `highlightSelectedRows` is on — the
|
|
47
|
+
default in row selection mode, off with checkboxes. */
|
|
48
|
+
.bodyRow[data-highlighted="true"] {
|
|
49
|
+
--row-bg: var(--dg-row-selected-bg);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/* Row selection mode: the row is the control, so it takes focus and must not
|
|
53
|
+
start a text selection on the click that toggles it. */
|
|
54
|
+
.bodyRow[data-selects-on-click="true"] {
|
|
55
|
+
user-select: none;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.bodyRow:focus-visible {
|
|
59
|
+
outline: 2px solid var(--mantine-primary-color-filled);
|
|
60
|
+
outline-offset: -2px;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/* Clipping lives on .cellContent, not here — a pinned cell has to let its
|
|
64
|
+
boundary gradient escape past its own right/left edge. */
|
|
65
|
+
.bodyCell {
|
|
66
|
+
display: flex;
|
|
67
|
+
align-items: center;
|
|
68
|
+
padding: 0 var(--dg-padding);
|
|
69
|
+
border-bottom: 1px solid var(--mantine-color-default-border);
|
|
70
|
+
/* Inherited from .bodyRow — sticky cells need an opaque background. */
|
|
71
|
+
background-color: var(--row-bg);
|
|
72
|
+
font-size: var(--dg-font-size);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
.cellContent {
|
|
76
|
+
min-width: 0;
|
|
77
|
+
overflow: hidden;
|
|
78
|
+
white-space: nowrap;
|
|
79
|
+
text-overflow: ellipsis;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.bodyCell[data-align="right"] {
|
|
83
|
+
justify-content: flex-end;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.bodyCell[data-align="center"] {
|
|
87
|
+
justify-content: center;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/* Only stretch to fill when left-aligned, so right/centre stay put. */
|
|
91
|
+
.bodyCell[data-align="left"] .cellContent {
|
|
92
|
+
flex: 1 1 auto;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.messageRow {
|
|
96
|
+
grid-column: 1 / -1;
|
|
97
|
+
display: flex;
|
|
98
|
+
flex-direction: column;
|
|
99
|
+
align-items: center;
|
|
100
|
+
justify-content: center;
|
|
101
|
+
gap: var(--mantine-spacing-xs);
|
|
102
|
+
padding: var(--mantine-spacing-xl);
|
|
103
|
+
}
|