@prairielearn/ui 1.3.0 → 1.5.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/CHANGELOG.md +28 -0
- package/README.md +4 -2
- package/dist/components/CategoricalColumnFilter.d.ts +7 -12
- package/dist/components/CategoricalColumnFilter.d.ts.map +1 -1
- package/dist/components/CategoricalColumnFilter.js +15 -11
- package/dist/components/CategoricalColumnFilter.js.map +1 -1
- package/dist/components/ColumnManager.d.ts +6 -3
- package/dist/components/ColumnManager.d.ts.map +1 -1
- package/dist/components/ColumnManager.js +98 -18
- package/dist/components/ColumnManager.js.map +1 -1
- package/dist/components/MultiSelectColumnFilter.d.ts +8 -12
- package/dist/components/MultiSelectColumnFilter.d.ts.map +1 -1
- package/dist/components/MultiSelectColumnFilter.js +21 -13
- package/dist/components/MultiSelectColumnFilter.js.map +1 -1
- package/dist/components/NumericInputColumnFilter.d.ts +13 -13
- package/dist/components/NumericInputColumnFilter.d.ts.map +1 -1
- package/dist/components/NumericInputColumnFilter.js +44 -15
- package/dist/components/NumericInputColumnFilter.js.map +1 -1
- package/dist/components/NumericInputColumnFilter.test.d.ts +2 -0
- package/dist/components/NumericInputColumnFilter.test.d.ts.map +1 -0
- package/dist/components/NumericInputColumnFilter.test.js +90 -0
- package/dist/components/NumericInputColumnFilter.test.js.map +1 -0
- package/dist/components/OverlayTrigger.d.ts +78 -0
- package/dist/components/OverlayTrigger.d.ts.map +1 -0
- package/dist/components/OverlayTrigger.js +89 -0
- package/dist/components/OverlayTrigger.js.map +1 -0
- package/dist/components/PresetFilterDropdown.d.ts +19 -0
- package/dist/components/PresetFilterDropdown.d.ts.map +1 -0
- package/dist/components/PresetFilterDropdown.js +93 -0
- package/dist/components/PresetFilterDropdown.js.map +1 -0
- package/dist/components/TanstackTable.d.ts +15 -4
- package/dist/components/TanstackTable.d.ts.map +1 -1
- package/dist/components/TanstackTable.js +148 -197
- package/dist/components/TanstackTable.js.map +1 -1
- package/dist/components/TanstackTableDownloadButton.d.ts +4 -2
- package/dist/components/TanstackTableDownloadButton.d.ts.map +1 -1
- package/dist/components/TanstackTableDownloadButton.js +4 -3
- package/dist/components/TanstackTableDownloadButton.js.map +1 -1
- package/dist/components/TanstackTableHeaderCell.d.ts +13 -0
- package/dist/components/TanstackTableHeaderCell.d.ts.map +1 -0
- package/dist/components/TanstackTableHeaderCell.js +98 -0
- package/dist/components/TanstackTableHeaderCell.js.map +1 -0
- package/dist/components/{TanstackTable.css → styles.css} +11 -6
- package/dist/components/useAutoSizeColumns.d.ts +17 -0
- package/dist/components/useAutoSizeColumns.d.ts.map +1 -0
- package/dist/components/useAutoSizeColumns.js +99 -0
- package/dist/components/useAutoSizeColumns.js.map +1 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/react-table.d.ts +13 -0
- package/dist/react-table.d.ts.map +1 -0
- package/dist/react-table.js +3 -0
- package/dist/react-table.js.map +1 -0
- package/package.json +2 -2
- package/src/components/CategoricalColumnFilter.tsx +28 -28
- package/src/components/ColumnManager.tsx +222 -46
- package/src/components/MultiSelectColumnFilter.tsx +45 -32
- package/src/components/NumericInputColumnFilter.test.ts +67 -19
- package/src/components/NumericInputColumnFilter.tsx +102 -42
- package/src/components/OverlayTrigger.tsx +168 -0
- package/src/components/PresetFilterDropdown.tsx +155 -0
- package/src/components/TanstackTable.tsx +315 -363
- package/src/components/TanstackTableDownloadButton.tsx +8 -5
- package/src/components/TanstackTableHeaderCell.tsx +207 -0
- package/src/components/{TanstackTable.css → styles.css} +11 -6
- package/src/components/useAutoSizeColumns.tsx +168 -0
- package/src/index.ts +7 -0
- package/src/react-table.ts +17 -0
- package/tsconfig.json +1 -2
|
@@ -1,105 +1,86 @@
|
|
|
1
1
|
import { flexRender } from '@tanstack/react-table';
|
|
2
|
-
import {
|
|
3
|
-
import type { Header, Row,
|
|
2
|
+
import { useVirtualizer } from '@tanstack/react-virtual';
|
|
3
|
+
import type { Cell, Header, Row, Table } from '@tanstack/table-core';
|
|
4
4
|
import clsx from 'clsx';
|
|
5
5
|
import type { ComponentChildren } from 'preact';
|
|
6
|
-
import { useEffect,
|
|
6
|
+
import { useEffect, useMemo, useRef } from 'preact/hooks';
|
|
7
7
|
import type { JSX } from 'preact/jsx-runtime';
|
|
8
8
|
import OverlayTrigger from 'react-bootstrap/OverlayTrigger';
|
|
9
9
|
import Tooltip from 'react-bootstrap/Tooltip';
|
|
10
10
|
|
|
11
|
+
import type { ComponentProps } from '@prairielearn/preact-cjs';
|
|
12
|
+
import { run } from '@prairielearn/run';
|
|
13
|
+
|
|
11
14
|
import { ColumnManager } from './ColumnManager.js';
|
|
12
15
|
import {
|
|
13
16
|
TanstackTableDownloadButton,
|
|
14
17
|
type TanstackTableDownloadButtonProps,
|
|
15
18
|
} from './TanstackTableDownloadButton.js';
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
function ResizeHandle<RowDataModel>({
|
|
28
|
-
header,
|
|
29
|
-
setColumnSizing,
|
|
19
|
+
import { TanstackTableHeaderCell } from './TanstackTableHeaderCell.js';
|
|
20
|
+
import { useAutoSizeColumns } from './useAutoSizeColumns.js';
|
|
21
|
+
|
|
22
|
+
function TableCell<RowDataModel>({
|
|
23
|
+
cell,
|
|
24
|
+
rowIdx,
|
|
25
|
+
colIdx,
|
|
26
|
+
canSort,
|
|
27
|
+
canFilter,
|
|
28
|
+
wrapText,
|
|
29
|
+
handleGridKeyDown,
|
|
30
30
|
}: {
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
cell: Cell<RowDataModel, unknown>;
|
|
32
|
+
rowIdx: number;
|
|
33
|
+
colIdx: number;
|
|
34
|
+
canSort: boolean;
|
|
35
|
+
canFilter: boolean;
|
|
36
|
+
wrapText: boolean;
|
|
37
|
+
handleGridKeyDown: (e: KeyboardEvent, rowIdx: number, colIdx: number) => void;
|
|
33
38
|
}) {
|
|
34
|
-
const minSize = header.column.columnDef.minSize ?? 0;
|
|
35
|
-
const maxSize = header.column.columnDef.maxSize ?? 0;
|
|
36
|
-
const handleKeyDown = (e: KeyboardEvent) => {
|
|
37
|
-
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
|
|
38
|
-
e.preventDefault();
|
|
39
|
-
const currentSize = header.getSize();
|
|
40
|
-
const increment = e.shiftKey ? 20 : 5; // Larger increment with Shift key
|
|
41
|
-
const newSize =
|
|
42
|
-
e.key === 'ArrowLeft'
|
|
43
|
-
? Math.max(minSize, currentSize - increment)
|
|
44
|
-
: Math.min(maxSize, currentSize + increment);
|
|
45
|
-
|
|
46
|
-
setColumnSizing((prevSizing) => ({
|
|
47
|
-
...prevSizing,
|
|
48
|
-
[header.column.id]: newSize,
|
|
49
|
-
}));
|
|
50
|
-
} else if (e.key === 'Home') {
|
|
51
|
-
e.preventDefault();
|
|
52
|
-
header.column.resetSize();
|
|
53
|
-
}
|
|
54
|
-
};
|
|
55
|
-
|
|
56
|
-
const columnName =
|
|
57
|
-
typeof header.column.columnDef.header === 'string'
|
|
58
|
-
? header.column.columnDef.header
|
|
59
|
-
: header.column.id;
|
|
60
|
-
|
|
61
39
|
return (
|
|
62
|
-
<
|
|
63
|
-
{
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
40
|
+
<td
|
|
41
|
+
key={cell.id}
|
|
42
|
+
tabIndex={0}
|
|
43
|
+
data-grid-cell-row={rowIdx}
|
|
44
|
+
data-grid-cell-col={colIdx}
|
|
45
|
+
class={clsx(!canSort && !canFilter && 'text-center')}
|
|
46
|
+
style={{
|
|
47
|
+
display: 'flex',
|
|
48
|
+
width: cell.column.getSize(),
|
|
49
|
+
minWidth: 0,
|
|
50
|
+
maxWidth: cell.column.getSize(),
|
|
51
|
+
flexShrink: 0,
|
|
52
|
+
position: cell.column.getIsPinned() === 'left' ? 'sticky' : undefined,
|
|
53
|
+
left: cell.column.getIsPinned() === 'left' ? cell.column.getStart() : undefined,
|
|
54
|
+
verticalAlign: 'middle',
|
|
55
|
+
}}
|
|
56
|
+
onKeyDown={(e) => handleGridKeyDown(e, rowIdx, colIdx)}
|
|
57
|
+
>
|
|
67
58
|
<div
|
|
68
|
-
role="separator"
|
|
69
|
-
aria-label={`Resize '${columnName}' column`}
|
|
70
|
-
aria-valuetext={`${header.getSize()}px`}
|
|
71
|
-
aria-orientation="vertical"
|
|
72
|
-
aria-valuemin={minSize}
|
|
73
|
-
aria-valuemax={maxSize}
|
|
74
|
-
aria-valuenow={header.getSize()}
|
|
75
|
-
// eslint-disable-next-line jsx-a11y-x/no-noninteractive-tabindex
|
|
76
|
-
tabIndex={0}
|
|
77
|
-
class="h-100"
|
|
78
59
|
style={{
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
60
|
+
display: 'block',
|
|
61
|
+
minWidth: 0,
|
|
62
|
+
maxWidth: '100%',
|
|
63
|
+
overflow: wrapText ? 'visible' : 'hidden',
|
|
64
|
+
textOverflow: wrapText ? undefined : 'ellipsis',
|
|
65
|
+
whiteSpace: wrapText ? 'normal' : 'nowrap',
|
|
66
|
+
flex: '1 1 0%',
|
|
67
|
+
width: 0, // Allow flex to control width, but start from 0
|
|
82
68
|
}}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
</div>
|
|
69
|
+
>
|
|
70
|
+
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
71
|
+
</div>
|
|
72
|
+
</td>
|
|
88
73
|
);
|
|
89
74
|
}
|
|
90
75
|
|
|
91
76
|
const DefaultNoResultsState = (
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
</>
|
|
77
|
+
<TanstackTableEmptyState iconName="bi-search">
|
|
78
|
+
No results found matching your search criteria.
|
|
79
|
+
</TanstackTableEmptyState>
|
|
96
80
|
);
|
|
97
81
|
|
|
98
82
|
const DefaultEmptyState = (
|
|
99
|
-
|
|
100
|
-
<i class="bi bi-eye-slash display-4 mb-2" aria-hidden="true" />
|
|
101
|
-
<p class="mb-0">No results found.</p>
|
|
102
|
-
</>
|
|
83
|
+
<TanstackTableEmptyState iconName="bi-eye-slash">No results found.</TanstackTableEmptyState>
|
|
103
84
|
);
|
|
104
85
|
|
|
105
86
|
interface TanstackTableProps<RowDataModel> {
|
|
@@ -109,6 +90,7 @@ interface TanstackTableProps<RowDataModel> {
|
|
|
109
90
|
rowHeight?: number;
|
|
110
91
|
noResultsState?: JSX.Element;
|
|
111
92
|
emptyState?: JSX.Element;
|
|
93
|
+
scrollRef?: React.RefObject<HTMLDivElement> | null;
|
|
112
94
|
}
|
|
113
95
|
|
|
114
96
|
const DEFAULT_FILTER_MAP = {};
|
|
@@ -122,6 +104,7 @@ const DEFAULT_FILTER_MAP = {};
|
|
|
122
104
|
* @param params.rowHeight - The height of the rows in the table
|
|
123
105
|
* @param params.noResultsState - The no results state for the table
|
|
124
106
|
* @param params.emptyState - The empty state for the table
|
|
107
|
+
* @param params.scrollRef - Optional ref that will be attached to the scroll container element.
|
|
125
108
|
*/
|
|
126
109
|
export function TanstackTable<RowDataModel>({
|
|
127
110
|
table,
|
|
@@ -130,19 +113,60 @@ export function TanstackTable<RowDataModel>({
|
|
|
130
113
|
rowHeight = 42,
|
|
131
114
|
noResultsState = DefaultNoResultsState,
|
|
132
115
|
emptyState = DefaultEmptyState,
|
|
116
|
+
scrollRef,
|
|
133
117
|
}: TanstackTableProps<RowDataModel>) {
|
|
134
118
|
const parentRef = useRef<HTMLDivElement>(null);
|
|
135
119
|
const tableRef = useRef<HTMLDivElement>(null);
|
|
120
|
+
const scrollContainerRef = scrollRef ?? parentRef;
|
|
121
|
+
|
|
136
122
|
const rows = [...table.getTopRows(), ...table.getCenterRows()];
|
|
137
123
|
const rowVirtualizer = useVirtualizer({
|
|
138
124
|
count: rows.length,
|
|
139
|
-
getScrollElement: () =>
|
|
125
|
+
getScrollElement: () => scrollContainerRef.current,
|
|
140
126
|
estimateSize: () => rowHeight,
|
|
141
127
|
overscan: 10,
|
|
128
|
+
measureElement: (el) => el?.getBoundingClientRect().height ?? rowHeight,
|
|
142
129
|
});
|
|
143
130
|
|
|
144
|
-
|
|
145
|
-
const
|
|
131
|
+
const visibleColumns = table.getVisibleLeafColumns();
|
|
132
|
+
const centerColumns = visibleColumns.filter((col) => !col.getIsPinned());
|
|
133
|
+
|
|
134
|
+
const columnVirtualizer = useVirtualizer({
|
|
135
|
+
count: centerColumns.length,
|
|
136
|
+
estimateSize: (index) => centerColumns[index]?.getSize(),
|
|
137
|
+
// `useAutoSizeColumns` solves a different problem (happens once when the column set changes)
|
|
138
|
+
// and we don't need to measure the cells themselves, so we can use the default estimateSize.
|
|
139
|
+
getScrollElement: () => scrollContainerRef.current,
|
|
140
|
+
horizontal: true,
|
|
141
|
+
overscan: 3,
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const virtualColumns = columnVirtualizer.getVirtualItems();
|
|
145
|
+
|
|
146
|
+
const virtualPaddingLeft = run(() => {
|
|
147
|
+
if (columnVirtualizer && virtualColumns?.length > 0) {
|
|
148
|
+
return virtualColumns[0]?.start ?? 0;
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const virtualPaddingRight = run(() => {
|
|
154
|
+
if (columnVirtualizer && virtualColumns?.length > 0) {
|
|
155
|
+
return (
|
|
156
|
+
columnVirtualizer.getTotalSize() - (virtualColumns[virtualColumns.length - 1]?.end ?? 0)
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
return null;
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// Check if any column has wrapping enabled
|
|
163
|
+
const hasWrappedColumns = table.getAllLeafColumns().some((col) => col.columnDef.meta?.wrapText);
|
|
164
|
+
|
|
165
|
+
// Create callback for remeasuring after resize
|
|
166
|
+
const handleResizeEnd = useMemo(() => {
|
|
167
|
+
if (!hasWrappedColumns) return undefined;
|
|
168
|
+
return () => rowVirtualizer.measure();
|
|
169
|
+
}, [hasWrappedColumns, rowVirtualizer]);
|
|
146
170
|
|
|
147
171
|
const getVisibleCells = (row: Row<RowDataModel>) => [
|
|
148
172
|
...row.getLeftVisibleCells(),
|
|
@@ -176,108 +200,57 @@ export function TanstackTable<RowDataModel>({
|
|
|
176
200
|
return;
|
|
177
201
|
}
|
|
178
202
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
if (
|
|
182
|
-
|
|
183
|
-
|
|
203
|
+
// Only handle arrow keys if we're in the cell itself, not in an interactive element
|
|
204
|
+
const target = e.target as HTMLElement;
|
|
205
|
+
if (target.tagName === 'TD') {
|
|
206
|
+
// If we are on the leftmost column, we should allow left scrolling.
|
|
207
|
+
if (colIdx === 0 && e.key === 'ArrowLeft') {
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
184
210
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
211
|
+
// If we are on the top row, we should allow up scrolling.
|
|
212
|
+
if (rowIdx === 0 && e.key === 'ArrowUp') {
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
189
215
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
216
|
+
// If we are on the rightmost column, we should allow right scrolling.
|
|
217
|
+
if (colIdx === rowLength - 1 && e.key === 'ArrowRight') {
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
194
220
|
|
|
195
|
-
|
|
221
|
+
e.preventDefault();
|
|
222
|
+
const selector = `[data-grid-cell-row="${next.row}"][data-grid-cell-col="${next.col}"]`;
|
|
223
|
+
const nextCell = tableRef.current?.querySelector(selector) as HTMLElement | null;
|
|
224
|
+
nextCell?.focus();
|
|
225
|
+
}
|
|
196
226
|
};
|
|
197
227
|
|
|
198
|
-
useEffect(() => {
|
|
199
|
-
const selector = `[data-grid-cell-row="${focusedCell.row}"][data-grid-cell-col="${focusedCell.col}"]`;
|
|
200
|
-
const cell = tableRef.current?.querySelector(selector) as HTMLElement | null;
|
|
201
|
-
if (!cell) return;
|
|
202
|
-
|
|
203
|
-
// eslint-disable-next-line react-you-might-not-need-an-effect/no-chain-state-updates
|
|
204
|
-
cell.focus();
|
|
205
|
-
}, [focusedCell]);
|
|
206
|
-
|
|
207
228
|
const virtualRows = rowVirtualizer.getVirtualItems();
|
|
208
|
-
|
|
209
|
-
virtualRows.length > 0
|
|
210
|
-
? [
|
|
211
|
-
notUndefined(virtualRows[0]).start - rowVirtualizer.options.scrollMargin,
|
|
212
|
-
rowVirtualizer.getTotalSize() - notUndefined(virtualRows.at(-1)).end,
|
|
213
|
-
]
|
|
214
|
-
: [0, 0];
|
|
229
|
+
|
|
215
230
|
const headerGroups = table.getHeaderGroups();
|
|
216
|
-
|
|
217
|
-
|
|
231
|
+
|
|
232
|
+
const leafHeaderGroup = headerGroups[headerGroups.length - 1];
|
|
233
|
+
|
|
234
|
+
const leftPinnedHeaders = leafHeaderGroup.headers.filter(
|
|
235
|
+
(header) => header.column.getIsPinned() === 'left',
|
|
218
236
|
);
|
|
219
|
-
const
|
|
237
|
+
const centerHeaders = leafHeaderGroup.headers.filter((header) => !header.column.getIsPinned());
|
|
220
238
|
|
|
221
|
-
const
|
|
239
|
+
const isTableResizing = leafHeaderGroup.headers.some((header) => header.column.getIsResizing());
|
|
222
240
|
|
|
223
241
|
// We toggle this here instead of in the parent since this component logically manages all UI for the table.
|
|
224
|
-
// eslint-disable-next-line react-you-might-not-need-an-effect/no-manage-parent
|
|
225
242
|
useEffect(() => {
|
|
226
|
-
document.body.classList.toggle('no-user-select', isTableResizing);
|
|
243
|
+
document.body.classList.toggle('pl-ui-no-user-select', isTableResizing);
|
|
227
244
|
}, [isTableResizing]);
|
|
228
245
|
|
|
229
|
-
|
|
230
|
-
useEffect(() => {
|
|
231
|
-
const handleScroll = () => {
|
|
232
|
-
const scrollElement = parentRef.current;
|
|
233
|
-
if (!scrollElement) return;
|
|
234
|
-
|
|
235
|
-
// Find and check all open popovers
|
|
236
|
-
const popovers = document.querySelectorAll('.popover.show');
|
|
237
|
-
popovers.forEach((popover) => {
|
|
238
|
-
// Find the trigger element for this popover
|
|
239
|
-
const triggerElement = document.querySelector(`[aria-describedby="${popover.id}"]`);
|
|
240
|
-
if (!triggerElement) return;
|
|
241
|
-
|
|
242
|
-
// Check if the trigger element is still visible in the scroll container
|
|
243
|
-
const scrollRect = scrollElement.getBoundingClientRect();
|
|
244
|
-
const triggerRect = triggerElement.getBoundingClientRect();
|
|
245
|
-
|
|
246
|
-
// Check if trigger is outside the visible scroll area
|
|
247
|
-
const isOutOfView =
|
|
248
|
-
triggerRect.bottom < scrollRect.top ||
|
|
249
|
-
triggerRect.top > scrollRect.bottom ||
|
|
250
|
-
triggerRect.right < scrollRect.left ||
|
|
251
|
-
triggerRect.left > scrollRect.right;
|
|
252
|
-
|
|
253
|
-
if (isOutOfView) {
|
|
254
|
-
// Use Bootstrap's Popover API to properly hide it
|
|
255
|
-
const popoverInstance = (window as any).bootstrap?.Popover?.getInstance(triggerElement);
|
|
256
|
-
if (popoverInstance) {
|
|
257
|
-
popoverInstance.hide();
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
});
|
|
261
|
-
};
|
|
262
|
-
|
|
263
|
-
const scrollElement = parentRef.current;
|
|
264
|
-
if (scrollElement) {
|
|
265
|
-
scrollElement.addEventListener('scroll', handleScroll);
|
|
266
|
-
return () => scrollElement.removeEventListener('scroll', handleScroll);
|
|
267
|
-
}
|
|
268
|
-
}, []);
|
|
246
|
+
const hasAutoSized = useAutoSizeColumns(table, tableRef, filters);
|
|
269
247
|
|
|
270
|
-
//
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
return 'ascending';
|
|
275
|
-
case 'desc':
|
|
276
|
-
return 'descending';
|
|
277
|
-
default:
|
|
278
|
-
return 'none';
|
|
248
|
+
// Re-measure the virtualizer when auto-sizing completes
|
|
249
|
+
useEffect(() => {
|
|
250
|
+
if (hasAutoSized) {
|
|
251
|
+
columnVirtualizer.measure();
|
|
279
252
|
}
|
|
280
|
-
};
|
|
253
|
+
}, [columnVirtualizer, hasAutoSized]);
|
|
281
254
|
|
|
282
255
|
const displayedCount = table.getRowModel().rows.length;
|
|
283
256
|
const totalCount = table.getCoreRowModel().rows.length;
|
|
@@ -285,7 +258,7 @@ export function TanstackTable<RowDataModel>({
|
|
|
285
258
|
return (
|
|
286
259
|
<div style={{ position: 'relative' }} class="d-flex flex-column h-100">
|
|
287
260
|
<div
|
|
288
|
-
ref={
|
|
261
|
+
ref={scrollContainerRef}
|
|
289
262
|
style={{
|
|
290
263
|
position: 'absolute',
|
|
291
264
|
top: 0,
|
|
@@ -305,178 +278,144 @@ export function TanstackTable<RowDataModel>({
|
|
|
305
278
|
>
|
|
306
279
|
<table
|
|
307
280
|
class="table table-hover mb-0"
|
|
308
|
-
style={{ tableLayout: 'fixed' }}
|
|
281
|
+
style={{ display: 'grid', tableLayout: 'fixed' }}
|
|
309
282
|
aria-label={title}
|
|
310
283
|
role="grid"
|
|
311
284
|
>
|
|
312
|
-
<thead
|
|
313
|
-
{
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
}}
|
|
366
|
-
type="button"
|
|
367
|
-
aria-label={
|
|
368
|
-
canSort
|
|
369
|
-
? `'${columnName}' column, current sort is ${getAriaSort(sortDirection)}`
|
|
370
|
-
: undefined
|
|
371
|
-
}
|
|
372
|
-
onClick={canSort ? header.column.getToggleSortingHandler() : undefined}
|
|
373
|
-
onKeyDown={
|
|
374
|
-
canSort
|
|
375
|
-
? (e) => {
|
|
376
|
-
const handleSort = header.column.getToggleSortingHandler();
|
|
377
|
-
if (e.key === 'Enter' && handleSort) {
|
|
378
|
-
e.preventDefault();
|
|
379
|
-
handleSort(e);
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
: undefined
|
|
383
|
-
}
|
|
384
|
-
>
|
|
385
|
-
{header.isPlaceholder
|
|
386
|
-
? null
|
|
387
|
-
: flexRender(header.column.columnDef.header, header.getContext())}
|
|
388
|
-
{canSort && (
|
|
389
|
-
<span class="visually-hidden">
|
|
390
|
-
, {getAriaSort(sortDirection)}, click to sort
|
|
391
|
-
</span>
|
|
392
|
-
)}
|
|
393
|
-
</button>
|
|
394
|
-
|
|
395
|
-
{(canSort || canFilter) && (
|
|
396
|
-
<div class="d-flex align-items-center">
|
|
397
|
-
{canSort && (
|
|
398
|
-
<button
|
|
399
|
-
type="button"
|
|
400
|
-
class="btn btn-link text-muted p-0"
|
|
401
|
-
aria-label={`Sort ${columnName.toLowerCase()}`}
|
|
402
|
-
title={`Sort ${columnName.toLowerCase()}`}
|
|
403
|
-
onClick={header.column.getToggleSortingHandler()}
|
|
404
|
-
>
|
|
405
|
-
<SortIcon sortMethod={sortDirection || false} />
|
|
406
|
-
</button>
|
|
407
|
-
)}
|
|
408
|
-
{canFilter && filters[header.column.id]?.({ header })}
|
|
409
|
-
</div>
|
|
410
|
-
)}
|
|
411
|
-
</div>
|
|
412
|
-
{tableRect?.width &&
|
|
413
|
-
tableRect.width > table.getTotalSize() &&
|
|
414
|
-
index === headerGroup.headers.length - 1 ? null : (
|
|
415
|
-
<ResizeHandle header={header} setColumnSizing={table.setColumnSizing} />
|
|
416
|
-
)}
|
|
417
|
-
</th>
|
|
418
|
-
);
|
|
419
|
-
})}
|
|
420
|
-
</tr>
|
|
421
|
-
))}
|
|
285
|
+
<thead
|
|
286
|
+
style={{
|
|
287
|
+
display: 'grid',
|
|
288
|
+
position: 'sticky',
|
|
289
|
+
top: 0,
|
|
290
|
+
zIndex: 1,
|
|
291
|
+
}}
|
|
292
|
+
>
|
|
293
|
+
<tr
|
|
294
|
+
key={leafHeaderGroup.id}
|
|
295
|
+
style={{ display: 'flex', width: `${table.getTotalSize()}px` }}
|
|
296
|
+
>
|
|
297
|
+
{/* Left pinned columns */}
|
|
298
|
+
{leftPinnedHeaders.map((header) => {
|
|
299
|
+
return (
|
|
300
|
+
<TanstackTableHeaderCell
|
|
301
|
+
key={header.id}
|
|
302
|
+
header={header}
|
|
303
|
+
filters={filters}
|
|
304
|
+
table={table}
|
|
305
|
+
handleResizeEnd={handleResizeEnd}
|
|
306
|
+
isPinned="left"
|
|
307
|
+
/>
|
|
308
|
+
);
|
|
309
|
+
})}
|
|
310
|
+
|
|
311
|
+
{/* Virtual padding for left side of center columns */}
|
|
312
|
+
{virtualPaddingLeft ? (
|
|
313
|
+
<th style={{ display: 'flex', width: virtualPaddingLeft }} />
|
|
314
|
+
) : null}
|
|
315
|
+
|
|
316
|
+
{/* Virtualized center columns */}
|
|
317
|
+
{virtualColumns.map((virtualColumn) => {
|
|
318
|
+
const header = centerHeaders[virtualColumn.index];
|
|
319
|
+
if (!header) return null;
|
|
320
|
+
|
|
321
|
+
return (
|
|
322
|
+
<TanstackTableHeaderCell
|
|
323
|
+
key={header.id}
|
|
324
|
+
header={header}
|
|
325
|
+
filters={filters}
|
|
326
|
+
table={table}
|
|
327
|
+
handleResizeEnd={handleResizeEnd}
|
|
328
|
+
isPinned={false}
|
|
329
|
+
/>
|
|
330
|
+
);
|
|
331
|
+
})}
|
|
332
|
+
|
|
333
|
+
{/* Virtual padding for right side of center columns */}
|
|
334
|
+
{virtualPaddingRight ? (
|
|
335
|
+
<th style={{ display: 'flex', width: virtualPaddingRight }} />
|
|
336
|
+
) : null}
|
|
337
|
+
</tr>
|
|
422
338
|
</thead>
|
|
423
|
-
<tbody
|
|
424
|
-
{
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
339
|
+
<tbody
|
|
340
|
+
style={{
|
|
341
|
+
display: 'grid',
|
|
342
|
+
height: `${rowVirtualizer.getTotalSize()}px`,
|
|
343
|
+
position: 'relative',
|
|
344
|
+
}}
|
|
345
|
+
>
|
|
429
346
|
{virtualRows.map((virtualRow) => {
|
|
430
347
|
const row = rows[virtualRow.index];
|
|
431
|
-
const visibleCells = getVisibleCells(row);
|
|
432
348
|
const rowIdx = virtualRow.index;
|
|
349
|
+
const leftPinnedCells = row.getLeftVisibleCells();
|
|
350
|
+
const centerCells = row.getCenterVisibleCells();
|
|
351
|
+
|
|
352
|
+
let currentColIdx = 0;
|
|
433
353
|
|
|
434
354
|
return (
|
|
435
|
-
<tr
|
|
436
|
-
{
|
|
355
|
+
<tr
|
|
356
|
+
key={row.id}
|
|
357
|
+
ref={(node) => rowVirtualizer.measureElement(node)}
|
|
358
|
+
data-index={virtualRow.index}
|
|
359
|
+
style={{
|
|
360
|
+
display: 'flex',
|
|
361
|
+
position: 'absolute',
|
|
362
|
+
transform: `translateY(${virtualRow.start}px)`,
|
|
363
|
+
width: `${table.getTotalSize()}px`,
|
|
364
|
+
}}
|
|
365
|
+
>
|
|
366
|
+
{leftPinnedCells.map((cell) => {
|
|
367
|
+
const colIdx = currentColIdx++;
|
|
437
368
|
const canSort = cell.column.getCanSort();
|
|
438
369
|
const canFilter = cell.column.getCanFilter();
|
|
370
|
+
const wrapText = cell.column.columnDef.meta?.wrapText ?? false;
|
|
439
371
|
|
|
440
372
|
return (
|
|
441
|
-
<
|
|
373
|
+
<TableCell
|
|
442
374
|
key={cell.id}
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
style={{
|
|
452
|
-
width:
|
|
453
|
-
cell.column.id === lastColumnId
|
|
454
|
-
? `max(100%, ${cell.column.getSize()}px)`
|
|
455
|
-
: cell.column.getSize(),
|
|
456
|
-
position: cell.column.getIsPinned() === 'left' ? 'sticky' : undefined,
|
|
457
|
-
left:
|
|
458
|
-
cell.column.getIsPinned() === 'left'
|
|
459
|
-
? cell.column.getStart()
|
|
460
|
-
: undefined,
|
|
461
|
-
whiteSpace: 'nowrap',
|
|
462
|
-
overflow: 'hidden',
|
|
463
|
-
textOverflow: 'ellipsis',
|
|
464
|
-
}}
|
|
465
|
-
onFocus={() => setFocusedCell({ row: rowIdx, col: colIdx })}
|
|
466
|
-
onKeyDown={(e) => handleGridKeyDown(e, rowIdx, colIdx)}
|
|
467
|
-
>
|
|
468
|
-
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
469
|
-
</td>
|
|
375
|
+
cell={cell}
|
|
376
|
+
rowIdx={rowIdx}
|
|
377
|
+
colIdx={colIdx}
|
|
378
|
+
canSort={canSort}
|
|
379
|
+
canFilter={canFilter}
|
|
380
|
+
wrapText={wrapText}
|
|
381
|
+
handleGridKeyDown={handleGridKeyDown}
|
|
382
|
+
/>
|
|
470
383
|
);
|
|
471
384
|
})}
|
|
385
|
+
|
|
386
|
+
{virtualPaddingLeft ? (
|
|
387
|
+
<td style={{ display: 'flex', width: virtualPaddingLeft }} />
|
|
388
|
+
) : null}
|
|
389
|
+
|
|
390
|
+
{virtualColumns.map((virtualColumn) => {
|
|
391
|
+
const cell = centerCells[virtualColumn.index];
|
|
392
|
+
if (!cell) return null;
|
|
393
|
+
|
|
394
|
+
const colIdx = currentColIdx++;
|
|
395
|
+
const canSort = cell.column.getCanSort();
|
|
396
|
+
const canFilter = cell.column.getCanFilter();
|
|
397
|
+
const wrapText = cell.column.columnDef.meta?.wrapText ?? false;
|
|
398
|
+
|
|
399
|
+
return (
|
|
400
|
+
<TableCell
|
|
401
|
+
key={cell.id}
|
|
402
|
+
cell={cell}
|
|
403
|
+
rowIdx={rowIdx}
|
|
404
|
+
colIdx={colIdx}
|
|
405
|
+
canSort={canSort}
|
|
406
|
+
canFilter={canFilter}
|
|
407
|
+
wrapText={wrapText}
|
|
408
|
+
handleGridKeyDown={handleGridKeyDown}
|
|
409
|
+
/>
|
|
410
|
+
);
|
|
411
|
+
})}
|
|
412
|
+
|
|
413
|
+
{virtualPaddingRight ? (
|
|
414
|
+
<td style={{ display: 'flex', width: virtualPaddingRight }} />
|
|
415
|
+
) : null}
|
|
472
416
|
</tr>
|
|
473
417
|
);
|
|
474
418
|
})}
|
|
475
|
-
{after > 0 && (
|
|
476
|
-
<tr tabIndex={-1}>
|
|
477
|
-
<td colSpan={headerGroups[0].headers.length} style={{ height: after }} />
|
|
478
|
-
</tr>
|
|
479
|
-
)}
|
|
480
419
|
</tbody>
|
|
481
420
|
</table>
|
|
482
421
|
</div>
|
|
@@ -528,8 +467,13 @@ export function TanstackTable<RowDataModel>({
|
|
|
528
467
|
* @param params
|
|
529
468
|
* @param params.table - The table model
|
|
530
469
|
* @param params.title - The title of the card
|
|
470
|
+
* @param params.className - The class name to apply to the card
|
|
471
|
+
* @param params.style - The style to apply to the card
|
|
472
|
+
* @param params.singularLabel - The singular label for a single row in the table, e.g. "student"
|
|
473
|
+
* @param params.pluralLabel - The plural label for multiple rows in the table, e.g. "students"
|
|
531
474
|
* @param params.headerButtons - The buttons to display in the header
|
|
532
475
|
* @param params.columnManagerButtons - The buttons to display next to the column manager (View button)
|
|
476
|
+
* @param params.columnManagerTopContent - Optional content to display at the top of the column manager (View) dropdown menu
|
|
533
477
|
* @param params.globalFilter - State management for the global filter
|
|
534
478
|
* @param params.globalFilter.value
|
|
535
479
|
* @param params.globalFilter.setValue
|
|
@@ -540,24 +484,35 @@ export function TanstackTable<RowDataModel>({
|
|
|
540
484
|
export function TanstackTableCard<RowDataModel>({
|
|
541
485
|
table,
|
|
542
486
|
title,
|
|
487
|
+
singularLabel,
|
|
488
|
+
pluralLabel,
|
|
543
489
|
headerButtons,
|
|
544
490
|
columnManagerButtons,
|
|
491
|
+
columnManagerTopContent,
|
|
545
492
|
globalFilter,
|
|
546
493
|
tableOptions,
|
|
547
|
-
downloadButtonOptions
|
|
494
|
+
downloadButtonOptions,
|
|
495
|
+
className,
|
|
496
|
+
...divProps
|
|
548
497
|
}: {
|
|
549
498
|
table: Table<RowDataModel>;
|
|
550
499
|
title: string;
|
|
500
|
+
singularLabel: string;
|
|
501
|
+
pluralLabel: string;
|
|
551
502
|
headerButtons: JSX.Element;
|
|
552
503
|
columnManagerButtons?: JSX.Element;
|
|
504
|
+
columnManagerTopContent?: JSX.Element;
|
|
553
505
|
globalFilter: {
|
|
554
506
|
value: string;
|
|
555
507
|
setValue: (value: string) => void;
|
|
556
508
|
placeholder: string;
|
|
557
509
|
};
|
|
558
510
|
tableOptions: Partial<Omit<TanstackTableProps<RowDataModel>, 'table'>>;
|
|
559
|
-
downloadButtonOptions?: Omit<
|
|
560
|
-
|
|
511
|
+
downloadButtonOptions?: Omit<
|
|
512
|
+
TanstackTableDownloadButtonProps<RowDataModel>,
|
|
513
|
+
'table' | 'singularLabel' | 'pluralLabel'
|
|
514
|
+
>;
|
|
515
|
+
} & Omit<ComponentProps<'div'>, 'class'>) {
|
|
561
516
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
|
562
517
|
|
|
563
518
|
// Focus the search input when Ctrl+F is pressed
|
|
@@ -578,7 +533,7 @@ export function TanstackTableCard<RowDataModel>({
|
|
|
578
533
|
const totalCount = table.getCoreRowModel().rows.length;
|
|
579
534
|
|
|
580
535
|
return (
|
|
581
|
-
<div class=
|
|
536
|
+
<div class={clsx('card d-flex flex-column', className)} {...divProps}>
|
|
582
537
|
<div class="card-header bg-primary text-white">
|
|
583
538
|
<div class="d-flex align-items-center justify-content-between gap-2">
|
|
584
539
|
<div>{title}</div>
|
|
@@ -586,53 +541,50 @@ export function TanstackTableCard<RowDataModel>({
|
|
|
586
541
|
{headerButtons}
|
|
587
542
|
|
|
588
543
|
{downloadButtonOptions && (
|
|
589
|
-
<TanstackTableDownloadButton
|
|
544
|
+
<TanstackTableDownloadButton
|
|
545
|
+
table={table}
|
|
546
|
+
pluralLabel={pluralLabel}
|
|
547
|
+
singularLabel={singularLabel}
|
|
548
|
+
{...downloadButtonOptions}
|
|
549
|
+
/>
|
|
590
550
|
)}
|
|
591
551
|
</div>
|
|
592
552
|
</div>
|
|
593
553
|
</div>
|
|
594
554
|
<div class="card-body d-flex flex-row flex-wrap flex-grow-0 align-items-center gap-2">
|
|
595
|
-
<div class="
|
|
596
|
-
<
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
{
|
|
611
|
-
<
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
)}
|
|
622
|
-
</div>
|
|
623
|
-
<div class="d-none d-md-block">
|
|
624
|
-
<ColumnManager table={table} id="column-manager-button-wide" />
|
|
625
|
-
{columnManagerButtons}
|
|
626
|
-
</div>
|
|
555
|
+
<div class="position-relative w-100" style={{ maxWidth: 'min(400px, 100%)' }}>
|
|
556
|
+
<input
|
|
557
|
+
ref={searchInputRef}
|
|
558
|
+
type="text"
|
|
559
|
+
class="form-control pl-ui-tanstack-table-search-input pl-ui-tanstack-table-focusable-shadow"
|
|
560
|
+
aria-label={globalFilter.placeholder}
|
|
561
|
+
placeholder={globalFilter.placeholder}
|
|
562
|
+
value={globalFilter.value}
|
|
563
|
+
autoComplete="off"
|
|
564
|
+
onInput={(e) => {
|
|
565
|
+
if (!(e.target instanceof HTMLInputElement)) return;
|
|
566
|
+
globalFilter.setValue(e.target.value);
|
|
567
|
+
}}
|
|
568
|
+
/>
|
|
569
|
+
{globalFilter.value && (
|
|
570
|
+
<OverlayTrigger overlay={<Tooltip>Clear search</Tooltip>}>
|
|
571
|
+
<button
|
|
572
|
+
type="button"
|
|
573
|
+
class="btn btn-floating-icon"
|
|
574
|
+
aria-label="Clear search"
|
|
575
|
+
onClick={() => globalFilter.setValue('')}
|
|
576
|
+
>
|
|
577
|
+
<i class="bi bi-x-circle-fill" aria-hidden="true" />
|
|
578
|
+
</button>
|
|
579
|
+
</OverlayTrigger>
|
|
580
|
+
)}
|
|
627
581
|
</div>
|
|
628
|
-
<div class="d-
|
|
629
|
-
<ColumnManager table={table}
|
|
582
|
+
<div class="d-flex flex-wrap flex-row align-items-center gap-2">
|
|
583
|
+
<ColumnManager table={table} topContent={columnManagerTopContent} />
|
|
630
584
|
{columnManagerButtons}
|
|
631
585
|
</div>
|
|
632
|
-
<div class="
|
|
633
|
-
|
|
634
|
-
Showing {displayedCount} of {totalCount} {title.toLowerCase()}
|
|
635
|
-
</div>
|
|
586
|
+
<div class="ms-auto text-muted text-nowrap">
|
|
587
|
+
Showing {displayedCount} of {totalCount} {totalCount === 1 ? singularLabel : pluralLabel}
|
|
636
588
|
</div>
|
|
637
589
|
</div>
|
|
638
590
|
<div class="flex-grow-1">
|