@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,28 +1,34 @@
|
|
|
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
6
|
|
|
6
7
|
interface ColumnMenuItemProps<RowDataModel> {
|
|
7
8
|
column: Column<RowDataModel>;
|
|
8
|
-
|
|
9
|
+
onPinningBoundary: boolean;
|
|
9
10
|
onTogglePin: (columnId: string) => void;
|
|
11
|
+
className?: string;
|
|
10
12
|
}
|
|
11
13
|
|
|
12
|
-
function
|
|
14
|
+
function ColumnLeafItem<RowDataModel>({
|
|
13
15
|
column,
|
|
14
|
-
|
|
16
|
+
onPinningBoundary = false,
|
|
15
17
|
onTogglePin,
|
|
18
|
+
className,
|
|
16
19
|
}: ColumnMenuItemProps<RowDataModel>) {
|
|
17
|
-
if (!column.getCanHide()
|
|
20
|
+
if (!column.getCanHide()) return null;
|
|
18
21
|
|
|
19
22
|
// Use meta.label if available, otherwise fall back to header or column.id
|
|
20
23
|
const header =
|
|
21
|
-
|
|
24
|
+
column.columnDef.meta?.label ??
|
|
22
25
|
(typeof column.columnDef.header === 'string' ? column.columnDef.header : column.id);
|
|
23
26
|
|
|
24
27
|
return (
|
|
25
|
-
<div
|
|
28
|
+
<div
|
|
29
|
+
key={column.id}
|
|
30
|
+
class={clsx('px-2 py-1 d-flex align-items-center justify-content-between', className)}
|
|
31
|
+
>
|
|
26
32
|
<label class="form-check me-auto text-nowrap d-flex align-items-stretch">
|
|
27
33
|
<input
|
|
28
34
|
type="checkbox"
|
|
@@ -37,47 +43,164 @@ function ColumnMenuItem<RowDataModel>({
|
|
|
37
43
|
{header}
|
|
38
44
|
</span>
|
|
39
45
|
</label>
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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>
|
|
56
143
|
)}
|
|
57
144
|
</div>
|
|
58
145
|
);
|
|
59
146
|
}
|
|
60
147
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
148
|
+
function ColumnItem<RowDataModel>({
|
|
149
|
+
column,
|
|
150
|
+
onTogglePin,
|
|
151
|
+
getIsOnPinningBoundary,
|
|
64
152
|
}: {
|
|
65
|
-
|
|
66
|
-
|
|
153
|
+
column: Column<RowDataModel>;
|
|
154
|
+
onTogglePin: (columnId: string) => void;
|
|
155
|
+
getIsOnPinningBoundary: (columnId: string) => boolean;
|
|
67
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>) {
|
|
68
184
|
const [activeElementId, setActiveElementId] = useState<string | null>(null);
|
|
69
185
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
|
70
186
|
const menuRef = useRef<HTMLDivElement>(null);
|
|
71
187
|
const handleTogglePin = (columnId: string) => {
|
|
72
188
|
const currentLeft = table.getState().columnPinning.left ?? [];
|
|
73
189
|
const isPinned = currentLeft.includes(columnId);
|
|
190
|
+
const allLeafColumns = table.getAllLeafColumns();
|
|
191
|
+
const currentColumnIndex = allLeafColumns.findIndex((c) => c.id === columnId);
|
|
74
192
|
let newLeft: string[];
|
|
75
193
|
if (isPinned) {
|
|
76
|
-
|
|
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);
|
|
77
200
|
} else {
|
|
78
|
-
|
|
79
|
-
const
|
|
80
|
-
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);
|
|
81
204
|
}
|
|
82
205
|
table.setColumnPinning({ left: newLeft, right: [] });
|
|
83
206
|
setActiveElementId(`${columnId}-pin`);
|
|
@@ -96,8 +219,53 @@ export function ColumnManager<RowDataModel>({
|
|
|
96
219
|
initialPinning.some((id) => !currentPinning.includes(id));
|
|
97
220
|
const showResetButton = isVisibilityChanged || isPinningChanged;
|
|
98
221
|
|
|
99
|
-
const
|
|
100
|
-
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
|
+
});
|
|
101
269
|
|
|
102
270
|
useEffect(() => {
|
|
103
271
|
// When we use the pin or reset button, we want to refocus to another element.
|
|
@@ -123,26 +291,33 @@ export function ColumnManager<RowDataModel>({
|
|
|
123
291
|
}}
|
|
124
292
|
>
|
|
125
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"
|
|
126
297
|
variant="tanstack-table"
|
|
127
|
-
id={id}
|
|
128
|
-
// eslint-disable-next-line @eslint-react/no-forbidden-props
|
|
129
|
-
className="tanstack-table-focusable-shadow"
|
|
130
298
|
>
|
|
131
299
|
<i class="bi bi-view-list me-2" aria-hidden="true" /> View{' '}
|
|
132
300
|
</Dropdown.Toggle>
|
|
133
301
|
<Dropdown.Menu style={{ maxHeight: '60vh', overflowY: 'auto' }}>
|
|
134
|
-
{
|
|
302
|
+
{topContent && (
|
|
303
|
+
<>
|
|
304
|
+
{topContent}
|
|
305
|
+
<Dropdown.Divider />
|
|
306
|
+
</>
|
|
307
|
+
)}
|
|
308
|
+
{pinnedMenuColumns.length > 0 && (
|
|
135
309
|
<>
|
|
136
310
|
<div class="px-2 py-1 text-muted small" role="presentation">
|
|
137
311
|
Frozen columns
|
|
138
312
|
</div>
|
|
139
313
|
<div role="group">
|
|
140
|
-
{
|
|
314
|
+
{/* Only leaf columns can be pinned in the current implementation. */}
|
|
315
|
+
{pinnedMenuColumns.map((column, index) => {
|
|
141
316
|
return (
|
|
142
|
-
<
|
|
317
|
+
<ColumnLeafItem
|
|
143
318
|
key={column.id}
|
|
144
319
|
column={column}
|
|
145
|
-
|
|
320
|
+
onPinningBoundary={index === pinnedMenuColumns.length - 1}
|
|
146
321
|
onTogglePin={handleTogglePin}
|
|
147
322
|
/>
|
|
148
323
|
);
|
|
@@ -151,15 +326,15 @@ export function ColumnManager<RowDataModel>({
|
|
|
151
326
|
<Dropdown.Divider />
|
|
152
327
|
</>
|
|
153
328
|
)}
|
|
154
|
-
{
|
|
329
|
+
{unpinnedRootColumns.length > 0 && (
|
|
155
330
|
<>
|
|
156
331
|
<div role="group">
|
|
157
|
-
{
|
|
332
|
+
{unpinnedRootColumns.map((column) => {
|
|
158
333
|
return (
|
|
159
|
-
<
|
|
334
|
+
<ColumnItem
|
|
160
335
|
key={column.id}
|
|
161
336
|
column={column}
|
|
162
|
-
|
|
337
|
+
getIsOnPinningBoundary={getIsOnPinningBoundary}
|
|
163
338
|
onTogglePin={handleTogglePin}
|
|
164
339
|
/>
|
|
165
340
|
);
|
|
@@ -178,7 +353,8 @@ export function ColumnManager<RowDataModel>({
|
|
|
178
353
|
onClick={() => {
|
|
179
354
|
table.resetColumnVisibility();
|
|
180
355
|
table.resetColumnPinning();
|
|
181
|
-
|
|
356
|
+
// Move focus to the column manager button after resetting.
|
|
357
|
+
setActiveElementId('column-manager');
|
|
182
358
|
}}
|
|
183
359
|
>
|
|
184
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
|
});
|