@justin_evo/evo-ui 1.2.0 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +70 -70
- package/dist/declarations.d.ts +6 -6
- package/package.json +52 -52
- package/src/Alert/Alert.tsx +49 -49
- package/src/AutoComplete/AutoComplete.tsx +810 -810
- package/src/Badge/Badge.tsx +53 -53
- package/src/Breadcrumb/Breadcrumb.tsx +53 -53
- package/src/Button/Button.tsx +125 -125
- package/src/Card/Card.tsx +257 -257
- package/src/Checkbox/Checkbox.tsx +59 -59
- package/src/CommandPalette/CommandPalette.tsx +185 -185
- package/src/Container/Container.tsx +31 -31
- package/src/Divider/Divider.tsx +31 -31
- package/src/Form/Form.tsx +185 -185
- package/src/Grid/Grid.tsx +66 -66
- package/src/ImageCropper/ImageCropper.tsx +911 -911
- package/src/Input/Input.tsx +74 -74
- package/src/Modal/Modal.tsx +77 -77
- package/src/Nav/Nav.tsx +708 -708
- package/src/Notification/Notification.tsx +1503 -1503
- package/src/Pagination/Pagination.tsx +76 -76
- package/src/Radio/Radio.tsx +69 -69
- package/src/RichTextArea/RichTextArea.tsx +886 -886
- package/src/Select/Select.tsx +515 -515
- package/src/Skeleton/Skeleton.tsx +70 -70
- package/src/Stack/Stack.tsx +52 -52
- package/src/Table/Table.tsx +335 -335
- package/src/Tabs/Tabs.tsx +90 -90
- package/src/Theme/ThemeProvider.tsx +253 -253
- package/src/Theme/ThemeToggle.tsx +79 -79
- package/src/Toggle/Toggle.tsx +48 -48
- package/src/Tooltip/Tooltip.tsx +38 -38
- package/src/TopNav/TopNav.tsx +1163 -1163
- package/src/TreeSelect/TreeSelect.tsx +825 -825
- package/src/css/alert.module.scss +93 -93
- package/src/css/autocomplete.module.scss +416 -416
- package/src/css/badge.module.scss +82 -82
- package/src/css/base/_color.scss +159 -159
- package/src/css/base/_theme.scss +237 -237
- package/src/css/base/_variables.scss +161 -161
- package/src/css/breadcrumb.module.scss +50 -50
- package/src/css/button.module.scss +385 -385
- package/src/css/card.module.scss +217 -217
- package/src/css/checkbox.module.scss +123 -123
- package/src/css/commandpalette.module.scss +211 -211
- package/src/css/container.module.scss +18 -18
- package/src/css/divider.module.scss +41 -41
- package/src/css/form.module.scss +245 -245
- package/src/css/imagecropper.module.scss +397 -397
- package/src/css/input.module.scss +89 -89
- package/src/css/modal.module.scss +105 -105
- package/src/css/nav.module.scss +494 -494
- package/src/css/notification.module.scss +691 -691
- package/src/css/pagination.module.scss +63 -63
- package/src/css/radio.module.scss +89 -89
- package/src/css/richtextarea.module.scss +307 -307
- package/src/css/select.module.scss +525 -525
- package/src/css/skeleton.module.scss +30 -30
- package/src/css/table.module.scss +386 -386
- package/src/css/tabs.module.scss +63 -63
- package/src/css/theme-toggle.module.scss +83 -83
- package/src/css/toggle.module.scss +54 -54
- package/src/css/tooltip.module.scss +97 -97
- package/src/css/topnav.module.scss +568 -568
- package/src/css/treeselect.module.scss +558 -558
- package/src/css/utilities/_borders.scss +111 -111
- package/src/css/utilities/_colors.scss +66 -66
- package/src/css/utilities/_effects.scss +216 -216
- package/src/css/utilities/_layout.scss +181 -181
- package/src/css/utilities/_position.scss +75 -75
- package/src/css/utilities/_sizing.scss +138 -138
- package/src/css/utilities/_spacing.scss +99 -99
- package/src/css/utilities/_typography.scss +121 -121
- package/src/css/utilities/index.scss +24 -24
- package/src/declarations.d.ts +6 -6
- package/src/index.ts +60 -60
package/src/Select/Select.tsx
CHANGED
|
@@ -1,515 +1,515 @@
|
|
|
1
|
-
import React, { useCallback, useEffect, useId, useRef, useState } from 'react';
|
|
2
|
-
import styles from '../css/select.module.scss';
|
|
3
|
-
|
|
4
|
-
export interface SelectOption {
|
|
5
|
-
value: string;
|
|
6
|
-
label: string;
|
|
7
|
-
description?: string;
|
|
8
|
-
icon?: React.ReactNode;
|
|
9
|
-
disabled?: boolean;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
interface EvoSelectBaseProps {
|
|
13
|
-
label?: string;
|
|
14
|
-
options: SelectOption[];
|
|
15
|
-
placeholder?: string;
|
|
16
|
-
helperText?: string;
|
|
17
|
-
error?: string;
|
|
18
|
-
size?: 'sm' | 'md' | 'lg';
|
|
19
|
-
fullWidth?: boolean;
|
|
20
|
-
disabled?: boolean;
|
|
21
|
-
searchable?: boolean;
|
|
22
|
-
clearable?: boolean;
|
|
23
|
-
id?: string;
|
|
24
|
-
name?: string;
|
|
25
|
-
className?: string;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export interface EvoSelectSingleProps extends EvoSelectBaseProps {
|
|
29
|
-
multiple?: false;
|
|
30
|
-
value?: string;
|
|
31
|
-
defaultValue?: string;
|
|
32
|
-
onChange?: (value: string) => void;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export interface EvoSelectMultipleProps extends EvoSelectBaseProps {
|
|
36
|
-
multiple: true;
|
|
37
|
-
value?: string[];
|
|
38
|
-
defaultValue?: string[];
|
|
39
|
-
onChange?: (value: string[]) => void;
|
|
40
|
-
/** How the trigger displays selected items. Defaults to 'chips'. */
|
|
41
|
-
multipleDisplay?: 'chips' | 'count';
|
|
42
|
-
/** Maximum number of options that can be selected at once. */
|
|
43
|
-
maxSelections?: number;
|
|
44
|
-
/** Show Select-all / Clear-all buttons at the top of the menu. */
|
|
45
|
-
showSelectAll?: boolean;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export type EvoSelectProps = EvoSelectSingleProps | EvoSelectMultipleProps;
|
|
49
|
-
|
|
50
|
-
const ChevronIcon = () => (
|
|
51
|
-
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" aria-hidden="true">
|
|
52
|
-
<path d="M4 6l4 4 4-4" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" />
|
|
53
|
-
</svg>
|
|
54
|
-
);
|
|
55
|
-
|
|
56
|
-
const CheckIcon = () => (
|
|
57
|
-
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" aria-hidden="true">
|
|
58
|
-
<path d="M3.5 8.5l3 3 6-7" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" />
|
|
59
|
-
</svg>
|
|
60
|
-
);
|
|
61
|
-
|
|
62
|
-
const ClearIcon = () => (
|
|
63
|
-
<svg viewBox="0 0 16 16" width="12" height="12" fill="none" aria-hidden="true">
|
|
64
|
-
<path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" />
|
|
65
|
-
</svg>
|
|
66
|
-
);
|
|
67
|
-
|
|
68
|
-
const SearchIcon = () => (
|
|
69
|
-
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" aria-hidden="true">
|
|
70
|
-
<circle cx="7" cy="7" r="4.5" stroke="currentColor" strokeWidth="1.5" />
|
|
71
|
-
<path d="M10.5 10.5L13 13" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
|
72
|
-
</svg>
|
|
73
|
-
);
|
|
74
|
-
|
|
75
|
-
export const EvoSelect = (props: EvoSelectProps) => {
|
|
76
|
-
const {
|
|
77
|
-
label,
|
|
78
|
-
options,
|
|
79
|
-
placeholder = 'Select an option',
|
|
80
|
-
helperText,
|
|
81
|
-
error,
|
|
82
|
-
size = 'md',
|
|
83
|
-
fullWidth = false,
|
|
84
|
-
disabled = false,
|
|
85
|
-
searchable = false,
|
|
86
|
-
clearable = false,
|
|
87
|
-
id,
|
|
88
|
-
name,
|
|
89
|
-
className = '',
|
|
90
|
-
} = props;
|
|
91
|
-
|
|
92
|
-
const isMultiple = props.multiple === true;
|
|
93
|
-
const multipleDisplay = isMultiple ? (props.multipleDisplay ?? 'chips') : 'chips';
|
|
94
|
-
const maxSelections = isMultiple ? props.maxSelections : undefined;
|
|
95
|
-
const showSelectAll = isMultiple ? (props.showSelectAll ?? false) : false;
|
|
96
|
-
|
|
97
|
-
const reactId = useId();
|
|
98
|
-
const selectId = id ?? `evo-select-${reactId}`;
|
|
99
|
-
const listId = `${selectId}-listbox`;
|
|
100
|
-
|
|
101
|
-
const controlledValue = props.value as string | string[] | undefined;
|
|
102
|
-
const defaultValue = props.defaultValue as string | string[] | undefined;
|
|
103
|
-
|
|
104
|
-
const isControlled = controlledValue !== undefined;
|
|
105
|
-
const initial: string | string[] = isMultiple
|
|
106
|
-
? (Array.isArray(defaultValue) ? defaultValue : [])
|
|
107
|
-
: (typeof defaultValue === 'string' ? defaultValue : '');
|
|
108
|
-
const [internalValue, setInternalValue] = useState<string | string[]>(initial);
|
|
109
|
-
const value = isControlled ? controlledValue! : internalValue;
|
|
110
|
-
|
|
111
|
-
const selectedValues: string[] = isMultiple
|
|
112
|
-
? (Array.isArray(value) ? value : [])
|
|
113
|
-
: (typeof value === 'string' && value ? [value] : []);
|
|
114
|
-
|
|
115
|
-
const [open, setOpen] = useState(false);
|
|
116
|
-
const [activeIdx, setActiveIdx] = useState(-1);
|
|
117
|
-
const [query, setQuery] = useState('');
|
|
118
|
-
|
|
119
|
-
const wrapperRef = useRef<HTMLDivElement>(null);
|
|
120
|
-
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
121
|
-
const searchRef = useRef<HTMLInputElement>(null);
|
|
122
|
-
const listRef = useRef<HTMLDivElement>(null);
|
|
123
|
-
|
|
124
|
-
const filtered = searchable && query
|
|
125
|
-
? options.filter(o => o.label.toLowerCase().includes(query.toLowerCase()))
|
|
126
|
-
: options;
|
|
127
|
-
|
|
128
|
-
const selectedOption = !isMultiple
|
|
129
|
-
? options.find(o => o.value === value)
|
|
130
|
-
: undefined;
|
|
131
|
-
|
|
132
|
-
const selectedOptions = isMultiple
|
|
133
|
-
? options.filter(o => selectedValues.includes(o.value))
|
|
134
|
-
: [];
|
|
135
|
-
|
|
136
|
-
const atMax = isMultiple && maxSelections !== undefined && selectedValues.length >= maxSelections;
|
|
137
|
-
|
|
138
|
-
const emit = useCallback((next: string | string[]) => {
|
|
139
|
-
if (!isControlled) setInternalValue(next);
|
|
140
|
-
if (isMultiple) {
|
|
141
|
-
(props.onChange as ((v: string[]) => void) | undefined)?.(next as string[]);
|
|
142
|
-
} else {
|
|
143
|
-
(props.onChange as ((v: string) => void) | undefined)?.(next as string);
|
|
144
|
-
}
|
|
145
|
-
}, [isControlled, isMultiple, props.onChange]);
|
|
146
|
-
|
|
147
|
-
const toggleValue = useCallback((v: string) => {
|
|
148
|
-
if (isMultiple) {
|
|
149
|
-
const current = Array.isArray(value) ? value : [];
|
|
150
|
-
if (current.includes(v)) {
|
|
151
|
-
emit(current.filter(x => x !== v));
|
|
152
|
-
} else {
|
|
153
|
-
if (maxSelections !== undefined && current.length >= maxSelections) return;
|
|
154
|
-
emit([...current, v]);
|
|
155
|
-
}
|
|
156
|
-
} else {
|
|
157
|
-
emit(v);
|
|
158
|
-
}
|
|
159
|
-
}, [emit, isMultiple, maxSelections, value]);
|
|
160
|
-
|
|
161
|
-
useEffect(() => {
|
|
162
|
-
if (!open) return;
|
|
163
|
-
const handler = (e: MouseEvent) => {
|
|
164
|
-
if (!wrapperRef.current?.contains(e.target as Node)) {
|
|
165
|
-
setOpen(false);
|
|
166
|
-
setQuery('');
|
|
167
|
-
}
|
|
168
|
-
};
|
|
169
|
-
document.addEventListener('mousedown', handler);
|
|
170
|
-
return () => document.removeEventListener('mousedown', handler);
|
|
171
|
-
}, [open]);
|
|
172
|
-
|
|
173
|
-
useEffect(() => {
|
|
174
|
-
if (open) {
|
|
175
|
-
const firstSelectedIdx = filtered.findIndex(o => selectedValues.includes(o.value));
|
|
176
|
-
setActiveIdx(firstSelectedIdx >= 0 ? firstSelectedIdx : filtered.findIndex(o => !o.disabled));
|
|
177
|
-
if (searchable) {
|
|
178
|
-
const t = setTimeout(() => searchRef.current?.focus(), 30);
|
|
179
|
-
return () => clearTimeout(t);
|
|
180
|
-
}
|
|
181
|
-
} else {
|
|
182
|
-
setQuery('');
|
|
183
|
-
setActiveIdx(-1);
|
|
184
|
-
}
|
|
185
|
-
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
186
|
-
|
|
187
|
-
useEffect(() => {
|
|
188
|
-
if (!open || activeIdx < 0) return;
|
|
189
|
-
const el = listRef.current?.querySelector(`[data-idx="${activeIdx}"]`) as HTMLElement | null;
|
|
190
|
-
el?.scrollIntoView({ block: 'nearest' });
|
|
191
|
-
}, [activeIdx, open]);
|
|
192
|
-
|
|
193
|
-
const moveActive = (dir: 1 | -1) => {
|
|
194
|
-
if (filtered.length === 0) return;
|
|
195
|
-
let next = activeIdx;
|
|
196
|
-
for (let i = 0; i < filtered.length; i++) {
|
|
197
|
-
next = (next + dir + filtered.length) % filtered.length;
|
|
198
|
-
if (!filtered[next].disabled) {
|
|
199
|
-
setActiveIdx(next);
|
|
200
|
-
return;
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
};
|
|
204
|
-
|
|
205
|
-
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
206
|
-
if (disabled) return;
|
|
207
|
-
|
|
208
|
-
if (!open) {
|
|
209
|
-
if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
|
210
|
-
e.preventDefault();
|
|
211
|
-
setOpen(true);
|
|
212
|
-
}
|
|
213
|
-
return;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
if (e.key === 'Escape') {
|
|
217
|
-
e.preventDefault();
|
|
218
|
-
setOpen(false);
|
|
219
|
-
triggerRef.current?.focus();
|
|
220
|
-
} else if (e.key === 'ArrowDown') {
|
|
221
|
-
e.preventDefault();
|
|
222
|
-
moveActive(1);
|
|
223
|
-
} else if (e.key === 'ArrowUp') {
|
|
224
|
-
e.preventDefault();
|
|
225
|
-
moveActive(-1);
|
|
226
|
-
} else if (e.key === 'Enter') {
|
|
227
|
-
e.preventDefault();
|
|
228
|
-
const opt = filtered[activeIdx];
|
|
229
|
-
if (opt && !opt.disabled) {
|
|
230
|
-
const isSelected = selectedValues.includes(opt.value);
|
|
231
|
-
if (!isSelected && atMax) return;
|
|
232
|
-
toggleValue(opt.value);
|
|
233
|
-
if (!isMultiple) {
|
|
234
|
-
setOpen(false);
|
|
235
|
-
triggerRef.current?.focus();
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
} else if (e.key === 'Home') {
|
|
239
|
-
e.preventDefault();
|
|
240
|
-
const idx = filtered.findIndex(o => !o.disabled);
|
|
241
|
-
if (idx >= 0) setActiveIdx(idx);
|
|
242
|
-
} else if (e.key === 'End') {
|
|
243
|
-
e.preventDefault();
|
|
244
|
-
for (let i = filtered.length - 1; i >= 0; i--) {
|
|
245
|
-
if (!filtered[i].disabled) { setActiveIdx(i); break; }
|
|
246
|
-
}
|
|
247
|
-
} else if (e.key === 'Tab') {
|
|
248
|
-
setOpen(false);
|
|
249
|
-
}
|
|
250
|
-
};
|
|
251
|
-
|
|
252
|
-
const handleSelect = (opt: SelectOption) => {
|
|
253
|
-
if (opt.disabled) return;
|
|
254
|
-
const isSelected = selectedValues.includes(opt.value);
|
|
255
|
-
if (!isSelected && atMax) return;
|
|
256
|
-
toggleValue(opt.value);
|
|
257
|
-
if (!isMultiple) {
|
|
258
|
-
setOpen(false);
|
|
259
|
-
triggerRef.current?.focus();
|
|
260
|
-
}
|
|
261
|
-
};
|
|
262
|
-
|
|
263
|
-
const handleClearAll = (e: React.MouseEvent) => {
|
|
264
|
-
e.stopPropagation();
|
|
265
|
-
if (isMultiple) emit([]);
|
|
266
|
-
else emit('');
|
|
267
|
-
};
|
|
268
|
-
|
|
269
|
-
const handleRemoveChip = (e: React.MouseEvent, v: string) => {
|
|
270
|
-
e.stopPropagation();
|
|
271
|
-
if (!isMultiple) return;
|
|
272
|
-
const current = Array.isArray(value) ? value : [];
|
|
273
|
-
emit(current.filter(x => x !== v));
|
|
274
|
-
};
|
|
275
|
-
|
|
276
|
-
const handleSelectAll = () => {
|
|
277
|
-
if (!isMultiple) return;
|
|
278
|
-
const selectable = filtered.filter(o => !o.disabled).map(o => o.value);
|
|
279
|
-
const merged = Array.from(new Set([...(Array.isArray(value) ? value : []), ...selectable]));
|
|
280
|
-
const limited = maxSelections !== undefined ? merged.slice(0, maxSelections) : merged;
|
|
281
|
-
emit(limited);
|
|
282
|
-
};
|
|
283
|
-
|
|
284
|
-
const hasSelection = isMultiple ? selectedValues.length > 0 : !!value;
|
|
285
|
-
|
|
286
|
-
const renderTriggerContent = () => {
|
|
287
|
-
if (!hasSelection) {
|
|
288
|
-
return (
|
|
289
|
-
<span className={styles.triggerPlaceholder}>
|
|
290
|
-
<span className={styles.triggerText}>{placeholder}</span>
|
|
291
|
-
</span>
|
|
292
|
-
);
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
if (!isMultiple) {
|
|
296
|
-
return (
|
|
297
|
-
<span className={styles.triggerValue}>
|
|
298
|
-
{selectedOption?.icon && (
|
|
299
|
-
<span className={styles.triggerIcon}>{selectedOption.icon}</span>
|
|
300
|
-
)}
|
|
301
|
-
<span className={styles.triggerText}>{selectedOption?.label}</span>
|
|
302
|
-
</span>
|
|
303
|
-
);
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
if (multipleDisplay === 'count') {
|
|
307
|
-
const first = selectedOptions[0]?.label ?? '';
|
|
308
|
-
const more = selectedOptions.length - 1;
|
|
309
|
-
return (
|
|
310
|
-
<span className={styles.triggerValue}>
|
|
311
|
-
<span className={styles.triggerText}>
|
|
312
|
-
{first}{more > 0 && <span className={styles.countMore}>, +{more} more</span>}
|
|
313
|
-
</span>
|
|
314
|
-
</span>
|
|
315
|
-
);
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
return (
|
|
319
|
-
<span className={styles.chipRow}>
|
|
320
|
-
{selectedOptions.map(opt => (
|
|
321
|
-
<span key={opt.value} className={styles.chip}>
|
|
322
|
-
{opt.icon && <span className={styles.chipIcon}>{opt.icon}</span>}
|
|
323
|
-
<span className={styles.chipLabel}>{opt.label}</span>
|
|
324
|
-
{!disabled && (
|
|
325
|
-
<span
|
|
326
|
-
role="button"
|
|
327
|
-
tabIndex={-1}
|
|
328
|
-
aria-label={`Remove ${opt.label}`}
|
|
329
|
-
className={styles.chipRemove}
|
|
330
|
-
onClick={(e) => handleRemoveChip(e, opt.value)}
|
|
331
|
-
onMouseDown={(e) => e.preventDefault()}
|
|
332
|
-
>
|
|
333
|
-
<ClearIcon />
|
|
334
|
-
</span>
|
|
335
|
-
)}
|
|
336
|
-
</span>
|
|
337
|
-
))}
|
|
338
|
-
</span>
|
|
339
|
-
);
|
|
340
|
-
};
|
|
341
|
-
|
|
342
|
-
return (
|
|
343
|
-
<div
|
|
344
|
-
ref={wrapperRef}
|
|
345
|
-
className={[
|
|
346
|
-
styles.field,
|
|
347
|
-
fullWidth ? styles.fullWidth : '',
|
|
348
|
-
disabled ? styles.disabled : '',
|
|
349
|
-
className,
|
|
350
|
-
].filter(Boolean).join(' ')}
|
|
351
|
-
>
|
|
352
|
-
{label && (
|
|
353
|
-
<label htmlFor={selectId} className={styles.label}>
|
|
354
|
-
{label}
|
|
355
|
-
</label>
|
|
356
|
-
)}
|
|
357
|
-
|
|
358
|
-
<div className={styles.selectWrapper}>
|
|
359
|
-
<button
|
|
360
|
-
ref={triggerRef}
|
|
361
|
-
id={selectId}
|
|
362
|
-
type="button"
|
|
363
|
-
role="combobox"
|
|
364
|
-
aria-haspopup="listbox"
|
|
365
|
-
aria-expanded={open}
|
|
366
|
-
aria-controls={listId}
|
|
367
|
-
aria-invalid={!!error}
|
|
368
|
-
aria-multiselectable={isMultiple || undefined}
|
|
369
|
-
disabled={disabled}
|
|
370
|
-
className={[
|
|
371
|
-
styles.trigger,
|
|
372
|
-
styles[size],
|
|
373
|
-
open ? styles.open : '',
|
|
374
|
-
error ? styles.hasError : '',
|
|
375
|
-
isMultiple && multipleDisplay === 'chips' && selectedValues.length > 0 ? styles.triggerChips : '',
|
|
376
|
-
].filter(Boolean).join(' ')}
|
|
377
|
-
onClick={() => !disabled && setOpen(o => !o)}
|
|
378
|
-
onKeyDown={handleKeyDown}
|
|
379
|
-
>
|
|
380
|
-
{renderTriggerContent()}
|
|
381
|
-
|
|
382
|
-
<span className={styles.triggerActions}>
|
|
383
|
-
{clearable && hasSelection && !disabled && (
|
|
384
|
-
<span
|
|
385
|
-
role="button"
|
|
386
|
-
tabIndex={-1}
|
|
387
|
-
aria-label="Clear selection"
|
|
388
|
-
className={styles.clearBtn}
|
|
389
|
-
onClick={handleClearAll}
|
|
390
|
-
onMouseDown={(e) => e.preventDefault()}
|
|
391
|
-
>
|
|
392
|
-
<ClearIcon />
|
|
393
|
-
</span>
|
|
394
|
-
)}
|
|
395
|
-
<span className={[styles.chevron, open ? styles.chevronOpen : ''].filter(Boolean).join(' ')}>
|
|
396
|
-
<ChevronIcon />
|
|
397
|
-
</span>
|
|
398
|
-
</span>
|
|
399
|
-
</button>
|
|
400
|
-
|
|
401
|
-
{open && (
|
|
402
|
-
<div
|
|
403
|
-
className={styles.menu}
|
|
404
|
-
role="listbox"
|
|
405
|
-
id={listId}
|
|
406
|
-
aria-labelledby={selectId}
|
|
407
|
-
aria-multiselectable={isMultiple || undefined}
|
|
408
|
-
aria-activedescendant={activeIdx >= 0 ? `${selectId}-opt-${activeIdx}` : undefined}
|
|
409
|
-
>
|
|
410
|
-
{searchable && (
|
|
411
|
-
<div className={styles.searchRow}>
|
|
412
|
-
<span className={styles.searchIconWrap}><SearchIcon /></span>
|
|
413
|
-
<input
|
|
414
|
-
ref={searchRef}
|
|
415
|
-
type="text"
|
|
416
|
-
className={styles.searchInput}
|
|
417
|
-
placeholder="Search..."
|
|
418
|
-
value={query}
|
|
419
|
-
onChange={(e) => { setQuery(e.target.value); setActiveIdx(0); }}
|
|
420
|
-
onKeyDown={handleKeyDown}
|
|
421
|
-
/>
|
|
422
|
-
</div>
|
|
423
|
-
)}
|
|
424
|
-
|
|
425
|
-
{isMultiple && showSelectAll && filtered.length > 0 && (
|
|
426
|
-
<div className={styles.bulkRow}>
|
|
427
|
-
<button
|
|
428
|
-
type="button"
|
|
429
|
-
className={styles.bulkBtn}
|
|
430
|
-
onClick={handleSelectAll}
|
|
431
|
-
disabled={maxSelections !== undefined && selectedValues.length >= maxSelections}
|
|
432
|
-
>
|
|
433
|
-
Select all
|
|
434
|
-
</button>
|
|
435
|
-
<button
|
|
436
|
-
type="button"
|
|
437
|
-
className={styles.bulkBtn}
|
|
438
|
-
onClick={() => emit([])}
|
|
439
|
-
disabled={selectedValues.length === 0}
|
|
440
|
-
>
|
|
441
|
-
Clear all
|
|
442
|
-
</button>
|
|
443
|
-
{maxSelections !== undefined && (
|
|
444
|
-
<span className={styles.bulkCount}>
|
|
445
|
-
{selectedValues.length} / {maxSelections}
|
|
446
|
-
</span>
|
|
447
|
-
)}
|
|
448
|
-
</div>
|
|
449
|
-
)}
|
|
450
|
-
|
|
451
|
-
<div className={styles.list} ref={listRef} role="presentation">
|
|
452
|
-
{filtered.length === 0 ? (
|
|
453
|
-
<div className={styles.empty}>No results</div>
|
|
454
|
-
) : (
|
|
455
|
-
filtered.map((opt, idx) => {
|
|
456
|
-
const isSelected = selectedValues.includes(opt.value);
|
|
457
|
-
const isActive = idx === activeIdx;
|
|
458
|
-
const reachedMax = !isSelected && atMax;
|
|
459
|
-
const rowDisabled = opt.disabled || reachedMax;
|
|
460
|
-
return (
|
|
461
|
-
<div
|
|
462
|
-
key={opt.value}
|
|
463
|
-
id={`${selectId}-opt-${idx}`}
|
|
464
|
-
role="option"
|
|
465
|
-
aria-selected={isSelected}
|
|
466
|
-
aria-disabled={rowDisabled}
|
|
467
|
-
data-idx={idx}
|
|
468
|
-
className={[
|
|
469
|
-
styles.option,
|
|
470
|
-
isSelected ? styles.optionSelected : '',
|
|
471
|
-
isActive ? styles.optionActive : '',
|
|
472
|
-
rowDisabled ? styles.optionDisabled : '',
|
|
473
|
-
].filter(Boolean).join(' ')}
|
|
474
|
-
onClick={() => !rowDisabled && handleSelect(opt)}
|
|
475
|
-
onMouseEnter={() => !rowDisabled && setActiveIdx(idx)}
|
|
476
|
-
>
|
|
477
|
-
{isMultiple && (
|
|
478
|
-
<span
|
|
479
|
-
className={[styles.checkbox, isSelected ? styles.checkboxChecked : ''].filter(Boolean).join(' ')}
|
|
480
|
-
aria-hidden="true"
|
|
481
|
-
>
|
|
482
|
-
{isSelected && <CheckIcon />}
|
|
483
|
-
</span>
|
|
484
|
-
)}
|
|
485
|
-
{opt.icon && <span className={styles.optionIcon}>{opt.icon}</span>}
|
|
486
|
-
<span className={styles.optionLabel}>
|
|
487
|
-
<span className={styles.optionTitle}>{opt.label}</span>
|
|
488
|
-
{opt.description && (
|
|
489
|
-
<span className={styles.optionDesc}>{opt.description}</span>
|
|
490
|
-
)}
|
|
491
|
-
</span>
|
|
492
|
-
{!isMultiple && (
|
|
493
|
-
<span className={styles.check} aria-hidden>
|
|
494
|
-
{isSelected && <CheckIcon />}
|
|
495
|
-
</span>
|
|
496
|
-
)}
|
|
497
|
-
</div>
|
|
498
|
-
);
|
|
499
|
-
})
|
|
500
|
-
)}
|
|
501
|
-
</div>
|
|
502
|
-
</div>
|
|
503
|
-
)}
|
|
504
|
-
|
|
505
|
-
{name && !isMultiple && <input type="hidden" name={name} value={value as string} />}
|
|
506
|
-
{name && isMultiple && selectedValues.map(v => (
|
|
507
|
-
<input key={v} type="hidden" name={name} value={v} />
|
|
508
|
-
))}
|
|
509
|
-
</div>
|
|
510
|
-
|
|
511
|
-
{error && <p className={styles.errorText}>{error}</p>}
|
|
512
|
-
{!error && helperText && <p className={styles.helperText}>{helperText}</p>}
|
|
513
|
-
</div>
|
|
514
|
-
);
|
|
515
|
-
};
|
|
1
|
+
import React, { useCallback, useEffect, useId, useRef, useState } from 'react';
|
|
2
|
+
import styles from '../css/select.module.scss';
|
|
3
|
+
|
|
4
|
+
export interface SelectOption {
|
|
5
|
+
value: string;
|
|
6
|
+
label: string;
|
|
7
|
+
description?: string;
|
|
8
|
+
icon?: React.ReactNode;
|
|
9
|
+
disabled?: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface EvoSelectBaseProps {
|
|
13
|
+
label?: string;
|
|
14
|
+
options: SelectOption[];
|
|
15
|
+
placeholder?: string;
|
|
16
|
+
helperText?: string;
|
|
17
|
+
error?: string;
|
|
18
|
+
size?: 'sm' | 'md' | 'lg';
|
|
19
|
+
fullWidth?: boolean;
|
|
20
|
+
disabled?: boolean;
|
|
21
|
+
searchable?: boolean;
|
|
22
|
+
clearable?: boolean;
|
|
23
|
+
id?: string;
|
|
24
|
+
name?: string;
|
|
25
|
+
className?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface EvoSelectSingleProps extends EvoSelectBaseProps {
|
|
29
|
+
multiple?: false;
|
|
30
|
+
value?: string;
|
|
31
|
+
defaultValue?: string;
|
|
32
|
+
onChange?: (value: string) => void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface EvoSelectMultipleProps extends EvoSelectBaseProps {
|
|
36
|
+
multiple: true;
|
|
37
|
+
value?: string[];
|
|
38
|
+
defaultValue?: string[];
|
|
39
|
+
onChange?: (value: string[]) => void;
|
|
40
|
+
/** How the trigger displays selected items. Defaults to 'chips'. */
|
|
41
|
+
multipleDisplay?: 'chips' | 'count';
|
|
42
|
+
/** Maximum number of options that can be selected at once. */
|
|
43
|
+
maxSelections?: number;
|
|
44
|
+
/** Show Select-all / Clear-all buttons at the top of the menu. */
|
|
45
|
+
showSelectAll?: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type EvoSelectProps = EvoSelectSingleProps | EvoSelectMultipleProps;
|
|
49
|
+
|
|
50
|
+
const ChevronIcon = () => (
|
|
51
|
+
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" aria-hidden="true">
|
|
52
|
+
<path d="M4 6l4 4 4-4" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" />
|
|
53
|
+
</svg>
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
const CheckIcon = () => (
|
|
57
|
+
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" aria-hidden="true">
|
|
58
|
+
<path d="M3.5 8.5l3 3 6-7" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" />
|
|
59
|
+
</svg>
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const ClearIcon = () => (
|
|
63
|
+
<svg viewBox="0 0 16 16" width="12" height="12" fill="none" aria-hidden="true">
|
|
64
|
+
<path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" />
|
|
65
|
+
</svg>
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const SearchIcon = () => (
|
|
69
|
+
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" aria-hidden="true">
|
|
70
|
+
<circle cx="7" cy="7" r="4.5" stroke="currentColor" strokeWidth="1.5" />
|
|
71
|
+
<path d="M10.5 10.5L13 13" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
|
72
|
+
</svg>
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
export const EvoSelect = (props: EvoSelectProps) => {
|
|
76
|
+
const {
|
|
77
|
+
label,
|
|
78
|
+
options,
|
|
79
|
+
placeholder = 'Select an option',
|
|
80
|
+
helperText,
|
|
81
|
+
error,
|
|
82
|
+
size = 'md',
|
|
83
|
+
fullWidth = false,
|
|
84
|
+
disabled = false,
|
|
85
|
+
searchable = false,
|
|
86
|
+
clearable = false,
|
|
87
|
+
id,
|
|
88
|
+
name,
|
|
89
|
+
className = '',
|
|
90
|
+
} = props;
|
|
91
|
+
|
|
92
|
+
const isMultiple = props.multiple === true;
|
|
93
|
+
const multipleDisplay = isMultiple ? (props.multipleDisplay ?? 'chips') : 'chips';
|
|
94
|
+
const maxSelections = isMultiple ? props.maxSelections : undefined;
|
|
95
|
+
const showSelectAll = isMultiple ? (props.showSelectAll ?? false) : false;
|
|
96
|
+
|
|
97
|
+
const reactId = useId();
|
|
98
|
+
const selectId = id ?? `evo-select-${reactId}`;
|
|
99
|
+
const listId = `${selectId}-listbox`;
|
|
100
|
+
|
|
101
|
+
const controlledValue = props.value as string | string[] | undefined;
|
|
102
|
+
const defaultValue = props.defaultValue as string | string[] | undefined;
|
|
103
|
+
|
|
104
|
+
const isControlled = controlledValue !== undefined;
|
|
105
|
+
const initial: string | string[] = isMultiple
|
|
106
|
+
? (Array.isArray(defaultValue) ? defaultValue : [])
|
|
107
|
+
: (typeof defaultValue === 'string' ? defaultValue : '');
|
|
108
|
+
const [internalValue, setInternalValue] = useState<string | string[]>(initial);
|
|
109
|
+
const value = isControlled ? controlledValue! : internalValue;
|
|
110
|
+
|
|
111
|
+
const selectedValues: string[] = isMultiple
|
|
112
|
+
? (Array.isArray(value) ? value : [])
|
|
113
|
+
: (typeof value === 'string' && value ? [value] : []);
|
|
114
|
+
|
|
115
|
+
const [open, setOpen] = useState(false);
|
|
116
|
+
const [activeIdx, setActiveIdx] = useState(-1);
|
|
117
|
+
const [query, setQuery] = useState('');
|
|
118
|
+
|
|
119
|
+
const wrapperRef = useRef<HTMLDivElement>(null);
|
|
120
|
+
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
121
|
+
const searchRef = useRef<HTMLInputElement>(null);
|
|
122
|
+
const listRef = useRef<HTMLDivElement>(null);
|
|
123
|
+
|
|
124
|
+
const filtered = searchable && query
|
|
125
|
+
? options.filter(o => o.label.toLowerCase().includes(query.toLowerCase()))
|
|
126
|
+
: options;
|
|
127
|
+
|
|
128
|
+
const selectedOption = !isMultiple
|
|
129
|
+
? options.find(o => o.value === value)
|
|
130
|
+
: undefined;
|
|
131
|
+
|
|
132
|
+
const selectedOptions = isMultiple
|
|
133
|
+
? options.filter(o => selectedValues.includes(o.value))
|
|
134
|
+
: [];
|
|
135
|
+
|
|
136
|
+
const atMax = isMultiple && maxSelections !== undefined && selectedValues.length >= maxSelections;
|
|
137
|
+
|
|
138
|
+
const emit = useCallback((next: string | string[]) => {
|
|
139
|
+
if (!isControlled) setInternalValue(next);
|
|
140
|
+
if (isMultiple) {
|
|
141
|
+
(props.onChange as ((v: string[]) => void) | undefined)?.(next as string[]);
|
|
142
|
+
} else {
|
|
143
|
+
(props.onChange as ((v: string) => void) | undefined)?.(next as string);
|
|
144
|
+
}
|
|
145
|
+
}, [isControlled, isMultiple, props.onChange]);
|
|
146
|
+
|
|
147
|
+
const toggleValue = useCallback((v: string) => {
|
|
148
|
+
if (isMultiple) {
|
|
149
|
+
const current = Array.isArray(value) ? value : [];
|
|
150
|
+
if (current.includes(v)) {
|
|
151
|
+
emit(current.filter(x => x !== v));
|
|
152
|
+
} else {
|
|
153
|
+
if (maxSelections !== undefined && current.length >= maxSelections) return;
|
|
154
|
+
emit([...current, v]);
|
|
155
|
+
}
|
|
156
|
+
} else {
|
|
157
|
+
emit(v);
|
|
158
|
+
}
|
|
159
|
+
}, [emit, isMultiple, maxSelections, value]);
|
|
160
|
+
|
|
161
|
+
useEffect(() => {
|
|
162
|
+
if (!open) return;
|
|
163
|
+
const handler = (e: MouseEvent) => {
|
|
164
|
+
if (!wrapperRef.current?.contains(e.target as Node)) {
|
|
165
|
+
setOpen(false);
|
|
166
|
+
setQuery('');
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
document.addEventListener('mousedown', handler);
|
|
170
|
+
return () => document.removeEventListener('mousedown', handler);
|
|
171
|
+
}, [open]);
|
|
172
|
+
|
|
173
|
+
useEffect(() => {
|
|
174
|
+
if (open) {
|
|
175
|
+
const firstSelectedIdx = filtered.findIndex(o => selectedValues.includes(o.value));
|
|
176
|
+
setActiveIdx(firstSelectedIdx >= 0 ? firstSelectedIdx : filtered.findIndex(o => !o.disabled));
|
|
177
|
+
if (searchable) {
|
|
178
|
+
const t = setTimeout(() => searchRef.current?.focus(), 30);
|
|
179
|
+
return () => clearTimeout(t);
|
|
180
|
+
}
|
|
181
|
+
} else {
|
|
182
|
+
setQuery('');
|
|
183
|
+
setActiveIdx(-1);
|
|
184
|
+
}
|
|
185
|
+
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
186
|
+
|
|
187
|
+
useEffect(() => {
|
|
188
|
+
if (!open || activeIdx < 0) return;
|
|
189
|
+
const el = listRef.current?.querySelector(`[data-idx="${activeIdx}"]`) as HTMLElement | null;
|
|
190
|
+
el?.scrollIntoView({ block: 'nearest' });
|
|
191
|
+
}, [activeIdx, open]);
|
|
192
|
+
|
|
193
|
+
const moveActive = (dir: 1 | -1) => {
|
|
194
|
+
if (filtered.length === 0) return;
|
|
195
|
+
let next = activeIdx;
|
|
196
|
+
for (let i = 0; i < filtered.length; i++) {
|
|
197
|
+
next = (next + dir + filtered.length) % filtered.length;
|
|
198
|
+
if (!filtered[next].disabled) {
|
|
199
|
+
setActiveIdx(next);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
206
|
+
if (disabled) return;
|
|
207
|
+
|
|
208
|
+
if (!open) {
|
|
209
|
+
if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
|
210
|
+
e.preventDefault();
|
|
211
|
+
setOpen(true);
|
|
212
|
+
}
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (e.key === 'Escape') {
|
|
217
|
+
e.preventDefault();
|
|
218
|
+
setOpen(false);
|
|
219
|
+
triggerRef.current?.focus();
|
|
220
|
+
} else if (e.key === 'ArrowDown') {
|
|
221
|
+
e.preventDefault();
|
|
222
|
+
moveActive(1);
|
|
223
|
+
} else if (e.key === 'ArrowUp') {
|
|
224
|
+
e.preventDefault();
|
|
225
|
+
moveActive(-1);
|
|
226
|
+
} else if (e.key === 'Enter') {
|
|
227
|
+
e.preventDefault();
|
|
228
|
+
const opt = filtered[activeIdx];
|
|
229
|
+
if (opt && !opt.disabled) {
|
|
230
|
+
const isSelected = selectedValues.includes(opt.value);
|
|
231
|
+
if (!isSelected && atMax) return;
|
|
232
|
+
toggleValue(opt.value);
|
|
233
|
+
if (!isMultiple) {
|
|
234
|
+
setOpen(false);
|
|
235
|
+
triggerRef.current?.focus();
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
} else if (e.key === 'Home') {
|
|
239
|
+
e.preventDefault();
|
|
240
|
+
const idx = filtered.findIndex(o => !o.disabled);
|
|
241
|
+
if (idx >= 0) setActiveIdx(idx);
|
|
242
|
+
} else if (e.key === 'End') {
|
|
243
|
+
e.preventDefault();
|
|
244
|
+
for (let i = filtered.length - 1; i >= 0; i--) {
|
|
245
|
+
if (!filtered[i].disabled) { setActiveIdx(i); break; }
|
|
246
|
+
}
|
|
247
|
+
} else if (e.key === 'Tab') {
|
|
248
|
+
setOpen(false);
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const handleSelect = (opt: SelectOption) => {
|
|
253
|
+
if (opt.disabled) return;
|
|
254
|
+
const isSelected = selectedValues.includes(opt.value);
|
|
255
|
+
if (!isSelected && atMax) return;
|
|
256
|
+
toggleValue(opt.value);
|
|
257
|
+
if (!isMultiple) {
|
|
258
|
+
setOpen(false);
|
|
259
|
+
triggerRef.current?.focus();
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
const handleClearAll = (e: React.MouseEvent) => {
|
|
264
|
+
e.stopPropagation();
|
|
265
|
+
if (isMultiple) emit([]);
|
|
266
|
+
else emit('');
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
const handleRemoveChip = (e: React.MouseEvent, v: string) => {
|
|
270
|
+
e.stopPropagation();
|
|
271
|
+
if (!isMultiple) return;
|
|
272
|
+
const current = Array.isArray(value) ? value : [];
|
|
273
|
+
emit(current.filter(x => x !== v));
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
const handleSelectAll = () => {
|
|
277
|
+
if (!isMultiple) return;
|
|
278
|
+
const selectable = filtered.filter(o => !o.disabled).map(o => o.value);
|
|
279
|
+
const merged = Array.from(new Set([...(Array.isArray(value) ? value : []), ...selectable]));
|
|
280
|
+
const limited = maxSelections !== undefined ? merged.slice(0, maxSelections) : merged;
|
|
281
|
+
emit(limited);
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
const hasSelection = isMultiple ? selectedValues.length > 0 : !!value;
|
|
285
|
+
|
|
286
|
+
const renderTriggerContent = () => {
|
|
287
|
+
if (!hasSelection) {
|
|
288
|
+
return (
|
|
289
|
+
<span className={styles.triggerPlaceholder}>
|
|
290
|
+
<span className={styles.triggerText}>{placeholder}</span>
|
|
291
|
+
</span>
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (!isMultiple) {
|
|
296
|
+
return (
|
|
297
|
+
<span className={styles.triggerValue}>
|
|
298
|
+
{selectedOption?.icon && (
|
|
299
|
+
<span className={styles.triggerIcon}>{selectedOption.icon}</span>
|
|
300
|
+
)}
|
|
301
|
+
<span className={styles.triggerText}>{selectedOption?.label}</span>
|
|
302
|
+
</span>
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (multipleDisplay === 'count') {
|
|
307
|
+
const first = selectedOptions[0]?.label ?? '';
|
|
308
|
+
const more = selectedOptions.length - 1;
|
|
309
|
+
return (
|
|
310
|
+
<span className={styles.triggerValue}>
|
|
311
|
+
<span className={styles.triggerText}>
|
|
312
|
+
{first}{more > 0 && <span className={styles.countMore}>, +{more} more</span>}
|
|
313
|
+
</span>
|
|
314
|
+
</span>
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return (
|
|
319
|
+
<span className={styles.chipRow}>
|
|
320
|
+
{selectedOptions.map(opt => (
|
|
321
|
+
<span key={opt.value} className={styles.chip}>
|
|
322
|
+
{opt.icon && <span className={styles.chipIcon}>{opt.icon}</span>}
|
|
323
|
+
<span className={styles.chipLabel}>{opt.label}</span>
|
|
324
|
+
{!disabled && (
|
|
325
|
+
<span
|
|
326
|
+
role="button"
|
|
327
|
+
tabIndex={-1}
|
|
328
|
+
aria-label={`Remove ${opt.label}`}
|
|
329
|
+
className={styles.chipRemove}
|
|
330
|
+
onClick={(e) => handleRemoveChip(e, opt.value)}
|
|
331
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
332
|
+
>
|
|
333
|
+
<ClearIcon />
|
|
334
|
+
</span>
|
|
335
|
+
)}
|
|
336
|
+
</span>
|
|
337
|
+
))}
|
|
338
|
+
</span>
|
|
339
|
+
);
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
return (
|
|
343
|
+
<div
|
|
344
|
+
ref={wrapperRef}
|
|
345
|
+
className={[
|
|
346
|
+
styles.field,
|
|
347
|
+
fullWidth ? styles.fullWidth : '',
|
|
348
|
+
disabled ? styles.disabled : '',
|
|
349
|
+
className,
|
|
350
|
+
].filter(Boolean).join(' ')}
|
|
351
|
+
>
|
|
352
|
+
{label && (
|
|
353
|
+
<label htmlFor={selectId} className={styles.label}>
|
|
354
|
+
{label}
|
|
355
|
+
</label>
|
|
356
|
+
)}
|
|
357
|
+
|
|
358
|
+
<div className={styles.selectWrapper}>
|
|
359
|
+
<button
|
|
360
|
+
ref={triggerRef}
|
|
361
|
+
id={selectId}
|
|
362
|
+
type="button"
|
|
363
|
+
role="combobox"
|
|
364
|
+
aria-haspopup="listbox"
|
|
365
|
+
aria-expanded={open}
|
|
366
|
+
aria-controls={listId}
|
|
367
|
+
aria-invalid={!!error}
|
|
368
|
+
aria-multiselectable={isMultiple || undefined}
|
|
369
|
+
disabled={disabled}
|
|
370
|
+
className={[
|
|
371
|
+
styles.trigger,
|
|
372
|
+
styles[size],
|
|
373
|
+
open ? styles.open : '',
|
|
374
|
+
error ? styles.hasError : '',
|
|
375
|
+
isMultiple && multipleDisplay === 'chips' && selectedValues.length > 0 ? styles.triggerChips : '',
|
|
376
|
+
].filter(Boolean).join(' ')}
|
|
377
|
+
onClick={() => !disabled && setOpen(o => !o)}
|
|
378
|
+
onKeyDown={handleKeyDown}
|
|
379
|
+
>
|
|
380
|
+
{renderTriggerContent()}
|
|
381
|
+
|
|
382
|
+
<span className={styles.triggerActions}>
|
|
383
|
+
{clearable && hasSelection && !disabled && (
|
|
384
|
+
<span
|
|
385
|
+
role="button"
|
|
386
|
+
tabIndex={-1}
|
|
387
|
+
aria-label="Clear selection"
|
|
388
|
+
className={styles.clearBtn}
|
|
389
|
+
onClick={handleClearAll}
|
|
390
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
391
|
+
>
|
|
392
|
+
<ClearIcon />
|
|
393
|
+
</span>
|
|
394
|
+
)}
|
|
395
|
+
<span className={[styles.chevron, open ? styles.chevronOpen : ''].filter(Boolean).join(' ')}>
|
|
396
|
+
<ChevronIcon />
|
|
397
|
+
</span>
|
|
398
|
+
</span>
|
|
399
|
+
</button>
|
|
400
|
+
|
|
401
|
+
{open && (
|
|
402
|
+
<div
|
|
403
|
+
className={styles.menu}
|
|
404
|
+
role="listbox"
|
|
405
|
+
id={listId}
|
|
406
|
+
aria-labelledby={selectId}
|
|
407
|
+
aria-multiselectable={isMultiple || undefined}
|
|
408
|
+
aria-activedescendant={activeIdx >= 0 ? `${selectId}-opt-${activeIdx}` : undefined}
|
|
409
|
+
>
|
|
410
|
+
{searchable && (
|
|
411
|
+
<div className={styles.searchRow}>
|
|
412
|
+
<span className={styles.searchIconWrap}><SearchIcon /></span>
|
|
413
|
+
<input
|
|
414
|
+
ref={searchRef}
|
|
415
|
+
type="text"
|
|
416
|
+
className={styles.searchInput}
|
|
417
|
+
placeholder="Search..."
|
|
418
|
+
value={query}
|
|
419
|
+
onChange={(e) => { setQuery(e.target.value); setActiveIdx(0); }}
|
|
420
|
+
onKeyDown={handleKeyDown}
|
|
421
|
+
/>
|
|
422
|
+
</div>
|
|
423
|
+
)}
|
|
424
|
+
|
|
425
|
+
{isMultiple && showSelectAll && filtered.length > 0 && (
|
|
426
|
+
<div className={styles.bulkRow}>
|
|
427
|
+
<button
|
|
428
|
+
type="button"
|
|
429
|
+
className={styles.bulkBtn}
|
|
430
|
+
onClick={handleSelectAll}
|
|
431
|
+
disabled={maxSelections !== undefined && selectedValues.length >= maxSelections}
|
|
432
|
+
>
|
|
433
|
+
Select all
|
|
434
|
+
</button>
|
|
435
|
+
<button
|
|
436
|
+
type="button"
|
|
437
|
+
className={styles.bulkBtn}
|
|
438
|
+
onClick={() => emit([])}
|
|
439
|
+
disabled={selectedValues.length === 0}
|
|
440
|
+
>
|
|
441
|
+
Clear all
|
|
442
|
+
</button>
|
|
443
|
+
{maxSelections !== undefined && (
|
|
444
|
+
<span className={styles.bulkCount}>
|
|
445
|
+
{selectedValues.length} / {maxSelections}
|
|
446
|
+
</span>
|
|
447
|
+
)}
|
|
448
|
+
</div>
|
|
449
|
+
)}
|
|
450
|
+
|
|
451
|
+
<div className={styles.list} ref={listRef} role="presentation">
|
|
452
|
+
{filtered.length === 0 ? (
|
|
453
|
+
<div className={styles.empty}>No results</div>
|
|
454
|
+
) : (
|
|
455
|
+
filtered.map((opt, idx) => {
|
|
456
|
+
const isSelected = selectedValues.includes(opt.value);
|
|
457
|
+
const isActive = idx === activeIdx;
|
|
458
|
+
const reachedMax = !isSelected && atMax;
|
|
459
|
+
const rowDisabled = opt.disabled || reachedMax;
|
|
460
|
+
return (
|
|
461
|
+
<div
|
|
462
|
+
key={opt.value}
|
|
463
|
+
id={`${selectId}-opt-${idx}`}
|
|
464
|
+
role="option"
|
|
465
|
+
aria-selected={isSelected}
|
|
466
|
+
aria-disabled={rowDisabled}
|
|
467
|
+
data-idx={idx}
|
|
468
|
+
className={[
|
|
469
|
+
styles.option,
|
|
470
|
+
isSelected ? styles.optionSelected : '',
|
|
471
|
+
isActive ? styles.optionActive : '',
|
|
472
|
+
rowDisabled ? styles.optionDisabled : '',
|
|
473
|
+
].filter(Boolean).join(' ')}
|
|
474
|
+
onClick={() => !rowDisabled && handleSelect(opt)}
|
|
475
|
+
onMouseEnter={() => !rowDisabled && setActiveIdx(idx)}
|
|
476
|
+
>
|
|
477
|
+
{isMultiple && (
|
|
478
|
+
<span
|
|
479
|
+
className={[styles.checkbox, isSelected ? styles.checkboxChecked : ''].filter(Boolean).join(' ')}
|
|
480
|
+
aria-hidden="true"
|
|
481
|
+
>
|
|
482
|
+
{isSelected && <CheckIcon />}
|
|
483
|
+
</span>
|
|
484
|
+
)}
|
|
485
|
+
{opt.icon && <span className={styles.optionIcon}>{opt.icon}</span>}
|
|
486
|
+
<span className={styles.optionLabel}>
|
|
487
|
+
<span className={styles.optionTitle}>{opt.label}</span>
|
|
488
|
+
{opt.description && (
|
|
489
|
+
<span className={styles.optionDesc}>{opt.description}</span>
|
|
490
|
+
)}
|
|
491
|
+
</span>
|
|
492
|
+
{!isMultiple && (
|
|
493
|
+
<span className={styles.check} aria-hidden>
|
|
494
|
+
{isSelected && <CheckIcon />}
|
|
495
|
+
</span>
|
|
496
|
+
)}
|
|
497
|
+
</div>
|
|
498
|
+
);
|
|
499
|
+
})
|
|
500
|
+
)}
|
|
501
|
+
</div>
|
|
502
|
+
</div>
|
|
503
|
+
)}
|
|
504
|
+
|
|
505
|
+
{name && !isMultiple && <input type="hidden" name={name} value={value as string} />}
|
|
506
|
+
{name && isMultiple && selectedValues.map(v => (
|
|
507
|
+
<input key={v} type="hidden" name={name} value={v} />
|
|
508
|
+
))}
|
|
509
|
+
</div>
|
|
510
|
+
|
|
511
|
+
{error && <p className={styles.errorText}>{error}</p>}
|
|
512
|
+
{!error && helperText && <p className={styles.helperText}>{helperText}</p>}
|
|
513
|
+
</div>
|
|
514
|
+
);
|
|
515
|
+
};
|