@ecohouse/ui 0.1.2 → 0.1.4

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 (74) hide show
  1. package/dist/index.cjs +1734 -155
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +287 -13
  4. package/dist/index.d.ts +287 -13
  5. package/dist/index.js +1720 -159
  6. package/dist/index.js.map +1 -1
  7. package/package.json +1 -1
  8. package/src/components/Avatar/Avatar.stories.tsx +133 -0
  9. package/src/components/Avatar/Avatar.tsx +389 -0
  10. package/src/components/Avatar/index.ts +2 -0
  11. package/src/components/Button/Button.tsx +106 -88
  12. package/src/components/Checkbox/Checkbox.stories.tsx +112 -0
  13. package/src/components/Checkbox/Checkbox.tsx +150 -0
  14. package/src/components/Checkbox/index.ts +2 -0
  15. package/src/components/Input/Input.stories.tsx +11 -40
  16. package/src/components/Input/Input.tsx +58 -54
  17. package/src/components/Input/index.ts +1 -1
  18. package/src/components/Select/Select.stories.tsx +196 -0
  19. package/src/components/Select/Select.tsx +851 -0
  20. package/src/components/Select/index.ts +2 -0
  21. package/src/components/Textarea/Textarea.stories.tsx +71 -0
  22. package/src/components/Textarea/Textarea.tsx +241 -0
  23. package/src/components/Textarea/index.ts +2 -0
  24. package/src/components/Toggle/Toggle.stories.tsx +100 -0
  25. package/src/components/Toggle/Toggle.tsx +176 -0
  26. package/src/components/Toggle/index.ts +2 -0
  27. package/src/components/index.ts +16 -1
  28. package/src/icons/Calendar/CalendarIcon.tsx +21 -0
  29. package/src/icons/Calendar/CalendarIcon.web.tsx +27 -0
  30. package/src/icons/Calendar/calendarPath.ts +3 -0
  31. package/src/icons/Calendar/index.ts +1 -0
  32. package/src/icons/Check/CheckIcon.tsx +26 -0
  33. package/src/icons/Check/CheckIcon.web.tsx +32 -0
  34. package/src/icons/Check/checkPath.ts +2 -0
  35. package/src/icons/Check/index.ts +1 -0
  36. package/src/icons/ChevronDown/ChevronDownIcon.tsx +30 -0
  37. package/src/icons/ChevronDown/ChevronDownIcon.web.tsx +36 -0
  38. package/src/icons/ChevronDown/chevronDownPath.ts +2 -0
  39. package/src/icons/ChevronDown/index.ts +1 -0
  40. package/src/icons/Heart/HeartIcon.tsx +23 -0
  41. package/src/icons/Heart/HeartIcon.web.tsx +29 -0
  42. package/src/icons/Heart/heartPath.ts +3 -0
  43. package/src/icons/Heart/index.ts +1 -0
  44. package/src/icons/HelpCircle/HelpCircleIcon.tsx +25 -0
  45. package/src/icons/HelpCircle/HelpCircleIcon.web.tsx +31 -0
  46. package/src/icons/HelpCircle/helpCirclePath.ts +3 -0
  47. package/src/icons/HelpCircle/index.ts +1 -0
  48. package/src/icons/LayoutGrid/LayoutGridIcon.tsx +25 -0
  49. package/src/icons/LayoutGrid/LayoutGridIcon.web.tsx +31 -0
  50. package/src/icons/LayoutGrid/index.ts +1 -0
  51. package/src/icons/LayoutGrid/layoutGridPath.ts +3 -0
  52. package/src/icons/LogOut/LogOutIcon.tsx +21 -0
  53. package/src/icons/LogOut/LogOutIcon.web.tsx +27 -0
  54. package/src/icons/LogOut/index.ts +1 -0
  55. package/src/icons/LogOut/logOutPath.ts +3 -0
  56. package/src/icons/Search/SearchIcon.tsx +37 -0
  57. package/src/icons/Search/SearchIcon.web.tsx +43 -0
  58. package/src/icons/Search/index.ts +1 -0
  59. package/src/icons/Search/searchPath.ts +5 -0
  60. package/src/icons/Settings/SettingsIcon.tsx +21 -0
  61. package/src/icons/Settings/SettingsIcon.web.tsx +27 -0
  62. package/src/icons/Settings/index.ts +1 -0
  63. package/src/icons/Settings/settingsPath.ts +3 -0
  64. package/src/icons/Video/VideoIcon.tsx +24 -0
  65. package/src/icons/Video/VideoIcon.web.tsx +30 -0
  66. package/src/icons/Video/index.ts +1 -0
  67. package/src/icons/Video/videoPath.ts +5 -0
  68. package/src/icons/index.ts +10 -0
  69. package/src/icons/registry.ts +24 -3
  70. package/src/index.ts +31 -3
  71. package/src/theme/colors.ts +1 -0
  72. package/src/theme/index.ts +8 -1
  73. package/src/theme/typography.ts +77 -2
  74. package/src/utils/mergeRefs.ts +20 -0
@@ -0,0 +1,851 @@
1
+ import {
2
+ forwardRef,
3
+ useCallback,
4
+ useEffect,
5
+ useMemo,
6
+ useRef,
7
+ useState,
8
+ type ComponentRef,
9
+ type ReactNode,
10
+ } from "react";
11
+ import {
12
+ Platform,
13
+ Pressable,
14
+ ScrollView,
15
+ StyleSheet,
16
+ Text,
17
+ TextInput,
18
+ View,
19
+ type LayoutChangeEvent,
20
+ type StyleProp,
21
+ type TextStyle,
22
+ type ViewProps,
23
+ type ViewStyle,
24
+ } from "react-native";
25
+ import { Checkbox } from "../Checkbox";
26
+ import { ChevronDownIcon, SearchIcon, UserIcon } from "../../icons";
27
+ import { colors, selectTypography } from "../../theme";
28
+ import { mergeRefs } from "../../utils/mergeRefs";
29
+ import { useApplyWebClassName } from "../../utils/useApplyWebClassName";
30
+
31
+ export type SelectOption = {
32
+ label: string;
33
+ value: string;
34
+ };
35
+
36
+ type SelectBaseProps = Omit<ViewProps, "style" | "children"> & {
37
+ label?: string;
38
+ showLabel?: boolean;
39
+ hint?: string;
40
+ showHint?: boolean;
41
+ placeholder?: string;
42
+ options: SelectOption[];
43
+ open?: boolean;
44
+ defaultOpen?: boolean;
45
+ onOpenChange?: (open: boolean) => void;
46
+ disabled?: boolean;
47
+ error?: boolean;
48
+ leftIcon?: ReactNode;
49
+ showLeftIcon?: boolean;
50
+ showSearch?: boolean;
51
+ searchPlaceholder?: string;
52
+ /**
53
+ * Called with the raw query on every keystroke. When provided, the parent owns
54
+ * filtering (e.g. server search) and the options prop is rendered as-is;
55
+ * when omitted, options are filtered locally by label.
56
+ */
57
+ onSearchChange?: (query: string) => void;
58
+ /** Shows a loading row in the menu while the parent fetches options. */
59
+ loading?: boolean;
60
+ /** Text shown when no options match. */
61
+ emptyText?: string;
62
+ style?: StyleProp<ViewStyle>;
63
+ className?: string;
64
+ };
65
+
66
+ export type SelectSingleProps = SelectBaseProps & {
67
+ multiple?: false;
68
+ value?: string;
69
+ defaultValue?: string;
70
+ onValueChange?: (value: string) => void;
71
+ };
72
+
73
+ export type SelectMultipleProps = SelectBaseProps & {
74
+ multiple: true;
75
+ value?: string[];
76
+ defaultValue?: string[];
77
+ onValueChange?: (value: string[]) => void;
78
+ };
79
+
80
+ export type SelectProps = SelectSingleProps | SelectMultipleProps;
81
+
82
+ const DEFAULT_WIDTH = 308;
83
+ const TRIGGER_HEIGHT = 44;
84
+ const TRIGGER_RADIUS = 60;
85
+ const MENU_RADIUS = 24;
86
+ const MENU_MAX_LIST_HEIGHT = 248;
87
+ const ITEM_RADIUS = 12;
88
+ const ICON_SIZE = 20;
89
+ const CHIP_GAP = 6;
90
+ const FALLBACK_ELLIPSIS_WIDTH = 20;
91
+
92
+ type ItemVisualState = "default" | "hover" | "selected";
93
+
94
+ function itemBackground(state: ItemVisualState): string {
95
+ switch (state) {
96
+ case "hover":
97
+ return colors.grey600;
98
+ case "selected":
99
+ return colors.primaryMuted;
100
+ default:
101
+ return "transparent";
102
+ }
103
+ }
104
+
105
+ function toArray(value: string | string[] | undefined): string[] {
106
+ if (value == null || value === "") return [];
107
+ return Array.isArray(value) ? value : [value];
108
+ }
109
+
110
+ function resolveVisibleOptions(
111
+ options: SelectOption[],
112
+ containerWidth: number,
113
+ chipWidths: Record<string, number>,
114
+ ellipsisWidth: number,
115
+ ): { visible: SelectOption[]; hasOverflow: boolean } {
116
+ if (options.length === 0) return { visible: [], hasOverflow: false };
117
+ if (containerWidth <= 0) return { visible: options.slice(0, 1), hasOverflow: options.length > 1 };
118
+
119
+ const visible: SelectOption[] = [];
120
+ let used = 0;
121
+
122
+ for (let index = 0; index < options.length; index += 1) {
123
+ const option = options[index];
124
+ const chipWidth = chipWidths[option.value];
125
+ if (chipWidth == null) {
126
+ if (visible.length === 0) visible.push(option);
127
+ break;
128
+ }
129
+
130
+ const remainingAfter = options.length - index - 1;
131
+ const gap = visible.length > 0 ? CHIP_GAP : 0;
132
+ const reserve = remainingAfter > 0 ? CHIP_GAP + ellipsisWidth : 0;
133
+ const nextUsed = used + gap + chipWidth;
134
+
135
+ if (visible.length > 0 && nextUsed + reserve > containerWidth) {
136
+ return { visible, hasOverflow: true };
137
+ }
138
+
139
+ visible.push(option);
140
+ used = nextUsed;
141
+ }
142
+
143
+ return { visible, hasOverflow: visible.length < options.length };
144
+ }
145
+
146
+ type MultiValueChipsProps = {
147
+ options: SelectOption[];
148
+ disabled?: boolean;
149
+ onRemove: (value: string) => void;
150
+ };
151
+
152
+ function MultiValueChips({ options, disabled, onRemove }: MultiValueChipsProps) {
153
+ const [containerWidth, setContainerWidth] = useState(0);
154
+ const [chipWidths, setChipWidths] = useState<Record<string, number>>({});
155
+ const [ellipsisWidth, setEllipsisWidth] = useState(FALLBACK_ELLIPSIS_WIDTH);
156
+
157
+ useEffect(() => {
158
+ setChipWidths((current) => {
159
+ const validValues = new Set(options.map((option) => option.value));
160
+ const keys = Object.keys(current);
161
+ if (keys.every((key) => validValues.has(key))) return current;
162
+
163
+ const next: Record<string, number> = {};
164
+ for (const key of keys) {
165
+ if (validValues.has(key)) next[key] = current[key];
166
+ }
167
+ return next;
168
+ });
169
+ }, [options]);
170
+
171
+ const { visible, hasOverflow } = useMemo(
172
+ () => resolveVisibleOptions(options, containerWidth, chipWidths, ellipsisWidth),
173
+ [chipWidths, containerWidth, ellipsisWidth, options],
174
+ );
175
+
176
+ const handleContainerLayout = (event: LayoutChangeEvent) => {
177
+ const next = Math.floor(event.nativeEvent.layout.width);
178
+ setContainerWidth((current) => (current === next ? current : next));
179
+ };
180
+
181
+ const handleChipLayout = (value: string, event: LayoutChangeEvent) => {
182
+ const next = Math.ceil(event.nativeEvent.layout.width);
183
+ setChipWidths((current) => (current[value] === next ? current : { ...current, [value]: next }));
184
+ };
185
+
186
+ const handleEllipsisLayout = (event: LayoutChangeEvent) => {
187
+ const next = Math.ceil(event.nativeEvent.layout.width);
188
+ setEllipsisWidth((current) => (current === next ? current : next));
189
+ };
190
+
191
+ return (
192
+ <View style={styles.multiValueRoot}>
193
+ {/* Hidden copy of every chip; RN has no synchronous text measurement. */}
194
+ <View pointerEvents="none" style={styles.measureRow}>
195
+ {options.map((option) => (
196
+ <View
197
+ key={`measure-${option.value}`}
198
+ style={styles.chip}
199
+ onLayout={(event) => handleChipLayout(option.value, event)}
200
+ >
201
+ <Text style={styles.chipLabel} numberOfLines={1}>
202
+ {option.label}
203
+ </Text>
204
+ <Text style={styles.chipRemove}>×</Text>
205
+ </View>
206
+ ))}
207
+ <View style={styles.ellipsis} onLayout={handleEllipsisLayout}>
208
+ <Text style={styles.ellipsisText}>...</Text>
209
+ </View>
210
+ </View>
211
+
212
+ <View style={styles.chipsRow} onLayout={handleContainerLayout}>
213
+ {visible.map((option) => (
214
+ <View key={option.value} style={styles.chip}>
215
+ <Text style={styles.chipLabel} numberOfLines={1}>
216
+ {option.label}
217
+ </Text>
218
+ <Pressable
219
+ onPress={(event) => {
220
+ // Keep the surrounding trigger Pressable from toggling the menu.
221
+ event?.stopPropagation?.();
222
+ onRemove(option.value);
223
+ }}
224
+ disabled={disabled}
225
+ hitSlop={6}
226
+ accessibilityRole="button"
227
+ accessibilityLabel={`Remove ${option.label}`}
228
+ >
229
+ <Text style={styles.chipRemove}>×</Text>
230
+ </Pressable>
231
+ </View>
232
+ ))}
233
+
234
+ {hasOverflow ? (
235
+ <View style={styles.ellipsis}>
236
+ <Text style={styles.ellipsisText}>...</Text>
237
+ </View>
238
+ ) : null}
239
+ </View>
240
+ </View>
241
+ );
242
+ }
243
+
244
+ export const Select = forwardRef<ComponentRef<typeof View>, SelectProps>(
245
+ function Select(props, forwardedRef) {
246
+ const {
247
+ label = "Label",
248
+ showLabel = true,
249
+ hint,
250
+ showHint = false,
251
+ placeholder = "placeholder",
252
+ options,
253
+ open: openProp,
254
+ defaultOpen = false,
255
+ onOpenChange,
256
+ disabled = false,
257
+ error = false,
258
+ leftIcon,
259
+ showLeftIcon = true,
260
+ showSearch = true,
261
+ searchPlaceholder = "Search...",
262
+ onSearchChange,
263
+ loading = false,
264
+ emptyText = "No results",
265
+ style,
266
+ className,
267
+ accessibilityLabel,
268
+ // Pulled out only to keep them off `rest` (which is spread onto the root View);
269
+ // the real values are read from `props` directly below to preserve discriminated typing.
270
+ multiple: _multiple,
271
+ value: _value,
272
+ defaultValue: _defaultValue,
273
+ onValueChange: _onValueChange,
274
+ ...rest
275
+ } = props;
276
+
277
+ const multiple = props.multiple === true;
278
+ const valueProp = props.value;
279
+ const rootRef = useRef<ComponentRef<typeof View>>(null);
280
+ const triggerRef = useRef<ComponentRef<typeof Pressable>>(null);
281
+ const searchInputRef = useRef<ComponentRef<typeof TextInput>>(null);
282
+ const setRootRef = useMemo(() => mergeRefs(rootRef, forwardedRef), [forwardedRef]);
283
+ const isOpenControlled = typeof openProp === "boolean";
284
+ const isValueControlled = valueProp !== undefined;
285
+
286
+ const [uncontrolledSingle, setUncontrolledSingle] = useState(
287
+ !multiple ? ((props as SelectSingleProps).defaultValue ?? "") : "",
288
+ );
289
+ const [uncontrolledMultiple, setUncontrolledMultiple] = useState<string[]>(
290
+ multiple ? ((props as SelectMultipleProps).defaultValue ?? []) : [],
291
+ );
292
+ const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
293
+ const [searchQuery, setSearchQuery] = useState("");
294
+ const [hoveredValue, setHoveredValue] = useState<string | null>(null);
295
+
296
+ const selectedValues = useMemo(() => {
297
+ const current = isValueControlled
298
+ ? valueProp
299
+ : multiple
300
+ ? uncontrolledMultiple
301
+ : uncontrolledSingle;
302
+ return toArray(current);
303
+ }, [isValueControlled, multiple, valueProp, uncontrolledMultiple, uncontrolledSingle]);
304
+
305
+ const isOpen = isOpenControlled ? openProp : uncontrolledOpen;
306
+
307
+ // Mapped from selectedValues (not filtered from options) to preserve selection order.
308
+ const selectedOptions = useMemo(
309
+ () =>
310
+ selectedValues
311
+ .map((value) => options.find((option) => option.value === value))
312
+ .filter((option): option is SelectOption => option != null),
313
+ [options, selectedValues],
314
+ );
315
+ const hasValue = selectedOptions.length > 0;
316
+
317
+ const isExternalSearch = onSearchChange != null;
318
+
319
+ const filteredOptions = useMemo(() => {
320
+ if (isExternalSearch) return options;
321
+ const query = searchQuery.trim().toLowerCase();
322
+ if (!query) return options;
323
+ return options.filter((option) => option.label.toLowerCase().includes(query));
324
+ }, [isExternalSearch, options, searchQuery]);
325
+
326
+ useApplyWebClassName(rootRef, className);
327
+
328
+ const setOpen = useCallback(
329
+ (next: boolean) => {
330
+ if (!isOpenControlled) setUncontrolledOpen(next);
331
+ onOpenChange?.(next);
332
+ if (next) {
333
+ // Seed keyboard highlight from the current selection so Arrow keys
334
+ // start from a sensible position.
335
+ setHoveredValue(selectedValues[0] ?? null);
336
+ } else {
337
+ setSearchQuery("");
338
+ setHoveredValue(null);
339
+ // Let the parent reset its externally fetched options.
340
+ onSearchChange?.("");
341
+ }
342
+ },
343
+ [isOpenControlled, onOpenChange, onSearchChange, selectedValues],
344
+ );
345
+
346
+ const handleSearchChange = (text: string) => {
347
+ setSearchQuery(text);
348
+ onSearchChange?.(text);
349
+ };
350
+
351
+ // Close on outside click (web only; native has no document-level events).
352
+ useEffect(() => {
353
+ if (Platform.OS !== "web" || !isOpen) return;
354
+
355
+ const handlePointerDown = (event: MouseEvent | TouchEvent) => {
356
+ const root = rootRef.current as unknown as { contains?: (node: Node) => boolean } | null;
357
+ const target = event.target;
358
+ if (root?.contains && target instanceof Node && !root.contains(target)) {
359
+ setOpen(false);
360
+ }
361
+ };
362
+
363
+ document.addEventListener("mousedown", handlePointerDown);
364
+ document.addEventListener("touchstart", handlePointerDown);
365
+ return () => {
366
+ document.removeEventListener("mousedown", handlePointerDown);
367
+ document.removeEventListener("touchstart", handlePointerDown);
368
+ };
369
+ }, [isOpen, setOpen]);
370
+
371
+ const commitValues = useCallback(
372
+ (nextValues: string[]) => {
373
+ if (multiple) {
374
+ if (!isValueControlled) setUncontrolledMultiple(nextValues);
375
+ (props as SelectMultipleProps).onValueChange?.(nextValues);
376
+ return;
377
+ }
378
+
379
+ const nextValue = nextValues[0] ?? "";
380
+ if (!isValueControlled) setUncontrolledSingle(nextValue);
381
+ (props as SelectSingleProps).onValueChange?.(nextValue);
382
+ },
383
+ [isValueControlled, multiple, props],
384
+ );
385
+
386
+ const handleTriggerPress = () => {
387
+ if (disabled) return;
388
+ setOpen(!isOpen);
389
+ };
390
+
391
+ const handleSelect = useCallback(
392
+ (optionValue: string) => {
393
+ if (disabled) return;
394
+ if (multiple) {
395
+ const exists = selectedValues.includes(optionValue);
396
+ const nextValues = exists
397
+ ? selectedValues.filter((value) => value !== optionValue)
398
+ : [...selectedValues, optionValue];
399
+ commitValues(nextValues);
400
+ return;
401
+ }
402
+
403
+ commitValues([optionValue]);
404
+ setOpen(false);
405
+ },
406
+ [commitValues, disabled, multiple, selectedValues, setOpen],
407
+ );
408
+
409
+ const handleRemoveChip = (optionValue: string) => {
410
+ if (disabled || !multiple) return;
411
+ commitValues(selectedValues.filter((value) => value !== optionValue));
412
+ };
413
+
414
+ const focusTrigger = useCallback(() => {
415
+ const node = triggerRef.current as unknown as { focus?: () => void } | null;
416
+ node?.focus?.();
417
+ }, []);
418
+
419
+ const closeAndFocusTrigger = useCallback(() => {
420
+ setOpen(false);
421
+ focusTrigger();
422
+ }, [focusTrigger, setOpen]);
423
+
424
+ // ARIA combobox keyboard pattern (web only): Arrow keys move the highlighted
425
+ // option, Enter/Space selects it, Escape closes and returns focus to the trigger.
426
+ useEffect(() => {
427
+ if (Platform.OS !== "web") return;
428
+
429
+ const isTriggerFocused = () => document.activeElement === triggerRef.current;
430
+ const isSearchFocused = () => document.activeElement === searchInputRef.current;
431
+
432
+ const handleKeyDown = (event: KeyboardEvent) => {
433
+ if (!isOpen) {
434
+ if (!isTriggerFocused() || disabled) return;
435
+ if (event.key === "ArrowDown" || event.key === "ArrowUp" || event.key === "Enter") {
436
+ event.preventDefault();
437
+ setOpen(true);
438
+ }
439
+ return;
440
+ }
441
+
442
+ if (event.key === "Escape") {
443
+ event.preventDefault();
444
+ closeAndFocusTrigger();
445
+ return;
446
+ }
447
+
448
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
449
+ event.preventDefault();
450
+ setHoveredValue((current) => {
451
+ if (filteredOptions.length === 0) return current;
452
+ const currentIndex = filteredOptions.findIndex((option) => option.value === current);
453
+ const delta = event.key === "ArrowDown" ? 1 : -1;
454
+ const nextIndex =
455
+ currentIndex === -1
456
+ ? delta === 1
457
+ ? 0
458
+ : filteredOptions.length - 1
459
+ : Math.min(Math.max(currentIndex + delta, 0), filteredOptions.length - 1);
460
+ return filteredOptions[nextIndex]?.value ?? current;
461
+ });
462
+ return;
463
+ }
464
+
465
+ // Home/End and Space are left alone while typing in the search field
466
+ // (text-cursor navigation and literal spaces must keep working).
467
+ if (
468
+ isSearchFocused() &&
469
+ (event.key === "Home" || event.key === "End" || event.key === " ")
470
+ ) {
471
+ return;
472
+ }
473
+
474
+ if (event.key === "Home" || event.key === "End") {
475
+ if (filteredOptions.length === 0) return;
476
+ event.preventDefault();
477
+ setHoveredValue(
478
+ event.key === "Home"
479
+ ? filteredOptions[0].value
480
+ : filteredOptions[filteredOptions.length - 1].value,
481
+ );
482
+ return;
483
+ }
484
+
485
+ if (event.key === "Enter" || event.key === " ") {
486
+ if (hoveredValue == null) return;
487
+ event.preventDefault();
488
+ handleSelect(hoveredValue);
489
+ if (!multiple) focusTrigger();
490
+ }
491
+ };
492
+
493
+ document.addEventListener("keydown", handleKeyDown);
494
+ return () => document.removeEventListener("keydown", handleKeyDown);
495
+ }, [
496
+ isOpen,
497
+ disabled,
498
+ filteredOptions,
499
+ hoveredValue,
500
+ multiple,
501
+ closeAndFocusTrigger,
502
+ focusTrigger,
503
+ handleSelect,
504
+ setOpen,
505
+ ]);
506
+
507
+ const leftIconNode = leftIcon ?? (
508
+ <UserIcon
509
+ size={ICON_SIZE}
510
+ color={error ? colors.red : isOpen ? colors.primary : colors.grey100}
511
+ />
512
+ );
513
+ const hasLeftIcon = showLeftIcon || leftIcon != null;
514
+
515
+ const triggerBorderColor = error
516
+ ? colors.redMuted
517
+ : isOpen
518
+ ? colors.primaryMuted
519
+ : colors.grey700;
520
+ const triggerTextColor = hasValue
521
+ ? error
522
+ ? colors.red
523
+ : colors.white
524
+ : error
525
+ ? colors.red
526
+ : colors.grey100;
527
+ const labelColor = error ? colors.red : colors.white;
528
+ const hintColor = error ? colors.red : colors.grey100;
529
+
530
+ const singleLabel = selectedOptions[0]?.label ?? placeholder;
531
+ const resolvedAccessibilityLabel =
532
+ accessibilityLabel ?? (typeof label === "string" ? label : undefined);
533
+
534
+ return (
535
+ <View
536
+ {...rest}
537
+ ref={setRootRef}
538
+ style={[styles.root, isOpen && styles.rootOpen, disabled && styles.disabled, style]}
539
+ accessibilityState={{ disabled, expanded: isOpen }}
540
+ >
541
+ {showLabel && label ? (
542
+ <Text style={[styles.label, { color: labelColor }]}>{label}</Text>
543
+ ) : null}
544
+
545
+ <View style={[styles.triggerWrap, isOpen && styles.triggerWrapOpen]}>
546
+ <Pressable
547
+ ref={triggerRef}
548
+ onPress={handleTriggerPress}
549
+ disabled={disabled}
550
+ accessibilityRole="combobox"
551
+ accessibilityLabel={resolvedAccessibilityLabel}
552
+ accessibilityState={{ disabled, expanded: isOpen }}
553
+ style={[
554
+ styles.trigger,
555
+ {
556
+ borderColor: triggerBorderColor,
557
+ backgroundColor: colors.grey700,
558
+ },
559
+ ]}
560
+ >
561
+ {hasLeftIcon ? <View style={styles.iconSlot}>{leftIconNode}</View> : null}
562
+
563
+ <View style={styles.triggerContent}>
564
+ {multiple && hasValue ? (
565
+ <MultiValueChips
566
+ options={selectedOptions}
567
+ disabled={disabled}
568
+ onRemove={handleRemoveChip}
569
+ />
570
+ ) : (
571
+ <Text style={[styles.triggerText, { color: triggerTextColor }]} numberOfLines={1}>
572
+ {multiple ? placeholder : singleLabel}
573
+ </Text>
574
+ )}
575
+ </View>
576
+
577
+ <View style={[styles.iconSlot, isOpen && styles.chevronOpen]}>
578
+ <ChevronDownIcon size={ICON_SIZE} color={isOpen ? colors.primary : colors.grey100} />
579
+ </View>
580
+ </Pressable>
581
+
582
+ {isOpen ? (
583
+ <View style={styles.menu}>
584
+ {showSearch ? (
585
+ <View style={styles.searchField}>
586
+ <View style={styles.iconSlot}>
587
+ <SearchIcon size={ICON_SIZE} color={colors.grey100} />
588
+ </View>
589
+ <TextInput
590
+ ref={searchInputRef}
591
+ value={searchQuery}
592
+ onChangeText={handleSearchChange}
593
+ editable={!disabled}
594
+ placeholder={searchPlaceholder}
595
+ placeholderTextColor={colors.grey100}
596
+ style={[
597
+ styles.searchInput,
598
+ Platform.OS === "web" ? styles.searchInputWeb : null,
599
+ Platform.OS === "android" ? styles.searchInputAndroid : null,
600
+ ]}
601
+ accessibilityLabel={searchPlaceholder}
602
+ />
603
+ </View>
604
+ ) : null}
605
+
606
+ <ScrollView
607
+ style={styles.optionList}
608
+ contentContainerStyle={styles.optionListContent}
609
+ keyboardShouldPersistTaps="handled"
610
+ showsVerticalScrollIndicator={false}
611
+ >
612
+ {loading ? (
613
+ <View style={styles.statusRow}>
614
+ <Text style={styles.statusText}>Loading...</Text>
615
+ </View>
616
+ ) : filteredOptions.length === 0 ? (
617
+ <View style={styles.statusRow}>
618
+ <Text style={styles.statusText}>{emptyText}</Text>
619
+ </View>
620
+ ) : null}
621
+
622
+ {!loading &&
623
+ filteredOptions.map((option) => {
624
+ const isSelected = selectedValues.includes(option.value);
625
+ const isHovered = hoveredValue === option.value;
626
+ const visualState: ItemVisualState = isSelected
627
+ ? "selected"
628
+ : isHovered
629
+ ? "hover"
630
+ : "default";
631
+
632
+ return (
633
+ <Pressable
634
+ key={option.value}
635
+ onPress={() => handleSelect(option.value)}
636
+ disabled={disabled}
637
+ onHoverIn={() => setHoveredValue(option.value)}
638
+ onHoverOut={() =>
639
+ setHoveredValue((current) => (current === option.value ? null : current))
640
+ }
641
+ onPressIn={() => setHoveredValue(option.value)}
642
+ onPressOut={() =>
643
+ setHoveredValue((current) => (current === option.value ? null : current))
644
+ }
645
+ accessibilityRole={multiple ? "checkbox" : "button"}
646
+ accessibilityState={{ selected: isSelected, checked: isSelected, disabled }}
647
+ style={[
648
+ styles.item,
649
+ {
650
+ backgroundColor: itemBackground(visualState),
651
+ },
652
+ ]}
653
+ >
654
+ {multiple ? (
655
+ <View pointerEvents="none" style={styles.itemCheckbox}>
656
+ <Checkbox value={isSelected} variant="light" />
657
+ </View>
658
+ ) : null}
659
+ <Text style={styles.itemLabel} numberOfLines={1}>
660
+ {option.label}
661
+ </Text>
662
+ </Pressable>
663
+ );
664
+ })}
665
+ </ScrollView>
666
+ </View>
667
+ ) : null}
668
+ </View>
669
+
670
+ {showHint && hint ? <Text style={[styles.hint, { color: hintColor }]}>{hint}</Text> : null}
671
+ </View>
672
+ );
673
+ },
674
+ );
675
+
676
+ const styles = StyleSheet.create({
677
+ root: {
678
+ width: DEFAULT_WIDTH,
679
+ gap: 8,
680
+ alignSelf: "flex-start",
681
+ },
682
+ rootOpen: {
683
+ zIndex: 30,
684
+ },
685
+ disabled: {
686
+ opacity: 0.6,
687
+ },
688
+ label: {
689
+ ...selectTypography.label,
690
+ },
691
+ triggerWrap: {
692
+ position: "relative",
693
+ zIndex: 1,
694
+ },
695
+ triggerWrapOpen: {
696
+ zIndex: 20,
697
+ },
698
+ trigger: {
699
+ height: TRIGGER_HEIGHT,
700
+ borderRadius: TRIGGER_RADIUS,
701
+ borderWidth: 1,
702
+ paddingVertical: 12,
703
+ paddingHorizontal: 16,
704
+ flexDirection: "row",
705
+ alignItems: "center",
706
+ gap: 10,
707
+ overflow: "hidden",
708
+ },
709
+ triggerContent: {
710
+ flex: 1,
711
+ justifyContent: "center",
712
+ minWidth: 0,
713
+ overflow: "hidden",
714
+ },
715
+ triggerText: {
716
+ ...selectTypography.field,
717
+ },
718
+ multiValueRoot: {
719
+ width: "100%",
720
+ position: "relative",
721
+ },
722
+ measureRow: {
723
+ position: "absolute",
724
+ left: 0,
725
+ top: 0,
726
+ flexDirection: "row",
727
+ opacity: 0,
728
+ zIndex: -1,
729
+ },
730
+ chipsRow: {
731
+ flexDirection: "row",
732
+ alignItems: "center",
733
+ gap: CHIP_GAP,
734
+ overflow: "hidden",
735
+ },
736
+ chip: {
737
+ flexDirection: "row",
738
+ alignItems: "center",
739
+ gap: 4,
740
+ paddingVertical: 2,
741
+ paddingHorizontal: 8,
742
+ borderRadius: 999,
743
+ backgroundColor: colors.grey600,
744
+ flexShrink: 0,
745
+ },
746
+ chipLabel: {
747
+ ...selectTypography.hint,
748
+ color: colors.white,
749
+ maxWidth: 120,
750
+ },
751
+ chipRemove: {
752
+ color: colors.grey100,
753
+ fontSize: 14,
754
+ lineHeight: 14,
755
+ },
756
+ ellipsis: {
757
+ flexShrink: 0,
758
+ paddingHorizontal: 2,
759
+ },
760
+ ellipsisText: {
761
+ ...selectTypography.field,
762
+ color: colors.white,
763
+ lineHeight: 16,
764
+ },
765
+ iconSlot: {
766
+ width: ICON_SIZE,
767
+ height: ICON_SIZE,
768
+ alignItems: "center",
769
+ justifyContent: "center",
770
+ flexShrink: 0,
771
+ },
772
+ chevronOpen: {
773
+ transform: [{ rotate: "180deg" }],
774
+ },
775
+ menu: {
776
+ position: "absolute",
777
+ top: "100%",
778
+ left: 0,
779
+ right: 0,
780
+ marginTop: 8,
781
+ zIndex: 10,
782
+ borderRadius: MENU_RADIUS,
783
+ padding: 12,
784
+ gap: 8,
785
+ backgroundColor: colors.grey700,
786
+ ...Platform.select({
787
+ web: {
788
+ boxShadow: `0 8px 24px ${colors.black}73`,
789
+ },
790
+ default: {
791
+ elevation: 8,
792
+ },
793
+ }),
794
+ },
795
+ optionList: {
796
+ maxHeight: MENU_MAX_LIST_HEIGHT,
797
+ },
798
+ optionListContent: {
799
+ gap: 8,
800
+ },
801
+ statusRow: {
802
+ minHeight: 43,
803
+ padding: 12,
804
+ justifyContent: "center",
805
+ },
806
+ statusText: {
807
+ ...selectTypography.field,
808
+ color: colors.grey100,
809
+ },
810
+ searchField: {
811
+ height: 40,
812
+ borderRadius: ITEM_RADIUS,
813
+ paddingHorizontal: 12,
814
+ flexDirection: "row",
815
+ alignItems: "center",
816
+ gap: 8,
817
+ backgroundColor: colors.grey800,
818
+ },
819
+ searchInput: {
820
+ ...selectTypography.field,
821
+ flex: 1,
822
+ color: colors.white,
823
+ paddingVertical: 0,
824
+ margin: 0,
825
+ },
826
+ searchInputWeb: {
827
+ outlineStyle: "none",
828
+ } as TextStyle,
829
+ searchInputAndroid: {
830
+ includeFontPadding: false,
831
+ },
832
+ item: {
833
+ minHeight: 43,
834
+ borderRadius: ITEM_RADIUS,
835
+ padding: 12,
836
+ flexDirection: "row",
837
+ alignItems: "center",
838
+ gap: 8,
839
+ },
840
+ itemCheckbox: {
841
+ flexShrink: 0,
842
+ },
843
+ itemLabel: {
844
+ ...selectTypography.field,
845
+ color: colors.white,
846
+ flex: 1,
847
+ },
848
+ hint: {
849
+ ...selectTypography.hint,
850
+ },
851
+ });