@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.
Files changed (68) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +4 -2
  3. package/dist/components/CategoricalColumnFilter.d.ts +7 -12
  4. package/dist/components/CategoricalColumnFilter.d.ts.map +1 -1
  5. package/dist/components/CategoricalColumnFilter.js +26 -14
  6. package/dist/components/CategoricalColumnFilter.js.map +1 -1
  7. package/dist/components/ColumnManager.d.ts +6 -2
  8. package/dist/components/ColumnManager.d.ts.map +1 -1
  9. package/dist/components/ColumnManager.js +98 -35
  10. package/dist/components/ColumnManager.js.map +1 -1
  11. package/dist/components/MultiSelectColumnFilter.d.ts +8 -12
  12. package/dist/components/MultiSelectColumnFilter.d.ts.map +1 -1
  13. package/dist/components/MultiSelectColumnFilter.js +21 -13
  14. package/dist/components/MultiSelectColumnFilter.js.map +1 -1
  15. package/dist/components/NumericInputColumnFilter.d.ts +13 -13
  16. package/dist/components/NumericInputColumnFilter.d.ts.map +1 -1
  17. package/dist/components/NumericInputColumnFilter.js +44 -15
  18. package/dist/components/NumericInputColumnFilter.js.map +1 -1
  19. package/dist/components/NumericInputColumnFilter.test.d.ts +2 -0
  20. package/dist/components/NumericInputColumnFilter.test.d.ts.map +1 -0
  21. package/dist/components/NumericInputColumnFilter.test.js +90 -0
  22. package/dist/components/NumericInputColumnFilter.test.js.map +1 -0
  23. package/dist/components/OverlayTrigger.d.ts +78 -0
  24. package/dist/components/OverlayTrigger.d.ts.map +1 -0
  25. package/dist/components/OverlayTrigger.js +89 -0
  26. package/dist/components/OverlayTrigger.js.map +1 -0
  27. package/dist/components/TanstackTable.d.ts +19 -3
  28. package/dist/components/TanstackTable.d.ts.map +1 -1
  29. package/dist/components/TanstackTable.js +159 -219
  30. package/dist/components/TanstackTable.js.map +1 -1
  31. package/dist/components/TanstackTableDownloadButton.d.ts +4 -2
  32. package/dist/components/TanstackTableDownloadButton.d.ts.map +1 -1
  33. package/dist/components/TanstackTableDownloadButton.js +4 -3
  34. package/dist/components/TanstackTableDownloadButton.js.map +1 -1
  35. package/dist/components/TanstackTableHeaderCell.d.ts +13 -0
  36. package/dist/components/TanstackTableHeaderCell.d.ts.map +1 -0
  37. package/dist/components/TanstackTableHeaderCell.js +98 -0
  38. package/dist/components/TanstackTableHeaderCell.js.map +1 -0
  39. package/dist/components/styles.css +58 -0
  40. package/dist/components/useAutoSizeColumns.d.ts +17 -0
  41. package/dist/components/useAutoSizeColumns.d.ts.map +1 -0
  42. package/dist/components/useAutoSizeColumns.js +99 -0
  43. package/dist/components/useAutoSizeColumns.js.map +1 -0
  44. package/dist/index.d.ts +4 -2
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +4 -1
  47. package/dist/index.js.map +1 -1
  48. package/dist/react-table.d.ts +13 -0
  49. package/dist/react-table.d.ts.map +1 -0
  50. package/dist/react-table.js +3 -0
  51. package/dist/react-table.js.map +1 -0
  52. package/package.json +2 -2
  53. package/src/components/CategoricalColumnFilter.tsx +84 -54
  54. package/src/components/ColumnManager.tsx +236 -88
  55. package/src/components/MultiSelectColumnFilter.tsx +45 -32
  56. package/src/components/NumericInputColumnFilter.test.ts +67 -19
  57. package/src/components/NumericInputColumnFilter.tsx +102 -42
  58. package/src/components/OverlayTrigger.tsx +168 -0
  59. package/src/components/TanstackTable.tsx +357 -410
  60. package/src/components/TanstackTableDownloadButton.tsx +8 -5
  61. package/src/components/TanstackTableHeaderCell.tsx +207 -0
  62. package/src/components/styles.css +58 -0
  63. package/src/components/useAutoSizeColumns.tsx +168 -0
  64. package/src/index.ts +10 -1
  65. package/src/react-table.ts +17 -0
  66. package/tsconfig.json +1 -2
  67. package/dist/components/TanstackTable.css +0 -4
  68. package/src/components/TanstackTable.css +0 -4
@@ -1,113 +1,206 @@
1
1
  import { type Column, type Table } from '@tanstack/react-table';
2
- import { useEffect, useRef, useState } from 'preact/compat';
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
- hidePinButton: boolean;
9
+ onPinningBoundary: boolean;
11
10
  onTogglePin: (columnId: string) => void;
12
- onClearElementFocus: () => void;
11
+ className?: string;
13
12
  }
14
13
 
15
- function ColumnMenuItem<RowDataModel>({
14
+ function ColumnLeafItem<RowDataModel>({
16
15
  column,
17
- hidePinButton = false,
16
+ onPinningBoundary = false,
18
17
  onTogglePin,
19
- onClearElementFocus,
18
+ className,
20
19
  }: ColumnMenuItemProps<RowDataModel>) {
21
- const pinButtonRef = useRef<HTMLButtonElement>(null);
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
- (column.columnDef.meta as any)?.label ??
24
+ column.columnDef.meta?.label ??
28
25
  (typeof column.columnDef.header === 'string' ? column.columnDef.header : column.id);
29
26
 
30
27
  return (
31
- <Dropdown.Item
28
+ <div
32
29
  key={column.id}
33
- as="div"
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
- <OverlayTrigger
39
- placement="top"
40
- overlay={<Tooltip>{column.getIsVisible() ? 'Hide column' : 'Show column'}</Tooltip>}
41
- >
42
- <input
43
- type="checkbox"
44
- class="form-check-input"
45
- checked={column.getIsVisible()}
46
- disabled={!column.getCanHide()}
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
- {column.getCanPin() && !hidePinButton && (
59
- <button
60
- ref={pinButtonRef}
61
- type="button"
62
- // Since the HTML changes, but we want to refocus the pin button, we track
63
- // the active pin button and refocuses it when the column manager is rerendered.
64
- id={`${column.id}-pin`}
65
- class="btn btn-sm btn-ghost ms-2"
66
- aria-label={
67
- column.getIsPinned() ? `Unfreeze '${header}' column` : `Freeze '${header}' column`
68
- }
69
- title={column.getIsPinned() ? 'Unfreeze column' : 'Freeze column'}
70
- data-bs-toggle="tooltip"
71
- tabIndex={0}
72
- onKeyDown={(e) => {
73
- if (!pinButtonRef.current) {
74
- throw new Error('pinButtonRef.current is null');
75
- }
76
- if (e.key === 'Enter' || e.key === ' ') {
77
- e.preventDefault();
78
- onTogglePin(column.id);
79
- return;
80
- }
81
- }}
82
- // Instead, use the arrow keys to move between interactive elements in each menu item.
83
- onClick={() => {
84
- if (!pinButtonRef.current) {
85
- throw new Error('pinButtonRef.current is null');
86
- }
87
- onTogglePin(column.id);
88
- }}
89
- >
90
- <i class={`bi ${column.getIsPinned() ? 'bi-x' : 'bi-snow'}`} aria-hidden="true" />
91
- </button>
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
- </Dropdown.Item>
144
+ </div>
94
145
  );
95
146
  }
96
147
 
97
- export function ColumnManager<RowDataModel>({ table }: { table: Table<RowDataModel> }) {
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
- newLeft = currentLeft.filter((id) => id !== columnId);
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
- const columnOrder = table.getAllLeafColumns().map((c) => c.id);
109
- const newPinned = new Set([...currentLeft, columnId]);
110
- newLeft = columnOrder.filter((id) => newPinned.has(id));
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 pinnedColumns = table.getAllLeafColumns().filter((c) => c.getIsPinned() === 'left');
130
- const unpinnedColumns = table.getAllLeafColumns().filter((c) => c.getIsPinned() !== 'left');
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 variant="outline-secondary" id="column-manager-button">
156
- <i class="bi bi-view-list me-2" aria-hidden="true" />
157
- View
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
- {pinnedColumns.length > 0 && (
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
- {pinnedColumns.map((column, index) => {
314
+ {/* Only leaf columns can be pinned in the current implementation. */}
315
+ {pinnedMenuColumns.map((column, index) => {
167
316
  return (
168
- <ColumnMenuItem
317
+ <ColumnLeafItem
169
318
  key={column.id}
170
319
  column={column}
171
- hidePinButton={index !== pinnedColumns.length - 1}
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
- {unpinnedColumns.length > 0 && (
329
+ {unpinnedRootColumns.length > 0 && (
182
330
  <>
183
331
  <div role="group">
184
- {unpinnedColumns.map((column, index) => {
332
+ {unpinnedRootColumns.map((column) => {
185
333
  return (
186
- <ColumnMenuItem
334
+ <ColumnItem
187
335
  key={column.id}
188
336
  column={column}
189
- hidePinButton={index !== 0}
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
- setActiveElementId('column-manager-button');
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.columnId - The ID of the column
15
- * @param params.columnLabel - The label of the column, e.g. "Rubric Items"
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<T extends readonly any[]>({
22
- columnId,
23
- columnLabel,
19
+ export function MultiSelectColumnFilter<TData, TValue>({
20
+ column,
24
21
  allColumnValues,
25
22
  renderValueLabel = defaultRenderValueLabel,
26
- columnValuesFilter,
27
- setColumnValuesFilter,
28
23
  }: {
29
- columnId: string;
30
- columnLabel: string;
31
- allColumnValues: T;
32
- renderValueLabel?: (props: { value: T[number]; isSelected: boolean }) => JSX.Element;
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 selected = useMemo(() => new Set(columnValuesFilter), [columnValuesFilter]);
29
+ const columnId = column.id;
37
30
 
38
- const toggleSelected = (value: T[number]) => {
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
- setColumnValuesFilter(Array.from(set));
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 ${columnLabel.toLowerCase()}`}
57
- title={`Filter ${columnLabel.toLowerCase()}`}
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">{columnLabel}</div>
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={() => setColumnValuesFilter([])}
75
+ onClick={() => column.setFilterValue([])}
72
76
  >
73
77
  Clear
74
78
  </button>
75
79
  </div>
80
+ </div>
76
81
 
77
- <div class="list-group list-group-flush">
78
- {allColumnValues.map((value) => {
79
- const isSelected = selected.has(value);
80
- return (
81
- <div key={value} class="list-group-item d-flex align-items-center gap-3 px-0">
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
- </div>
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(numericColumnFilterFn(createMockRow(5), 'col', '5')).toBe(true);
62
- expect(numericColumnFilterFn(createMockRow(5), 'col', '=5')).toBe(true);
63
- expect(numericColumnFilterFn(createMockRow(5), 'col', '4')).toBe(false);
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(numericColumnFilterFn(createMockRow(3), 'col', '<5')).toBe(true);
68
- expect(numericColumnFilterFn(createMockRow(5), 'col', '<5')).toBe(false);
69
- expect(numericColumnFilterFn(createMockRow(7), 'col', '<5')).toBe(false);
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(numericColumnFilterFn(createMockRow(7), 'col', '>5')).toBe(true);
74
- expect(numericColumnFilterFn(createMockRow(5), 'col', '>5')).toBe(false);
75
- expect(numericColumnFilterFn(createMockRow(3), 'col', '>5')).toBe(false);
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(numericColumnFilterFn(createMockRow(3), 'col', '<=5')).toBe(true);
80
- expect(numericColumnFilterFn(createMockRow(5), 'col', '<=5')).toBe(true);
81
- expect(numericColumnFilterFn(createMockRow(7), 'col', '<=5')).toBe(false);
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(numericColumnFilterFn(createMockRow(7), 'col', '>=5')).toBe(true);
86
- expect(numericColumnFilterFn(createMockRow(5), 'col', '>=5')).toBe(true);
87
- expect(numericColumnFilterFn(createMockRow(3), 'col', '>=5')).toBe(false);
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(numericColumnFilterFn(createMockRow(5), 'col', '')).toBe(true);
92
- expect(numericColumnFilterFn(createMockRow(5), 'col', 'invalid')).toBe(true);
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(numericColumnFilterFn(createMockRow(null), 'col', '>5')).toBe(false);
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(numericColumnFilterFn(createMockRow(null), 'col', '')).toBe(true);
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
  });