@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,410 @@
|
|
|
1
|
+
/* Size scale. Row heights mirror SIZE_ROW_HEIGHT in sizes.ts — the virtualizer
|
|
2
|
+
needs the number in JS, so the two have to agree. */
|
|
3
|
+
.root[data-size="xs"] {
|
|
4
|
+
--dg-row-height: 34px;
|
|
5
|
+
--dg-header-height: 32px;
|
|
6
|
+
--dg-font-size: var(--mantine-font-size-xs);
|
|
7
|
+
--dg-padding: 6px;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
.root[data-size="sm"] {
|
|
11
|
+
--dg-row-height: 42px;
|
|
12
|
+
--dg-header-height: 38px;
|
|
13
|
+
--dg-font-size: var(--mantine-font-size-sm);
|
|
14
|
+
--dg-padding: 8px;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.root[data-size="md"] {
|
|
18
|
+
--dg-row-height: 52px;
|
|
19
|
+
--dg-header-height: 44px;
|
|
20
|
+
--dg-font-size: var(--mantine-font-size-sm);
|
|
21
|
+
--dg-padding: 10px;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.root[data-size="lg"] {
|
|
25
|
+
--dg-row-height: 62px;
|
|
26
|
+
--dg-header-height: 52px;
|
|
27
|
+
--dg-font-size: var(--mantine-font-size-md);
|
|
28
|
+
--dg-padding: 14px;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.root[data-size="xl"] {
|
|
32
|
+
--dg-row-height: 72px;
|
|
33
|
+
--dg-header-height: 60px;
|
|
34
|
+
--dg-font-size: var(--mantine-font-size-lg);
|
|
35
|
+
--dg-padding: 18px;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.root {
|
|
39
|
+
/* Fallbacks, so the parts still work if rendered outside a sized root. */
|
|
40
|
+
--dg-row-height: 52px;
|
|
41
|
+
--dg-header-height: 44px;
|
|
42
|
+
--dg-font-size: var(--mantine-font-size-sm);
|
|
43
|
+
--dg-padding: 10px;
|
|
44
|
+
|
|
45
|
+
display: flex;
|
|
46
|
+
flex-direction: column;
|
|
47
|
+
min-height: 0;
|
|
48
|
+
overflow: hidden;
|
|
49
|
+
/* Header text is a stretched flex item, so an inherited `text-align: center`
|
|
50
|
+
from the host page would centre headers away from their cell content. */
|
|
51
|
+
text-align: left;
|
|
52
|
+
border: 1px solid var(--mantine-color-default-border);
|
|
53
|
+
border-radius: var(--mantine-radius-md);
|
|
54
|
+
background-color: var(--mantine-color-body);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.toolbar {
|
|
58
|
+
display: flex;
|
|
59
|
+
align-items: center;
|
|
60
|
+
gap: var(--mantine-spacing-xs);
|
|
61
|
+
padding: 6px var(--mantine-spacing-xs);
|
|
62
|
+
min-height: 42px;
|
|
63
|
+
flex-shrink: 0;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.toolbarSpacer {
|
|
67
|
+
flex: 1;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/* Anchors the filter panel, which floats over the first body rows. */
|
|
71
|
+
.tableWrapper {
|
|
72
|
+
position: relative;
|
|
73
|
+
flex: 1;
|
|
74
|
+
min-height: 0;
|
|
75
|
+
display: flex;
|
|
76
|
+
flex-direction: column;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.scrollContainer {
|
|
80
|
+
position: relative;
|
|
81
|
+
flex: 1;
|
|
82
|
+
min-height: 0;
|
|
83
|
+
overflow: auto;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/* Column tracks are set inline via gridTemplateColumns. */
|
|
87
|
+
.grid {
|
|
88
|
+
display: grid;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/* Rows are real layout boxes (not display: contents) so position: sticky works,
|
|
92
|
+
and subgrid keeps their cells on the parent's column tracks. */
|
|
93
|
+
.headerRow {
|
|
94
|
+
display: grid;
|
|
95
|
+
grid-column: 1 / -1;
|
|
96
|
+
grid-template-columns: subgrid;
|
|
97
|
+
position: sticky;
|
|
98
|
+
top: 0;
|
|
99
|
+
z-index: 3;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.headerCell {
|
|
103
|
+
position: relative;
|
|
104
|
+
display: flex;
|
|
105
|
+
align-items: center;
|
|
106
|
+
gap: 2px;
|
|
107
|
+
padding: 0 var(--dg-padding);
|
|
108
|
+
min-width: 0;
|
|
109
|
+
min-height: var(--dg-header-height);
|
|
110
|
+
font-weight: 600;
|
|
111
|
+
font-size: var(--dg-font-size);
|
|
112
|
+
background-color: var(--mantine-color-body);
|
|
113
|
+
border-bottom: 1px solid var(--mantine-color-default-border);
|
|
114
|
+
user-select: none;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.headerCell[data-align="right"] {
|
|
118
|
+
justify-content: flex-end;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.headerCell[data-align="center"] {
|
|
122
|
+
justify-content: center;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/* Sorted or filtered columns read as active, like the MUI data grid. */
|
|
126
|
+
.headerCell[data-active="true"] .headerTitle {
|
|
127
|
+
color: var(--mantine-primary-color-filled);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
.headerTitle {
|
|
131
|
+
flex: 1 1 auto;
|
|
132
|
+
min-width: 0;
|
|
133
|
+
overflow: hidden;
|
|
134
|
+
text-overflow: ellipsis;
|
|
135
|
+
white-space: nowrap;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/* Right-aligned headers stay text so the ellipsis still works; centered ones
|
|
139
|
+
hold a control (the select-all checkbox), so they become a flex box. */
|
|
140
|
+
.headerCell[data-align="right"] .headerTitle {
|
|
141
|
+
text-align: right;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
.headerCell[data-align="center"] .headerTitle {
|
|
145
|
+
display: flex;
|
|
146
|
+
justify-content: center;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
.headerActions {
|
|
150
|
+
display: flex;
|
|
151
|
+
align-items: center;
|
|
152
|
+
gap: 0;
|
|
153
|
+
flex-shrink: 0;
|
|
154
|
+
margin-left: auto;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/* Actions always reserve their width. On right-aligned columns they move to the
|
|
158
|
+
leading edge so the header text stays flush with the cell values below it. */
|
|
159
|
+
.headerCell[data-align="right"] .headerActions {
|
|
160
|
+
order: -1;
|
|
161
|
+
margin-left: 0;
|
|
162
|
+
margin-right: auto;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/* Idle actions are removed from layout, not just faded — reserving their width
|
|
166
|
+
would truncate header text that otherwise fits. Indicators that must stay
|
|
167
|
+
visible (active sort, active filter, open menu) set data-pinned-visible. */
|
|
168
|
+
.headerAction {
|
|
169
|
+
display: none;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
.headerCell:hover .headerAction,
|
|
173
|
+
.headerCell:focus-within .headerAction,
|
|
174
|
+
.headerAction[data-pinned-visible="true"] {
|
|
175
|
+
display: flex;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
.headerCellSortable {
|
|
179
|
+
cursor: pointer;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/* Column reordering. The dragged header stays in place and dims — the browser
|
|
183
|
+
already carries a snapshot of it under the pointer — while the header being
|
|
184
|
+
dragged over draws a bar on the edge the column will land against. */
|
|
185
|
+
.headerCellDragging {
|
|
186
|
+
opacity: 0.4;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
.headerCellDropBefore::before,
|
|
190
|
+
.headerCellDropAfter::before {
|
|
191
|
+
content: "";
|
|
192
|
+
position: absolute;
|
|
193
|
+
top: 0;
|
|
194
|
+
bottom: 0;
|
|
195
|
+
width: 2px;
|
|
196
|
+
background-color: var(--mantine-primary-color-filled);
|
|
197
|
+
z-index: 5;
|
|
198
|
+
pointer-events: none;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
.headerCellDropBefore::before {
|
|
202
|
+
left: 0;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
.headerCellDropAfter::before {
|
|
206
|
+
right: 0;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/* Doubles as the column divider seen between every header cell. */
|
|
210
|
+
.columnSeparator {
|
|
211
|
+
position: absolute;
|
|
212
|
+
top: 25%;
|
|
213
|
+
right: 0;
|
|
214
|
+
height: 50%;
|
|
215
|
+
width: 10px;
|
|
216
|
+
transform: translateX(50%);
|
|
217
|
+
display: flex;
|
|
218
|
+
justify-content: center;
|
|
219
|
+
z-index: 1;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
.columnSeparatorResizable {
|
|
223
|
+
cursor: col-resize;
|
|
224
|
+
touch-action: none;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/* The divider is a hover affordance, not permanent chrome — it only appears on
|
|
228
|
+
the header cell you are pointing at, or on the column being resized. */
|
|
229
|
+
.columnSeparator::after {
|
|
230
|
+
content: "";
|
|
231
|
+
width: 1px;
|
|
232
|
+
height: 100%;
|
|
233
|
+
background-color: transparent;
|
|
234
|
+
transition:
|
|
235
|
+
background-color 120ms ease,
|
|
236
|
+
width 120ms ease;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
.headerCell:hover .columnSeparator::after,
|
|
240
|
+
.headerCell:focus-within .columnSeparator::after {
|
|
241
|
+
background-color: var(--mantine-color-default-border);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
.columnSeparatorResizable:hover::after,
|
|
245
|
+
.columnSeparatorActive::after {
|
|
246
|
+
width: 2px;
|
|
247
|
+
background-color: var(--mantine-primary-color-filled);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
.bodyRow {
|
|
251
|
+
--row-bg: var(--mantine-color-body);
|
|
252
|
+
display: grid;
|
|
253
|
+
grid-column: 1 / -1;
|
|
254
|
+
grid-template-columns: subgrid;
|
|
255
|
+
align-items: stretch;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
.bodyRow:hover {
|
|
259
|
+
--row-bg: var(--mantine-color-default-hover);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
.bodyRow[data-selected="true"] {
|
|
263
|
+
--row-bg: var(--mantine-primary-color-light);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/* Clipping lives on .cellContent, not here — a pinned cell has to let its
|
|
267
|
+
boundary gradient escape past its own right/left edge. */
|
|
268
|
+
.bodyCell {
|
|
269
|
+
display: flex;
|
|
270
|
+
align-items: center;
|
|
271
|
+
padding: 0 var(--dg-padding);
|
|
272
|
+
border-bottom: 1px solid var(--mantine-color-default-border);
|
|
273
|
+
/* Inherited from .bodyRow — sticky cells need an opaque background. */
|
|
274
|
+
background-color: var(--row-bg);
|
|
275
|
+
font-size: var(--dg-font-size);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
.cellContent {
|
|
279
|
+
min-width: 0;
|
|
280
|
+
overflow: hidden;
|
|
281
|
+
white-space: nowrap;
|
|
282
|
+
text-overflow: ellipsis;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
.bodyCell[data-align="right"] {
|
|
286
|
+
justify-content: flex-end;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
.bodyCell[data-align="center"] {
|
|
290
|
+
justify-content: center;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/* Only stretch to fill when left-aligned, so right/centre stay put. */
|
|
294
|
+
.bodyCell[data-align="left"] .cellContent {
|
|
295
|
+
flex: 1 1 auto;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/* Boundary of a pinned column group.
|
|
299
|
+
A box-shadow is drawn per cell, and its blur fades in at each cell's own top
|
|
300
|
+
and bottom edge — stacked down a column that reads as banding at every row
|
|
301
|
+
seam. Instead: a crisp 1px border for the hard edge, plus a gradient pseudo
|
|
302
|
+
element pinned to `top: 0; bottom: 0` so consecutive cells butt together into
|
|
303
|
+
one unbroken band. Mantine sets `color-scheme` on the root, so light-dark()
|
|
304
|
+
resolves without a second selector. */
|
|
305
|
+
.stickyLeft {
|
|
306
|
+
border-right: 1px solid var(--mantine-color-default-border);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
.stickyRight {
|
|
310
|
+
border-left: 1px solid var(--mantine-color-default-border);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
.stickyLeft::after,
|
|
314
|
+
.stickyRight::after {
|
|
315
|
+
content: "";
|
|
316
|
+
position: absolute;
|
|
317
|
+
top: 0;
|
|
318
|
+
bottom: 0;
|
|
319
|
+
width: 4px;
|
|
320
|
+
pointer-events: none;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
.stickyLeft::after {
|
|
324
|
+
left: 100%;
|
|
325
|
+
background: linear-gradient(
|
|
326
|
+
to right,
|
|
327
|
+
light-dark(rgba(0, 0, 0, 0.08), rgba(0, 0, 0, 0.28)),
|
|
328
|
+
transparent
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
.stickyRight::after {
|
|
333
|
+
right: 100%;
|
|
334
|
+
background: linear-gradient(
|
|
335
|
+
to left,
|
|
336
|
+
light-dark(rgba(0, 0, 0, 0.08), rgba(0, 0, 0, 0.28)),
|
|
337
|
+
transparent
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
.messageRow {
|
|
342
|
+
grid-column: 1 / -1;
|
|
343
|
+
display: flex;
|
|
344
|
+
flex-direction: column;
|
|
345
|
+
align-items: center;
|
|
346
|
+
justify-content: center;
|
|
347
|
+
gap: var(--mantine-spacing-xs);
|
|
348
|
+
padding: var(--mantine-spacing-xl);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
.footer {
|
|
352
|
+
display: flex;
|
|
353
|
+
align-items: center;
|
|
354
|
+
justify-content: flex-end;
|
|
355
|
+
gap: var(--mantine-spacing-lg);
|
|
356
|
+
padding: 4px var(--mantine-spacing-sm);
|
|
357
|
+
min-height: 48px;
|
|
358
|
+
flex-shrink: 0;
|
|
359
|
+
border-top: 1px solid var(--mantine-color-default-border);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
.filterPanel {
|
|
363
|
+
position: absolute;
|
|
364
|
+
top: 46px;
|
|
365
|
+
left: var(--mantine-spacing-md);
|
|
366
|
+
z-index: 5;
|
|
367
|
+
padding: var(--mantine-spacing-sm);
|
|
368
|
+
border-radius: var(--mantine-radius-md);
|
|
369
|
+
border: 1px solid var(--mantine-color-default-border);
|
|
370
|
+
background-color: var(--mantine-color-body);
|
|
371
|
+
box-shadow: var(--mantine-shadow-md);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
.filterRow {
|
|
375
|
+
display: flex;
|
|
376
|
+
align-items: flex-end;
|
|
377
|
+
gap: var(--mantine-spacing-xs);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/* Marks the pin position a column is currently in. */
|
|
381
|
+
.menuItemActive {
|
|
382
|
+
background-color: var(--mantine-color-default-hover);
|
|
383
|
+
font-weight: 600;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
.columnsPanel {
|
|
387
|
+
width: 280px;
|
|
388
|
+
display: flex;
|
|
389
|
+
flex-direction: column;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
.columnsPanelSearch {
|
|
393
|
+
padding: var(--mantine-spacing-sm);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
.columnsPanelList {
|
|
397
|
+
display: flex;
|
|
398
|
+
flex-direction: column;
|
|
399
|
+
gap: var(--mantine-spacing-sm);
|
|
400
|
+
padding: 0 var(--mantine-spacing-sm) var(--mantine-spacing-sm);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
.columnsPanelFooter {
|
|
404
|
+
display: flex;
|
|
405
|
+
align-items: center;
|
|
406
|
+
justify-content: space-between;
|
|
407
|
+
gap: var(--mantine-spacing-sm);
|
|
408
|
+
padding: var(--mantine-spacing-sm);
|
|
409
|
+
border-top: 1px solid var(--mantine-color-default-border);
|
|
410
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { RowData } from "@tanstack/react-table";
|
|
2
|
+
import { type CSSProperties, type ReactNode, useMemo } from "react";
|
|
3
|
+
import classes from "./TMDataGrid.module.css";
|
|
4
|
+
import { TMDataGridColumnsPanel } from "./TMDataGridColumnsPanel";
|
|
5
|
+
import { TMDataGridContextProvider } from "./TMDataGridContext";
|
|
6
|
+
import { TMDataGridFilterPanel } from "./TMDataGridFilterPanel";
|
|
7
|
+
import { TMDataGridFooter } from "./TMDataGridFooter";
|
|
8
|
+
import { TMDataGridTable } from "./TMDataGridTable";
|
|
9
|
+
import {
|
|
10
|
+
TMDataGridColumnsButton,
|
|
11
|
+
TMDataGridFilterButton,
|
|
12
|
+
TMDataGridSummaryCount,
|
|
13
|
+
TMDataGridToolbar,
|
|
14
|
+
TMDataGridToolbarSpacer,
|
|
15
|
+
} from "./TMDataGridToolbar";
|
|
16
|
+
import {
|
|
17
|
+
DEFAULT_TMDATAGRID_SIZE,
|
|
18
|
+
SIZE_CONTROL_SIZE,
|
|
19
|
+
SIZE_ROW_HEIGHT,
|
|
20
|
+
type TMDataGridSize,
|
|
21
|
+
} from "./sizes";
|
|
22
|
+
import type { TMDataGridApi } from "./useTMDataGrid";
|
|
23
|
+
|
|
24
|
+
export type TMDataGridProps<TData extends RowData> = TMDataGridApi<TData> & {
|
|
25
|
+
children: ReactNode;
|
|
26
|
+
/**
|
|
27
|
+
* Mantine size scale. Drives row height, font size, cell padding and the
|
|
28
|
+
* size of the controls in the chrome. `meta.rowHeight` still wins if set.
|
|
29
|
+
*/
|
|
30
|
+
size?: TMDataGridSize;
|
|
31
|
+
className?: string;
|
|
32
|
+
style?: CSSProperties;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Root of the grid. Takes the object returned by `useTMDataGrid` — spread it —
|
|
37
|
+
* and publishes it to the compound components below it:
|
|
38
|
+
*
|
|
39
|
+
* ```tsx
|
|
40
|
+
* const grid = useTMDataGrid({ data, columns });
|
|
41
|
+
*
|
|
42
|
+
* <TMDataGrid {...grid}>
|
|
43
|
+
* <TMDataGrid.Toolbar>
|
|
44
|
+
* <TMDataGrid.SummaryCount />
|
|
45
|
+
* <TMDataGrid.Spacer />
|
|
46
|
+
* <TMDataGrid.ColumnsButton />
|
|
47
|
+
* </TMDataGrid.Toolbar>
|
|
48
|
+
* <TMDataGrid.Table />
|
|
49
|
+
* <TMDataGrid.Footer />
|
|
50
|
+
* </TMDataGrid>
|
|
51
|
+
* ```
|
|
52
|
+
*/
|
|
53
|
+
function TMDataGridRoot<TData extends RowData>({
|
|
54
|
+
table,
|
|
55
|
+
ui,
|
|
56
|
+
features,
|
|
57
|
+
size = DEFAULT_TMDATAGRID_SIZE,
|
|
58
|
+
children,
|
|
59
|
+
className,
|
|
60
|
+
style,
|
|
61
|
+
}: TMDataGridProps<TData>) {
|
|
62
|
+
const rowHeight = table.options.meta?.rowHeight ?? SIZE_ROW_HEIGHT[size];
|
|
63
|
+
|
|
64
|
+
const api = useMemo(
|
|
65
|
+
() => ({
|
|
66
|
+
table,
|
|
67
|
+
ui,
|
|
68
|
+
features,
|
|
69
|
+
size,
|
|
70
|
+
rowHeight,
|
|
71
|
+
controlSize: SIZE_CONTROL_SIZE[size],
|
|
72
|
+
}),
|
|
73
|
+
[table, ui, features, size, rowHeight],
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<TMDataGridContextProvider value={api}>
|
|
78
|
+
<div
|
|
79
|
+
data-size={size}
|
|
80
|
+
className={[classes.root, className].filter(Boolean).join(" ")}
|
|
81
|
+
style={style}
|
|
82
|
+
>
|
|
83
|
+
{children}
|
|
84
|
+
</div>
|
|
85
|
+
</TMDataGridContextProvider>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export const TMDataGrid = Object.assign(TMDataGridRoot, {
|
|
90
|
+
Toolbar: TMDataGridToolbar,
|
|
91
|
+
Spacer: TMDataGridToolbarSpacer,
|
|
92
|
+
SummaryCount: TMDataGridSummaryCount,
|
|
93
|
+
ColumnsButton: TMDataGridColumnsButton,
|
|
94
|
+
FilterButton: TMDataGridFilterButton,
|
|
95
|
+
Table: TMDataGridTable,
|
|
96
|
+
Footer: TMDataGridFooter,
|
|
97
|
+
/** Rendered by `TMDataGrid.Table`; exported for custom layouts. */
|
|
98
|
+
FilterPanel: TMDataGridFilterPanel,
|
|
99
|
+
/** Rendered by `TMDataGrid.ColumnsButton`; exported for custom layouts. */
|
|
100
|
+
ColumnsPanel: TMDataGridColumnsPanel,
|
|
101
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { Button, Checkbox, ScrollArea, Text, TextInput } from "@mantine/core";
|
|
2
|
+
import { useSelector } from "@tanstack/react-store";
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import classes from "./TMDataGrid.module.css";
|
|
5
|
+
import { useTMDataGridContext } from "./TMDataGridContext";
|
|
6
|
+
import { getColumnLabel } from "./columnUtils";
|
|
7
|
+
import { SearchIcon } from "./icons";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The "Manage columns" surface. Reachable from every column header menu and
|
|
11
|
+
* from the grid's burger menu, so it lives on its own and is rendered by
|
|
12
|
+
* `TMDataGrid.ColumnsButton`.
|
|
13
|
+
*/
|
|
14
|
+
export function TMDataGridColumnsPanel() {
|
|
15
|
+
const { table, controlSize } = useTMDataGridContext();
|
|
16
|
+
const [search, setSearch] = useState("");
|
|
17
|
+
|
|
18
|
+
const columnVisibility = useSelector(
|
|
19
|
+
table.store,
|
|
20
|
+
(state) => state.columnVisibility,
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
const columns = table.getAllLeafColumns();
|
|
24
|
+
const needle = search.trim().toLowerCase();
|
|
25
|
+
const visibleInPanel = needle
|
|
26
|
+
? columns.filter((column) =>
|
|
27
|
+
getColumnLabel(column).toLowerCase().includes(needle),
|
|
28
|
+
)
|
|
29
|
+
: columns;
|
|
30
|
+
|
|
31
|
+
const hideableColumns = columns.filter((column) => column.getCanHide());
|
|
32
|
+
const shownCount = hideableColumns.filter(
|
|
33
|
+
(column) => columnVisibility[column.id] !== false,
|
|
34
|
+
).length;
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<div className={classes.columnsPanel}>
|
|
38
|
+
<div className={classes.columnsPanelSearch}>
|
|
39
|
+
<TextInput
|
|
40
|
+
value={search}
|
|
41
|
+
onChange={(event) => setSearch(event.currentTarget.value)}
|
|
42
|
+
placeholder="Search"
|
|
43
|
+
leftSection={<SearchIcon size={16} stroke={1.6} />}
|
|
44
|
+
size={controlSize}
|
|
45
|
+
data-autofocus
|
|
46
|
+
/>
|
|
47
|
+
</div>
|
|
48
|
+
|
|
49
|
+
<ScrollArea.Autosize mah={260} type="auto">
|
|
50
|
+
<div className={classes.columnsPanelList}>
|
|
51
|
+
{visibleInPanel.map((column) => (
|
|
52
|
+
<Checkbox
|
|
53
|
+
key={column.id}
|
|
54
|
+
size={controlSize}
|
|
55
|
+
label={getColumnLabel(column)}
|
|
56
|
+
disabled={!column.getCanHide()}
|
|
57
|
+
checked={columnVisibility[column.id] !== false}
|
|
58
|
+
onChange={(event) =>
|
|
59
|
+
column.toggleVisibility(event.currentTarget.checked)
|
|
60
|
+
}
|
|
61
|
+
/>
|
|
62
|
+
))}
|
|
63
|
+
{visibleInPanel.length === 0 && (
|
|
64
|
+
<Text size={controlSize} c="dimmed">
|
|
65
|
+
No columns match “{search}”
|
|
66
|
+
</Text>
|
|
67
|
+
)}
|
|
68
|
+
</div>
|
|
69
|
+
</ScrollArea.Autosize>
|
|
70
|
+
|
|
71
|
+
<div className={classes.columnsPanelFooter}>
|
|
72
|
+
<Checkbox
|
|
73
|
+
size={controlSize}
|
|
74
|
+
label="Show/Hide All"
|
|
75
|
+
checked={shownCount === hideableColumns.length}
|
|
76
|
+
indeterminate={shownCount > 0 && shownCount < hideableColumns.length}
|
|
77
|
+
onChange={(event) =>
|
|
78
|
+
table.toggleAllColumnsVisible(event.currentTarget.checked)
|
|
79
|
+
}
|
|
80
|
+
/>
|
|
81
|
+
<Button
|
|
82
|
+
variant="subtle"
|
|
83
|
+
size="compact-sm"
|
|
84
|
+
onClick={() => table.resetColumnVisibility()}
|
|
85
|
+
>
|
|
86
|
+
RESET
|
|
87
|
+
</Button>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { createContext, useContext } from "react";
|
|
2
|
+
import type { TMDataGridSize } from "./sizes";
|
|
3
|
+
import type { TMDataGridApi } from "./useTMDataGrid";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Row shape the chrome components work against. They only ever touch columns,
|
|
7
|
+
* rows and cells generically, so the concrete row type is erased at the context
|
|
8
|
+
* boundary — the same trick Mantine uses for its compound components.
|
|
9
|
+
*/
|
|
10
|
+
export type TMDataGridRowData = Record<string, unknown>;
|
|
11
|
+
|
|
12
|
+
export type TMDataGridContextValue = TMDataGridApi<TMDataGridRowData> & {
|
|
13
|
+
size: TMDataGridSize;
|
|
14
|
+
/** Row height in px, already resolved from `size` and `meta.rowHeight`. */
|
|
15
|
+
rowHeight: number;
|
|
16
|
+
/** Mantine control size that pairs with `size`. */
|
|
17
|
+
controlSize: TMDataGridSize;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const TMDataGridContext = createContext<unknown>(null);
|
|
21
|
+
|
|
22
|
+
export const TMDataGridContextProvider = TMDataGridContext.Provider;
|
|
23
|
+
|
|
24
|
+
export function useTMDataGridContext(): TMDataGridContextValue {
|
|
25
|
+
const api = useContext(TMDataGridContext);
|
|
26
|
+
if (!api) {
|
|
27
|
+
throw new Error(
|
|
28
|
+
"TMDataGrid compound components must be rendered inside <TMDataGrid>",
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
return api as TMDataGridContextValue;
|
|
32
|
+
}
|