import { Button, Host, Menu } from "@expo/ui/swift-ui"; import { disabled } from "@expo/ui/swift-ui/modifiers"; import { Ionicons } from "@expo/vector-icons"; import { BottomSheetScrollView } from "@gorhom/bottom-sheet"; import React, { useEffect, useState } from "react"; import { type LayoutChangeEvent, Platform, StyleSheet, TouchableOpacity, View, } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Text } from "@/components/common/Text"; import { useGlobalModal } from "@/providers/GlobalModalProvider"; // Option types export type RadioOption = { type: "radio"; label: string; value: T; selected: boolean; onPress: () => void; disabled?: boolean; }; export type ToggleOption = { type: "toggle"; label: string; value: boolean; onToggle: () => void; disabled?: boolean; }; export type ActionOption = { type: "action"; label: string; onPress: () => void; disabled?: boolean; }; export type Option = RadioOption | ToggleOption | ActionOption; // Option group structure export type OptionGroup = { title?: string; options: Option[]; }; interface PlatformDropdownProps { trigger?: React.ReactNode; title?: string; groups: OptionGroup[]; open?: boolean; onOpenChange?: (open: boolean) => void; onOptionSelect?: (value?: any) => void; expoUIConfig?: { hostStyle?: any; }; bottomSheetConfig?: { enableDynamicSizing?: boolean; enablePanDownToClose?: boolean; }; } const ToggleSwitch: React.FC<{ value: boolean }> = ({ value }) => ( ); const OptionItem: React.FC<{ option: Option; isLast?: boolean }> = ({ option, isLast, }) => { const isToggle = option.type === "toggle"; const isAction = option.type === "action"; const handlePress = isToggle ? option.onToggle : (option as RadioOption | ActionOption).onPress; return ( <> {option.label} {isToggle ? ( ) : isAction ? null : (option as RadioOption).selected ? ( ) : ( )} {!isLast && ( )} ); }; const OptionGroupComponent: React.FC<{ group: OptionGroup }> = ({ group }) => ( {group.title && ( {group.title} )} {group.options.map((option, index) => ( ))} ); const BottomSheetContent: React.FC<{ title?: string; groups: OptionGroup[]; onOptionSelect?: (value?: any) => void; onClose?: () => void; }> = ({ title, groups, onOptionSelect, onClose }) => { const insets = useSafeAreaInsets(); // Wrap the groups to call onOptionSelect when an option is pressed const wrappedGroups = groups.map((group) => ({ ...group, options: group.options.map((option) => { if (option.type === "radio") { return { ...option, onPress: () => { option.onPress(); onOptionSelect?.(option.value); onClose?.(); }, }; } if (option.type === "toggle") { return { ...option, onToggle: () => { option.onToggle(); onOptionSelect?.(option.value); }, }; } if (option.type === "action") { return { ...option, onPress: () => { option.onPress(); onClose?.(); }, }; } return option; }), })); return ( {title && {title}} {wrappedGroups.map((group, index) => ( ))} ); }; const PlatformDropdownComponent = ({ trigger, title, groups, open: controlledOpen, onOpenChange: controlledOnOpenChange, onOptionSelect, expoUIConfig, bottomSheetConfig, }: PlatformDropdownProps) => { const { showModal, hideModal, isVisible } = useGlobalModal(); // @expo/ui's (SDK 55) fills its available space by default, and // `matchContents` doesn't help here: it reports the native Menu's size via // setStyleSize and overrides any explicit size. Instead we measure the // trigger's intrinsic size in plain RN (off-layout) and pin it on the Host. const [triggerSize, setTriggerSize] = useState<{ width: number; height: number; } | null>(null); const handleMeasureTrigger = (e: LayoutChangeEvent) => { const { width, height } = e.nativeEvent.layout; setTriggerSize((prev) => prev && prev.width === width && prev.height === height ? prev : { width, height }, ); }; // Handle controlled open state for Android useEffect(() => { if (Platform.OS === "android" && controlledOpen === true) { showModal( { hideModal(); controlledOnOpenChange?.(false); }} />, { snapPoints: ["90%"], enablePanDownToClose: bottomSheetConfig?.enablePanDownToClose ?? true, }, ); } }, [controlledOpen]); // Watch for modal dismissal on Android (e.g., swipe down, backdrop tap) // and sync the controlled open state useEffect(() => { if (Platform.OS === "android" && controlledOpen === true && !isVisible) { controlledOnOpenChange?.(false); } }, [isVisible, controlledOpen, controlledOnOpenChange]); if (Platform.OS === "ios") { // Pin the wrapper to the measured trigger size. @expo/ui's (SDK 55) // fills its parent and reports its own size via setStyleSize, so it can't // size itself to content. If the wrapper has no size, the Host's `flex: 1` // height depends on the parent while the parent depends on the Host — a // circular dependency that collapses to 0 for any selector nested more than // one level deep (so only the first, shallowest dropdown stays visible). // Giving the wrapper the measured size breaks the cycle; the Host then // fills a concrete box. return ( {/* Hidden measurer: lays the trigger out off-flow to capture its intrinsic size. Absolutely positioned WITHOUT right/bottom so it sizes to the trigger's content rather than to its parent. */} {trigger} {groups.flatMap((group, groupIndex) => { // Check if this group has radio options const radioOptions = group.options.filter( (opt) => opt.type === "radio", ) as RadioOption[]; const toggleOptions = group.options.filter( (opt) => opt.type === "toggle", ) as ToggleOption[]; const actionOptions = group.options.filter( (opt) => opt.type === "action", ) as ActionOption[]; const items = []; // Group radio options under a submenu ONLY if there's a title // Otherwise render as individual buttons if (radioOptions.length > 0) { if (group.title) { // Use a nested Menu as a submenu for grouped options. This // reads as "Title: Selected" and expands to the choices on // tap, keeping the nested look while staying a dropdown. // (Menu opens on a single tap and nests cleanly; ContextMenu // would require a long-press and read as a context menu.) const selectedOption = radioOptions.find( (opt) => opt.selected, ); const displayTitle = selectedOption ? `${group.title}: ${selectedOption.label}` : group.title; items.push( {radioOptions.map((option, optionIndex) => ( , ); } else { // Render radio options as direct buttons radioOptions.forEach((option, optionIndex) => { items.push( ); } // Android: Direct modal trigger const handlePress = () => { showModal( , { snapPoints: ["90%"], enablePanDownToClose: bottomSheetConfig?.enablePanDownToClose ?? true, }, ); }; return ( {trigger || Open Menu} ); }; // Memoize to prevent unnecessary re-renders when parent re-renders export const PlatformDropdown = React.memo( PlatformDropdownComponent, (prevProps, nextProps) => { // Custom comparison - only re-render if these props actually change return ( prevProps.title === nextProps.title && prevProps.open === nextProps.open && prevProps.groups === nextProps.groups && // Reference equality (works because we memoize groups in caller) prevProps.trigger === nextProps.trigger // Reference equality ); }, );