import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import type { ReactElement, ReactNode } from 'react' import { View, Text, Pressable, Modal, TextInput, ScrollView, Platform, StatusBar, useWindowDimensions, } 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, File, Folder, 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' import { buildVisibleComboboxOptions, getComboboxFallbackIndex, orderVisibleComboboxOptions, shouldShowCustomComboboxOption, } from './combobox-options' import type { ComboboxOptionModel } from './combobox-options' const IS_WEB = Platform.OS === 'web' export type ComboboxOption = ComboboxOptionModel export interface ComboboxProps { options: ComboboxOption[] value: string onSelect: (id: string) => void onSearchQueryChange?: (query: string) => void searchable?: boolean placeholder?: string searchPlaceholder?: string emptyText?: string allowCustomValue?: boolean customValuePrefix?: string customValueDescription?: string customValueKind?: 'directory' | 'file' optionsPosition?: 'below-search' | 'above-search' title?: string open?: boolean onOpenChange?: (open: boolean) => void desktopPlacement?: 'top-start' | 'bottom-start' /** * Prevents an initial frame at 0,0 by hiding desktop content until floating * coordinates resolve. This intentionally disables fade enter/exit animation * for that combobox instance to avoid animation overriding hidden opacity. */ desktopPreventInitialFlash?: boolean anchorRef: React.RefObject children?: ReactNode } function toNumericStyleValue(value: unknown): number | null { if (typeof value === 'number' && Number.isFinite(value)) { return value } if (typeof value === 'string') { const parsed = Number.parseFloat(value) if (Number.isFinite(parsed)) { return parsed } } return null } 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 kind?: 'directory' | 'file' selected?: boolean active?: boolean onPress: () => void testID?: string } export function ComboboxItem({ label, description, kind, selected, active, onPress, testID, }: ComboboxItemProps): ReactElement { const { theme } = useUnistyles() return ( [ styles.comboboxItem, hovered && styles.comboboxItemHovered, pressed && styles.comboboxItemPressed, active && styles.comboboxItemActive, ]} > {kind === 'directory' || kind === 'file' ? ( {kind === 'directory' ? ( ) : ( )} ) : null} {label} {description ? ( {description} ) : null} {selected ? ( ) : null} ) } export function ComboboxEmpty({ children }: { children: ReactNode }): ReactElement { return ( {children} ) } export function Combobox({ options, value, onSelect, onSearchQueryChange, searchable = true, placeholder = 'Search...', searchPlaceholder, emptyText = 'No options match your search.', allowCustomValue = false, customValuePrefix = 'Use', customValueDescription, customValueKind, optionsPosition = 'below-search', title = 'Select', open, onOpenChange, desktopPlacement = 'top-start', desktopPreventInitialFlash = true, anchorRef, children, }: ComboboxProps): ReactElement { const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm' const effectiveOptionsPosition = isMobile ? 'below-search' : optionsPosition const isDesktopAboveSearch = !isMobile && Platform.OS === 'web' && effectiveOptionsPosition === 'above-search' const { height: windowHeight } = useWindowDimensions() 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 [referenceTop, setReferenceTop] = useState(null) const [referenceAtOrigin, setReferenceAtOrigin] = useState(false) const [searchQuery, setSearchQuery] = useState('') const [activeIndex, setActiveIndex] = useState(-1) const desktopOptionsScrollRef = useRef(null) 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 })]), ...(isDesktopAboveSearch ? [] : [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, isDesktopAboveSearch] ) const { refs, floatingStyles, update } = useFloating({ placement: Platform.OS === 'web' ? desktopPlacement : 'bottom-start', middleware, sameScrollView: false, elements: { reference: anchorRef.current ?? undefined, }, }) useEffect(() => { if (!isOpen || isMobile) { setAvailableSize(null) setReferenceWidth(null) return } const raf = requestAnimationFrame(() => void update()) return () => cancelAnimationFrame(raf) }, [desktopPlacement, isMobile, update, isOpen]) useEffect(() => { if (!isOpen || isMobile) { setReferenceAtOrigin(false) setReferenceTop(null) return } const referenceEl = anchorRef.current if (!referenceEl) { setReferenceAtOrigin(false) setReferenceTop(null) return } const measure = () => { referenceEl.measureInWindow((x, y) => { setReferenceAtOrigin(Math.abs(x) <= 1 && Math.abs(y) <= 1) setReferenceTop((prev) => (prev === y ? prev : y)) }) } measure() const raf = requestAnimationFrame(measure) return () => cancelAnimationFrame(raf) }, [anchorRef, isMobile, isOpen, searchQuery, windowHeight]) const floatingTop = toNumericStyleValue(floatingStyles.top) const floatingLeft = toNumericStyleValue(floatingStyles.left) const desktopAboveSearchBottom = isDesktopAboveSearch && referenceTop !== null ? Math.max(windowHeight - referenceTop, collisionPadding) : null const hasResolvedDesktopPosition = referenceWidth !== null && floatingLeft !== null && (isDesktopAboveSearch ? desktopAboveSearchBottom !== null : floatingTop !== null) && ((floatingTop ?? 0) !== 0 || floatingLeft !== 0 || referenceAtOrigin) const shouldHideDesktopContent = desktopPreventInitialFlash && !hasResolvedDesktopPosition const shouldUseDesktopFade = !desktopPreventInitialFlash const desktopPositionStyle = isDesktopAboveSearch ? { left: floatingLeft ?? 0, bottom: desktopAboveSearchBottom ?? 0, } : floatingStyles 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 = searchable ? searchQuery.trim().toLowerCase() : '' const sanitizedSearchValue = searchQuery.trim() const showCustomOption = useMemo( () => shouldShowCustomComboboxOption({ options, searchQuery, searchable, allowCustomValue, }), [allowCustomValue, options, searchQuery, searchable] ) const visibleOptions = useMemo( () => buildVisibleComboboxOptions({ options, searchQuery, searchable, allowCustomValue, customValuePrefix, customValueDescription, customValueKind, }), [ allowCustomValue, customValueDescription, customValueKind, customValuePrefix, options, searchQuery, searchable, ] ) const orderedVisibleOptions = useMemo( () => orderVisibleComboboxOptions(visibleOptions, effectiveOptionsPosition), [effectiveOptionsPosition, visibleOptions] ) const pinDesktopOptionsToBottom = useCallback(() => { if (isMobile || effectiveOptionsPosition !== 'above-search') { return } desktopOptionsScrollRef.current?.scrollToEnd({ animated: false }) requestAnimationFrame(() => { desktopOptionsScrollRef.current?.scrollToEnd({ animated: false }) }) }, [effectiveOptionsPosition, isMobile]) const handleDesktopOptionsContentSizeChange = useCallback(() => { if (!isOpen) { return } pinDesktopOptionsToBottom() }, [isOpen, pinDesktopOptionsToBottom]) useEffect(() => { if (!isOpen) { return } pinDesktopOptionsToBottom() }, [isOpen, orderedVisibleOptions, pinDesktopOptionsToBottom]) useLayoutEffect(() => { if (!isOpen || isMobile) { return } void update() }, [isOpen, isMobile, orderedVisibleOptions.length, searchQuery, update]) useEffect(() => { if (!isOpen) return if (!IS_WEB && isMobile) return if (orderedVisibleOptions.length === 0) { setActiveIndex(-1) return } const fallbackIndex = getComboboxFallbackIndex( orderedVisibleOptions.length, effectiveOptionsPosition ) if (normalizedSearch) { setActiveIndex(fallbackIndex) return } const selectedIndex = orderedVisibleOptions.findIndex((opt) => opt.id === value) setActiveIndex(selectedIndex >= 0 ? selectedIndex : fallbackIndex) }, [effectiveOptionsPosition, isMobile, isOpen, normalizedSearch, value, orderedVisibleOptions]) 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: orderedVisibleOptions.length, key, }) ) return } if (key === 'Enter') { if (orderedVisibleOptions.length === 0) return event?.preventDefault() const index = activeIndex >= 0 && activeIndex < orderedVisibleOptions.length ? activeIndex : 0 handleSelect(orderedVisibleOptions[index]!.id) return } if (key === 'Escape') { event?.preventDefault() handleClose() } }, [activeIndex, handleClose, handleSelect, isMobile, isOpen, orderedVisibleOptions] ) 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 = ( <> {orderedVisibleOptions.length > 0 ? ( orderedVisibleOptions.map((opt, index) => ( handleSelect(opt.id)} /> )) ) : ( {emptyText} )} ) const defaultContent = ( <> {effectiveOptionsPosition === 'above-search' ? optionsList : null} {searchable ? searchInput : null} {effectiveOptionsPosition === 'below-search' ? optionsList : null} ) const content = children ?? defaultContent if (isMobile) { return ( {title} {content} ) } if (!isOpen) return <> return ( update()} > {children ? ( {content} ) : ( <> {effectiveOptionsPosition === 'above-search' ? ( {optionsList} ) : null} {searchable ? searchInput : null} {effectiveOptionsPosition === 'below-search' ? ( {optionsList} ) : null} )} ) } 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: 0, ...(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, }, comboboxItemLeadingSlot: { width: 16, alignItems: 'center', justifyContent: 'center', }, 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: { flexShrink: 1, minHeight: 0, }, desktopScrollContent: { paddingVertical: theme.spacing[1], }, desktopScrollContentAboveSearch: { flexGrow: 1, justifyContent: 'flex-end', }, }))