@prairielearn/ui 1.1.2 → 1.3.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 (43) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/components/CategoricalColumnFilter.d.ts.map +1 -1
  3. package/dist/components/CategoricalColumnFilter.js +13 -5
  4. package/dist/components/CategoricalColumnFilter.js.map +1 -1
  5. package/dist/components/ColumnManager.d.ts +2 -1
  6. package/dist/components/ColumnManager.d.ts.map +1 -1
  7. package/dist/components/ColumnManager.js +13 -28
  8. package/dist/components/ColumnManager.js.map +1 -1
  9. package/dist/components/MultiSelectColumnFilter.d.ts +25 -0
  10. package/dist/components/MultiSelectColumnFilter.d.ts.map +1 -0
  11. package/dist/components/MultiSelectColumnFilter.js +41 -0
  12. package/dist/components/MultiSelectColumnFilter.js.map +1 -0
  13. package/dist/components/NumericInputColumnFilter.d.ts +42 -0
  14. package/dist/components/NumericInputColumnFilter.d.ts.map +1 -0
  15. package/dist/components/NumericInputColumnFilter.js +79 -0
  16. package/dist/components/NumericInputColumnFilter.js.map +1 -0
  17. package/dist/components/TanstackTable.css +49 -0
  18. package/dist/components/TanstackTable.d.ts +8 -1
  19. package/dist/components/TanstackTable.d.ts.map +1 -1
  20. package/dist/components/TanstackTable.js +78 -46
  21. package/dist/components/TanstackTable.js.map +1 -1
  22. package/dist/components/TanstackTableDownloadButton.d.ts.map +1 -1
  23. package/dist/components/TanstackTableDownloadButton.js +3 -1
  24. package/dist/components/TanstackTableDownloadButton.js.map +1 -1
  25. package/dist/components/useShiftClickCheckbox.d.ts +26 -0
  26. package/dist/components/useShiftClickCheckbox.d.ts.map +1 -0
  27. package/dist/components/useShiftClickCheckbox.js +59 -0
  28. package/dist/components/useShiftClickCheckbox.js.map +1 -0
  29. package/dist/index.d.ts +4 -1
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +4 -1
  32. package/dist/index.js.map +1 -1
  33. package/package.json +6 -4
  34. package/src/components/CategoricalColumnFilter.tsx +57 -27
  35. package/src/components/ColumnManager.tsx +32 -57
  36. package/src/components/MultiSelectColumnFilter.tsx +103 -0
  37. package/src/components/NumericInputColumnFilter.test.ts +102 -0
  38. package/src/components/NumericInputColumnFilter.tsx +153 -0
  39. package/src/components/TanstackTable.css +49 -0
  40. package/src/components/TanstackTable.tsx +193 -116
  41. package/src/components/TanstackTableDownloadButton.tsx +27 -1
  42. package/src/components/useShiftClickCheckbox.tsx +67 -0
  43. package/src/index.ts +12 -1
package/dist/index.d.ts CHANGED
@@ -1,5 +1,8 @@
1
- export { TanstackTable, TanstackTableCard } from './components/TanstackTable.js';
1
+ export { TanstackTable, TanstackTableCard, TanstackTableEmptyState, } from './components/TanstackTable.js';
2
2
  export { ColumnManager } from './components/ColumnManager.js';
3
3
  export { TanstackTableDownloadButton } from './components/TanstackTableDownloadButton.js';
4
4
  export { CategoricalColumnFilter } from './components/CategoricalColumnFilter.js';
5
+ export { MultiSelectColumnFilter } from './components/MultiSelectColumnFilter.js';
6
+ export { NumericInputColumnFilter, parseNumericFilter, numericColumnFilterFn, } from './components/NumericInputColumnFilter.js';
7
+ export { useShiftClickCheckbox } from './components/useShiftClickCheckbox.js';
5
8
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,iBAAiB,EAAE,MAAM,+BAA+B,CAAC;AACjF,OAAO,EAAE,aAAa,EAAE,MAAM,+BAA+B,CAAC;AAC9D,OAAO,EAAE,2BAA2B,EAAE,MAAM,6CAA6C,CAAC;AAC1F,OAAO,EAAE,uBAAuB,EAAE,MAAM,yCAAyC,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,aAAa,EACb,iBAAiB,EACjB,uBAAuB,GACxB,MAAM,+BAA+B,CAAC;AACvC,OAAO,EAAE,aAAa,EAAE,MAAM,+BAA+B,CAAC;AAC9D,OAAO,EAAE,2BAA2B,EAAE,MAAM,6CAA6C,CAAC;AAC1F,OAAO,EAAE,uBAAuB,EAAE,MAAM,yCAAyC,CAAC;AAClF,OAAO,EAAE,uBAAuB,EAAE,MAAM,yCAAyC,CAAC;AAClF,OAAO,EACL,wBAAwB,EACxB,kBAAkB,EAClB,qBAAqB,GACtB,MAAM,0CAA0C,CAAC;AAClD,OAAO,EAAE,qBAAqB,EAAE,MAAM,uCAAuC,CAAC"}
package/dist/index.js CHANGED
@@ -1,5 +1,8 @@
1
- export { TanstackTable, TanstackTableCard } from './components/TanstackTable.js';
1
+ export { TanstackTable, TanstackTableCard, TanstackTableEmptyState, } from './components/TanstackTable.js';
2
2
  export { ColumnManager } from './components/ColumnManager.js';
3
3
  export { TanstackTableDownloadButton } from './components/TanstackTableDownloadButton.js';
4
4
  export { CategoricalColumnFilter } from './components/CategoricalColumnFilter.js';
5
+ export { MultiSelectColumnFilter } from './components/MultiSelectColumnFilter.js';
6
+ export { NumericInputColumnFilter, parseNumericFilter, numericColumnFilterFn, } from './components/NumericInputColumnFilter.js';
7
+ export { useShiftClickCheckbox } from './components/useShiftClickCheckbox.js';
5
8
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,iBAAiB,EAAE,MAAM,+BAA+B,CAAC;AACjF,OAAO,EAAE,aAAa,EAAE,MAAM,+BAA+B,CAAC;AAC9D,OAAO,EAAE,2BAA2B,EAAE,MAAM,6CAA6C,CAAC;AAC1F,OAAO,EAAE,uBAAuB,EAAE,MAAM,yCAAyC,CAAC","sourcesContent":["export { TanstackTable, TanstackTableCard } from './components/TanstackTable.js';\nexport { ColumnManager } from './components/ColumnManager.js';\nexport { TanstackTableDownloadButton } from './components/TanstackTableDownloadButton.js';\nexport { CategoricalColumnFilter } from './components/CategoricalColumnFilter.js';\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,aAAa,EACb,iBAAiB,EACjB,uBAAuB,GACxB,MAAM,+BAA+B,CAAC;AACvC,OAAO,EAAE,aAAa,EAAE,MAAM,+BAA+B,CAAC;AAC9D,OAAO,EAAE,2BAA2B,EAAE,MAAM,6CAA6C,CAAC;AAC1F,OAAO,EAAE,uBAAuB,EAAE,MAAM,yCAAyC,CAAC;AAClF,OAAO,EAAE,uBAAuB,EAAE,MAAM,yCAAyC,CAAC;AAClF,OAAO,EACL,wBAAwB,EACxB,kBAAkB,EAClB,qBAAqB,GACtB,MAAM,0CAA0C,CAAC;AAClD,OAAO,EAAE,qBAAqB,EAAE,MAAM,uCAAuC,CAAC","sourcesContent":["export {\n TanstackTable,\n TanstackTableCard,\n TanstackTableEmptyState,\n} from './components/TanstackTable.js';\nexport { ColumnManager } from './components/ColumnManager.js';\nexport { TanstackTableDownloadButton } from './components/TanstackTableDownloadButton.js';\nexport { CategoricalColumnFilter } from './components/CategoricalColumnFilter.js';\nexport { MultiSelectColumnFilter } from './components/MultiSelectColumnFilter.js';\nexport {\n NumericInputColumnFilter,\n parseNumericFilter,\n numericColumnFilterFn,\n} from './components/NumericInputColumnFilter.js';\nexport { useShiftClickCheckbox } from './components/useShiftClickCheckbox.js';\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prairielearn/ui",
3
- "version": "1.1.2",
3
+ "version": "1.3.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -13,7 +13,8 @@
13
13
  },
14
14
  "scripts": {
15
15
  "build": "tsc && tscp",
16
- "dev": "tsc --watch --preserveWatchOutput & tscp --watch"
16
+ "dev": "tsc --watch --preserveWatchOutput & tscp --watch",
17
+ "test": "vitest run --coverage"
17
18
  },
18
19
  "dependencies": {
19
20
  "@prairielearn/browser-utils": "^2.5.1",
@@ -26,8 +27,9 @@
26
27
  },
27
28
  "devDependencies": {
28
29
  "@prairielearn/tsconfig": "^0.0.0",
29
- "@types/node": "^22.18.13",
30
+ "@types/node": "^22.19.0",
30
31
  "typescript": "^5.9.3",
31
- "typescript-cp": "^0.1.9"
32
+ "typescript-cp": "^0.1.9",
33
+ "vitest": "^4.0.7"
32
34
  }
33
35
  }
@@ -70,7 +70,7 @@ export function CategoricalColumnFilter<T extends readonly any[]>({
70
70
  <Dropdown align="end">
71
71
  <Dropdown.Toggle
72
72
  variant="link"
73
- class="text-muted p-0 ms-2"
73
+ class="text-muted p-0"
74
74
  id={`filter-${columnId}`}
75
75
  aria-label={`Filter ${columnLabel.toLowerCase()}`}
76
76
  title={`Filter ${columnLabel.toLowerCase()}`}
@@ -81,40 +81,70 @@ export function CategoricalColumnFilter<T extends readonly any[]>({
81
81
  />
82
82
  </Dropdown.Toggle>
83
83
  <Dropdown.Menu class="p-0">
84
- <div class="p-3">
84
+ <div class="p-3 pb-0">
85
85
  <div class="d-flex align-items-center justify-content-between mb-2">
86
86
  <div class="fw-semibold">{columnLabel}</div>
87
87
  <button
88
88
  type="button"
89
- class="btn btn-link btn-sm text-decoration-none"
90
- onClick={() => apply(mode, new Set())}
89
+ class={clsx('btn btn-link btn-sm text-decoration-none', {
90
+ // Hide the clear button if no filters are applied.
91
+ // Use `visibility` instead of conditional rendering to avoid layout shift.
92
+ invisible: selected.size === 0 && mode === 'include',
93
+ })}
94
+ onClick={() => apply('include', new Set())}
91
95
  >
92
96
  Clear
93
97
  </button>
94
98
  </div>
95
99
 
96
- <div class="btn-group w-100 mb-2" role="group" aria-label="Include or exclude values">
97
- <button
98
- type="button"
99
- class={clsx('btn', mode === 'include' ? 'btn-primary' : 'btn-outline-secondary')}
100
- onClick={() => apply('include', selected)}
101
- >
102
- Include
103
- </button>
104
- <button
105
- type="button"
106
- class={clsx('btn', mode === 'exclude' ? 'btn-primary' : 'btn-outline-secondary')}
107
- onClick={() => apply('exclude', selected)}
108
- >
109
- Exclude
110
- </button>
100
+ <div class="btn-group btn-group-sm w-100 mb-2">
101
+ <input
102
+ type="radio"
103
+ class="btn-check"
104
+ name={`filter-${columnId}-options`}
105
+ id={`filter-${columnId}-include`}
106
+ autocomplete="off"
107
+ checked={mode === 'include'}
108
+ onChange={() => apply('include', selected)}
109
+ />
110
+ <label class="btn btn-outline-primary" for={`filter-${columnId}-include`}>
111
+ <span class="text-nowrap">
112
+ {mode === 'include' && <i class="bi bi-check-lg me-1" aria-hidden="true" />}
113
+ Include
114
+ </span>
115
+ </label>
116
+
117
+ <input
118
+ type="radio"
119
+ class="btn-check"
120
+ name={`filter-${columnId}-options`}
121
+ id={`filter-${columnId}-exclude`}
122
+ autocomplete="off"
123
+ checked={mode === 'exclude'}
124
+ onChange={() => apply('exclude', selected)}
125
+ />
126
+ <label class="btn btn-outline-primary" for={`filter-${columnId}-exclude`}>
127
+ <span class="text-nowrap">
128
+ {mode === 'exclude' && <i class="bi bi-check-lg me-1" aria-hidden="true" />}
129
+ Exclude
130
+ </span>
131
+ </label>
111
132
  </div>
133
+ </div>
112
134
 
113
- <div class="list-group list-group-flush">
114
- {allColumnValues.map((value) => {
115
- const isSelected = selected.has(value);
116
- return (
117
- <div key={value} class="list-group-item d-flex align-items-center gap-3">
135
+ <div
136
+ class="list-group list-group-flush"
137
+ style={{
138
+ // This is needed to prevent the last item's background from covering
139
+ // the dropdown's border radius.
140
+ '--bs-list-group-bg': 'transparent',
141
+ }}
142
+ >
143
+ {allColumnValues.map((value) => {
144
+ const isSelected = selected.has(value);
145
+ return (
146
+ <div key={value} class="list-group-item d-flex align-items-center gap-3">
147
+ <div class="form-check">
118
148
  <input
119
149
  class="form-check-input"
120
150
  type="checkbox"
@@ -129,9 +159,9 @@ export function CategoricalColumnFilter<T extends readonly any[]>({
129
159
  })}
130
160
  </label>
131
161
  </div>
132
- );
133
- })}
134
- </div>
162
+ </div>
163
+ );
164
+ })}
135
165
  </div>
136
166
  </Dropdown.Menu>
137
167
  </Dropdown>
@@ -2,59 +2,43 @@ import { type Column, type Table } from '@tanstack/react-table';
2
2
  import { useEffect, useRef, useState } from 'preact/compat';
3
3
  import Button from 'react-bootstrap/Button';
4
4
  import Dropdown from 'react-bootstrap/Dropdown';
5
- import OverlayTrigger from 'react-bootstrap/OverlayTrigger';
6
- import Tooltip from 'react-bootstrap/Tooltip';
7
5
 
8
6
  interface ColumnMenuItemProps<RowDataModel> {
9
7
  column: Column<RowDataModel>;
10
8
  hidePinButton: boolean;
11
9
  onTogglePin: (columnId: string) => void;
12
- onClearElementFocus: () => void;
13
10
  }
14
11
 
15
12
  function ColumnMenuItem<RowDataModel>({
16
13
  column,
17
14
  hidePinButton = false,
18
15
  onTogglePin,
19
- onClearElementFocus,
20
16
  }: ColumnMenuItemProps<RowDataModel>) {
21
- const pinButtonRef = useRef<HTMLButtonElement>(null);
22
-
23
17
  if (!column.getCanHide() && !column.getCanPin()) return null;
24
18
 
25
- const header = typeof column.columnDef.header === 'string' ? column.columnDef.header : column.id;
19
+ // Use meta.label if available, otherwise fall back to header or column.id
20
+ const header =
21
+ (column.columnDef.meta as any)?.label ??
22
+ (typeof column.columnDef.header === 'string' ? column.columnDef.header : column.id);
26
23
 
27
24
  return (
28
- <Dropdown.Item
29
- key={column.id}
30
- as="div"
31
- class="px-2 py-1 d-flex align-items-center justify-content-between"
32
- onKeyDown={onClearElementFocus}
33
- >
25
+ <div key={column.id} class="px-2 py-1 d-flex align-items-center justify-content-between">
34
26
  <label class="form-check me-auto text-nowrap d-flex align-items-stretch">
35
- <OverlayTrigger
36
- placement="top"
37
- overlay={<Tooltip>{column.getIsVisible() ? 'Hide column' : 'Show column'}</Tooltip>}
38
- >
39
- <input
40
- type="checkbox"
41
- class="form-check-input"
42
- checked={column.getIsVisible()}
43
- disabled={!column.getCanHide()}
44
- aria-label={
45
- column.getIsVisible() ? `Hide '${header}' column` : `Show '${header}' column`
46
- }
47
- aria-describedby={`${column.id}-label`}
48
- onChange={column.getToggleVisibilityHandler()}
49
- />
50
- </OverlayTrigger>
27
+ <input
28
+ type="checkbox"
29
+ class="form-check-input"
30
+ checked={column.getIsVisible()}
31
+ disabled={!column.getCanHide()}
32
+ aria-label={column.getIsVisible() ? `Hide '${header}' column` : `Show '${header}' column`}
33
+ aria-describedby={`${column.id}-label`}
34
+ onChange={column.getToggleVisibilityHandler()}
35
+ />
51
36
  <span class="form-check-label ms-2" id={`${column.id}-label`}>
52
37
  {header}
53
38
  </span>
54
39
  </label>
55
40
  {column.getCanPin() && !hidePinButton && (
56
41
  <button
57
- ref={pinButtonRef}
58
42
  type="button"
59
43
  // Since the HTML changes, but we want to refocus the pin button, we track
60
44
  // the active pin button and refocuses it when the column manager is rerendered.
@@ -65,33 +49,22 @@ function ColumnMenuItem<RowDataModel>({
65
49
  }
66
50
  title={column.getIsPinned() ? 'Unfreeze column' : 'Freeze column'}
67
51
  data-bs-toggle="tooltip"
68
- tabIndex={0}
69
- onKeyDown={(e) => {
70
- if (!pinButtonRef.current) {
71
- throw new Error('pinButtonRef.current is null');
72
- }
73
- if (e.key === 'Enter' || e.key === ' ') {
74
- e.preventDefault();
75
- onTogglePin(column.id);
76
- return;
77
- }
78
- }}
79
- // Instead, use the arrow keys to move between interactive elements in each menu item.
80
- onClick={() => {
81
- if (!pinButtonRef.current) {
82
- throw new Error('pinButtonRef.current is null');
83
- }
84
- onTogglePin(column.id);
85
- }}
52
+ onClick={() => onTogglePin(column.id)}
86
53
  >
87
54
  <i class={`bi ${column.getIsPinned() ? 'bi-x' : 'bi-snow'}`} aria-hidden="true" />
88
55
  </button>
89
56
  )}
90
- </Dropdown.Item>
57
+ </div>
91
58
  );
92
59
  }
93
60
 
94
- export function ColumnManager<RowDataModel>({ table }: { table: Table<RowDataModel> }) {
61
+ export function ColumnManager<RowDataModel>({
62
+ table,
63
+ id,
64
+ }: {
65
+ table: Table<RowDataModel>;
66
+ id: string;
67
+ }) {
95
68
  const [activeElementId, setActiveElementId] = useState<string | null>(null);
96
69
  const [dropdownOpen, setDropdownOpen] = useState(false);
97
70
  const menuRef = useRef<HTMLDivElement>(null);
@@ -149,11 +122,15 @@ export function ColumnManager<RowDataModel>({ table }: { table: Table<RowDataMod
149
122
  }
150
123
  }}
151
124
  >
152
- <Dropdown.Toggle variant="outline-secondary" id="column-manager-button">
153
- <i class="bi bi-view-list me-2" aria-hidden="true" />
154
- View
125
+ <Dropdown.Toggle
126
+ variant="tanstack-table"
127
+ id={id}
128
+ // eslint-disable-next-line @eslint-react/no-forbidden-props
129
+ className="tanstack-table-focusable-shadow"
130
+ >
131
+ <i class="bi bi-view-list me-2" aria-hidden="true" /> View{' '}
155
132
  </Dropdown.Toggle>
156
- <Dropdown.Menu>
133
+ <Dropdown.Menu style={{ maxHeight: '60vh', overflowY: 'auto' }}>
157
134
  {pinnedColumns.length > 0 && (
158
135
  <>
159
136
  <div class="px-2 py-1 text-muted small" role="presentation">
@@ -167,7 +144,6 @@ export function ColumnManager<RowDataModel>({ table }: { table: Table<RowDataMod
167
144
  column={column}
168
145
  hidePinButton={index !== pinnedColumns.length - 1}
169
146
  onTogglePin={handleTogglePin}
170
- onClearElementFocus={() => setActiveElementId(null)}
171
147
  />
172
148
  );
173
149
  })}
@@ -185,7 +161,6 @@ export function ColumnManager<RowDataModel>({ table }: { table: Table<RowDataMod
185
161
  column={column}
186
162
  hidePinButton={index !== 0}
187
163
  onTogglePin={handleTogglePin}
188
- onClearElementFocus={() => setActiveElementId(null)}
189
164
  />
190
165
  );
191
166
  })}
@@ -203,7 +178,7 @@ export function ColumnManager<RowDataModel>({ table }: { table: Table<RowDataMod
203
178
  onClick={() => {
204
179
  table.resetColumnVisibility();
205
180
  table.resetColumnPinning();
206
- setActiveElementId('column-manager-button');
181
+ setActiveElementId(id);
207
182
  }}
208
183
  >
209
184
  <i class="bi bi-arrow-counterclockwise me-2" aria-hidden="true" />
@@ -0,0 +1,103 @@
1
+ import clsx from 'clsx';
2
+ import { type JSX, useMemo } from 'preact/compat';
3
+ import Dropdown from 'react-bootstrap/Dropdown';
4
+
5
+ function defaultRenderValueLabel<T>({ value }: { value: T }) {
6
+ return <span>{String(value)}</span>;
7
+ }
8
+
9
+ /**
10
+ * A component that allows the user to filter a column containing arrays of values.
11
+ * Uses AND logic: rows must contain ALL selected values to match.
12
+ *
13
+ * @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
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
+ */
21
+ export function MultiSelectColumnFilter<T extends readonly any[]>({
22
+ columnId,
23
+ columnLabel,
24
+ allColumnValues,
25
+ renderValueLabel = defaultRenderValueLabel,
26
+ columnValuesFilter,
27
+ setColumnValuesFilter,
28
+ }: {
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;
35
+ }) {
36
+ const selected = useMemo(() => new Set(columnValuesFilter), [columnValuesFilter]);
37
+
38
+ const toggleSelected = (value: T[number]) => {
39
+ const set = new Set(selected);
40
+ if (set.has(value)) {
41
+ set.delete(value);
42
+ } else {
43
+ set.add(value);
44
+ }
45
+ setColumnValuesFilter(Array.from(set));
46
+ };
47
+
48
+ const hasActiveFilter = selected.size > 0;
49
+
50
+ return (
51
+ <Dropdown align="end">
52
+ <Dropdown.Toggle
53
+ variant="link"
54
+ class="text-muted p-0"
55
+ id={`filter-${columnId}`}
56
+ aria-label={`Filter ${columnLabel.toLowerCase()}`}
57
+ title={`Filter ${columnLabel.toLowerCase()}`}
58
+ >
59
+ <i
60
+ class={clsx('bi', hasActiveFilter ? ['bi-funnel-fill', 'text-primary'] : 'bi-funnel')}
61
+ aria-hidden="true"
62
+ />
63
+ </Dropdown.Toggle>
64
+ <Dropdown.Menu class="p-0">
65
+ <div class="p-3" style={{ minWidth: '250px' }}>
66
+ <div class="d-flex align-items-center justify-content-between mb-2">
67
+ <div class="fw-semibold">{columnLabel}</div>
68
+ <button
69
+ type="button"
70
+ class="btn btn-link btn-sm text-decoration-none p-0"
71
+ onClick={() => setColumnValuesFilter([])}
72
+ >
73
+ Clear
74
+ </button>
75
+ </div>
76
+
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
+ <input
83
+ class="form-check-input"
84
+ type="checkbox"
85
+ checked={isSelected}
86
+ id={`${columnId}-${value}`}
87
+ onChange={() => toggleSelected(value)}
88
+ />
89
+ <label class="form-check-label fw-normal" for={`${columnId}-${value}`}>
90
+ {renderValueLabel({
91
+ value,
92
+ isSelected,
93
+ })}
94
+ </label>
95
+ </div>
96
+ );
97
+ })}
98
+ </div>
99
+ </div>
100
+ </Dropdown.Menu>
101
+ </Dropdown>
102
+ );
103
+ }
@@ -0,0 +1,102 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { numericColumnFilterFn, parseNumericFilter } from './NumericInputColumnFilter.js';
4
+
5
+ describe('parseNumericFilter', () => {
6
+ it('should parse equals operator', () => {
7
+ expect(parseNumericFilter('5')).toEqual({ operator: '=', value: 5 });
8
+ expect(parseNumericFilter('=5')).toEqual({ operator: '=', value: 5 });
9
+ expect(parseNumericFilter('= 5')).toEqual({ operator: '=', value: 5 });
10
+ });
11
+
12
+ it('should parse less than operator', () => {
13
+ expect(parseNumericFilter('<5')).toEqual({ operator: '<', value: 5 });
14
+ expect(parseNumericFilter('< 5')).toEqual({ operator: '<', value: 5 });
15
+ });
16
+
17
+ it('should parse greater than operator', () => {
18
+ expect(parseNumericFilter('>5')).toEqual({ operator: '>', value: 5 });
19
+ expect(parseNumericFilter('> 5')).toEqual({ operator: '>', value: 5 });
20
+ });
21
+
22
+ it('should parse less than or equal operator', () => {
23
+ expect(parseNumericFilter('<=5')).toEqual({ operator: '<=', value: 5 });
24
+ expect(parseNumericFilter('<= 5')).toEqual({ operator: '<=', value: 5 });
25
+ });
26
+
27
+ it('should parse greater than or equal operator', () => {
28
+ expect(parseNumericFilter('>=5')).toEqual({ operator: '>=', value: 5 });
29
+ expect(parseNumericFilter('>= 5')).toEqual({ operator: '>=', value: 5 });
30
+ });
31
+
32
+ it('should handle decimals', () => {
33
+ expect(parseNumericFilter('5.5')).toEqual({ operator: '=', value: 5.5 });
34
+ expect(parseNumericFilter('>3.14')).toEqual({ operator: '>', value: 3.14 });
35
+ });
36
+
37
+ it('should handle negative numbers', () => {
38
+ expect(parseNumericFilter('-5')).toEqual({ operator: '=', value: -5 });
39
+ expect(parseNumericFilter('<-3')).toEqual({ operator: '<', value: -3 });
40
+ });
41
+
42
+ it('should return null for invalid input', () => {
43
+ expect(parseNumericFilter('')).toBeNull();
44
+ expect(parseNumericFilter(' ')).toBeNull();
45
+ expect(parseNumericFilter('abc')).toBeNull();
46
+ expect(parseNumericFilter('>>')).toBeNull();
47
+ expect(parseNumericFilter('5.5.5')).toBeNull();
48
+ });
49
+
50
+ it('should handle whitespace', () => {
51
+ expect(parseNumericFilter(' > 5 ')).toEqual({ operator: '>', value: 5 });
52
+ });
53
+ });
54
+
55
+ describe('numericColumnFilterFn', () => {
56
+ const createMockRow = (value: number | null) => ({
57
+ getValue: () => value,
58
+ });
59
+
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);
64
+ });
65
+
66
+ 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);
70
+ });
71
+
72
+ 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);
76
+ });
77
+
78
+ 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);
82
+ });
83
+
84
+ 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);
88
+ });
89
+
90
+ 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);
93
+ });
94
+
95
+ it('should return false for null values when filter is active', () => {
96
+ expect(numericColumnFilterFn(createMockRow(null), 'col', '>5')).toBe(false);
97
+ });
98
+
99
+ it('should return true for null values when filter is empty', () => {
100
+ expect(numericColumnFilterFn(createMockRow(null), 'col', '')).toBe(true);
101
+ });
102
+ });