@prairielearn/ui 1.3.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 (66) hide show
  1. package/CHANGELOG.md +22 -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 +15 -11
  6. package/dist/components/CategoricalColumnFilter.js.map +1 -1
  7. package/dist/components/ColumnManager.d.ts +6 -3
  8. package/dist/components/ColumnManager.d.ts.map +1 -1
  9. package/dist/components/ColumnManager.js +98 -18
  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 +15 -4
  28. package/dist/components/TanstackTable.d.ts.map +1 -1
  29. package/dist/components/TanstackTable.js +148 -197
  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/{TanstackTable.css → styles.css} +11 -6
  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 +3 -1
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +3 -0
  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 +28 -28
  54. package/src/components/ColumnManager.tsx +222 -46
  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 +315 -363
  60. package/src/components/TanstackTableDownloadButton.tsx +8 -5
  61. package/src/components/TanstackTableHeaderCell.tsx +207 -0
  62. package/src/components/{TanstackTable.css → styles.css} +11 -6
  63. package/src/components/useAutoSizeColumns.tsx +168 -0
  64. package/src/index.ts +5 -0
  65. package/src/react-table.ts +17 -0
  66. package/tsconfig.json +1 -2
@@ -1,28 +1,34 @@
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
6
 
6
7
  interface ColumnMenuItemProps<RowDataModel> {
7
8
  column: Column<RowDataModel>;
8
- hidePinButton: boolean;
9
+ onPinningBoundary: boolean;
9
10
  onTogglePin: (columnId: string) => void;
11
+ className?: string;
10
12
  }
11
13
 
12
- function ColumnMenuItem<RowDataModel>({
14
+ function ColumnLeafItem<RowDataModel>({
13
15
  column,
14
- hidePinButton = false,
16
+ onPinningBoundary = false,
15
17
  onTogglePin,
18
+ className,
16
19
  }: ColumnMenuItemProps<RowDataModel>) {
17
- if (!column.getCanHide() && !column.getCanPin()) return null;
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
- (column.columnDef.meta as any)?.label ??
24
+ column.columnDef.meta?.label ??
22
25
  (typeof column.columnDef.header === 'string' ? column.columnDef.header : column.id);
23
26
 
24
27
  return (
25
- <div key={column.id} class="px-2 py-1 d-flex align-items-center justify-content-between">
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
- {column.getCanPin() && !hidePinButton && (
41
- <button
42
- type="button"
43
- // Since the HTML changes, but we want to refocus the pin button, we track
44
- // the active pin button and refocuses it when the column manager is rerendered.
45
- id={`${column.id}-pin`}
46
- class="btn btn-sm btn-ghost ms-2"
47
- aria-label={
48
- column.getIsPinned() ? `Unfreeze '${header}' column` : `Freeze '${header}' column`
49
- }
50
- title={column.getIsPinned() ? 'Unfreeze column' : 'Freeze column'}
51
- data-bs-toggle="tooltip"
52
- onClick={() => onTogglePin(column.id)}
53
- >
54
- <i class={`bi ${column.getIsPinned() ? 'bi-x' : 'bi-snow'}`} aria-hidden="true" />
55
- </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>
56
143
  )}
57
144
  </div>
58
145
  );
59
146
  }
60
147
 
61
- export function ColumnManager<RowDataModel>({
62
- table,
63
- id,
148
+ function ColumnItem<RowDataModel>({
149
+ column,
150
+ onTogglePin,
151
+ getIsOnPinningBoundary,
64
152
  }: {
65
- table: Table<RowDataModel>;
66
- id: string;
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
- 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);
77
200
  } else {
78
- const columnOrder = table.getAllLeafColumns().map((c) => c.id);
79
- const newPinned = new Set([...currentLeft, columnId]);
80
- 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);
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 pinnedColumns = table.getAllLeafColumns().filter((c) => c.getIsPinned() === 'left');
100
- 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
+ });
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
- {pinnedColumns.length > 0 && (
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
- {pinnedColumns.map((column, index) => {
314
+ {/* Only leaf columns can be pinned in the current implementation. */}
315
+ {pinnedMenuColumns.map((column, index) => {
141
316
  return (
142
- <ColumnMenuItem
317
+ <ColumnLeafItem
143
318
  key={column.id}
144
319
  column={column}
145
- hidePinButton={index !== pinnedColumns.length - 1}
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
- {unpinnedColumns.length > 0 && (
329
+ {unpinnedRootColumns.length > 0 && (
155
330
  <>
156
331
  <div role="group">
157
- {unpinnedColumns.map((column, index) => {
332
+ {unpinnedRootColumns.map((column) => {
158
333
  return (
159
- <ColumnMenuItem
334
+ <ColumnItem
160
335
  key={column.id}
161
336
  column={column}
162
- hidePinButton={index !== 0}
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
- setActiveElementId(id);
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.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
  });