import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { ReactElement, ReactNode } from "react"; import { View, Text, Pressable, Modal, TextInput, ScrollView, Platform, StatusBar, } from "react-native"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import { BottomSheetModal, BottomSheetScrollView, BottomSheetBackdrop, BottomSheetTextInput, BottomSheetBackgroundProps, } from "@gorhom/bottom-sheet"; import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; import { Check, Search } from "lucide-react-native"; import { flip, offset as floatingOffset, shift, size as floatingSize, useFloating } from "@floating-ui/react-native"; import { getNextActiveIndex } from "./combobox-keyboard"; const IS_WEB = Platform.OS === "web"; export interface ComboboxOption { id: string; label: string; description?: string; } export interface ComboboxProps { options: ComboboxOption[]; value: string; onSelect: (id: string) => void; onSearchQueryChange?: (query: string) => void; placeholder?: string; searchPlaceholder?: string; emptyText?: string; allowCustomValue?: boolean; customValuePrefix?: string; customValueDescription?: string; title?: string; open?: boolean; onOpenChange?: (open: boolean) => void; anchorRef: React.RefObject; children?: ReactNode; } function ComboboxSheetBackground({ style }: BottomSheetBackgroundProps) { return ( ); } interface SearchInputProps { placeholder: string; value: string; onChangeText: (text: string) => void; onSubmitEditing?: () => void; autoFocus?: boolean; } function SearchInput({ placeholder, value, onChangeText, onSubmitEditing, autoFocus = false, }: SearchInputProps): ReactElement { const { theme } = useUnistyles(); const inputRef = useRef(null); const InputComponent = Platform.OS === "web" ? TextInput : BottomSheetTextInput; useEffect(() => { if (autoFocus && IS_WEB && inputRef.current) { const timer = setTimeout(() => { inputRef.current?.focus(); }, 50); return () => clearTimeout(timer); } }, [autoFocus]); return ( ); } export interface ComboboxItemProps { label: string; description?: string; selected?: boolean; active?: boolean; onPress: () => void; testID?: string; } export function ComboboxItem({ label, description, selected, active, onPress, testID, }: ComboboxItemProps): ReactElement { const { theme } = useUnistyles(); return ( [ styles.comboboxItem, hovered && styles.comboboxItemHovered, pressed && styles.comboboxItemPressed, active && styles.comboboxItemActive, ]} > {label} {description ? ( {description} ) : null} {selected ? ( ) : null} ); } export function ComboboxEmpty({ children }: { children: ReactNode }): ReactElement { return {children}; } export function Combobox({ options, value, onSelect, onSearchQueryChange, placeholder = "Search...", searchPlaceholder, emptyText = "No options match your search.", allowCustomValue = false, customValuePrefix = "Use", customValueDescription, title = "Select", open, onOpenChange, anchorRef, children, }: ComboboxProps): ReactElement { const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; const bottomSheetRef = useRef(null); const snapPoints = useMemo(() => ["60%", "90%"], []); const [availableSize, setAvailableSize] = useState<{ width?: number; height?: number } | null>(null); const [referenceWidth, setReferenceWidth] = useState(null); const [searchQuery, setSearchQuery] = useState(""); const [activeIndex, setActiveIndex] = useState(-1); const isControlled = typeof open === "boolean"; const [internalOpen, setInternalOpen] = useState(false); const isOpen = isControlled ? open : internalOpen; const setOpen = useCallback( (nextOpen: boolean) => { if (!isControlled) { setInternalOpen(nextOpen); } onOpenChange?.(nextOpen); }, [isControlled, onOpenChange] ); const setSearchQueryWithCallback = useCallback( (nextQuery: string) => { setSearchQuery(nextQuery); onSearchQueryChange?.(nextQuery); }, [onSearchQueryChange] ); const handleClose = useCallback(() => { setOpen(false); setSearchQueryWithCallback(""); }, [setOpen, setSearchQueryWithCallback]); useEffect(() => { if (isOpen) { setSearchQueryWithCallback(""); } }, [isOpen, setSearchQueryWithCallback]); const collisionPadding = useMemo(() => { const basePadding = 16; if (Platform.OS !== "android") return basePadding; const statusBarHeight = StatusBar.currentHeight ?? 0; return Math.max(basePadding, statusBarHeight + basePadding); }, []); const middleware = useMemo( () => [ floatingOffset(Platform.OS === "web" ? 0 : 4), ...(Platform.OS === "web" ? [] : [flip({ padding: collisionPadding })]), shift({ padding: collisionPadding }), floatingSize({ padding: collisionPadding, apply({ availableWidth, availableHeight, rects }) { setAvailableSize((prev) => { const next = { width: availableWidth, height: availableHeight }; if (!prev) return next; if (prev.width === next.width && prev.height === next.height) return prev; return next; }); setReferenceWidth((prev) => { const next = rects.reference.width; if (prev === next) return prev; return next; }); }, }), ], [collisionPadding] ); const { refs, floatingStyles, update } = useFloating({ placement: Platform.OS === "web" ? "top-start" : "bottom-start", middleware, sameScrollView: false, elements: { reference: anchorRef.current ?? undefined, }, }); useEffect(() => { if (!isOpen || isMobile) { setAvailableSize(null); setReferenceWidth(null); return; } const raf = requestAnimationFrame(() => update()); return () => cancelAnimationFrame(raf); }, [isMobile, update, isOpen]); useEffect(() => { if (!isMobile) return; if (isOpen) { bottomSheetRef.current?.present(); } else { bottomSheetRef.current?.dismiss(); } }, [isOpen, isMobile]); const handleSheetChange = useCallback( (index: number) => { if (index === -1) { handleClose(); } }, [handleClose] ); const renderBackdrop = useCallback( (props: React.ComponentProps) => ( ), [] ); const normalizedSearch = searchQuery.trim().toLowerCase(); const filteredOptions = useMemo(() => { if (!normalizedSearch) { return options; } return options.filter( (opt) => opt.label.toLowerCase().includes(normalizedSearch) || opt.id.toLowerCase().includes(normalizedSearch) || opt.description?.toLowerCase().includes(normalizedSearch) ); }, [options, normalizedSearch]); const sanitizedSearchValue = searchQuery.trim(); const showCustomOption = allowCustomValue && sanitizedSearchValue.length > 0 && !options.some( (opt) => opt.id.toLowerCase() === sanitizedSearchValue.toLowerCase() || opt.label.toLowerCase() === sanitizedSearchValue.toLowerCase() ); const visibleOptions = useMemo(() => { const next: Array<{ id: string; label: string; description?: string; }> = []; if (showCustomOption) { next.push({ id: sanitizedSearchValue, label: `${customValuePrefix} "${sanitizedSearchValue}"`, description: customValueDescription, }); } for (const opt of filteredOptions) { next.push({ id: opt.id, label: opt.label, description: opt.description, }); } return next; }, [ customValueDescription, customValuePrefix, filteredOptions, sanitizedSearchValue, showCustomOption, ]); useEffect(() => { if (!isOpen) return; if (!IS_WEB && isMobile) return; if (visibleOptions.length === 0) { setActiveIndex(-1); return; } if (normalizedSearch) { setActiveIndex(0); return; } const selectedIndex = visibleOptions.findIndex((opt) => opt.id === value); setActiveIndex(selectedIndex >= 0 ? selectedIndex : 0); }, [isMobile, isOpen, normalizedSearch, value, visibleOptions]); const handleSelect = useCallback( (id: string) => { onSelect(id); handleClose(); }, [handleClose, onSelect] ); const handleSubmitSearch = useCallback(() => { if (showCustomOption) { handleSelect(sanitizedSearchValue); } }, [handleSelect, sanitizedSearchValue, showCustomOption]); const handleDesktopKey = useCallback( (key: "ArrowDown" | "ArrowUp" | "Enter" | "Escape", event?: KeyboardEvent) => { if (!isOpen) return; if (!IS_WEB && isMobile) return; if (key === "ArrowDown" || key === "ArrowUp") { event?.preventDefault(); setActiveIndex((currentIndex) => getNextActiveIndex({ currentIndex, itemCount: visibleOptions.length, key, }) ); return; } if (key === "Enter") { if (visibleOptions.length === 0) return; event?.preventDefault(); const index = activeIndex >= 0 && activeIndex < visibleOptions.length ? activeIndex : 0; handleSelect(visibleOptions[index]!.id); return; } if (key === "Escape") { event?.preventDefault(); handleClose(); } }, [activeIndex, handleClose, handleSelect, isMobile, isOpen, visibleOptions] ); useEffect(() => { if (!IS_WEB || !isOpen) return; const handler = (event: KeyboardEvent) => { const key = event.key; if (key !== "ArrowDown" && key !== "ArrowUp" && key !== "Enter" && key !== "Escape") { return; } handleDesktopKey(key, event); }; // react-native-web's TextInput can stop propagation on key events, so listen in capture phase. window.addEventListener("keydown", handler, true); return () => { window.removeEventListener("keydown", handler, true); }; }, [handleDesktopKey, isOpen]); const searchInput = ( ); const optionsList = ( <> {visibleOptions.length > 0 ? ( visibleOptions.map((opt, index) => ( handleSelect(opt.id)} /> )) ) : ( {emptyText} )} ); const content = children ?? ( <> {searchInput} {optionsList} ); if (isMobile) { return ( {title} {content} ); } if (!isOpen) return <>; return ( update()} > {children ? ( {content} ) : ( <> {searchInput} {optionsList} )} ); } const styles = StyleSheet.create((theme) => ({ searchInputContainer: { flexDirection: "row", alignItems: "center", borderWidth: 1, borderColor: theme.colors.border, backgroundColor: theme.colors.surface1, borderRadius: theme.borderRadius.lg, paddingHorizontal: theme.spacing[3], marginHorizontal: theme.spacing[2], marginBottom: theme.spacing[2], marginTop: theme.spacing[1], gap: theme.spacing[2], }, searchInput: { flex: 1, paddingVertical: theme.spacing[3], color: theme.colors.foreground, fontSize: theme.fontSize.sm, }, comboboxItem: { flexDirection: "row", alignItems: "center", minHeight: 36, gap: theme.spacing[2], paddingHorizontal: theme.spacing[3], paddingVertical: theme.spacing[2], borderRadius: theme.borderRadius.md, ...(IS_WEB ? {} : { marginHorizontal: theme.spacing[1], marginBottom: theme.spacing[1], }), }, comboboxItemHovered: { backgroundColor: theme.colors.surface1, }, comboboxItemPressed: { backgroundColor: theme.colors.surface1, }, comboboxItemActive: { backgroundColor: theme.colors.surface1, }, comboboxItemTrailingSlot: { width: 16, alignItems: "center", justifyContent: "center", marginLeft: "auto", }, comboboxItemContent: { flex: 1, flexShrink: 1, }, comboboxItemLabel: { fontSize: theme.fontSize.sm, color: theme.colors.foreground, }, comboboxItemDescription: { marginTop: 2, fontSize: theme.fontSize.xs, color: theme.colors.foregroundMuted, }, emptyText: { paddingHorizontal: theme.spacing[3], paddingVertical: theme.spacing[2], color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, }, bottomSheetBackground: { backgroundColor: theme.colors.surface0, borderTopLeftRadius: theme.borderRadius["2xl"], borderTopRightRadius: theme.borderRadius["2xl"], }, bottomSheetHandle: { backgroundColor: theme.colors.palette.zinc[600], }, bottomSheetHeader: { paddingHorizontal: theme.spacing[6], paddingBottom: theme.spacing[2], }, comboboxTitle: { fontSize: theme.fontSize.lg, fontWeight: theme.fontWeight.medium, color: theme.colors.foreground, textAlign: "left", }, comboboxScrollContent: { paddingBottom: theme.spacing[8], paddingHorizontal: theme.spacing[2], paddingTop: theme.spacing[1], }, desktopOverlay: { flex: 1, }, desktopBackdrop: { position: "absolute", top: 0, right: 0, bottom: 0, left: 0, }, desktopContainer: { backgroundColor: theme.colors.surface0, borderRadius: theme.borderRadius.lg, borderWidth: 1, borderColor: theme.colors.border, shadowColor: "#000", shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.2, shadowRadius: 8, elevation: 8, maxHeight: 400, overflow: "hidden", }, desktopScroll: { maxHeight: 400, }, desktopScrollContent: { paddingVertical: theme.spacing[1], }, }));