@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
|
@@ -1,113 +1,206 @@
|
|
|
1
1
|
import { type Column, type Table } from '@tanstack/react-table';
|
|
2
|
-
import
|
|
2
|
+
import clsx from 'clsx';
|
|
3
|
+
import { type JSX, useEffect, useRef, useState } from 'preact/compat';
|
|
3
4
|
import Button from 'react-bootstrap/Button';
|
|
4
5
|
import Dropdown from 'react-bootstrap/Dropdown';
|
|
5
|
-
import OverlayTrigger from 'react-bootstrap/OverlayTrigger';
|
|
6
|
-
import Tooltip from 'react-bootstrap/Tooltip';
|
|
7
6
|
|
|
8
7
|
interface ColumnMenuItemProps<RowDataModel> {
|
|
9
8
|
column: Column<RowDataModel>;
|
|
10
|
-
|
|
9
|
+
onPinningBoundary: boolean;
|
|
11
10
|
onTogglePin: (columnId: string) => void;
|
|
12
|
-
|
|
11
|
+
className?: string;
|
|
13
12
|
}
|
|
14
13
|
|
|
15
|
-
function
|
|
14
|
+
function ColumnLeafItem<RowDataModel>({
|
|
16
15
|
column,
|
|
17
|
-
|
|
16
|
+
onPinningBoundary = false,
|
|
18
17
|
onTogglePin,
|
|
19
|
-
|
|
18
|
+
className,
|
|
20
19
|
}: ColumnMenuItemProps<RowDataModel>) {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
if (!column.getCanHide() && !column.getCanPin()) return null;
|
|
20
|
+
if (!column.getCanHide()) return null;
|
|
24
21
|
|
|
25
22
|
// Use meta.label if available, otherwise fall back to header or column.id
|
|
26
23
|
const header =
|
|
27
|
-
|
|
24
|
+
column.columnDef.meta?.label ??
|
|
28
25
|
(typeof column.columnDef.header === 'string' ? column.columnDef.header : column.id);
|
|
29
26
|
|
|
30
27
|
return (
|
|
31
|
-
<
|
|
28
|
+
<div
|
|
32
29
|
key={column.id}
|
|
33
|
-
|
|
34
|
-
class="px-2 py-1 d-flex align-items-center justify-content-between"
|
|
35
|
-
onKeyDown={onClearElementFocus}
|
|
30
|
+
class={clsx('px-2 py-1 d-flex align-items-center justify-content-between', className)}
|
|
36
31
|
>
|
|
37
32
|
<label class="form-check me-auto text-nowrap d-flex align-items-stretch">
|
|
38
|
-
<
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
aria-label={
|
|
48
|
-
column.getIsVisible() ? `Hide '${header}' column` : `Show '${header}' column`
|
|
49
|
-
}
|
|
50
|
-
aria-describedby={`${column.id}-label`}
|
|
51
|
-
onChange={column.getToggleVisibilityHandler()}
|
|
52
|
-
/>
|
|
53
|
-
</OverlayTrigger>
|
|
33
|
+
<input
|
|
34
|
+
type="checkbox"
|
|
35
|
+
class="form-check-input"
|
|
36
|
+
checked={column.getIsVisible()}
|
|
37
|
+
disabled={!column.getCanHide()}
|
|
38
|
+
aria-label={column.getIsVisible() ? `Hide '${header}' column` : `Show '${header}' column`}
|
|
39
|
+
aria-describedby={`${column.id}-label`}
|
|
40
|
+
onChange={column.getToggleVisibilityHandler()}
|
|
41
|
+
/>
|
|
54
42
|
<span class="form-check-label ms-2" id={`${column.id}-label`}>
|
|
55
43
|
{header}
|
|
56
44
|
</span>
|
|
57
45
|
</label>
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
46
|
+
<button
|
|
47
|
+
type="button"
|
|
48
|
+
// Since the HTML changes, but we want to refocus the pin button, we track
|
|
49
|
+
// the active pin button and refocuses it when the column manager is rerendered.
|
|
50
|
+
id={`${column.id}-pin`}
|
|
51
|
+
class={clsx(
|
|
52
|
+
'btn btn-sm btn-ghost ms-2',
|
|
53
|
+
(!column.getCanPin() || !onPinningBoundary) && 'invisible',
|
|
54
|
+
)}
|
|
55
|
+
aria-label={
|
|
56
|
+
column.getIsPinned() ? `Unfreeze '${header}' column` : `Freeze '${header}' column`
|
|
57
|
+
}
|
|
58
|
+
title={column.getIsPinned() ? 'Unfreeze column' : 'Freeze column'}
|
|
59
|
+
data-bs-toggle="tooltip"
|
|
60
|
+
onClick={() => onTogglePin(column.id)}
|
|
61
|
+
>
|
|
62
|
+
<i class={`bi ${column.getIsPinned() ? 'bi-x' : 'bi-snow'}`} aria-hidden="true" />
|
|
63
|
+
</button>
|
|
64
|
+
</div>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function ColumnGroupItem<RowDataModel>({
|
|
69
|
+
column,
|
|
70
|
+
onTogglePin,
|
|
71
|
+
getIsOnPinningBoundary,
|
|
72
|
+
}: {
|
|
73
|
+
column: Column<RowDataModel>;
|
|
74
|
+
onTogglePin: (columnId: string) => void;
|
|
75
|
+
getIsOnPinningBoundary: (columnId: string) => boolean;
|
|
76
|
+
}) {
|
|
77
|
+
const [isExpanded, setIsExpanded] = useState(false);
|
|
78
|
+
|
|
79
|
+
const leafColumns = column.getLeafColumns();
|
|
80
|
+
const visibleLeafColumns = leafColumns.filter((c) => c.getIsVisible());
|
|
81
|
+
const isAllVisible = visibleLeafColumns.length === leafColumns.length;
|
|
82
|
+
const isSomeVisible = visibleLeafColumns.length > 0 && !isAllVisible;
|
|
83
|
+
|
|
84
|
+
const handleToggleVisibility = (e: Event) => {
|
|
85
|
+
e.preventDefault();
|
|
86
|
+
e.stopPropagation();
|
|
87
|
+
const targetVisibility = !isAllVisible;
|
|
88
|
+
leafColumns.forEach((col) => {
|
|
89
|
+
if (col.getCanHide()) {
|
|
90
|
+
col.toggleVisibility(targetVisibility);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// Use meta.label if available, otherwise fall back to header or column.id
|
|
96
|
+
const header =
|
|
97
|
+
column.columnDef.meta?.label ??
|
|
98
|
+
(typeof column.columnDef.header === 'string' ? column.columnDef.header : column.id);
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<div class="d-flex flex-column">
|
|
102
|
+
<div class="px-2 py-1 d-flex align-items-center justify-content-between">
|
|
103
|
+
<div class="d-flex align-items-center flex-grow-1">
|
|
104
|
+
<input
|
|
105
|
+
type="checkbox"
|
|
106
|
+
class="form-check-input flex-shrink-0"
|
|
107
|
+
checked={isAllVisible}
|
|
108
|
+
indeterminate={isSomeVisible}
|
|
109
|
+
aria-label={`Toggle visibility for group '${header}'`}
|
|
110
|
+
onChange={handleToggleVisibility}
|
|
111
|
+
/>
|
|
112
|
+
<button
|
|
113
|
+
type="button"
|
|
114
|
+
class="btn btn-link text-decoration-none text-reset w-100 text-start d-flex align-items-center justify-content-between ps-2 py-0 pe-0"
|
|
115
|
+
aria-expanded={isExpanded}
|
|
116
|
+
onClick={(e) => {
|
|
117
|
+
e.stopPropagation();
|
|
118
|
+
setIsExpanded(!isExpanded);
|
|
119
|
+
}}
|
|
120
|
+
>
|
|
121
|
+
<span class="fw-bold text-truncate">{header}</span>
|
|
122
|
+
<i
|
|
123
|
+
class={clsx(
|
|
124
|
+
'bi ms-2 text-muted',
|
|
125
|
+
isExpanded ? 'bi-chevron-down' : 'bi-chevron-right',
|
|
126
|
+
)}
|
|
127
|
+
aria-hidden="true"
|
|
128
|
+
/>
|
|
129
|
+
</button>
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
{isExpanded && (
|
|
133
|
+
<div class="ps-3 border-start ms-3 mb-1">
|
|
134
|
+
{column.columns.map((childCol) => (
|
|
135
|
+
<ColumnItem
|
|
136
|
+
key={childCol.id}
|
|
137
|
+
column={childCol}
|
|
138
|
+
getIsOnPinningBoundary={getIsOnPinningBoundary}
|
|
139
|
+
onTogglePin={onTogglePin}
|
|
140
|
+
/>
|
|
141
|
+
))}
|
|
142
|
+
</div>
|
|
92
143
|
)}
|
|
93
|
-
</
|
|
144
|
+
</div>
|
|
94
145
|
);
|
|
95
146
|
}
|
|
96
147
|
|
|
97
|
-
|
|
148
|
+
function ColumnItem<RowDataModel>({
|
|
149
|
+
column,
|
|
150
|
+
onTogglePin,
|
|
151
|
+
getIsOnPinningBoundary,
|
|
152
|
+
}: {
|
|
153
|
+
column: Column<RowDataModel>;
|
|
154
|
+
onTogglePin: (columnId: string) => void;
|
|
155
|
+
getIsOnPinningBoundary: (columnId: string) => boolean;
|
|
156
|
+
}) {
|
|
157
|
+
if (column.columns.length > 0) {
|
|
158
|
+
return (
|
|
159
|
+
<ColumnGroupItem
|
|
160
|
+
column={column}
|
|
161
|
+
getIsOnPinningBoundary={getIsOnPinningBoundary}
|
|
162
|
+
onTogglePin={onTogglePin}
|
|
163
|
+
/>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
return (
|
|
167
|
+
<ColumnLeafItem
|
|
168
|
+
column={column}
|
|
169
|
+
onPinningBoundary={getIsOnPinningBoundary(column.id)}
|
|
170
|
+
onTogglePin={onTogglePin}
|
|
171
|
+
/>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
interface ColumnManagerProps<RowDataModel> {
|
|
176
|
+
table: Table<RowDataModel>;
|
|
177
|
+
topContent?: JSX.Element;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function ColumnManager<RowDataModel>({
|
|
181
|
+
table,
|
|
182
|
+
topContent,
|
|
183
|
+
}: ColumnManagerProps<RowDataModel>) {
|
|
98
184
|
const [activeElementId, setActiveElementId] = useState<string | null>(null);
|
|
99
185
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
|
100
186
|
const menuRef = useRef<HTMLDivElement>(null);
|
|
101
187
|
const handleTogglePin = (columnId: string) => {
|
|
102
188
|
const currentLeft = table.getState().columnPinning.left ?? [];
|
|
103
189
|
const isPinned = currentLeft.includes(columnId);
|
|
190
|
+
const allLeafColumns = table.getAllLeafColumns();
|
|
191
|
+
const currentColumnIndex = allLeafColumns.findIndex((c) => c.id === columnId);
|
|
104
192
|
let newLeft: string[];
|
|
105
193
|
if (isPinned) {
|
|
106
|
-
|
|
194
|
+
// Get the previous column that can be set to unpinned.
|
|
195
|
+
// This is useful since we want to unpin/pin columns that are not shown in the view manager.
|
|
196
|
+
const previousFrozenColumnIndex = allLeafColumns.findLastIndex(
|
|
197
|
+
(c, index) => c.getCanHide() && index < currentColumnIndex,
|
|
198
|
+
);
|
|
199
|
+
newLeft = allLeafColumns.slice(0, previousFrozenColumnIndex + 1).map((c) => c.id);
|
|
107
200
|
} else {
|
|
108
|
-
|
|
109
|
-
const
|
|
110
|
-
newLeft =
|
|
201
|
+
// Pin all columns to the left of the current column.
|
|
202
|
+
const leftColumns = allLeafColumns.slice(0, currentColumnIndex + 1);
|
|
203
|
+
newLeft = leftColumns.map((c) => c.id);
|
|
111
204
|
}
|
|
112
205
|
table.setColumnPinning({ left: newLeft, right: [] });
|
|
113
206
|
setActiveElementId(`${columnId}-pin`);
|
|
@@ -126,8 +219,53 @@ export function ColumnManager<RowDataModel>({ table }: { table: Table<RowDataMod
|
|
|
126
219
|
initialPinning.some((id) => !currentPinning.includes(id));
|
|
127
220
|
const showResetButton = isVisibilityChanged || isPinningChanged;
|
|
128
221
|
|
|
129
|
-
const
|
|
130
|
-
const
|
|
222
|
+
const allLeafColumns = table.getAllLeafColumns();
|
|
223
|
+
const pinnedMenuColumns = allLeafColumns.filter(
|
|
224
|
+
(c) => c.getCanHide() && c.getIsPinned() === 'left',
|
|
225
|
+
);
|
|
226
|
+
// Only the first unpinned menu column can be pinned, so we only need to find the first one
|
|
227
|
+
const firstUnpinnedMenuColumn = allLeafColumns.find(
|
|
228
|
+
(c) => c.getCanHide() && c.getIsPinned() !== 'left',
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
// Determine if a column is on the pinning boundary (can toggle its pin state).
|
|
232
|
+
// - Columns in a group cannot be pinned
|
|
233
|
+
// - Columns after a group cannot be pinned
|
|
234
|
+
// - Only the last pinned menu column can be unpinned
|
|
235
|
+
// - Only the first unpinned menu column can be pinned
|
|
236
|
+
const getIsOnPinningBoundary = (columnId: string) => {
|
|
237
|
+
const column = allLeafColumns.find((c) => c.id === columnId);
|
|
238
|
+
if (!column) return false;
|
|
239
|
+
|
|
240
|
+
// Columns in a group cannot be pinned
|
|
241
|
+
if (column.parent) return false;
|
|
242
|
+
|
|
243
|
+
// Check if any column at or before this one in the full column order is in a group
|
|
244
|
+
const columnIdx = allLeafColumns.findIndex((c) => c.id === columnId);
|
|
245
|
+
const hasGroupAtOrBefore = allLeafColumns.slice(0, columnIdx + 1).some((c) => c.parent);
|
|
246
|
+
|
|
247
|
+
if (column.getIsPinned() === 'left') {
|
|
248
|
+
// Only the last pinned menu column can be unpinned
|
|
249
|
+
return columnId === pinnedMenuColumns[pinnedMenuColumns.length - 1]?.id;
|
|
250
|
+
} else {
|
|
251
|
+
// Cannot pin if there's a group at or before this column
|
|
252
|
+
if (hasGroupAtOrBefore) return false;
|
|
253
|
+
// Only the first unpinned menu column can be pinned
|
|
254
|
+
return columnId === firstUnpinnedMenuColumn?.id;
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
// Get root columns (for showing hierarchy), but filter to only show unpinned ones
|
|
259
|
+
// We'll show pinned columns separately in the "Frozen columns" section
|
|
260
|
+
const unpinnedRootColumns = table.getAllColumns().filter((c) => {
|
|
261
|
+
if (c.depth !== 0) return false;
|
|
262
|
+
// A root column is considered unpinned if all its leaf columns are unpinned
|
|
263
|
+
const leafCols = c.getLeafColumns();
|
|
264
|
+
return (
|
|
265
|
+
leafCols.length > 0 &&
|
|
266
|
+
leafCols.every((leaf) => leaf.getIsPinned() !== 'left' && c.getCanHide())
|
|
267
|
+
);
|
|
268
|
+
});
|
|
131
269
|
|
|
132
270
|
useEffect(() => {
|
|
133
271
|
// When we use the pin or reset button, we want to refocus to another element.
|
|
@@ -152,25 +290,35 @@ export function ColumnManager<RowDataModel>({ table }: { table: Table<RowDataMod
|
|
|
152
290
|
}
|
|
153
291
|
}}
|
|
154
292
|
>
|
|
155
|
-
<Dropdown.Toggle
|
|
156
|
-
|
|
157
|
-
|
|
293
|
+
<Dropdown.Toggle
|
|
294
|
+
// We assume that this component will only appear once per page. If that changes,
|
|
295
|
+
// we'll need to do something to ensure ID uniqueness here.
|
|
296
|
+
id="column-manager"
|
|
297
|
+
variant="tanstack-table"
|
|
298
|
+
>
|
|
299
|
+
<i class="bi bi-view-list me-2" aria-hidden="true" /> View{' '}
|
|
158
300
|
</Dropdown.Toggle>
|
|
159
301
|
<Dropdown.Menu style={{ maxHeight: '60vh', overflowY: 'auto' }}>
|
|
160
|
-
{
|
|
302
|
+
{topContent && (
|
|
303
|
+
<>
|
|
304
|
+
{topContent}
|
|
305
|
+
<Dropdown.Divider />
|
|
306
|
+
</>
|
|
307
|
+
)}
|
|
308
|
+
{pinnedMenuColumns.length > 0 && (
|
|
161
309
|
<>
|
|
162
310
|
<div class="px-2 py-1 text-muted small" role="presentation">
|
|
163
311
|
Frozen columns
|
|
164
312
|
</div>
|
|
165
313
|
<div role="group">
|
|
166
|
-
{
|
|
314
|
+
{/* Only leaf columns can be pinned in the current implementation. */}
|
|
315
|
+
{pinnedMenuColumns.map((column, index) => {
|
|
167
316
|
return (
|
|
168
|
-
<
|
|
317
|
+
<ColumnLeafItem
|
|
169
318
|
key={column.id}
|
|
170
319
|
column={column}
|
|
171
|
-
|
|
320
|
+
onPinningBoundary={index === pinnedMenuColumns.length - 1}
|
|
172
321
|
onTogglePin={handleTogglePin}
|
|
173
|
-
onClearElementFocus={() => setActiveElementId(null)}
|
|
174
322
|
/>
|
|
175
323
|
);
|
|
176
324
|
})}
|
|
@@ -178,17 +326,16 @@ export function ColumnManager<RowDataModel>({ table }: { table: Table<RowDataMod
|
|
|
178
326
|
<Dropdown.Divider />
|
|
179
327
|
</>
|
|
180
328
|
)}
|
|
181
|
-
{
|
|
329
|
+
{unpinnedRootColumns.length > 0 && (
|
|
182
330
|
<>
|
|
183
331
|
<div role="group">
|
|
184
|
-
{
|
|
332
|
+
{unpinnedRootColumns.map((column) => {
|
|
185
333
|
return (
|
|
186
|
-
<
|
|
334
|
+
<ColumnItem
|
|
187
335
|
key={column.id}
|
|
188
336
|
column={column}
|
|
189
|
-
|
|
337
|
+
getIsOnPinningBoundary={getIsOnPinningBoundary}
|
|
190
338
|
onTogglePin={handleTogglePin}
|
|
191
|
-
onClearElementFocus={() => setActiveElementId(null)}
|
|
192
339
|
/>
|
|
193
340
|
);
|
|
194
341
|
})}
|
|
@@ -206,7 +353,8 @@ export function ColumnManager<RowDataModel>({ table }: { table: Table<RowDataMod
|
|
|
206
353
|
onClick={() => {
|
|
207
354
|
table.resetColumnVisibility();
|
|
208
355
|
table.resetColumnPinning();
|
|
209
|
-
|
|
356
|
+
// Move focus to the column manager button after resetting.
|
|
357
|
+
setActiveElementId('column-manager');
|
|
210
358
|
}}
|
|
211
359
|
>
|
|
212
360
|
<i class="bi bi-arrow-counterclockwise me-2" aria-hidden="true" />
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Column } from '@tanstack/table-core';
|
|
1
2
|
import clsx from 'clsx';
|
|
2
3
|
import { type JSX, useMemo } from 'preact/compat';
|
|
3
4
|
import Dropdown from 'react-bootstrap/Dropdown';
|
|
@@ -11,38 +12,41 @@ function defaultRenderValueLabel<T>({ value }: { value: T }) {
|
|
|
11
12
|
* Uses AND logic: rows must contain ALL selected values to match.
|
|
12
13
|
*
|
|
13
14
|
* @param params
|
|
14
|
-
* @param params.
|
|
15
|
-
* @param params.
|
|
16
|
-
* @param params.allColumnValues - All possible values that can appear in the column
|
|
15
|
+
* @param params.column - The TanStack Table column object
|
|
16
|
+
* @param params.allColumnValues - All possible values that can appear in the column filter
|
|
17
17
|
* @param params.renderValueLabel - A function that renders the label for a value
|
|
18
|
-
* @param params.columnValuesFilter - The current state of the column filter
|
|
19
|
-
* @param params.setColumnValuesFilter - A function that sets the state of the column filter
|
|
20
18
|
*/
|
|
21
|
-
export function MultiSelectColumnFilter<
|
|
22
|
-
|
|
23
|
-
columnLabel,
|
|
19
|
+
export function MultiSelectColumnFilter<TData, TValue>({
|
|
20
|
+
column,
|
|
24
21
|
allColumnValues,
|
|
25
22
|
renderValueLabel = defaultRenderValueLabel,
|
|
26
|
-
columnValuesFilter,
|
|
27
|
-
setColumnValuesFilter,
|
|
28
23
|
}: {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
allColumnValues:
|
|
32
|
-
renderValueLabel?: (props: { value:
|
|
33
|
-
columnValuesFilter: T[number][];
|
|
34
|
-
setColumnValuesFilter: (value: T[number][]) => void;
|
|
24
|
+
column: Column<TData, TValue>;
|
|
25
|
+
/** In some cases, the filter values are not the same as the column values, but `TValue` is a good estimation. */
|
|
26
|
+
allColumnValues: TValue[];
|
|
27
|
+
renderValueLabel?: (props: { value: TValue; isSelected: boolean }) => JSX.Element;
|
|
35
28
|
}) {
|
|
36
|
-
const
|
|
29
|
+
const columnId = column.id;
|
|
37
30
|
|
|
38
|
-
const
|
|
31
|
+
const label =
|
|
32
|
+
column.columnDef.meta?.label ??
|
|
33
|
+
(typeof column.columnDef.header === 'string' ? column.columnDef.header : column.id);
|
|
34
|
+
|
|
35
|
+
const columnValuesFilter = column.getFilterValue() as TValue[] | undefined;
|
|
36
|
+
|
|
37
|
+
const selected = useMemo(() => {
|
|
38
|
+
return new Set(columnValuesFilter);
|
|
39
|
+
}, [columnValuesFilter]);
|
|
40
|
+
|
|
41
|
+
const toggleSelected = (value: TValue) => {
|
|
39
42
|
const set = new Set(selected);
|
|
40
43
|
if (set.has(value)) {
|
|
41
44
|
set.delete(value);
|
|
42
45
|
} else {
|
|
43
46
|
set.add(value);
|
|
44
47
|
}
|
|
45
|
-
|
|
48
|
+
const newValue = Array.from(set);
|
|
49
|
+
column.setFilterValue(newValue);
|
|
46
50
|
};
|
|
47
51
|
|
|
48
52
|
const hasActiveFilter = selected.size > 0;
|
|
@@ -53,8 +57,8 @@ export function MultiSelectColumnFilter<T extends readonly any[]>({
|
|
|
53
57
|
variant="link"
|
|
54
58
|
class="text-muted p-0"
|
|
55
59
|
id={`filter-${columnId}`}
|
|
56
|
-
aria-label={`Filter ${
|
|
57
|
-
title={`Filter ${
|
|
60
|
+
aria-label={`Filter ${label.toLowerCase()}`}
|
|
61
|
+
title={`Filter ${label.toLowerCase()}`}
|
|
58
62
|
>
|
|
59
63
|
<i
|
|
60
64
|
class={clsx('bi', hasActiveFilter ? ['bi-funnel-fill', 'text-primary'] : 'bi-funnel')}
|
|
@@ -62,23 +66,32 @@ export function MultiSelectColumnFilter<T extends readonly any[]>({
|
|
|
62
66
|
/>
|
|
63
67
|
</Dropdown.Toggle>
|
|
64
68
|
<Dropdown.Menu class="p-0">
|
|
65
|
-
<div class="p-3" style={{ minWidth: '250px' }}>
|
|
69
|
+
<div class="p-3 pb-0" style={{ minWidth: '250px' }}>
|
|
66
70
|
<div class="d-flex align-items-center justify-content-between mb-2">
|
|
67
|
-
<div class="fw-semibold">{
|
|
71
|
+
<div class="fw-semibold">{label}</div>
|
|
68
72
|
<button
|
|
69
73
|
type="button"
|
|
70
74
|
class="btn btn-link btn-sm text-decoration-none p-0"
|
|
71
|
-
onClick={() =>
|
|
75
|
+
onClick={() => column.setFilterValue([])}
|
|
72
76
|
>
|
|
73
77
|
Clear
|
|
74
78
|
</button>
|
|
75
79
|
</div>
|
|
80
|
+
</div>
|
|
76
81
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
+
<div
|
|
83
|
+
class="list-group list-group-flush"
|
|
84
|
+
style={{
|
|
85
|
+
// This is needed to prevent the last item's background from covering
|
|
86
|
+
// the dropdown's border radius.
|
|
87
|
+
'--bs-list-group-bg': 'transparent',
|
|
88
|
+
}}
|
|
89
|
+
>
|
|
90
|
+
{allColumnValues.map((value) => {
|
|
91
|
+
const isSelected = selected.has(value);
|
|
92
|
+
return (
|
|
93
|
+
<div key={value} class="list-group-item d-flex align-items-center gap-3">
|
|
94
|
+
<div class="form-check">
|
|
82
95
|
<input
|
|
83
96
|
class="form-check-input"
|
|
84
97
|
type="checkbox"
|
|
@@ -93,9 +106,9 @@ export function MultiSelectColumnFilter<T extends readonly any[]>({
|
|
|
93
106
|
})}
|
|
94
107
|
</label>
|
|
95
108
|
</div>
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
109
|
+
</div>
|
|
110
|
+
);
|
|
111
|
+
})}
|
|
99
112
|
</div>
|
|
100
113
|
</Dropdown.Menu>
|
|
101
114
|
</Dropdown>
|
|
@@ -58,45 +58,93 @@ describe('numericColumnFilterFn', () => {
|
|
|
58
58
|
});
|
|
59
59
|
|
|
60
60
|
it('should filter with equals operator', () => {
|
|
61
|
-
expect(
|
|
62
|
-
|
|
63
|
-
|
|
61
|
+
expect(
|
|
62
|
+
numericColumnFilterFn(createMockRow(5), 'col', { filterValue: '5', emptyOnly: false }),
|
|
63
|
+
).toBe(true);
|
|
64
|
+
expect(
|
|
65
|
+
numericColumnFilterFn(createMockRow(5), 'col', { filterValue: '=5', emptyOnly: false }),
|
|
66
|
+
).toBe(true);
|
|
67
|
+
expect(
|
|
68
|
+
numericColumnFilterFn(createMockRow(5), 'col', { filterValue: '4', emptyOnly: false }),
|
|
69
|
+
).toBe(false);
|
|
64
70
|
});
|
|
65
71
|
|
|
66
72
|
it('should filter with less than operator', () => {
|
|
67
|
-
expect(
|
|
68
|
-
|
|
69
|
-
|
|
73
|
+
expect(
|
|
74
|
+
numericColumnFilterFn(createMockRow(3), 'col', { filterValue: '<5', emptyOnly: false }),
|
|
75
|
+
).toBe(true);
|
|
76
|
+
expect(
|
|
77
|
+
numericColumnFilterFn(createMockRow(5), 'col', { filterValue: '<5', emptyOnly: false }),
|
|
78
|
+
).toBe(false);
|
|
79
|
+
expect(
|
|
80
|
+
numericColumnFilterFn(createMockRow(7), 'col', { filterValue: '<5', emptyOnly: false }),
|
|
81
|
+
).toBe(false);
|
|
70
82
|
});
|
|
71
83
|
|
|
72
84
|
it('should filter with greater than operator', () => {
|
|
73
|
-
expect(
|
|
74
|
-
|
|
75
|
-
|
|
85
|
+
expect(
|
|
86
|
+
numericColumnFilterFn(createMockRow(7), 'col', { filterValue: '>5', emptyOnly: false }),
|
|
87
|
+
).toBe(true);
|
|
88
|
+
expect(
|
|
89
|
+
numericColumnFilterFn(createMockRow(5), 'col', { filterValue: '>5', emptyOnly: false }),
|
|
90
|
+
).toBe(false);
|
|
91
|
+
expect(
|
|
92
|
+
numericColumnFilterFn(createMockRow(3), 'col', { filterValue: '>5', emptyOnly: false }),
|
|
93
|
+
).toBe(false);
|
|
76
94
|
});
|
|
77
95
|
|
|
78
96
|
it('should filter with less than or equal operator', () => {
|
|
79
|
-
expect(
|
|
80
|
-
|
|
81
|
-
|
|
97
|
+
expect(
|
|
98
|
+
numericColumnFilterFn(createMockRow(3), 'col', { filterValue: '<=5', emptyOnly: false }),
|
|
99
|
+
).toBe(true);
|
|
100
|
+
expect(
|
|
101
|
+
numericColumnFilterFn(createMockRow(5), 'col', { filterValue: '<=5', emptyOnly: false }),
|
|
102
|
+
).toBe(true);
|
|
103
|
+
expect(
|
|
104
|
+
numericColumnFilterFn(createMockRow(7), 'col', { filterValue: '<=5', emptyOnly: false }),
|
|
105
|
+
).toBe(false);
|
|
82
106
|
});
|
|
83
107
|
|
|
84
108
|
it('should filter with greater than or equal operator', () => {
|
|
85
|
-
expect(
|
|
86
|
-
|
|
87
|
-
|
|
109
|
+
expect(
|
|
110
|
+
numericColumnFilterFn(createMockRow(7), 'col', { filterValue: '>=5', emptyOnly: false }),
|
|
111
|
+
).toBe(true);
|
|
112
|
+
expect(
|
|
113
|
+
numericColumnFilterFn(createMockRow(5), 'col', { filterValue: '>=5', emptyOnly: false }),
|
|
114
|
+
).toBe(true);
|
|
115
|
+
expect(
|
|
116
|
+
numericColumnFilterFn(createMockRow(3), 'col', { filterValue: '>=5', emptyOnly: false }),
|
|
117
|
+
).toBe(false);
|
|
88
118
|
});
|
|
89
119
|
|
|
90
120
|
it('should return true for invalid or empty filter', () => {
|
|
91
|
-
expect(
|
|
92
|
-
|
|
121
|
+
expect(
|
|
122
|
+
numericColumnFilterFn(createMockRow(5), 'col', { filterValue: '', emptyOnly: false }),
|
|
123
|
+
).toBe(true);
|
|
124
|
+
expect(
|
|
125
|
+
numericColumnFilterFn(createMockRow(5), 'col', { filterValue: 'invalid', emptyOnly: false }),
|
|
126
|
+
).toBe(true);
|
|
93
127
|
});
|
|
94
128
|
|
|
95
129
|
it('should return false for null values when filter is active', () => {
|
|
96
|
-
expect(
|
|
130
|
+
expect(
|
|
131
|
+
numericColumnFilterFn(createMockRow(null), 'col', { filterValue: '>5', emptyOnly: false }),
|
|
132
|
+
).toBe(false);
|
|
97
133
|
});
|
|
98
134
|
|
|
99
135
|
it('should return true for null values when filter is empty', () => {
|
|
100
|
-
expect(
|
|
136
|
+
expect(
|
|
137
|
+
numericColumnFilterFn(createMockRow(null), 'col', { filterValue: '', emptyOnly: false }),
|
|
138
|
+
).toBe(true);
|
|
139
|
+
});
|
|
140
|
+
it('should return true for null values when emptyOnly is true', () => {
|
|
141
|
+
expect(
|
|
142
|
+
numericColumnFilterFn(createMockRow(null), 'col', { filterValue: '', emptyOnly: true }),
|
|
143
|
+
).toBe(true);
|
|
144
|
+
});
|
|
145
|
+
it('should return false for set values when emptyOnly is true', () => {
|
|
146
|
+
expect(
|
|
147
|
+
numericColumnFilterFn(createMockRow(5), 'col', { filterValue: '', emptyOnly: true }),
|
|
148
|
+
).toBe(false);
|
|
101
149
|
});
|
|
102
150
|
});
|