@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.
Files changed (71) hide show
  1. package/CHANGELOG.md +28 -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/PresetFilterDropdown.d.ts +19 -0
  28. package/dist/components/PresetFilterDropdown.d.ts.map +1 -0
  29. package/dist/components/PresetFilterDropdown.js +93 -0
  30. package/dist/components/PresetFilterDropdown.js.map +1 -0
  31. package/dist/components/TanstackTable.d.ts +15 -4
  32. package/dist/components/TanstackTable.d.ts.map +1 -1
  33. package/dist/components/TanstackTable.js +148 -197
  34. package/dist/components/TanstackTable.js.map +1 -1
  35. package/dist/components/TanstackTableDownloadButton.d.ts +4 -2
  36. package/dist/components/TanstackTableDownloadButton.d.ts.map +1 -1
  37. package/dist/components/TanstackTableDownloadButton.js +4 -3
  38. package/dist/components/TanstackTableDownloadButton.js.map +1 -1
  39. package/dist/components/TanstackTableHeaderCell.d.ts +13 -0
  40. package/dist/components/TanstackTableHeaderCell.d.ts.map +1 -0
  41. package/dist/components/TanstackTableHeaderCell.js +98 -0
  42. package/dist/components/TanstackTableHeaderCell.js.map +1 -0
  43. package/dist/components/{TanstackTable.css → styles.css} +11 -6
  44. package/dist/components/useAutoSizeColumns.d.ts +17 -0
  45. package/dist/components/useAutoSizeColumns.d.ts.map +1 -0
  46. package/dist/components/useAutoSizeColumns.js +99 -0
  47. package/dist/components/useAutoSizeColumns.js.map +1 -0
  48. package/dist/index.d.ts +5 -1
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +5 -0
  51. package/dist/index.js.map +1 -1
  52. package/dist/react-table.d.ts +13 -0
  53. package/dist/react-table.d.ts.map +1 -0
  54. package/dist/react-table.js +3 -0
  55. package/dist/react-table.js.map +1 -0
  56. package/package.json +2 -2
  57. package/src/components/CategoricalColumnFilter.tsx +28 -28
  58. package/src/components/ColumnManager.tsx +222 -46
  59. package/src/components/MultiSelectColumnFilter.tsx +45 -32
  60. package/src/components/NumericInputColumnFilter.test.ts +67 -19
  61. package/src/components/NumericInputColumnFilter.tsx +102 -42
  62. package/src/components/OverlayTrigger.tsx +168 -0
  63. package/src/components/PresetFilterDropdown.tsx +155 -0
  64. package/src/components/TanstackTable.tsx +315 -363
  65. package/src/components/TanstackTableDownloadButton.tsx +8 -5
  66. package/src/components/TanstackTableHeaderCell.tsx +207 -0
  67. package/src/components/{TanstackTable.css → styles.css} +11 -6
  68. package/src/components/useAutoSizeColumns.tsx +168 -0
  69. package/src/index.ts +7 -0
  70. package/src/react-table.ts +17 -0
  71. package/tsconfig.json +1 -2
@@ -1,31 +1,43 @@
1
+ import type { Column } from '@tanstack/table-core';
1
2
  import clsx from 'clsx';
2
3
  import Dropdown from 'react-bootstrap/Dropdown';
3
4
 
4
- interface NumericInputColumnFilterProps {
5
- columnId: string;
6
- columnLabel: string;
7
- value: string;
8
- onChange: (value: string) => void;
9
- }
5
+ export type NumericColumnFilterValue =
6
+ | {
7
+ filterValue: string;
8
+ emptyOnly: false;
9
+ }
10
+ | {
11
+ filterValue: '';
12
+ emptyOnly: true;
13
+ };
10
14
 
11
15
  /**
12
16
  * A component that allows the user to filter a numeric column using comparison operators.
13
17
  * Supports syntax like: <1, >0, <=5, >=10, =5, or just 5 (implicit equals)
14
18
  *
15
19
  * @param params
16
- * @param params.columnId - The ID of the column
17
- * @param params.columnLabel - The label of the column, e.g. "Manual Points"
18
- * @param params.value - The current filter value (e.g., ">5" or "10")
19
- * @param params.onChange - Callback when the filter value changes
20
+ * @param params.column - The TanStack Table column object
20
21
  */
21
- export function NumericInputColumnFilter({
22
- columnId,
23
- columnLabel,
24
- value,
25
- onChange,
26
- }: NumericInputColumnFilterProps) {
27
- const hasActiveFilter = value.trim().length > 0;
28
- const isInvalid = hasActiveFilter && parseNumericFilter(value) === null;
22
+ export function NumericInputColumnFilter<TData, TValue>({
23
+ column,
24
+ }: {
25
+ column: Column<TData, TValue>;
26
+ }) {
27
+ const columnId = column.id;
28
+ const value = (column.getFilterValue() as NumericColumnFilterValue | undefined) ?? {
29
+ filterValue: '',
30
+ emptyOnly: false,
31
+ };
32
+
33
+ const label =
34
+ column.columnDef.meta?.label ??
35
+ (typeof column.columnDef.header === 'string' ? column.columnDef.header : column.id);
36
+
37
+ const filterValue = value.filterValue;
38
+ const emptyOnly = value.emptyOnly;
39
+ const hasActiveFilter = filterValue.trim().length > 0 || emptyOnly;
40
+ const isInvalid = filterValue.trim().length > 0 && parseNumericFilter(filterValue) === null;
29
41
 
30
42
  return (
31
43
  <Dropdown align="end">
@@ -36,8 +48,8 @@ export function NumericInputColumnFilter({
36
48
  hasActiveFilter && (isInvalid ? 'text-warning' : 'text-primary'),
37
49
  )}
38
50
  id={`filter-${columnId}`}
39
- aria-label={`Filter ${columnLabel.toLowerCase()}`}
40
- title={`Filter ${columnLabel.toLowerCase()}`}
51
+ aria-label={`Filter ${label.toLowerCase()}`}
52
+ title={`Filter ${label.toLowerCase()}`}
41
53
  >
42
54
  <i
43
55
  class={clsx(
@@ -51,17 +63,42 @@ export function NumericInputColumnFilter({
51
63
  aria-hidden="true"
52
64
  />
53
65
  </Dropdown.Toggle>
54
- <Dropdown.Menu>
66
+ <Dropdown.Menu
67
+ // eslint-disable-next-line @eslint-react/no-forbidden-props
68
+ className="p-0"
69
+ >
55
70
  <div class="p-3" style={{ minWidth: '240px' }}>
56
- <label class="form-label small fw-semibold mb-2">{columnLabel}</label>
71
+ <div class="d-flex align-items-center justify-content-between mb-2">
72
+ <label class="form-label fw-semibold mb-0" id={`${columnId}-filter-label`}>
73
+ {label}
74
+ </label>
75
+ <button
76
+ type="button"
77
+ class={clsx(
78
+ 'btn btn-link btn-sm text-decoration-none',
79
+ !hasActiveFilter && 'invisible',
80
+ )}
81
+ onClick={() => {
82
+ column.setFilterValue({ filterValue: '', emptyOnly: false });
83
+ }}
84
+ >
85
+ Clear
86
+ </button>
87
+ </div>
57
88
  <input
58
89
  type="text"
59
90
  class={clsx('form-control form-control-sm', isInvalid && 'is-invalid')}
60
91
  placeholder="e.g., >0, <5, =10"
61
- value={value}
92
+ aria-labelledby={`${columnId}-filter-label`}
93
+ value={filterValue}
94
+ disabled={emptyOnly}
95
+ aria-describedby={`${columnId}-filter-description`}
62
96
  onInput={(e) => {
63
97
  if (e.target instanceof HTMLInputElement) {
64
- onChange(e.target.value);
98
+ column.setFilterValue({
99
+ filterValue: e.target.value,
100
+ emptyOnly: false,
101
+ });
65
102
  }
66
103
  }}
67
104
  onClick={(e) => e.stopPropagation()}
@@ -72,22 +109,31 @@ export function NumericInputColumnFilter({
72
109
  </div>
73
110
  )}
74
111
  {!isInvalid && (
75
- <div class="form-text small mt-2">
76
- Use operators: <code>&lt;</code>, <code>&gt;</code>, <code>&lt;=</code>,{' '}
112
+ <small class="form-text text-nowrap" id={`${columnId}-filter-description`}>
113
+ Operators: <code>&lt;</code>, <code>&gt;</code>, <code>&lt;=</code>,{' '}
77
114
  <code>&gt;=</code>, <code>=</code>
78
- <br />
79
- Example: <code>&gt;5</code> or <code>&lt;=10</code>
80
- </div>
81
- )}
82
- {hasActiveFilter && (
83
- <button
84
- type="button"
85
- class="btn btn-sm btn-link text-decoration-none mt-2 p-0"
86
- onClick={() => onChange('')}
87
- >
88
- Clear filter
89
- </button>
115
+ </small>
90
116
  )}
117
+ <div class="form-check mt-2">
118
+ <input
119
+ class="form-check-input"
120
+ type="checkbox"
121
+ checked={emptyOnly}
122
+ id={`${columnId}-empty-filter`}
123
+ onChange={(e) => {
124
+ if (e.target instanceof HTMLInputElement) {
125
+ column.setFilterValue(
126
+ e.target.checked
127
+ ? { filterValue: '', emptyOnly: true }
128
+ : { filterValue: '', emptyOnly: false },
129
+ );
130
+ }
131
+ }}
132
+ />
133
+ <label class="form-check-label" for={`${columnId}-empty-filter`}>
134
+ Empty values
135
+ </label>
136
+ </div>
91
137
  </div>
92
138
  </Dropdown.Menu>
93
139
  </Dropdown>
@@ -129,13 +175,27 @@ export function parseNumericFilter(filterValue: string): {
129
175
  * filterFn: numericColumnFilterFn,
130
176
  * }
131
177
  */
132
- export function numericColumnFilterFn(row: any, columnId: string, filterValue: string): boolean {
178
+ export function numericColumnFilterFn(
179
+ row: any,
180
+ columnId: string,
181
+ { filterValue, emptyOnly }: NumericColumnFilterValue,
182
+ ): boolean {
183
+ // Handle object-based filter value
184
+ const cellValue = row.getValue(columnId) as number | null;
185
+ const isEmpty = cellValue == null;
186
+
187
+ if (emptyOnly) {
188
+ return isEmpty;
189
+ }
190
+
191
+ // If there's no numeric filter, show all rows
133
192
  const parsed = parseNumericFilter(filterValue);
134
- if (!parsed) return true; // Invalid or empty filter = show all
193
+ if (!parsed) return true;
135
194
 
136
- const cellValue = row.getValue(columnId) as number | null;
137
- if (cellValue === null || cellValue === undefined) return false;
195
+ // If cell is empty and we're doing numeric filtering, don't show it
196
+ if (isEmpty) return false;
138
197
 
198
+ // Apply numeric filter
139
199
  switch (parsed.operator) {
140
200
  case '<':
141
201
  return cellValue < parsed.value;
@@ -0,0 +1,168 @@
1
+ import { useEffect, useRef } from 'preact/compat';
2
+ import {
3
+ // eslint-disable-next-line no-restricted-imports
4
+ OverlayTrigger as BootstrapOverlayTrigger,
5
+ type OverlayTriggerProps as BootstrapOverlayTriggerProps,
6
+ Popover,
7
+ type PopoverProps,
8
+ Tooltip,
9
+ type TooltipProps,
10
+ } from 'react-bootstrap';
11
+
12
+ import { type FocusTrap, focusFirstFocusableChild, trapFocus } from '@prairielearn/browser-utils';
13
+
14
+ export interface OverlayTriggerProps extends Omit<BootstrapOverlayTriggerProps, 'overlay'> {
15
+ popover?: {
16
+ /**
17
+ * Additional props to pass to the Popover component.
18
+ */
19
+ props?: Omit<PopoverProps, 'children'>;
20
+ /**
21
+ * The content to display in the popover body.
22
+ */
23
+ body: React.ReactNode;
24
+ /**
25
+ * Optional header content for the popover.
26
+ */
27
+ header?: React.ReactNode;
28
+ };
29
+ tooltip?: {
30
+ /**
31
+ * Additional props to pass to the Tooltip component. `id` is required for accessibility.
32
+ */
33
+ props: Omit<TooltipProps, 'children' | 'id'> & { id: string };
34
+ /**
35
+ * The content to display in the tooltip body.
36
+ */
37
+ body: React.ReactNode;
38
+ };
39
+ /**
40
+ * Whether to trap focus inside the overlay when it's shown.
41
+ * If true, focus will be trapped and moved to the first focusable element.
42
+ * @default true
43
+ */
44
+ trapFocus?: boolean;
45
+ /**
46
+ * Whether to return focus to the trigger element when the overlay is hidden.
47
+ * @default true
48
+ */
49
+ returnFocus?: boolean;
50
+ }
51
+
52
+ /**
53
+ * A wrapper around react-bootstrap's OverlayTrigger that adds accessibility features:
54
+ * - Automatic focus trapping when the overlay is shown
55
+ * - Auto-focus on the first focusable element in the overlay
56
+ * - Returns focus to the trigger element when the overlay is hidden
57
+ * - Automatically constructs a Popover with proper ref management
58
+ *
59
+ * This component provides a simpler API than react-bootstrap's OverlayTrigger by
60
+ * handling the Popover construction and ref management internally.
61
+ *
62
+ * @example
63
+ * ```tsx
64
+ * <OverlayTrigger
65
+ * tooltip={{
66
+ * body: 'Tooltip content',
67
+ * props: { id: 'tooltip-id' },
68
+ * }}
69
+ * placement="right"
70
+ * >
71
+ * <button>Hover me</button>
72
+ * </OverlayTrigger>
73
+ * ```
74
+ *
75
+ * @example
76
+ * ```tsx
77
+ * <OverlayTrigger
78
+ * popover={{
79
+ * header: 'Popover title',
80
+ * body: 'Popover content',
81
+ * }}
82
+ * placement="right"
83
+ * >
84
+ * <button>Click me</button>
85
+ * </OverlayTrigger>
86
+ * ```
87
+ */
88
+ export function OverlayTrigger({
89
+ children,
90
+ popover,
91
+ tooltip,
92
+ trapFocus: shouldTrapFocus = true,
93
+ returnFocus = true,
94
+ onEntered,
95
+ onExit,
96
+ ...props
97
+ }: OverlayTriggerProps) {
98
+ const overlayBodyRef = useRef<HTMLDivElement>(null);
99
+ const focusTrapRef = useRef<FocusTrap | null>(null);
100
+ const triggerElementRef = useRef<HTMLElement | null>(null);
101
+
102
+ const handleEntered = (node: HTMLElement, isAppearing: boolean) => {
103
+ // Store the currently focused element (the trigger) before we move focus
104
+ if (returnFocus && document.activeElement instanceof HTMLElement) {
105
+ triggerElementRef.current = document.activeElement;
106
+ }
107
+
108
+ if (shouldTrapFocus && overlayBodyRef.current && props.trigger === 'click') {
109
+ // Trap focus inside the overlay body
110
+ focusTrapRef.current = trapFocus(overlayBodyRef.current);
111
+
112
+ // Move focus to the first focusable element
113
+ focusFirstFocusableChild(overlayBodyRef.current);
114
+ }
115
+
116
+ // Call the original onEntered callback if provided
117
+ onEntered?.(node, isAppearing);
118
+ };
119
+
120
+ // Deactivate the focus trap when the component unmounts
121
+ useEffect(() => {
122
+ return () => {
123
+ focusTrapRef.current?.deactivate();
124
+ };
125
+ }, []);
126
+
127
+ const handleExit = (node: HTMLElement) => {
128
+ // Deactivate the focus trap
129
+ if (focusTrapRef.current) {
130
+ focusTrapRef.current.deactivate();
131
+ focusTrapRef.current = null;
132
+ }
133
+
134
+ // Return focus to the trigger element
135
+ if (returnFocus && triggerElementRef.current) {
136
+ triggerElementRef.current.focus();
137
+ triggerElementRef.current = null;
138
+ }
139
+
140
+ // Call the original onExit callback if provided
141
+ onExit?.(node);
142
+ };
143
+
144
+ if (Boolean(popover) === Boolean(tooltip)) {
145
+ throw new Error('Only one of popover or tooltip must be provided');
146
+ }
147
+
148
+ // Construct the popover with our managed ref
149
+ const popoverOverlay = popover ? (
150
+ <Popover {...popover.props}>
151
+ {popover.header && <Popover.Header>{popover.header}</Popover.Header>}
152
+ <Popover.Body ref={overlayBodyRef}>{popover.body}</Popover.Body>
153
+ </Popover>
154
+ ) : null;
155
+
156
+ const tooltipOverlay = tooltip ? <Tooltip {...tooltip.props}>{tooltip.body}</Tooltip> : null;
157
+
158
+ return (
159
+ <BootstrapOverlayTrigger
160
+ {...props}
161
+ overlay={popoverOverlay ?? tooltipOverlay!}
162
+ onEntered={handleEntered}
163
+ onExit={handleExit}
164
+ >
165
+ {children}
166
+ </BootstrapOverlayTrigger>
167
+ );
168
+ }
@@ -0,0 +1,155 @@
1
+ import type { ColumnFiltersState, Table } from '@tanstack/react-table';
2
+ import { useMemo } from 'preact/compat';
3
+ import { ButtonGroup, Dropdown } from 'react-bootstrap';
4
+
5
+ /**
6
+ * Compares two filter values for deep equality using JSON serialization.
7
+ */
8
+ function filtersEqual(a: unknown, b: unknown): boolean {
9
+ return JSON.stringify(a) === JSON.stringify(b);
10
+ }
11
+
12
+ /**
13
+ * Extracts all unique column IDs referenced across all preset options.
14
+ */
15
+ function getRelevantColumnIds(options: Record<string, ColumnFiltersState>): Set<string> {
16
+ const columnIds = new Set<string>();
17
+ for (const filters of Object.values(options)) {
18
+ for (const filter of filters) {
19
+ columnIds.add(filter.id);
20
+ }
21
+ }
22
+ return columnIds;
23
+ }
24
+
25
+ /**
26
+ * Gets the current filter values for the relevant columns from the table.
27
+ */
28
+ function getRelevantFilters<TData>(
29
+ table: Table<TData>,
30
+ relevantColumnIds: Set<string>,
31
+ ): ColumnFiltersState {
32
+ const allFilters = table.getState().columnFilters;
33
+ return allFilters.filter((f) => relevantColumnIds.has(f.id));
34
+ }
35
+
36
+ /**
37
+ * Checks if the current filters match a preset's filters.
38
+ * Both must have the same column IDs with equal values.
39
+ */
40
+ function filtersMatchPreset(current: ColumnFiltersState, preset: ColumnFiltersState): boolean {
41
+ // If lengths differ, they don't match
42
+ if (current.length !== preset.length) return false;
43
+
44
+ // For empty presets, current must also be empty
45
+ if (preset.length === 0) return current.length === 0;
46
+
47
+ // Check that every preset filter exists in current with the same value
48
+ for (const presetFilter of preset) {
49
+ const currentFilter = current.find((f) => f.id === presetFilter.id);
50
+ if (!currentFilter || !filtersEqual(currentFilter.value, presetFilter.value)) {
51
+ return false;
52
+ }
53
+ }
54
+
55
+ return true;
56
+ }
57
+
58
+ /**
59
+ * A dropdown component that allows users to select from preset filter configurations.
60
+ * The selected state is derived from the table's current column filters.
61
+ * If no preset matches, a "Custom" option is shown as selected.
62
+ *
63
+ * Currently, this component expects that the filters states are arrays.
64
+ */
65
+ export function PresetFilterDropdown<OptionName extends string, TData>({
66
+ table,
67
+ options,
68
+ label = 'Filter',
69
+ onSelect,
70
+ }: {
71
+ /** The TanStack Table instance */
72
+ table: Table<TData>;
73
+ /** Mapping of option names to their filter configurations */
74
+ options: Record<OptionName, ColumnFiltersState>;
75
+ /** Label prefix for the dropdown button (e.g., "Filter") */
76
+ label?: string;
77
+ /** Callback when an option is selected, useful for side effects like column visibility */
78
+ onSelect?: (optionName: OptionName) => void;
79
+ }) {
80
+ const relevantColumnIds = getRelevantColumnIds(options);
81
+
82
+ const currentRelevantFilters = useMemo(
83
+ () => getRelevantFilters(table, relevantColumnIds),
84
+ [table, relevantColumnIds],
85
+ );
86
+
87
+ // Find which option matches the current filters
88
+ const selectedOption = useMemo<OptionName | null>(() => {
89
+ for (const [optionName, presetFilters] of Object.entries(options)) {
90
+ if (filtersMatchPreset(currentRelevantFilters, presetFilters as ColumnFiltersState)) {
91
+ return optionName as OptionName;
92
+ }
93
+ }
94
+ return null; // No preset matches - custom filter state
95
+ }, [options, currentRelevantFilters]);
96
+
97
+ const handleOptionClick = (optionName: OptionName) => {
98
+ const presetFilters = options[optionName];
99
+
100
+ // Get current filters, removing any that are in our relevant columns
101
+ const currentFilters = table.getState().columnFilters;
102
+ const preservedFilters = currentFilters.filter((f) => !relevantColumnIds.has(f.id));
103
+
104
+ // For columns not in the preset, explicitly set empty filter to clear them
105
+ // This ensures the table's onColumnFiltersChange handler can sync the cleared state
106
+ const clearedFilters = Array.from(relevantColumnIds)
107
+ .filter((colId) => !presetFilters.some((f) => f.id === colId))
108
+ .map((colId) => ({
109
+ id: colId,
110
+ // TODO: This expects that we are only clearing filters whose state is an array.
111
+ value: [],
112
+ }));
113
+
114
+ // Combine preserved filters with the new preset filters and cleared filters
115
+ const newFilters = [...preservedFilters, ...presetFilters, ...clearedFilters];
116
+ table.setColumnFilters(newFilters);
117
+
118
+ onSelect?.(optionName);
119
+ };
120
+
121
+ const displayLabel = selectedOption ?? 'Custom';
122
+
123
+ return (
124
+ <Dropdown as={ButtonGroup}>
125
+ <Dropdown.Toggle variant="tanstack-table">
126
+ <i class="bi bi-funnel me-2" aria-hidden="true" />
127
+ {label}: {displayLabel}
128
+ </Dropdown.Toggle>
129
+ <Dropdown.Menu>
130
+ {Object.keys(options).map((optionName) => {
131
+ const isSelected = selectedOption === optionName;
132
+ return (
133
+ <Dropdown.Item
134
+ key={optionName}
135
+ as="button"
136
+ type="button"
137
+ active={isSelected}
138
+ onClick={() => handleOptionClick(optionName as OptionName)}
139
+ >
140
+ <i class={`bi ${isSelected ? 'bi-check-circle-fill' : 'bi-circle'} me-2`} />
141
+ {optionName}
142
+ </Dropdown.Item>
143
+ );
144
+ })}
145
+ {/* Show Custom option only when no preset matches */}
146
+ {selectedOption === null && (
147
+ <Dropdown.Item as="button" type="button" active disabled>
148
+ <i class="bi bi-check-circle-fill me-2" />
149
+ Custom
150
+ </Dropdown.Item>
151
+ )}
152
+ </Dropdown.Menu>
153
+ </Dropdown>
154
+ );
155
+ }