@prairielearn/ui 1.2.0 → 1.4.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 +32 -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 +26 -14
- package/dist/components/CategoricalColumnFilter.js.map +1 -1
- package/dist/components/ColumnManager.d.ts +6 -2
- package/dist/components/ColumnManager.d.ts.map +1 -1
- package/dist/components/ColumnManager.js +98 -35
- 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/TanstackTable.d.ts +19 -3
- package/dist/components/TanstackTable.d.ts.map +1 -1
- package/dist/components/TanstackTable.js +159 -219
- 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/styles.css +58 -0
- 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 +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- 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 +84 -54
- package/src/components/ColumnManager.tsx +236 -88
- 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/TanstackTable.tsx +357 -410
- package/src/components/TanstackTableDownloadButton.tsx +8 -5
- package/src/components/TanstackTableHeaderCell.tsx +207 -0
- package/src/components/styles.css +58 -0
- package/src/components/useAutoSizeColumns.tsx +168 -0
- package/src/index.ts +10 -1
- package/src/react-table.ts +17 -0
- package/tsconfig.json +1 -2
- package/dist/components/TanstackTable.css +0 -4
- package/src/components/TanstackTable.css +0 -4
|
@@ -6,11 +6,13 @@ export interface TanstackTableDownloadButtonProps<RowDataModel> {
|
|
|
6
6
|
table: Table<RowDataModel>;
|
|
7
7
|
filenameBase: string;
|
|
8
8
|
mapRowToData: (row: RowDataModel) => Record<string, string | number | null> | null;
|
|
9
|
+
singularLabel: string;
|
|
9
10
|
pluralLabel: string;
|
|
10
11
|
}
|
|
11
12
|
/**
|
|
12
13
|
* @param params
|
|
13
|
-
* @param params.
|
|
14
|
+
* @param params.singularLabel - The singular label for a single row in the table, e.g. "student"
|
|
15
|
+
* @param params.pluralLabel - The plural label for multiple rows in the table, e.g. "students"
|
|
14
16
|
* @param params.table - The table model
|
|
15
17
|
* @param params.filenameBase - The base filename for the downloads
|
|
16
18
|
* @param params.mapRowToData - A function that maps a row to a record where the
|
|
@@ -22,6 +24,7 @@ export function TanstackTableDownloadButton<RowDataModel>({
|
|
|
22
24
|
table,
|
|
23
25
|
filenameBase,
|
|
24
26
|
mapRowToData,
|
|
27
|
+
singularLabel,
|
|
25
28
|
pluralLabel,
|
|
26
29
|
}: TanstackTableDownloadButtonProps<RowDataModel>) {
|
|
27
30
|
const allRows = table.getCoreRowModel().rows.map((row) => row.original);
|
|
@@ -91,7 +94,7 @@ export function TanstackTableDownloadButton<RowDataModel>({
|
|
|
91
94
|
disabled={selectedRowsJSON.length === 0}
|
|
92
95
|
onClick={() => downloadJSONAsCSV(selectedRowsJSON, `${filenameBase}_selected.csv`)}
|
|
93
96
|
>
|
|
94
|
-
Selected {pluralLabel} as CSV
|
|
97
|
+
Selected {selectedRowsJSON.length === 1 ? singularLabel : pluralLabel} as CSV
|
|
95
98
|
</button>
|
|
96
99
|
</li>
|
|
97
100
|
<li role="presentation">
|
|
@@ -103,7 +106,7 @@ export function TanstackTableDownloadButton<RowDataModel>({
|
|
|
103
106
|
disabled={selectedRowsJSON.length === 0}
|
|
104
107
|
onClick={() => downloadAsJSON(selectedRowsJSON, `${filenameBase}_selected.json`)}
|
|
105
108
|
>
|
|
106
|
-
Selected {pluralLabel} as JSON
|
|
109
|
+
Selected {selectedRowsJSON.length === 1 ? singularLabel : pluralLabel} as JSON
|
|
107
110
|
</button>
|
|
108
111
|
</li>
|
|
109
112
|
<li role="presentation">
|
|
@@ -115,7 +118,7 @@ export function TanstackTableDownloadButton<RowDataModel>({
|
|
|
115
118
|
disabled={filteredRowsJSON.length === 0}
|
|
116
119
|
onClick={() => downloadJSONAsCSV(filteredRowsJSON, `${filenameBase}_filtered.csv`)}
|
|
117
120
|
>
|
|
118
|
-
Filtered {pluralLabel} as CSV
|
|
121
|
+
Filtered {filteredRowsJSON.length === 1 ? singularLabel : pluralLabel} as CSV
|
|
119
122
|
</button>
|
|
120
123
|
</li>
|
|
121
124
|
<li role="presentation">
|
|
@@ -127,7 +130,7 @@ export function TanstackTableDownloadButton<RowDataModel>({
|
|
|
127
130
|
disabled={filteredRowsJSON.length === 0}
|
|
128
131
|
onClick={() => downloadAsJSON(filteredRowsJSON, `${filenameBase}_filtered.json`)}
|
|
129
132
|
>
|
|
130
|
-
Filtered {pluralLabel} as JSON
|
|
133
|
+
Filtered {filteredRowsJSON.length === 1 ? singularLabel : pluralLabel} as JSON
|
|
131
134
|
</button>
|
|
132
135
|
</li>
|
|
133
136
|
</ul>
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { flexRender } from '@tanstack/react-table';
|
|
2
|
+
import type { Header, SortDirection, Table } from '@tanstack/table-core';
|
|
3
|
+
import clsx from 'clsx';
|
|
4
|
+
import type { JSX } from 'preact/jsx-runtime';
|
|
5
|
+
|
|
6
|
+
function SortIcon({ sortMethod }: { sortMethod: false | SortDirection }) {
|
|
7
|
+
if (sortMethod === 'asc') {
|
|
8
|
+
return <i class="bi bi-sort-up-alt" aria-hidden="true" />;
|
|
9
|
+
} else if (sortMethod === 'desc') {
|
|
10
|
+
return <i class="bi bi-sort-down" aria-hidden="true" />;
|
|
11
|
+
} else {
|
|
12
|
+
return <i class="bi bi-arrow-down-up opacity-75 text-muted" aria-hidden="true" />;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function ResizeHandle<RowDataModel>({
|
|
17
|
+
header,
|
|
18
|
+
setColumnSizing,
|
|
19
|
+
onResizeEnd,
|
|
20
|
+
}: {
|
|
21
|
+
header: Header<RowDataModel, unknown>;
|
|
22
|
+
setColumnSizing: Table<RowDataModel>['setColumnSizing'];
|
|
23
|
+
onResizeEnd?: () => void;
|
|
24
|
+
}) {
|
|
25
|
+
const minSize = header.column.columnDef.minSize ?? 0;
|
|
26
|
+
const maxSize = header.column.columnDef.maxSize ?? 0;
|
|
27
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
28
|
+
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
|
|
29
|
+
e.preventDefault();
|
|
30
|
+
const currentSize = header.getSize();
|
|
31
|
+
const increment = e.shiftKey ? 20 : 5; // Larger increment with Shift key
|
|
32
|
+
const newSize =
|
|
33
|
+
e.key === 'ArrowLeft'
|
|
34
|
+
? Math.max(minSize, currentSize - increment)
|
|
35
|
+
: Math.min(maxSize, currentSize + increment);
|
|
36
|
+
|
|
37
|
+
setColumnSizing((prevSizing) => ({
|
|
38
|
+
...prevSizing,
|
|
39
|
+
[header.column.id]: newSize,
|
|
40
|
+
}));
|
|
41
|
+
} else if (e.key === 'Home') {
|
|
42
|
+
e.preventDefault();
|
|
43
|
+
header.column.resetSize();
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const columnName =
|
|
48
|
+
typeof header.column.columnDef.header === 'string'
|
|
49
|
+
? header.column.columnDef.header
|
|
50
|
+
: header.column.id;
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<div class="py-1 h-100" style={{ position: 'absolute', right: 0, top: 0, width: '4px' }}>
|
|
54
|
+
{/* separator role is focusable, so these jsx-a11y-x rules are false positives.
|
|
55
|
+
https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/separator_role#focusable_separator
|
|
56
|
+
*/}
|
|
57
|
+
{/* eslint-disable-next-line jsx-a11y-x/no-noninteractive-element-interactions */}
|
|
58
|
+
<div
|
|
59
|
+
role="separator"
|
|
60
|
+
aria-label={`Resize '${columnName}' column`}
|
|
61
|
+
aria-valuetext={`${header.getSize()}px`}
|
|
62
|
+
aria-orientation="vertical"
|
|
63
|
+
aria-valuemin={minSize}
|
|
64
|
+
aria-valuemax={maxSize}
|
|
65
|
+
aria-valuenow={header.getSize()}
|
|
66
|
+
// eslint-disable-next-line jsx-a11y-x/no-noninteractive-tabindex
|
|
67
|
+
tabIndex={0}
|
|
68
|
+
class="h-100"
|
|
69
|
+
style={{
|
|
70
|
+
background: header.column.getIsResizing() ? 'var(--bs-primary)' : 'var(--bs-gray-400)',
|
|
71
|
+
cursor: 'col-resize',
|
|
72
|
+
transition: 'background-color 0.2s',
|
|
73
|
+
}}
|
|
74
|
+
onMouseDown={header.getResizeHandler()}
|
|
75
|
+
onMouseUp={onResizeEnd}
|
|
76
|
+
onTouchStart={header.getResizeHandler()}
|
|
77
|
+
onTouchEnd={onResizeEnd}
|
|
78
|
+
onKeyDown={handleKeyDown}
|
|
79
|
+
/>
|
|
80
|
+
</div>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Helper function to get aria-sort value
|
|
86
|
+
*/
|
|
87
|
+
function getAriaSort(sortDirection: false | SortDirection) {
|
|
88
|
+
switch (sortDirection) {
|
|
89
|
+
case 'asc':
|
|
90
|
+
return 'ascending';
|
|
91
|
+
case 'desc':
|
|
92
|
+
return 'descending';
|
|
93
|
+
default:
|
|
94
|
+
return 'none';
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function TanstackTableHeaderCell<RowDataModel>({
|
|
99
|
+
header,
|
|
100
|
+
filters,
|
|
101
|
+
table,
|
|
102
|
+
handleResizeEnd,
|
|
103
|
+
isPinned,
|
|
104
|
+
measurementMode = false,
|
|
105
|
+
}: {
|
|
106
|
+
header: Header<RowDataModel, unknown>;
|
|
107
|
+
filters: Record<string, (props: { header: Header<RowDataModel, unknown> }) => JSX.Element>;
|
|
108
|
+
table: Table<RowDataModel>;
|
|
109
|
+
handleResizeEnd?: () => void;
|
|
110
|
+
isPinned: 'left' | false;
|
|
111
|
+
measurementMode?: boolean;
|
|
112
|
+
}) {
|
|
113
|
+
const sortDirection = header.column.getIsSorted();
|
|
114
|
+
const canSort = header.column.getCanSort();
|
|
115
|
+
const canFilter = header.column.getCanFilter();
|
|
116
|
+
const columnName =
|
|
117
|
+
header.column.columnDef.meta?.label ??
|
|
118
|
+
(typeof header.column.columnDef.header === 'string'
|
|
119
|
+
? header.column.columnDef.header
|
|
120
|
+
: header.column.id);
|
|
121
|
+
|
|
122
|
+
// In measurement mode, we don't want to set the size of the header from tanstack.
|
|
123
|
+
const headerSize = measurementMode ? undefined : header.getSize();
|
|
124
|
+
const style: JSX.CSSProperties = {
|
|
125
|
+
display: 'flex',
|
|
126
|
+
width: headerSize,
|
|
127
|
+
minWidth: 0,
|
|
128
|
+
maxWidth: headerSize,
|
|
129
|
+
flexShrink: 0,
|
|
130
|
+
position: isPinned === 'left' ? 'sticky' : 'relative',
|
|
131
|
+
top: 0,
|
|
132
|
+
zIndex: isPinned === 'left' ? 2 : 1,
|
|
133
|
+
left: isPinned === 'left' ? header.getStart() : undefined,
|
|
134
|
+
boxShadow:
|
|
135
|
+
'inset 0 calc(-1 * var(--bs-border-width)) 0 0 rgba(0, 0, 0, 1), inset 0 var(--bs-border-width) 0 0 var(--bs-border-color)',
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const isNormalColumn = canSort || canFilter;
|
|
139
|
+
|
|
140
|
+
return (
|
|
141
|
+
<th
|
|
142
|
+
key={header.id}
|
|
143
|
+
data-column-id={header.column.id}
|
|
144
|
+
class={clsx(isPinned === 'left' && 'bg-light')}
|
|
145
|
+
style={style}
|
|
146
|
+
aria-sort={canSort ? getAriaSort(sortDirection) : undefined}
|
|
147
|
+
role="columnheader"
|
|
148
|
+
>
|
|
149
|
+
<div
|
|
150
|
+
class={clsx(
|
|
151
|
+
'd-flex align-items-center flex-grow-1',
|
|
152
|
+
isNormalColumn ? 'justify-content-between' : 'justify-content-center',
|
|
153
|
+
)}
|
|
154
|
+
style={{
|
|
155
|
+
minWidth: 0,
|
|
156
|
+
}}
|
|
157
|
+
>
|
|
158
|
+
<div
|
|
159
|
+
class={clsx(
|
|
160
|
+
'text-nowrap text-start',
|
|
161
|
+
// e.g. checkboxes
|
|
162
|
+
!isNormalColumn && 'd-flex align-items-center justify-content-center',
|
|
163
|
+
)}
|
|
164
|
+
style={{
|
|
165
|
+
minWidth: 0,
|
|
166
|
+
flex: '1 1 0%',
|
|
167
|
+
overflow: 'hidden',
|
|
168
|
+
textOverflow: 'ellipsis',
|
|
169
|
+
background: 'transparent',
|
|
170
|
+
border: 'none',
|
|
171
|
+
}}
|
|
172
|
+
>
|
|
173
|
+
{header.isPlaceholder
|
|
174
|
+
? null
|
|
175
|
+
: flexRender(header.column.columnDef.header, header.getContext())}
|
|
176
|
+
{canSort && (
|
|
177
|
+
<span class="visually-hidden">, {getAriaSort(sortDirection)}, click to sort</span>
|
|
178
|
+
)}
|
|
179
|
+
</div>
|
|
180
|
+
|
|
181
|
+
{(canSort || canFilter) && (
|
|
182
|
+
<div class="d-flex align-items-center" style={{ flexShrink: 0 }}>
|
|
183
|
+
{canSort && (
|
|
184
|
+
<button
|
|
185
|
+
type="button"
|
|
186
|
+
class="btn btn-link text-muted p-0"
|
|
187
|
+
aria-label={`Sort ${columnName.toLowerCase()}, current sort is ${getAriaSort(sortDirection)}`}
|
|
188
|
+
title={`Sort ${columnName.toLowerCase()}`}
|
|
189
|
+
onClick={header.column.getToggleSortingHandler()}
|
|
190
|
+
>
|
|
191
|
+
<SortIcon sortMethod={sortDirection} />
|
|
192
|
+
</button>
|
|
193
|
+
)}
|
|
194
|
+
{canFilter && filters[header.column.id]?.({ header })}
|
|
195
|
+
</div>
|
|
196
|
+
)}
|
|
197
|
+
</div>
|
|
198
|
+
{header.column.getCanResize() && (
|
|
199
|
+
<ResizeHandle
|
|
200
|
+
header={header}
|
|
201
|
+
setColumnSizing={table.setColumnSizing}
|
|
202
|
+
onResizeEnd={handleResizeEnd}
|
|
203
|
+
/>
|
|
204
|
+
)}
|
|
205
|
+
</th>
|
|
206
|
+
);
|
|
207
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/* Global styles for the PrairieLearn UI components. These should be included in any
|
|
2
|
+
page that uses the PrairieLearn UI components, and class names should not be directly referenced by consumers.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
body.pl-ui-no-user-select {
|
|
6
|
+
user-select: none;
|
|
7
|
+
-webkit-user-select: none;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
.pl-ui-tanstack-table-search-input {
|
|
11
|
+
padding-right: 2.5rem;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
.btn.btn-floating-icon {
|
|
15
|
+
position: absolute;
|
|
16
|
+
right: 0;
|
|
17
|
+
top: 50%;
|
|
18
|
+
transform: translateY(-50%);
|
|
19
|
+
padding: 0.25rem 0.5rem;
|
|
20
|
+
color: var(--bs-secondary);
|
|
21
|
+
opacity: 0.6;
|
|
22
|
+
transition: opacity 0.15s ease-in-out;
|
|
23
|
+
text-decoration: none;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.btn.btn-floating-icon:hover {
|
|
27
|
+
opacity: 1;
|
|
28
|
+
color: var(--bs-secondary);
|
|
29
|
+
text-decoration: none;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.btn.btn-floating-icon:focus {
|
|
33
|
+
opacity: 1;
|
|
34
|
+
color: var(--bs-secondary);
|
|
35
|
+
text-decoration: none;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/* https://react-bootstrap.github.io/docs/getting-started/theming/#new-variants-and-sizes */
|
|
39
|
+
.btn.btn-tanstack-table {
|
|
40
|
+
--bs-btn-color: var(--bs-body-color);
|
|
41
|
+
--bs-btn-bg: var(--bs-input-bg);
|
|
42
|
+
--bs-btn-border-color: var(--bs-border-color);
|
|
43
|
+
--bs-btn-hover-color: var(--bs-body-color);
|
|
44
|
+
--bs-btn-hover-bg: var(--bs-border-color);
|
|
45
|
+
--bs-btn-hover-border-color: var(--bs-border-color);
|
|
46
|
+
--bs-btn-focus-shadow-rgb: var(--bs-primary-rgb);
|
|
47
|
+
--bs-btn-active-color: var(--bs-body-color);
|
|
48
|
+
--bs-btn-active-bg: var(--bs-border-color);
|
|
49
|
+
--bs-btn-active-border-color: var(--bs-border-color);
|
|
50
|
+
--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);
|
|
51
|
+
--bs-btn-disabled-color: var(--bs-body-color);
|
|
52
|
+
--bs-btn-disabled-bg: var(--bs-secondary-bg);
|
|
53
|
+
--bs-btn-disabled-border-color: var(--bs-border-color);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
:is(.btn.btn-tanstack-table, .pl-ui-tanstack-table-focusable-shadow):not(:focus-visible) {
|
|
57
|
+
box-shadow: var(--bs-box-shadow-sm);
|
|
58
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import type { ColumnSizingState, Header, Table } from '@tanstack/react-table';
|
|
2
|
+
import type { RefObject } from 'preact';
|
|
3
|
+
import { render } from 'preact/compat';
|
|
4
|
+
import { useEffect, useRef, useState } from 'preact/hooks';
|
|
5
|
+
import type { JSX } from 'preact/jsx-runtime';
|
|
6
|
+
|
|
7
|
+
import { TanstackTableHeaderCell } from './TanstackTableHeaderCell.js';
|
|
8
|
+
|
|
9
|
+
function HiddenMeasurementHeader<TData>({
|
|
10
|
+
table,
|
|
11
|
+
columnsToMeasure,
|
|
12
|
+
filters = {},
|
|
13
|
+
}: {
|
|
14
|
+
table: Table<TData>;
|
|
15
|
+
columnsToMeasure: { id: string }[];
|
|
16
|
+
filters?: Record<string, (props: { header: Header<TData, unknown> }) => JSX.Element>;
|
|
17
|
+
}) {
|
|
18
|
+
const headerGroups = table.getHeaderGroups();
|
|
19
|
+
const leafHeaderGroup = headerGroups[headerGroups.length - 1];
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<div
|
|
23
|
+
style={{
|
|
24
|
+
position: 'fixed',
|
|
25
|
+
visibility: 'hidden',
|
|
26
|
+
pointerEvents: 'none',
|
|
27
|
+
top: '-9999px',
|
|
28
|
+
}}
|
|
29
|
+
>
|
|
30
|
+
<table class="table table-hover mb-0" style={{ display: 'grid', tableLayout: 'fixed' }}>
|
|
31
|
+
<thead style={{ display: 'grid' }}>
|
|
32
|
+
<tr style={{ display: 'flex' }}>
|
|
33
|
+
{columnsToMeasure.map((col) => {
|
|
34
|
+
const header = leafHeaderGroup.headers.find((h) => h.column.id === col.id);
|
|
35
|
+
if (!header) return null;
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<TanstackTableHeaderCell
|
|
39
|
+
key={header.id}
|
|
40
|
+
header={header}
|
|
41
|
+
filters={filters}
|
|
42
|
+
table={table}
|
|
43
|
+
isPinned={false}
|
|
44
|
+
measurementMode={true}
|
|
45
|
+
/>
|
|
46
|
+
);
|
|
47
|
+
})}
|
|
48
|
+
</tr>
|
|
49
|
+
</thead>
|
|
50
|
+
</table>
|
|
51
|
+
</div>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Custom hook that automatically measures and sets column widths based on header content.
|
|
57
|
+
* Only measures columns that have `meta: { autoSize: true }` and don't have explicit sizes set.
|
|
58
|
+
* User resizes are preserved.
|
|
59
|
+
*
|
|
60
|
+
* @param table - The TanStack Table instance
|
|
61
|
+
* @param tableRef - Ref to the table container element
|
|
62
|
+
* @param filters - Optional filters map for rendering filter components in measurement
|
|
63
|
+
* @returns A boolean indicating whether the initial measurement has completed
|
|
64
|
+
*/
|
|
65
|
+
export function useAutoSizeColumns<TData>(
|
|
66
|
+
table: Table<TData>,
|
|
67
|
+
tableRef: RefObject<HTMLDivElement>,
|
|
68
|
+
filters?: Record<string, (props: { header: Header<TData, unknown> }) => JSX.Element>,
|
|
69
|
+
): boolean {
|
|
70
|
+
const [hasMeasured, setHasMeasured] = useState(false);
|
|
71
|
+
const measurementContainerRef = useRef<HTMLDivElement | null>(null);
|
|
72
|
+
|
|
73
|
+
// Perform measurement
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
if (hasMeasured || !tableRef.current) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const allColumns = table.getAllLeafColumns();
|
|
80
|
+
|
|
81
|
+
const columnsToMeasure = allColumns.filter((col) => col.columnDef.meta?.autoSize);
|
|
82
|
+
|
|
83
|
+
if (columnsToMeasure.length === 0) {
|
|
84
|
+
// eslint-disable-next-line @eslint-react/hooks-extra/no-direct-set-state-in-use-effect
|
|
85
|
+
setHasMeasured(true);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Wait for next frame to ensure DOM is ready
|
|
90
|
+
const rafId = requestAnimationFrame(() => {
|
|
91
|
+
if (!tableRef.current) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Create or reuse measurement container
|
|
96
|
+
let container = measurementContainerRef.current;
|
|
97
|
+
if (!container) {
|
|
98
|
+
container = document.createElement('div');
|
|
99
|
+
document.body.append(container);
|
|
100
|
+
measurementContainerRef.current = container;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Render headers into hidden container
|
|
104
|
+
render(
|
|
105
|
+
<HiddenMeasurementHeader
|
|
106
|
+
table={table}
|
|
107
|
+
columnsToMeasure={columnsToMeasure}
|
|
108
|
+
filters={filters ?? {}}
|
|
109
|
+
/>,
|
|
110
|
+
container,
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
// Force layout calculation
|
|
114
|
+
void container.offsetWidth;
|
|
115
|
+
|
|
116
|
+
// Measure each header and build sizing state
|
|
117
|
+
const newSizing: ColumnSizingState = {};
|
|
118
|
+
|
|
119
|
+
for (const col of columnsToMeasure) {
|
|
120
|
+
const headerElement = container.querySelector(
|
|
121
|
+
`th[data-column-id="${col.id}"]`,
|
|
122
|
+
) as HTMLElement;
|
|
123
|
+
|
|
124
|
+
if (headerElement) {
|
|
125
|
+
const measuredWidth = headerElement.scrollWidth;
|
|
126
|
+
const resizeHandlePadding = col.getCanResize() ? 4 : 0;
|
|
127
|
+
const minSize = col.columnDef.minSize ?? 0;
|
|
128
|
+
const maxSize = col.columnDef.maxSize ?? Infinity;
|
|
129
|
+
|
|
130
|
+
const finalWidth = Math.max(
|
|
131
|
+
minSize,
|
|
132
|
+
Math.min(maxSize, measuredWidth + resizeHandlePadding),
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
newSizing[col.id] = finalWidth;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Clear container content by unmounting Preact components
|
|
140
|
+
render(null, container);
|
|
141
|
+
|
|
142
|
+
// Apply measurements
|
|
143
|
+
if (Object.keys(newSizing).length > 0) {
|
|
144
|
+
table.setColumnSizing((prev) => ({ ...prev, ...newSizing }));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
setHasMeasured(true);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
return () => {
|
|
151
|
+
cancelAnimationFrame(rafId);
|
|
152
|
+
};
|
|
153
|
+
}, [table, tableRef, filters, hasMeasured]);
|
|
154
|
+
|
|
155
|
+
// Clean up measurement container on unmount
|
|
156
|
+
useEffect(() => {
|
|
157
|
+
return () => {
|
|
158
|
+
const container = measurementContainerRef.current;
|
|
159
|
+
if (container) {
|
|
160
|
+
render(null, container);
|
|
161
|
+
container.remove();
|
|
162
|
+
measurementContainerRef.current = null;
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
}, []);
|
|
166
|
+
|
|
167
|
+
return hasMeasured;
|
|
168
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
|
|
1
|
+
// Augment @tanstack/react-table types
|
|
2
|
+
import './react-table.js';
|
|
3
|
+
|
|
4
|
+
export {
|
|
5
|
+
TanstackTable,
|
|
6
|
+
TanstackTableCard,
|
|
7
|
+
TanstackTableEmptyState,
|
|
8
|
+
} from './components/TanstackTable.js';
|
|
2
9
|
export { ColumnManager } from './components/ColumnManager.js';
|
|
3
10
|
export { TanstackTableDownloadButton } from './components/TanstackTableDownloadButton.js';
|
|
4
11
|
export { CategoricalColumnFilter } from './components/CategoricalColumnFilter.js';
|
|
@@ -7,5 +14,7 @@ export {
|
|
|
7
14
|
NumericInputColumnFilter,
|
|
8
15
|
parseNumericFilter,
|
|
9
16
|
numericColumnFilterFn,
|
|
17
|
+
type NumericColumnFilterValue,
|
|
10
18
|
} from './components/NumericInputColumnFilter.js';
|
|
11
19
|
export { useShiftClickCheckbox } from './components/useShiftClickCheckbox.js';
|
|
20
|
+
export { OverlayTrigger, type OverlayTriggerProps } from './components/OverlayTrigger.js';
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { RowData } from '@tanstack/react-table';
|
|
2
|
+
|
|
3
|
+
declare module '@tanstack/react-table' {
|
|
4
|
+
// https://tanstack.com/table/latest/docs/api/core/column-def#meta
|
|
5
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
6
|
+
interface ColumnMeta<TData extends RowData, TValue> {
|
|
7
|
+
/** If true, the column will wrap text instead of being truncated. */
|
|
8
|
+
wrapText?: boolean;
|
|
9
|
+
/** If set, this will be used as the label for the column in the column manager. */
|
|
10
|
+
label?: string;
|
|
11
|
+
/** If true, the column will be automatically sized based on the header content. */
|
|
12
|
+
autoSize?: boolean;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// eslint-disable-next-line unicorn/require-module-specifiers
|
|
17
|
+
export {};
|
package/tsconfig.json
CHANGED