@ecohouse/ui 0.1.52 → 0.1.54

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ecohouse/ui",
3
- "version": "0.1.52",
3
+ "version": "0.1.54",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "description": "Cross-platform presentation-only UI components for EcoHouse (React Native + React Native Web)",
@@ -53,6 +53,21 @@ const DEFAULT_MONTH_NAMES = [
53
53
  /** Monday-first — override via `weekdayLabels` for other locales. */
54
54
  const DEFAULT_WEEKDAY_LABELS = ["ԵՐ", "ԵՔ", "ՉՐ", "ՀՆ", "ՈՒՐ", "ՇԲ", "ԿՐ"];
55
55
 
56
+ function rangeIncludesUnavailable(
57
+ start: Date,
58
+ end: Date,
59
+ isUnavailable: (day: Date) => boolean,
60
+ ): boolean {
61
+ const cursor = startOfDay(start);
62
+ const last = startOfDay(end);
63
+ const step = isBefore(last, cursor) ? -1 : 1;
64
+ while (true) {
65
+ if (isUnavailable(cursor)) return true;
66
+ if (isSameDay(cursor, last)) return false;
67
+ cursor.setDate(cursor.getDate() + step);
68
+ }
69
+ }
70
+
56
71
  export type DateRange = { start: Date | null; end: Date | null };
57
72
  export type DatePickerVariant = "default" | "range-horizontal" | "range-vertical";
58
73
 
@@ -82,6 +97,15 @@ type DatePickerBaseProps = Omit<ViewProps, "style" | "children"> & {
82
97
  closeOnSelect?: boolean;
83
98
  minDate?: Date;
84
99
  maxDate?: Date;
100
+ /** Calendar days that cannot be selected, such as dates already booked. */
101
+ unavailableDates?: readonly Date[];
102
+ /**
103
+ * When true, unavailable days cannot be selected and a range cannot include them.
104
+ * Defaults to false so booked days can stay visual-only.
105
+ */
106
+ blockUnavailableDates?: boolean;
107
+ /** Accessibility label for an unavailable day. */
108
+ unavailableDateLabel?: string;
85
109
  /** First visible month when the calendar opens; defaults to the selected value's month, else today. */
86
110
  defaultVisibleMonth?: Date;
87
111
  /** Monday-first full month names. Defaults to Armenian. */
@@ -183,6 +207,9 @@ export const DatePicker = forwardRef<ComponentRef<typeof View>, DatePickerProps>
183
207
  closeOnSelect = true,
184
208
  minDate,
185
209
  maxDate,
210
+ unavailableDates,
211
+ unavailableDateLabel = "Booked",
212
+ blockUnavailableDates = false,
186
213
  defaultVisibleMonth,
187
214
  monthNames = DEFAULT_MONTH_NAMES,
188
215
  weekdayLabels = DEFAULT_WEEKDAY_LABELS,
@@ -202,6 +229,19 @@ export const DatePicker = forwardRef<ComponentRef<typeof View>, DatePickerProps>
202
229
  ...rest
203
230
  } = props;
204
231
 
232
+ const unavailableDayKeys = useMemo(() => {
233
+ const keys = new Set<number>();
234
+ for (const date of unavailableDates ?? []) {
235
+ keys.add(startOfDay(date).getTime());
236
+ }
237
+ return keys;
238
+ }, [unavailableDates]);
239
+
240
+ const isUnavailable = useCallback(
241
+ (day: Date) => unavailableDayKeys.has(startOfDay(day).getTime()),
242
+ [unavailableDayKeys],
243
+ );
244
+
205
245
  const isRange = props.range === true;
206
246
  const usesRangeTrigger = isRange && variant !== "default";
207
247
  const formatValue =
@@ -343,6 +383,7 @@ export const DatePicker = forwardRef<ComponentRef<typeof View>, DatePickerProps>
343
383
  const handleDayPress = useCallback(
344
384
  (day: Date) => {
345
385
  if (disabled || isDateDisabled(day, minDate, maxDate)) return;
386
+ if (blockUnavailableDates && isUnavailable(day)) return;
346
387
  const picked = startOfDay(day);
347
388
 
348
389
  if (!isRange) {
@@ -360,14 +401,25 @@ export const DatePicker = forwardRef<ComponentRef<typeof View>, DatePickerProps>
360
401
  ? { start: picked, end: null }
361
402
  : { start, end: picked };
362
403
 
404
+ if (
405
+ blockUnavailableDates &&
406
+ nextRange.start &&
407
+ nextRange.end &&
408
+ rangeIncludesUnavailable(nextRange.start, nextRange.end, isUnavailable)
409
+ ) {
410
+ return;
411
+ }
412
+
363
413
  if (!isValueControlled) setUncontrolledRange(nextRange);
364
414
  (props as DatePickerRangeProps).onValueChange?.(nextRange);
365
415
  if (closeOnSelect && nextRange.start && nextRange.end) setOpen(false);
366
416
  },
367
417
  [
418
+ blockUnavailableDates,
368
419
  closeOnSelect,
369
420
  disabled,
370
421
  isRange,
422
+ isUnavailable,
371
423
  isValueControlled,
372
424
  maxDate,
373
425
  minDate,
@@ -483,6 +535,7 @@ export const DatePicker = forwardRef<ComponentRef<typeof View>, DatePickerProps>
483
535
  ? styles.menuAlignEnd
484
536
  : styles.menuAlignStart,
485
537
  calendarStyle,
538
+ styles.menuFitContent,
486
539
  ]}
487
540
  >
488
541
  <View style={styles.calendarHeader}>
@@ -513,7 +566,7 @@ export const DatePicker = forwardRef<ComponentRef<typeof View>, DatePickerProps>
513
566
  </Pressable>
514
567
  </View>
515
568
 
516
- <View>
569
+ <View style={styles.calendarGrid}>
517
570
  <View style={styles.weekdayRow}>
518
571
  {weekdayLabels.map((weekdayLabel, index) => (
519
572
  <View key={`${weekdayLabel}-${index}`} style={styles.weekdayCell}>
@@ -582,10 +635,15 @@ export const DatePicker = forwardRef<ComponentRef<typeof View>, DatePickerProps>
582
635
  const isRangeStart = isRange && isSameDay(day, rangeValue.start);
583
636
  const isRangeEnd = isRange && isSameDay(day, rangeValue.end);
584
637
  const isSelectedEndpoint = isSelectedSingle || isRangeStart || isRangeEnd;
638
+ const dayUnavailable = isUnavailable(day);
639
+ const dayBlocked = blockUnavailableDates && dayUnavailable;
585
640
  const dayDisabled = disabled || isDateDisabled(day, minDate, maxDate);
586
641
  const dayKey = startOfDay(day).getTime();
587
642
  const isHovered =
588
- !dayDisabled && !isSelectedEndpoint && hoveredDayKey === dayKey;
643
+ !dayDisabled &&
644
+ !dayBlocked &&
645
+ !isSelectedEndpoint &&
646
+ hoveredDayKey === dayKey;
589
647
 
590
648
  const dayTextColor = isSelectedEndpoint
591
649
  ? colors.white
@@ -601,7 +659,7 @@ export const DatePicker = forwardRef<ComponentRef<typeof View>, DatePickerProps>
601
659
  <Pressable
602
660
  key={dayIndex}
603
661
  onPress={() => handleDayPress(day)}
604
- disabled={dayDisabled}
662
+ disabled={dayDisabled || dayBlocked}
605
663
  onHoverIn={() => setHoveredDayKey(dayKey)}
606
664
  onHoverOut={() =>
607
665
  setHoveredDayKey((current) => (current === dayKey ? null : current))
@@ -612,9 +670,13 @@ export const DatePicker = forwardRef<ComponentRef<typeof View>, DatePickerProps>
612
670
  }
613
671
  style={styles.dayCellWrap}
614
672
  accessibilityRole="button"
615
- accessibilityLabel={formatValue(day)}
673
+ accessibilityLabel={
674
+ dayUnavailable
675
+ ? `${formatValue(day)}, ${unavailableDateLabel}`
676
+ : formatValue(day)
677
+ }
616
678
  accessibilityState={{
617
- disabled: dayDisabled,
679
+ disabled: dayDisabled || dayBlocked,
618
680
  selected: isSelectedEndpoint,
619
681
  }}
620
682
  >
@@ -622,10 +684,17 @@ export const DatePicker = forwardRef<ComponentRef<typeof View>, DatePickerProps>
622
684
  style={[
623
685
  styles.dayCircle,
624
686
  isSelectedEndpoint && styles.dayCircleSelected,
687
+ dayUnavailable && styles.dayCircleBooked,
625
688
  isHovered && styles.dayCircleHovered,
626
689
  ]}
627
690
  >
628
- <Text style={[styles.dayText, { color: dayTextColor }]}>
691
+ <Text
692
+ style={[
693
+ styles.dayText,
694
+ { color: dayTextColor },
695
+ dayUnavailable && styles.dayTextBooked,
696
+ ]}
697
+ >
629
698
  {day.getDate()}
630
699
  </Text>
631
700
  </View>
@@ -762,7 +831,7 @@ const styles = StyleSheet.create({
762
831
  menu: {
763
832
  position: "absolute",
764
833
  top: "100%",
765
- width: DEFAULT_WIDTH,
834
+ alignSelf: "flex-start",
766
835
  height: DEFAULT_MENU_HEIGHT,
767
836
  marginTop: 8,
768
837
  zIndex: 10,
@@ -796,10 +865,26 @@ const styles = StyleSheet.create({
796
865
  right: "auto",
797
866
  marginTop: 0,
798
867
  },
799
- rangeMenu: {
800
- width: RANGE_TRIGGER_WIDTH,
868
+ rangeMenu: {},
869
+ menuFitContent: {
870
+ width: MENU_CONTENT_WIDTH + MENU_PADDING * 2,
871
+ maxWidth: MENU_CONTENT_WIDTH + MENU_PADDING * 2,
872
+ alignSelf: "flex-start",
873
+ alignItems: "flex-start",
874
+ ...(Platform.select({
875
+ web: {
876
+ width: "fit-content",
877
+ maxWidth: "fit-content",
878
+ },
879
+ default: {},
880
+ }) as ViewStyle),
881
+ },
882
+ calendarGrid: {
883
+ width: MENU_CONTENT_WIDTH,
884
+ alignSelf: "flex-start",
801
885
  },
802
886
  calendarHeader: {
887
+ width: MENU_CONTENT_WIDTH,
803
888
  height: HEADER_HEIGHT,
804
889
  paddingTop: 8,
805
890
  paddingBottom: 8,
@@ -833,9 +918,8 @@ const styles = StyleSheet.create({
833
918
  weekdayCell: {
834
919
  width: DAY_CELL_WIDTH,
835
920
  height: WEEKDAY_ROW_HEIGHT,
836
- flexGrow: 1,
837
- flexShrink: 1,
838
- flexBasis: 0,
921
+ flexGrow: 0,
922
+ flexShrink: 0,
839
923
  alignItems: "center",
840
924
  justifyContent: "center",
841
925
  },
@@ -863,9 +947,8 @@ const styles = StyleSheet.create({
863
947
  dayCellWrap: {
864
948
  width: DAY_CELL_WIDTH,
865
949
  height: DAY_CELL_HEIGHT,
866
- flexGrow: 1,
867
- flexShrink: 1,
868
- flexBasis: 0,
950
+ flexGrow: 0,
951
+ flexShrink: 0,
869
952
  alignItems: "center",
870
953
  justifyContent: "center",
871
954
  opacity: 1,
@@ -882,6 +965,12 @@ const styles = StyleSheet.create({
882
965
  dayCircleSelected: {
883
966
  backgroundColor: colors.primary,
884
967
  },
968
+ dayCircleBooked: {
969
+ backgroundColor: colors.grey700,
970
+ },
971
+ dayTextBooked: {
972
+ textDecorationLine: "line-through",
973
+ },
885
974
  dayCircleHovered: {
886
975
  backgroundColor: colors.grey600,
887
976
  },
@@ -7,7 +7,9 @@ import {
7
7
  useRef,
8
8
  useState,
9
9
  type ComponentRef,
10
+ type ReactNode,
10
11
  } from "react";
12
+ import { createPortal } from "react-dom";
11
13
  import {
12
14
  Platform,
13
15
  Pressable,
@@ -95,6 +97,17 @@ const ITEM_RADIUS = 12;
95
97
  const ICON_SIZE = 20;
96
98
  const CHIP_GAP = 6;
97
99
  const FALLBACK_ELLIPSIS_WIDTH = 20;
100
+ /** Above Modal (500) so portaled menus stay clickable inside dialogs. */
101
+ const WEB_MENU_Z_INDEX = 600;
102
+ const WEB_VIEWPORT_PADDING = 8;
103
+ const WEB_MENU_GAP = 8;
104
+
105
+ type WebMenuPosition = {
106
+ top: number;
107
+ left: number;
108
+ width: number;
109
+ maxHeight: number;
110
+ };
98
111
 
99
112
  type ItemVisualState = "default" | "hover" | "selected";
100
113
 
@@ -287,6 +300,7 @@ export const Select = forwardRef<ComponentRef<typeof View>, SelectProps>(
287
300
  const valueProp = props.value;
288
301
  const rootRef = useRef<ComponentRef<typeof View>>(null);
289
302
  const triggerRef = useRef<ComponentRef<typeof Pressable>>(null);
303
+ const menuRef = useRef<ComponentRef<typeof View>>(null);
290
304
  const searchInputRef = useRef<ComponentRef<typeof TextInput>>(null);
291
305
  const setRootRef = useMemo(() => mergeRefs(rootRef, forwardedRef), [forwardedRef]);
292
306
  const isOpenControlled = typeof openProp === "boolean";
@@ -303,8 +317,10 @@ export const Select = forwardRef<ComponentRef<typeof View>, SelectProps>(
303
317
  const [hoveredValue, setHoveredValue] = useState<string | null>(null);
304
318
  /** Web: flip menu to the trigger's right edge when it would overflow the viewport. */
305
319
  const [menuAlignEnd, setMenuAlignEnd] = useState(false);
306
- /** Web: open above the trigger when there isn't enough space below. */
320
+ /** Native: open above the trigger when there isn't enough space below. */
307
321
  const [menuPlaceAbove, setMenuPlaceAbove] = useState(false);
322
+ /** Web: fixed coords for the portaled menu (avoids expanding overflow parents). */
323
+ const [webMenuPosition, setWebMenuPosition] = useState<WebMenuPosition | null>(null);
308
324
 
309
325
  const selectedValues = useMemo(() => {
310
326
  const current = isValueControlled
@@ -379,6 +395,7 @@ export const Select = forwardRef<ComponentRef<typeof View>, SelectProps>(
379
395
  setHoveredValue(null);
380
396
  setMenuAlignEnd(false);
381
397
  setMenuPlaceAbove(false);
398
+ setWebMenuPosition(null);
382
399
  // Let the parent reset its externally fetched options.
383
400
  onSearchChange?.("");
384
401
  }
@@ -387,6 +404,7 @@ export const Select = forwardRef<ComponentRef<typeof View>, SelectProps>(
387
404
  );
388
405
 
389
406
  // Prefer start (left) / below; flip when the menu would overflow the viewport (web).
407
+ // On web the menu is portaled with position:fixed so overflow parents don't grow/clip it.
390
408
  useLayoutEffect(() => {
391
409
  if (Platform.OS !== "web" || !isOpen) return;
392
410
 
@@ -397,16 +415,26 @@ export const Select = forwardRef<ComponentRef<typeof View>, SelectProps>(
397
415
  const rect = trigger?.getBoundingClientRect?.();
398
416
  if (!rect) return;
399
417
 
400
- const viewportPadding = 8;
401
- const gap = 8;
402
418
  const menuWidth = Math.max(rect.width, resolvedMenuWidth ?? 0);
403
- const overflowsRight = rect.left + menuWidth > window.innerWidth - viewportPadding;
419
+ const overflowsRight = rect.left + menuWidth > window.innerWidth - WEB_VIEWPORT_PADDING;
404
420
  setMenuAlignEnd(overflowsRight);
405
421
 
406
- const spaceBelow = window.innerHeight - viewportPadding - rect.bottom;
407
- const spaceAbove = rect.top - viewportPadding;
408
- const needsAbove = estimatedMenuHeight + gap > spaceBelow && spaceAbove > spaceBelow;
422
+ const spaceBelow = window.innerHeight - WEB_VIEWPORT_PADDING - rect.bottom;
423
+ const spaceAbove = rect.top - WEB_VIEWPORT_PADDING;
424
+ const needsAbove =
425
+ estimatedMenuHeight + WEB_MENU_GAP > spaceBelow && spaceAbove > spaceBelow;
409
426
  setMenuPlaceAbove(needsAbove);
427
+
428
+ const available = Math.max(0, (needsAbove ? spaceAbove : spaceBelow) - WEB_MENU_GAP);
429
+ const maxHeight = Math.min(estimatedMenuHeight, Math.max(available, TRIGGER_HEIGHT));
430
+ const left = overflowsRight
431
+ ? Math.max(WEB_VIEWPORT_PADDING, rect.right - menuWidth)
432
+ : Math.min(rect.left, window.innerWidth - WEB_VIEWPORT_PADDING - menuWidth);
433
+ const top = needsAbove
434
+ ? Math.max(WEB_VIEWPORT_PADDING, rect.top - WEB_MENU_GAP - maxHeight)
435
+ : rect.bottom + WEB_MENU_GAP;
436
+
437
+ setWebMenuPosition({ top, left, width: menuWidth, maxHeight });
410
438
  };
411
439
 
412
440
  updateMenuPlacement();
@@ -428,11 +456,12 @@ export const Select = forwardRef<ComponentRef<typeof View>, SelectProps>(
428
456
  if (Platform.OS !== "web" || !isOpen) return;
429
457
 
430
458
  const handlePointerDown = (event: MouseEvent | TouchEvent) => {
431
- const root = rootRef.current as unknown as { contains?: (node: Node) => boolean } | null;
432
459
  const target = event.target;
433
- if (root?.contains && target instanceof Node && !root.contains(target)) {
434
- setOpen(false);
435
- }
460
+ if (!(target instanceof Node)) return;
461
+ const root = rootRef.current as unknown as { contains?: (node: Node) => boolean } | null;
462
+ const menu = menuRef.current as unknown as { contains?: (node: Node) => boolean } | null;
463
+ if (root?.contains?.(target) || menu?.contains?.(target)) return;
464
+ setOpen(false);
436
465
  };
437
466
 
438
467
  document.addEventListener("mousedown", handlePointerDown);
@@ -581,7 +610,7 @@ export const Select = forwardRef<ComponentRef<typeof View>, SelectProps>(
581
610
 
582
611
  const leftIconNode = renderStatefulIcon(leftIcon, UserIcon, {
583
612
  size: ICON_SIZE,
584
- color: error ? colors.red : colors.primary,
613
+ color: error ? colors.red : isOpen ? colors.primary : colors.grey100,
585
614
  });
586
615
  const hasLeftIcon = showLeftIcon || leftIcon != null;
587
616
 
@@ -604,6 +633,127 @@ export const Select = forwardRef<ComponentRef<typeof View>, SelectProps>(
604
633
  const resolvedAccessibilityLabel =
605
634
  accessibilityLabel ?? (typeof label === "string" ? label : undefined);
606
635
 
636
+ const optionListMaxHeight =
637
+ Platform.OS === "web" && webMenuPosition != null
638
+ ? Math.max(43, webMenuPosition.maxHeight - (24 + (showSearch ? 48 : 0)))
639
+ : resolvedOptionListMaxHeight;
640
+
641
+ const menuNode: ReactNode = isOpen ? (
642
+ <View
643
+ ref={menuRef}
644
+ style={[
645
+ styles.menu,
646
+ Platform.OS === "web" && webMenuPosition != null
647
+ ? ({
648
+ position: "fixed",
649
+ top: webMenuPosition.top,
650
+ left: webMenuPosition.left,
651
+ width: webMenuPosition.width,
652
+ minWidth: webMenuPosition.width,
653
+ maxHeight: webMenuPosition.maxHeight,
654
+ zIndex: WEB_MENU_Z_INDEX,
655
+ marginTop: 0,
656
+ marginBottom: 0,
657
+ } as unknown as ViewStyle)
658
+ : Platform.OS === "web"
659
+ ? ({ position: "fixed", opacity: 0, pointerEvents: "none" } as unknown as ViewStyle)
660
+ : [
661
+ menuAlignEnd ? styles.menuAlignEnd : styles.menuAlignStart,
662
+ menuPlaceAbove ? styles.menuAbove : styles.menuBelow,
663
+ ],
664
+ menuStyle,
665
+ ]}
666
+ >
667
+ {showSearch ? (
668
+ <View style={styles.searchField}>
669
+ <View style={styles.iconSlot}>
670
+ <SearchIcon size={ICON_SIZE} color={colors.grey100} />
671
+ </View>
672
+ <TextInput
673
+ ref={searchInputRef}
674
+ value={searchQuery}
675
+ onChangeText={handleSearchChange}
676
+ editable={!disabled}
677
+ placeholder={searchPlaceholder}
678
+ placeholderTextColor={colors.grey100}
679
+ style={[
680
+ styles.searchInput,
681
+ Platform.OS === "web" ? styles.searchInputWeb : null,
682
+ Platform.OS === "android" ? styles.searchInputAndroid : null,
683
+ ]}
684
+ accessibilityLabel={searchPlaceholder}
685
+ />
686
+ </View>
687
+ ) : null}
688
+
689
+ <ScrollView
690
+ style={[styles.optionList, { maxHeight: optionListMaxHeight }]}
691
+ contentContainerStyle={styles.optionListContent}
692
+ keyboardShouldPersistTaps="handled"
693
+ showsVerticalScrollIndicator={false}
694
+ >
695
+ {loading ? (
696
+ <View style={styles.statusRow}>
697
+ <Text style={styles.statusText}>Loading...</Text>
698
+ </View>
699
+ ) : filteredOptions.length === 0 ? (
700
+ <View style={styles.statusRow}>
701
+ <Text style={styles.statusText}>{emptyText}</Text>
702
+ </View>
703
+ ) : null}
704
+
705
+ {!loading &&
706
+ filteredOptions.map((option) => {
707
+ const isSelected = selectedValues.includes(option.value);
708
+ const isHovered = hoveredValue === option.value;
709
+ const visualState: ItemVisualState = isSelected
710
+ ? "selected"
711
+ : isHovered
712
+ ? "hover"
713
+ : "default";
714
+
715
+ return (
716
+ <Pressable
717
+ key={option.value}
718
+ onPress={() => handleSelect(option.value)}
719
+ disabled={disabled}
720
+ onHoverIn={() => setHoveredValue(option.value)}
721
+ onHoverOut={() =>
722
+ setHoveredValue((current) => (current === option.value ? null : current))
723
+ }
724
+ onPressIn={() => setHoveredValue(option.value)}
725
+ onPressOut={() =>
726
+ setHoveredValue((current) => (current === option.value ? null : current))
727
+ }
728
+ accessibilityRole={multiple ? "checkbox" : "button"}
729
+ accessibilityState={{ selected: isSelected, checked: isSelected, disabled }}
730
+ style={[
731
+ styles.item,
732
+ {
733
+ backgroundColor: itemBackground(visualState),
734
+ },
735
+ ]}
736
+ >
737
+ {multiple ? (
738
+ <View style={[styles.itemCheckbox, { pointerEvents: "none" }]}>
739
+ <Checkbox value={isSelected} variant="light" />
740
+ </View>
741
+ ) : null}
742
+ <Text style={styles.itemLabel} numberOfLines={1}>
743
+ {option.label}
744
+ </Text>
745
+ </Pressable>
746
+ );
747
+ })}
748
+ </ScrollView>
749
+ </View>
750
+ ) : null;
751
+
752
+ const webMenuPortal =
753
+ Platform.OS === "web" && isOpen && typeof document !== "undefined"
754
+ ? createPortal(menuNode, document.body)
755
+ : null;
756
+
607
757
  return (
608
758
  <View
609
759
  {...rest}
@@ -652,99 +802,7 @@ export const Select = forwardRef<ComponentRef<typeof View>, SelectProps>(
652
802
  </View>
653
803
  </Pressable>
654
804
 
655
- {isOpen ? (
656
- <View
657
- style={[
658
- styles.menu,
659
- menuAlignEnd ? styles.menuAlignEnd : styles.menuAlignStart,
660
- menuPlaceAbove ? styles.menuAbove : styles.menuBelow,
661
- menuStyle,
662
- ]}
663
- >
664
- {showSearch ? (
665
- <View style={styles.searchField}>
666
- <View style={styles.iconSlot}>
667
- <SearchIcon size={ICON_SIZE} color={colors.grey100} />
668
- </View>
669
- <TextInput
670
- ref={searchInputRef}
671
- value={searchQuery}
672
- onChangeText={handleSearchChange}
673
- editable={!disabled}
674
- placeholder={searchPlaceholder}
675
- placeholderTextColor={colors.grey100}
676
- style={[
677
- styles.searchInput,
678
- Platform.OS === "web" ? styles.searchInputWeb : null,
679
- Platform.OS === "android" ? styles.searchInputAndroid : null,
680
- ]}
681
- accessibilityLabel={searchPlaceholder}
682
- />
683
- </View>
684
- ) : null}
685
-
686
- <ScrollView
687
- style={[styles.optionList, { maxHeight: resolvedOptionListMaxHeight }]}
688
- contentContainerStyle={styles.optionListContent}
689
- keyboardShouldPersistTaps="handled"
690
- showsVerticalScrollIndicator={false}
691
- >
692
- {loading ? (
693
- <View style={styles.statusRow}>
694
- <Text style={styles.statusText}>Loading...</Text>
695
- </View>
696
- ) : filteredOptions.length === 0 ? (
697
- <View style={styles.statusRow}>
698
- <Text style={styles.statusText}>{emptyText}</Text>
699
- </View>
700
- ) : null}
701
-
702
- {!loading &&
703
- filteredOptions.map((option) => {
704
- const isSelected = selectedValues.includes(option.value);
705
- const isHovered = hoveredValue === option.value;
706
- const visualState: ItemVisualState = isSelected
707
- ? "selected"
708
- : isHovered
709
- ? "hover"
710
- : "default";
711
-
712
- return (
713
- <Pressable
714
- key={option.value}
715
- onPress={() => handleSelect(option.value)}
716
- disabled={disabled}
717
- onHoverIn={() => setHoveredValue(option.value)}
718
- onHoverOut={() =>
719
- setHoveredValue((current) => (current === option.value ? null : current))
720
- }
721
- onPressIn={() => setHoveredValue(option.value)}
722
- onPressOut={() =>
723
- setHoveredValue((current) => (current === option.value ? null : current))
724
- }
725
- accessibilityRole={multiple ? "checkbox" : "button"}
726
- accessibilityState={{ selected: isSelected, checked: isSelected, disabled }}
727
- style={[
728
- styles.item,
729
- {
730
- backgroundColor: itemBackground(visualState),
731
- },
732
- ]}
733
- >
734
- {multiple ? (
735
- <View style={[styles.itemCheckbox, { pointerEvents: "none" }]}>
736
- <Checkbox value={isSelected} variant="light" />
737
- </View>
738
- ) : null}
739
- <Text style={styles.itemLabel} numberOfLines={1}>
740
- {option.label}
741
- </Text>
742
- </Pressable>
743
- );
744
- })}
745
- </ScrollView>
746
- </View>
747
- ) : null}
805
+ {Platform.OS === "web" ? webMenuPortal : menuNode}
748
806
  </View>
749
807
 
750
808
  {showHint && hint ? <Text style={[styles.hint, { color: hintColor }]}>{hint}</Text> : null}