diff --git a/app/(site)/docs/components/combobox/page.tsx b/app/(site)/docs/components/combobox/page.tsx new file mode 100644 index 0000000..ede3207 --- /dev/null +++ b/app/(site)/docs/components/combobox/page.tsx @@ -0,0 +1,437 @@ +import { ComponentPreview } from "@/components/docs/component-preview"; + +export default function ComboboxPage() { + return ( + \n Click me\n \n );\n}", + "language": "tsx" + } +]} + componentCode={`import * as React from "react"; +import { + View, + Text, + Pressable, + Platform, + FlatList, + TextInput, +} from "react-native"; +import { Ionicons } from "@expo/vector-icons"; +import { cn } from "@/lib/utils"; +import { Drawer, useDrawer } from "@/components/ui/drawer"; +import { Input } from "@/components/ui/input"; + +interface ComboboxProps { + value?: string; + onValueChange?: (value: string) => void; + placeholder?: string; + searchPlaceholder?: string; + disabled?: boolean; + className?: string; + triggerClassName?: string; + contentClassName?: string; + items: { + value: string; + label: string; + disabled?: boolean; + }[]; + filter?: (value: string, search: string) => boolean; + emptyText?: string; +} + +interface ComboboxItemProps { + value: string; + children: React.ReactNode; + disabled?: boolean; + className?: string; + onSelect?: (value: string, label: React.ReactNode) => void; + selectedValue?: string; +} + +interface ComboboxLabelProps { + children: React.ReactNode; + className?: string; +} + +interface ComboboxGroupProps { + children: React.ReactNode; + className?: string; +} + +interface ComboboxSeparatorProps { + className?: string; +} + +const searchState = { + value: "", + listeners: new Set<() => void>(), + + setValue(newValue: string) { + this.value = newValue; + this.notifyListeners(); + }, + + addListener(listener: () => void) { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + }, + + notifyListeners() { + this.listeners.forEach((listener) => listener()); + }, +}; + +const ComboboxSearchInput = () => { + const [localValue, setLocalValue] = React.useState(searchState.value); + const inputRef = React.useRef(null); + + React.useEffect(() => { + const unsubscribe = searchState.addListener(() => { + setLocalValue(searchState.value); + }); + return unsubscribe; + }, []); + + const handleChangeText = (text: string) => { + setLocalValue(text); + searchState.setValue(text); + }; + + const handleClear = () => { + setLocalValue(""); + searchState.setValue(""); + inputRef.current?.clear(); + }; + + return ( + + + + + + + {localValue.length > 0 && ( + + + + + + )} + + + ); +}; + +const ComboboxItemsList = ({ + items, + selectedValue, + onSelect, + filter, + emptyText, +}: { + items: ComboboxProps["items"]; + selectedValue?: string; + onSelect: (value: string) => void; + filter: (value: string, search: string) => boolean; + emptyText: string; +}) => { + const [filteredItems, setFilteredItems] = React.useState(items); + + React.useEffect(() => { + const updateFilter = () => { + if (!searchState.value) { + setFilteredItems(items); + } else { + setFilteredItems( + items.filter((item) => filter(item.value, searchState.value)) + ); + } + }; + + updateFilter(); + + const unsubscribe = searchState.addListener(updateFilter); + return unsubscribe; + }, [items, filter]); + + if (filteredItems.length === 0) { + return ( + + {emptyText} + + ); + } + + return ( + item.value} + keyboardShouldPersistTaps="handled" + nestedScrollEnabled={true} + renderItem={({ item }) => ( + + {item.label} + + )} + contentContainerStyle={{ paddingBottom: 20 }} + /> + ); +}; + +const Combobox = React.forwardRef( + ( + { + value, + onValueChange, + placeholder = "Select an option", + searchPlaceholder = "Search...", + disabled = false, + className, + triggerClassName, + contentClassName, + items = [], + filter, + emptyText = "No results found.", + }, + ref + ) => { + const [isOpen, setIsOpen] = React.useState(false); + const [selectedValue, setSelectedValue] = React.useState(value); + + React.useEffect(() => { + if (!isOpen) { + setTimeout(() => { + searchState.setValue(""); + }, 100); + } + }, [isOpen]); + + React.useEffect(() => { + setSelectedValue(value); + }, [value]); + + const defaultFilter = React.useCallback( + (itemValue: string, search: string) => { + const label = + items.find((item) => item.value === itemValue)?.label || ""; + return label.toLowerCase().includes(search.toLowerCase()); + }, + [items] + ); + + const filterFn = filter || defaultFilter; + + const selectedLabel = React.useMemo(() => { + if (!selectedValue) return ""; + return items.find((item) => item.value === selectedValue)?.label || ""; + }, [selectedValue, items]); + + const handleSelect = React.useCallback( + (itemValue: string) => { + setSelectedValue(itemValue); + if (onValueChange) { + onValueChange(itemValue); + } + }, + [onValueChange] + ); + + return ( + + setIsOpen(true)} + className={cn( + "flex-row h-12 items-center justify-between rounded-md border border-input bg-transparent px-3 py-2", + "shadow-sm", + "active:opacity-70", + disabled && "opacity-50", + Platform.OS === "ios" + ? "ios:shadow-sm ios:shadow-foreground/10" + : "android:elevation-1", + triggerClassName + )} + > + + {selectedValue ? selectedLabel : placeholder} + + + + + + setIsOpen(false)} + title={placeholder} + snapPoints={[0.5, 0.8]} + initialSnapIndex={0} + contentClassName={contentClassName} + > + + + + + ); + } +); + +Combobox.displayName = "Combobox"; + +const ComboboxGroup = React.forwardRef( + ({ className, children, ...props }, ref) => { + return ( + + {children} + + ); + } +); + +ComboboxGroup.displayName = "ComboboxGroup"; + +const ComboboxItem = React.forwardRef( + ( + { className, children, value, disabled, onSelect, selectedValue, ...props }, + ref + ) => { + const isSelected = selectedValue === value; + const drawer = useDrawer(); + + return ( + { + if (onSelect) { + onSelect(value, children); + } + drawer.animateClose(); + }} + className={cn( + "flex-row h-14 items-center justify-between px-4 py-2 active:bg-accent/50", + isSelected ? "bg-accent" : "", + disabled && "opacity-50", + className + )} + {...props} + > + + {children} + + + {isSelected && } + + ); + } +); + +ComboboxItem.displayName = "ComboboxItem"; + +const ComboboxLabel = React.forwardRef( + ({ className, children, ...props }, ref) => { + return ( + + {children} + + ); + } +); + +ComboboxLabel.displayName = "ComboboxLabel"; + +const ComboboxSeparator = React.forwardRef( + ({ className, ...props }, ref) => { + return ( + + ); + } +); + +ComboboxSeparator.displayName = "ComboboxSeparator"; + +export { + Combobox, + ComboboxGroup, + ComboboxItem, + ComboboxLabel, + ComboboxSeparator, +}; +`} + previewCode={`import { Combobox } from "@nativeui/ui"; + +export default function ComboboxDemo() { + return ( +
+ Default Combobox + Delete + Outline + Secondary + Ghost + Link +
+ ); +}`} + registryName="combobox" + packageName="@nativeui/ui" + /> + ); +} diff --git a/app/(site)/docs/components/drawer/page.tsx b/app/(site)/docs/components/drawer/page.tsx index 79086f2..f2c6b32 100644 --- a/app/(site)/docs/components/drawer/page.tsx +++ b/app/(site)/docs/components/drawer/page.tsx @@ -25,7 +25,9 @@ import { Dimensions, StyleSheet, Easing, + KeyboardAvoidingView, } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; import { cn } from "@/lib/utils"; interface DrawerProps { @@ -37,6 +39,9 @@ interface DrawerProps { initialSnapIndex?: number; className?: string; contentClassName?: string; + avoidKeyboard?: boolean; + closeOnBackdropPress?: boolean; + disableBackHandler?: boolean; } const { height: SCREEN_HEIGHT } = Dimensions.get("window"); @@ -59,9 +64,13 @@ const Drawer = React.forwardRef( initialSnapIndex = 0, className, contentClassName, + avoidKeyboard = true, + closeOnBackdropPress = true, + disableBackHandler = false, }, ref ) => { + const [isVisible, setIsVisible] = React.useState(false); const snapPointsPixels = snapPoints.map( (point) => SCREEN_HEIGHT - SCREEN_HEIGHT * point ); @@ -114,23 +123,21 @@ const Drawer = React.forwardRef( useNativeDriver: true, delay: 100, }).start(() => { - onClose(); + setIsVisible(false); isClosing.current = false; + onClose(); }); }, [backdropOpacity, translateY, onClose]); React.useEffect(() => { - if (open && !isClosing.current) { + if (open && !isVisible) { + setIsVisible(true); + } else if (open && !isClosing.current) { animateOpen(); - } - }, [open, animateOpen]); - - // Ensure drawer animates close when open becomes false - React.useEffect(() => { - if (!open && !isClosing.current) { + } else if (!open && isVisible && !isClosing.current) { animateClose(); } - }, [open, animateClose]); + }, [open, isVisible, animateOpen, animateClose, isClosing]); const animateToSnapPoint = (index: number, velocity = 0) => { if (index < 0 || index >= snapPointsPixels.length) return; @@ -242,57 +249,71 @@ const Drawer = React.forwardRef( } }, }); - }, [snapPointsPixels, onClose, translateY, animateClose]); + }, [snapPointsPixels, animateClose]); + + if (!isVisible) return null; + + const renderContent = () => ( + + + {closeOnBackdropPress && ( + + + + )} + + + + + + + + + {title && ( + + + {title} + + + )} + - if (!open) return null; + + + {children} + + + + + ); return ( - - - - - - - - - - - - - - {title && ( - - - {title} - - - )} - - - - {children} - - - + {renderContent()} + + ) : ( + renderContent() + )} ); diff --git a/app/(site)/docs/components/select/page.tsx b/app/(site)/docs/components/select/page.tsx index 3eecda9..bfb1468 100644 --- a/app/(site)/docs/components/select/page.tsx +++ b/app/(site)/docs/components/select/page.tsx @@ -27,6 +27,9 @@ interface SelectProps { className?: string; triggerClassName?: string; contentClassName?: string; + snapPoints?: number[]; + initialSnapIndex?: number; + avoidKeyboard?: boolean; children: React.ReactNode; } @@ -63,6 +66,9 @@ const Select = React.forwardRef( className, triggerClassName, contentClassName, + snapPoints = [0.5, 0.8], + initialSnapIndex = 0, + avoidKeyboard = true, children, }, ref @@ -73,49 +79,53 @@ const Select = React.forwardRef( React.useState(""); React.useEffect(() => { - if (value === undefined) return; + setSelectedValue(value); + }, [value]); + + React.useEffect(() => { + if (selectedValue === undefined) return; + + let found = false; - React.Children.forEach(children, (child) => { + const findLabel = (child: React.ReactNode) => { if (!React.isValidElement(child)) return; const childElement = child as React.ReactElement; if ( childElement.type === SelectItem && - childElement.props.value === value + childElement.props.value === selectedValue ) { setSelectedLabel(childElement.props.children); - setSelectedValue(value); + found = true; return; } if (childElement.type === SelectGroup) { - React.Children.forEach(childElement.props.children, (groupChild) => { - if ( - React.isValidElement(groupChild) && - (groupChild as React.ReactElement).type === SelectItem && - (groupChild as React.ReactElement).props.value === value - ) { - setSelectedLabel( - (groupChild as React.ReactElement).props.children - ); - setSelectedValue(value); - } - }); + React.Children.forEach(childElement.props.children, findLabel); } - }); - }, [value, children]); + }; + + React.Children.forEach(children, findLabel); + + if (!found) { + setSelectedLabel(""); + } + }, [selectedValue, children]); const handleSelect = (value: string, label: React.ReactNode) => { - setSelectedValue(value); - setSelectedLabel(label); if (onValueChange) { onValueChange(value); } + if (!onValueChange) { + setSelectedValue(value); + setSelectedLabel(label); + } + setTimeout(() => { setOpen(false); - }, 300); // Delay setting open to false until after the animation completes + }, 300); }; const enhancedChildren = React.Children.map(children, (child) => { @@ -191,11 +201,19 @@ const Select = React.forwardRef( open={open} onClose={() => setOpen(false)} title={placeholder || "Select an option"} - snapPoints={[0.5, 0.8]} - initialSnapIndex={0} + snapPoints={snapPoints} + initialSnapIndex={initialSnapIndex} contentClassName={contentClassName} + avoidKeyboard={avoidKeyboard} + closeOnBackdropPress={true} > - {enhancedChildren} + + {enhancedChildren} +
); diff --git a/public/r/combobox.json b/public/r/combobox.json new file mode 100644 index 0000000..e19ed78 --- /dev/null +++ b/public/r/combobox.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "combobox", + "type": "registry:component", + "title": "Combobox", + "description": "A combobox component for React Native applications.", + "dependencies": [ + "react-native", + "@expo/vector-icons" + ], + "registryDependencies": [ + "https://nativeui.io/registry/drawer.json", + "https://nativeui.io/registry/input.json" + ], + "files": [ + { + "path": "registry/combobox/combobox.tsx", + "content": "import * as React from \"react\";\nimport {\n View,\n Text,\n Pressable,\n Platform,\n FlatList,\n TextInput,\n} from \"react-native\";\nimport { Ionicons } from \"@expo/vector-icons\";\nimport { cn } from \"@/lib/utils\";\nimport { Drawer, useDrawer } from \"@/components/ui/drawer\";\nimport { Input } from \"@/components/ui/input\";\n\ninterface ComboboxProps {\n value?: string;\n onValueChange?: (value: string) => void;\n placeholder?: string;\n searchPlaceholder?: string;\n disabled?: boolean;\n className?: string;\n triggerClassName?: string;\n contentClassName?: string;\n items: {\n value: string;\n label: string;\n disabled?: boolean;\n }[];\n filter?: (value: string, search: string) => boolean;\n emptyText?: string;\n}\n\ninterface ComboboxItemProps {\n value: string;\n children: React.ReactNode;\n disabled?: boolean;\n className?: string;\n onSelect?: (value: string, label: React.ReactNode) => void;\n selectedValue?: string;\n}\n\ninterface ComboboxLabelProps {\n children: React.ReactNode;\n className?: string;\n}\n\ninterface ComboboxGroupProps {\n children: React.ReactNode;\n className?: string;\n}\n\ninterface ComboboxSeparatorProps {\n className?: string;\n}\n\nconst searchState = {\n value: \"\",\n listeners: new Set<() => void>(),\n\n setValue(newValue: string) {\n this.value = newValue;\n this.notifyListeners();\n },\n\n addListener(listener: () => void) {\n this.listeners.add(listener);\n return () => {\n this.listeners.delete(listener);\n };\n },\n\n notifyListeners() {\n this.listeners.forEach((listener) => listener());\n },\n};\n\nconst ComboboxSearchInput = () => {\n const [localValue, setLocalValue] = React.useState(searchState.value);\n const inputRef = React.useRef(null);\n\n React.useEffect(() => {\n const unsubscribe = searchState.addListener(() => {\n setLocalValue(searchState.value);\n });\n return unsubscribe;\n }, []);\n\n const handleChangeText = (text: string) => {\n setLocalValue(text);\n searchState.setValue(text);\n };\n\n const handleClear = () => {\n setLocalValue(\"\");\n searchState.setValue(\"\");\n inputRef.current?.clear();\n };\n\n return (\n \n \n \n \n \n \n {localValue.length > 0 && (\n \n \n \n \n \n )}\n \n \n );\n};\n\nconst ComboboxItemsList = ({\n items,\n selectedValue,\n onSelect,\n filter,\n emptyText,\n}: {\n items: ComboboxProps[\"items\"];\n selectedValue?: string;\n onSelect: (value: string) => void;\n filter: (value: string, search: string) => boolean;\n emptyText: string;\n}) => {\n const [filteredItems, setFilteredItems] = React.useState(items);\n\n React.useEffect(() => {\n const updateFilter = () => {\n if (!searchState.value) {\n setFilteredItems(items);\n } else {\n setFilteredItems(\n items.filter((item) => filter(item.value, searchState.value))\n );\n }\n };\n\n updateFilter();\n\n const unsubscribe = searchState.addListener(updateFilter);\n return unsubscribe;\n }, [items, filter]);\n\n if (filteredItems.length === 0) {\n return (\n \n {emptyText}\n \n );\n }\n\n return (\n item.value}\n keyboardShouldPersistTaps=\"handled\"\n nestedScrollEnabled={true}\n renderItem={({ item }) => (\n \n {item.label}\n \n )}\n contentContainerStyle={{ paddingBottom: 20 }}\n />\n );\n};\n\nconst Combobox = React.forwardRef(\n (\n {\n value,\n onValueChange,\n placeholder = \"Select an option\",\n searchPlaceholder = \"Search...\",\n disabled = false,\n className,\n triggerClassName,\n contentClassName,\n items = [],\n filter,\n emptyText = \"No results found.\",\n },\n ref\n ) => {\n const [isOpen, setIsOpen] = React.useState(false);\n const [selectedValue, setSelectedValue] = React.useState(value);\n\n React.useEffect(() => {\n if (!isOpen) {\n setTimeout(() => {\n searchState.setValue(\"\");\n }, 100);\n }\n }, [isOpen]);\n\n React.useEffect(() => {\n setSelectedValue(value);\n }, [value]);\n\n const defaultFilter = React.useCallback(\n (itemValue: string, search: string) => {\n const label =\n items.find((item) => item.value === itemValue)?.label || \"\";\n return label.toLowerCase().includes(search.toLowerCase());\n },\n [items]\n );\n\n const filterFn = filter || defaultFilter;\n\n const selectedLabel = React.useMemo(() => {\n if (!selectedValue) return \"\";\n return items.find((item) => item.value === selectedValue)?.label || \"\";\n }, [selectedValue, items]);\n\n const handleSelect = React.useCallback(\n (itemValue: string) => {\n setSelectedValue(itemValue);\n if (onValueChange) {\n onValueChange(itemValue);\n }\n },\n [onValueChange]\n );\n\n return (\n \n setIsOpen(true)}\n className={cn(\n \"flex-row h-12 items-center justify-between rounded-md border border-input bg-transparent px-3 py-2\",\n \"shadow-sm\",\n \"active:opacity-70\",\n disabled && \"opacity-50\",\n Platform.OS === \"ios\"\n ? \"ios:shadow-sm ios:shadow-foreground/10\"\n : \"android:elevation-1\",\n triggerClassName\n )}\n >\n \n {selectedValue ? selectedLabel : placeholder}\n \n\n \n \n\n setIsOpen(false)}\n title={placeholder}\n snapPoints={[0.5, 0.8]}\n initialSnapIndex={0}\n contentClassName={contentClassName}\n >\n \n \n \n \n );\n }\n);\n\nCombobox.displayName = \"Combobox\";\n\nconst ComboboxGroup = React.forwardRef(\n ({ className, children, ...props }, ref) => {\n return (\n \n {children}\n \n );\n }\n);\n\nComboboxGroup.displayName = \"ComboboxGroup\";\n\nconst ComboboxItem = React.forwardRef(\n (\n { className, children, value, disabled, onSelect, selectedValue, ...props },\n ref\n ) => {\n const isSelected = selectedValue === value;\n const drawer = useDrawer();\n\n return (\n {\n if (onSelect) {\n onSelect(value, children);\n }\n drawer.animateClose();\n }}\n className={cn(\n \"flex-row h-14 items-center justify-between px-4 py-2 active:bg-accent/50\",\n isSelected ? \"bg-accent\" : \"\",\n disabled && \"opacity-50\",\n className\n )}\n {...props}\n >\n \n {children}\n \n\n {isSelected && }\n \n );\n }\n);\n\nComboboxItem.displayName = \"ComboboxItem\";\n\nconst ComboboxLabel = React.forwardRef(\n ({ className, children, ...props }, ref) => {\n return (\n \n {children}\n \n );\n }\n);\n\nComboboxLabel.displayName = \"ComboboxLabel\";\n\nconst ComboboxSeparator = React.forwardRef(\n ({ className, ...props }, ref) => {\n return (\n \n );\n }\n);\n\nComboboxSeparator.displayName = \"ComboboxSeparator\";\n\nexport {\n Combobox,\n ComboboxGroup,\n ComboboxItem,\n ComboboxLabel,\n ComboboxSeparator,\n};\n", + "type": "registry:component" + } + ] +} \ No newline at end of file diff --git a/public/r/drawer.json b/public/r/drawer.json index d96b305..8de5b8c 100644 --- a/public/r/drawer.json +++ b/public/r/drawer.json @@ -11,7 +11,7 @@ "files": [ { "path": "registry/drawer/drawer.tsx", - "content": "import * as React from \"react\";\nimport {\n View,\n Text,\n Modal,\n TouchableWithoutFeedback,\n Platform,\n Animated,\n PanResponder,\n Dimensions,\n StyleSheet,\n Easing,\n} from \"react-native\";\nimport { cn } from \"@/lib/utils\";\n\ninterface DrawerProps {\n open: boolean;\n onClose: () => void;\n children: React.ReactNode;\n title?: string;\n snapPoints?: number[];\n initialSnapIndex?: number;\n className?: string;\n contentClassName?: string;\n}\n\nconst { height: SCREEN_HEIGHT } = Dimensions.get(\"window\");\nconst DEFAULT_SNAP_POINTS = [0.5, 0.9]; // 0.5 = 50% of screen height, 0.9 = 90% of screen height\n\nexport const DrawerContext = React.createContext<{ animateClose: () => void }>({\n animateClose: () => {},\n});\n\nexport const useDrawer = () => React.useContext(DrawerContext);\n\nconst Drawer = React.forwardRef(\n (\n {\n open,\n onClose,\n children,\n title,\n snapPoints = DEFAULT_SNAP_POINTS,\n initialSnapIndex = 0,\n className,\n contentClassName,\n },\n ref\n ) => {\n const snapPointsPixels = snapPoints.map(\n (point) => SCREEN_HEIGHT - SCREEN_HEIGHT * point\n );\n\n const activeSnapIndex = React.useRef(initialSnapIndex);\n const translateY = React.useRef(new Animated.Value(SCREEN_HEIGHT)).current;\n const backdropOpacity = React.useRef(new Animated.Value(0)).current;\n const isClosing = React.useRef(false);\n\n const animateOpen = React.useCallback(() => {\n translateY.setValue(SCREEN_HEIGHT);\n backdropOpacity.setValue(0);\n isClosing.current = false;\n\n Animated.timing(backdropOpacity, {\n toValue: 1,\n duration: 180,\n useNativeDriver: true,\n easing: Easing.out(Easing.ease),\n }).start();\n\n Animated.spring(translateY, {\n toValue: snapPointsPixels[initialSnapIndex],\n useNativeDriver: true,\n velocity: 3,\n tension: 120,\n friction: 22,\n }).start();\n\n activeSnapIndex.current = initialSnapIndex;\n }, [backdropOpacity, translateY, snapPointsPixels, initialSnapIndex]);\n\n const animateClose = React.useCallback(() => {\n if (isClosing.current) return;\n\n isClosing.current = true;\n\n Animated.spring(translateY, {\n toValue: SCREEN_HEIGHT,\n useNativeDriver: true,\n friction: 26,\n tension: 100,\n velocity: 0.5,\n }).start();\n\n Animated.timing(backdropOpacity, {\n toValue: 0,\n duration: 280,\n easing: Easing.out(Easing.ease),\n useNativeDriver: true,\n delay: 100,\n }).start(() => {\n onClose();\n isClosing.current = false;\n });\n }, [backdropOpacity, translateY, onClose]);\n\n React.useEffect(() => {\n if (open && !isClosing.current) {\n animateOpen();\n }\n }, [open, animateOpen]);\n\n // Ensure drawer animates close when open becomes false\n React.useEffect(() => {\n if (!open && !isClosing.current) {\n animateClose();\n }\n }, [open, animateClose]);\n\n const animateToSnapPoint = (index: number, velocity = 0) => {\n if (index < 0 || index >= snapPointsPixels.length) return;\n\n activeSnapIndex.current = index;\n\n Animated.spring(translateY, {\n toValue: snapPointsPixels[index],\n useNativeDriver: true,\n velocity: velocity,\n tension: 120,\n friction: 22,\n }).start();\n };\n\n const getTargetSnapIndex = (\n currentY: number,\n velocity: number,\n dragDirection: \"up\" | \"down\"\n ) => {\n const isDraggingDown = dragDirection === \"down\";\n\n if (\n activeSnapIndex.current === snapPointsPixels.length - 1 &&\n isDraggingDown\n ) {\n return snapPointsPixels.length - 2;\n }\n\n if (activeSnapIndex.current === 1 && isDraggingDown && velocity > 0.3) {\n return 0;\n }\n\n if (activeSnapIndex.current === 0 && isDraggingDown && velocity > 0.5) {\n return -1;\n }\n\n if (currentY > snapPointsPixels[0] + 100) {\n return -1;\n }\n\n if (dragDirection === \"up\" && velocity > 0.3) {\n const nextIndex = Math.min(\n activeSnapIndex.current + 1,\n snapPointsPixels.length - 1\n );\n return nextIndex;\n }\n\n let closestIndex = 0;\n let minDistance = Math.abs(currentY - snapPointsPixels[0]);\n\n for (let i = 1; i < snapPointsPixels.length; i++) {\n const distance = Math.abs(currentY - snapPointsPixels[i]);\n if (distance < minDistance) {\n minDistance = distance;\n closestIndex = i;\n }\n }\n\n return closestIndex;\n };\n\n const panResponder = React.useMemo(() => {\n let startY = 0;\n const maxDragPoint = snapPointsPixels[snapPointsPixels.length - 1];\n\n return PanResponder.create({\n onStartShouldSetPanResponder: () => true,\n onMoveShouldSetPanResponder: (_, { dy }) => Math.abs(dy) > 5,\n\n onPanResponderGrant: (_, { y0 }) => {\n startY = y0;\n translateY.stopAnimation();\n },\n\n onPanResponderMove: (_, { dy }) => {\n if (isClosing.current) return;\n\n const currentSnapY = snapPointsPixels[activeSnapIndex.current];\n let newY = currentSnapY + dy;\n\n if (newY < maxDragPoint) {\n const overscroll = maxDragPoint - newY;\n const resistedOverscroll = -Math.log10(1 + overscroll * 0.1) * 10;\n newY = maxDragPoint + resistedOverscroll;\n }\n\n translateY.setValue(newY);\n },\n\n onPanResponderRelease: (_, { dy, vy, moveY }) => {\n if (isClosing.current) return;\n\n const dragDirection = dy > 0 ? \"down\" : \"up\";\n const currentY = snapPointsPixels[activeSnapIndex.current] + dy;\n const absVelocity = Math.abs(vy);\n\n const targetIndex = getTargetSnapIndex(\n currentY,\n absVelocity,\n dragDirection\n );\n\n if (targetIndex === -1) {\n animateClose();\n } else {\n animateToSnapPoint(targetIndex, vy);\n }\n },\n });\n }, [snapPointsPixels, onClose, translateY, animateClose]);\n\n if (!open) return null;\n\n return (\n \n \n \n \n \n \n \n \n\n \n \n \n \n \n\n {title && (\n \n \n {title}\n \n \n )}\n \n\n \n {children}\n \n \n \n \n \n );\n }\n);\n\nconst styles = StyleSheet.create({\n backdrop: {\n ...StyleSheet.absoluteFillObject,\n backgroundColor: \"rgba(0, 0, 0, 0.4)\",\n },\n drawerContainer: {\n height: SCREEN_HEIGHT,\n paddingBottom: 20,\n shadowColor: \"#000\",\n shadowOffset: { width: 0, height: -3 },\n shadowOpacity: 0.15,\n shadowRadius: 8,\n elevation: 24,\n },\n});\n\nDrawer.displayName = \"Drawer\";\n\nexport { Drawer };\n", + "content": "import * as React from \"react\";\nimport {\n View,\n Text,\n Modal,\n TouchableWithoutFeedback,\n Platform,\n Animated,\n PanResponder,\n Dimensions,\n StyleSheet,\n Easing,\n KeyboardAvoidingView,\n} from \"react-native\";\nimport { SafeAreaView } from \"react-native-safe-area-context\";\nimport { cn } from \"@/lib/utils\";\n\ninterface DrawerProps {\n open: boolean;\n onClose: () => void;\n children: React.ReactNode;\n title?: string;\n snapPoints?: number[];\n initialSnapIndex?: number;\n className?: string;\n contentClassName?: string;\n avoidKeyboard?: boolean;\n closeOnBackdropPress?: boolean;\n disableBackHandler?: boolean;\n}\n\nconst { height: SCREEN_HEIGHT } = Dimensions.get(\"window\");\nconst DEFAULT_SNAP_POINTS = [0.5, 0.9]; // 0.5 = 50% of screen height, 0.9 = 90% of screen height\n\nexport const DrawerContext = React.createContext<{ animateClose: () => void }>({\n animateClose: () => {},\n});\n\nexport const useDrawer = () => React.useContext(DrawerContext);\n\nconst Drawer = React.forwardRef(\n (\n {\n open,\n onClose,\n children,\n title,\n snapPoints = DEFAULT_SNAP_POINTS,\n initialSnapIndex = 0,\n className,\n contentClassName,\n avoidKeyboard = true,\n closeOnBackdropPress = true,\n disableBackHandler = false,\n },\n ref\n ) => {\n const [isVisible, setIsVisible] = React.useState(false);\n const snapPointsPixels = snapPoints.map(\n (point) => SCREEN_HEIGHT - SCREEN_HEIGHT * point\n );\n\n const activeSnapIndex = React.useRef(initialSnapIndex);\n const translateY = React.useRef(new Animated.Value(SCREEN_HEIGHT)).current;\n const backdropOpacity = React.useRef(new Animated.Value(0)).current;\n const isClosing = React.useRef(false);\n\n const animateOpen = React.useCallback(() => {\n translateY.setValue(SCREEN_HEIGHT);\n backdropOpacity.setValue(0);\n isClosing.current = false;\n\n Animated.timing(backdropOpacity, {\n toValue: 1,\n duration: 180,\n useNativeDriver: true,\n easing: Easing.out(Easing.ease),\n }).start();\n\n Animated.spring(translateY, {\n toValue: snapPointsPixels[initialSnapIndex],\n useNativeDriver: true,\n velocity: 3,\n tension: 120,\n friction: 22,\n }).start();\n\n activeSnapIndex.current = initialSnapIndex;\n }, [backdropOpacity, translateY, snapPointsPixels, initialSnapIndex]);\n\n const animateClose = React.useCallback(() => {\n if (isClosing.current) return;\n\n isClosing.current = true;\n\n Animated.spring(translateY, {\n toValue: SCREEN_HEIGHT,\n useNativeDriver: true,\n friction: 26,\n tension: 100,\n velocity: 0.5,\n }).start();\n\n Animated.timing(backdropOpacity, {\n toValue: 0,\n duration: 280,\n easing: Easing.out(Easing.ease),\n useNativeDriver: true,\n delay: 100,\n }).start(() => {\n setIsVisible(false);\n isClosing.current = false;\n onClose();\n });\n }, [backdropOpacity, translateY, onClose]);\n\n React.useEffect(() => {\n if (open && !isVisible) {\n setIsVisible(true);\n } else if (open && !isClosing.current) {\n animateOpen();\n } else if (!open && isVisible && !isClosing.current) {\n animateClose();\n }\n }, [open, isVisible, animateOpen, animateClose, isClosing]);\n\n const animateToSnapPoint = (index: number, velocity = 0) => {\n if (index < 0 || index >= snapPointsPixels.length) return;\n\n activeSnapIndex.current = index;\n\n Animated.spring(translateY, {\n toValue: snapPointsPixels[index],\n useNativeDriver: true,\n velocity: velocity,\n tension: 120,\n friction: 22,\n }).start();\n };\n\n const getTargetSnapIndex = (\n currentY: number,\n velocity: number,\n dragDirection: \"up\" | \"down\"\n ) => {\n const isDraggingDown = dragDirection === \"down\";\n\n if (\n activeSnapIndex.current === snapPointsPixels.length - 1 &&\n isDraggingDown\n ) {\n return snapPointsPixels.length - 2;\n }\n\n if (activeSnapIndex.current === 1 && isDraggingDown && velocity > 0.3) {\n return 0;\n }\n\n if (activeSnapIndex.current === 0 && isDraggingDown && velocity > 0.5) {\n return -1;\n }\n\n if (currentY > snapPointsPixels[0] + 100) {\n return -1;\n }\n\n if (dragDirection === \"up\" && velocity > 0.3) {\n const nextIndex = Math.min(\n activeSnapIndex.current + 1,\n snapPointsPixels.length - 1\n );\n return nextIndex;\n }\n\n let closestIndex = 0;\n let minDistance = Math.abs(currentY - snapPointsPixels[0]);\n\n for (let i = 1; i < snapPointsPixels.length; i++) {\n const distance = Math.abs(currentY - snapPointsPixels[i]);\n if (distance < minDistance) {\n minDistance = distance;\n closestIndex = i;\n }\n }\n\n return closestIndex;\n };\n\n const panResponder = React.useMemo(() => {\n let startY = 0;\n const maxDragPoint = snapPointsPixels[snapPointsPixels.length - 1];\n\n return PanResponder.create({\n onStartShouldSetPanResponder: () => true,\n onMoveShouldSetPanResponder: (_, { dy }) => Math.abs(dy) > 5,\n\n onPanResponderGrant: (_, { y0 }) => {\n startY = y0;\n translateY.stopAnimation();\n },\n\n onPanResponderMove: (_, { dy }) => {\n if (isClosing.current) return;\n\n const currentSnapY = snapPointsPixels[activeSnapIndex.current];\n let newY = currentSnapY + dy;\n\n if (newY < maxDragPoint) {\n const overscroll = maxDragPoint - newY;\n const resistedOverscroll = -Math.log10(1 + overscroll * 0.1) * 10;\n newY = maxDragPoint + resistedOverscroll;\n }\n\n translateY.setValue(newY);\n },\n\n onPanResponderRelease: (_, { dy, vy, moveY }) => {\n if (isClosing.current) return;\n\n const dragDirection = dy > 0 ? \"down\" : \"up\";\n const currentY = snapPointsPixels[activeSnapIndex.current] + dy;\n const absVelocity = Math.abs(vy);\n\n const targetIndex = getTargetSnapIndex(\n currentY,\n absVelocity,\n dragDirection\n );\n\n if (targetIndex === -1) {\n animateClose();\n } else {\n animateToSnapPoint(targetIndex, vy);\n }\n },\n });\n }, [snapPointsPixels, animateClose]);\n\n if (!isVisible) return null;\n\n const renderContent = () => (\n \n \n {closeOnBackdropPress && (\n \n \n \n )}\n \n\n \n \n \n \n \n\n {title && (\n \n \n {title}\n \n \n )}\n \n\n \n \n {children}\n \n \n \n \n );\n\n return (\n \n \n {avoidKeyboard && Platform.OS === \"ios\" ? (\n \n {renderContent()}\n \n ) : (\n renderContent()\n )}\n \n \n );\n }\n);\n\nconst styles = StyleSheet.create({\n backdrop: {\n ...StyleSheet.absoluteFillObject,\n backgroundColor: \"rgba(0, 0, 0, 0.4)\",\n },\n drawerContainer: {\n height: SCREEN_HEIGHT,\n paddingBottom: 20,\n shadowColor: \"#000\",\n shadowOffset: { width: 0, height: -3 },\n shadowOpacity: 0.15,\n shadowRadius: 8,\n elevation: 24,\n },\n});\n\nDrawer.displayName = \"Drawer\";\n\nexport { Drawer };\n", "type": "registry:component" } ] diff --git a/public/r/select.json b/public/r/select.json index cd9e6ff..eace019 100644 --- a/public/r/select.json +++ b/public/r/select.json @@ -14,7 +14,7 @@ "files": [ { "path": "registry/select/select.tsx", - "content": "import * as React from \"react\";\nimport { View, Text, Pressable, ScrollView, Platform } from \"react-native\";\nimport { Ionicons } from \"@expo/vector-icons\";\nimport { cn } from \"@/lib/utils\";\nimport { Drawer, useDrawer } from \"@/components/ui/drawer\";\n\ninterface SelectProps {\n value?: string;\n onValueChange?: (value: string) => void;\n placeholder?: string;\n disabled?: boolean;\n className?: string;\n triggerClassName?: string;\n contentClassName?: string;\n children: React.ReactNode;\n}\n\ninterface SelectItemProps {\n value: string;\n children: React.ReactNode;\n disabled?: boolean;\n className?: string;\n onSelect?: (value: string, label: React.ReactNode) => void;\n selectedValue?: string;\n}\n\ninterface SelectLabelProps {\n children: React.ReactNode;\n className?: string;\n}\n\ninterface SelectGroupProps {\n children: React.ReactNode;\n className?: string;\n}\n\ninterface SelectSeparatorProps {\n className?: string;\n}\n\nconst Select = React.forwardRef(\n (\n {\n value,\n onValueChange,\n placeholder,\n disabled = false,\n className,\n triggerClassName,\n contentClassName,\n children,\n },\n ref\n ) => {\n const [open, setOpen] = React.useState(false);\n const [selectedValue, setSelectedValue] = React.useState(value);\n const [selectedLabel, setSelectedLabel] =\n React.useState(\"\");\n\n React.useEffect(() => {\n if (value === undefined) return;\n\n React.Children.forEach(children, (child) => {\n if (!React.isValidElement(child)) return;\n\n const childElement = child as React.ReactElement;\n\n if (\n childElement.type === SelectItem &&\n childElement.props.value === value\n ) {\n setSelectedLabel(childElement.props.children);\n setSelectedValue(value);\n return;\n }\n\n if (childElement.type === SelectGroup) {\n React.Children.forEach(childElement.props.children, (groupChild) => {\n if (\n React.isValidElement(groupChild) &&\n (groupChild as React.ReactElement).type === SelectItem &&\n (groupChild as React.ReactElement).props.value === value\n ) {\n setSelectedLabel(\n (groupChild as React.ReactElement).props.children\n );\n setSelectedValue(value);\n }\n });\n }\n });\n }, [value, children]);\n\n const handleSelect = (value: string, label: React.ReactNode) => {\n setSelectedValue(value);\n setSelectedLabel(label);\n if (onValueChange) {\n onValueChange(value);\n }\n\n setTimeout(() => {\n setOpen(false);\n }, 300); // Delay setting open to false until after the animation completes\n };\n\n const enhancedChildren = React.Children.map(children, (child) => {\n if (!React.isValidElement(child)) return child;\n\n const childElement = child as React.ReactElement;\n\n if (childElement.type === SelectItem) {\n return React.cloneElement(childElement, {\n onSelect: handleSelect,\n selectedValue,\n });\n }\n\n if (childElement.type === SelectGroup) {\n const groupChildren = React.Children.map(\n childElement.props.children,\n (groupChild) => {\n if (\n React.isValidElement(groupChild) &&\n (groupChild as React.ReactElement).type === SelectItem\n ) {\n return React.cloneElement(groupChild as React.ReactElement, {\n onSelect: handleSelect,\n selectedValue,\n });\n }\n return groupChild;\n }\n );\n return React.cloneElement(childElement, {}, groupChildren);\n }\n\n return child;\n });\n\n return (\n \n setOpen(true)}\n className={cn(\n \"flex-row h-12 items-center justify-between rounded-md border border-input bg-transparent px-3 py-2\",\n \"shadow-sm\",\n \"active:opacity-70\",\n disabled && \"opacity-50\",\n Platform.OS === \"ios\"\n ? \"ios:shadow-sm ios:shadow-foreground/10\"\n : \"android:elevation-1\",\n triggerClassName\n )}\n >\n \n {selectedValue ? selectedLabel : placeholder || \"Select an option\"}\n \n\n \n \n\n setOpen(false)}\n title={placeholder || \"Select an option\"}\n snapPoints={[0.5, 0.8]}\n initialSnapIndex={0}\n contentClassName={contentClassName}\n >\n {enhancedChildren}\n \n \n );\n }\n);\n\nSelect.displayName = \"Select\";\n\nconst SelectGroup = React.forwardRef(\n ({ className, children, ...props }, ref) => {\n return (\n \n {children}\n \n );\n }\n);\n\nSelectGroup.displayName = \"SelectGroup\";\n\nconst SelectItem = React.forwardRef(\n (\n { className, children, value, disabled, onSelect, selectedValue, ...props },\n ref\n ) => {\n const isSelected = selectedValue === value;\n const drawer = useDrawer();\n\n return (\n {\n if (onSelect) {\n onSelect(value, children);\n }\n\n drawer.animateClose();\n }}\n className={cn(\n \"flex-row h-14 items-center justify-between px-4 py-2 active:bg-accent/50\",\n isSelected ? \"bg-accent\" : \"\",\n disabled && \"opacity-50\",\n className\n )}\n {...props}\n >\n \n {children}\n \n\n {isSelected && }\n \n );\n }\n);\n\nSelectItem.displayName = \"SelectItem\";\n\nconst SelectLabel = React.forwardRef(\n ({ className, children, ...props }, ref) => {\n return (\n \n {children}\n \n );\n }\n);\n\nSelectLabel.displayName = \"SelectLabel\";\n\nconst SelectSeparator = React.forwardRef(\n ({ className, ...props }, ref) => {\n return (\n \n );\n }\n);\n\nSelectSeparator.displayName = \"SelectSeparator\";\n\nexport { Select, SelectGroup, SelectItem, SelectLabel, SelectSeparator };\n", + "content": "import * as React from \"react\";\nimport { View, Text, Pressable, ScrollView, Platform } from \"react-native\";\nimport { Ionicons } from \"@expo/vector-icons\";\nimport { cn } from \"@/lib/utils\";\nimport { Drawer, useDrawer } from \"@/components/ui/drawer\";\n\ninterface SelectProps {\n value?: string;\n onValueChange?: (value: string) => void;\n placeholder?: string;\n disabled?: boolean;\n className?: string;\n triggerClassName?: string;\n contentClassName?: string;\n snapPoints?: number[];\n initialSnapIndex?: number;\n avoidKeyboard?: boolean;\n children: React.ReactNode;\n}\n\ninterface SelectItemProps {\n value: string;\n children: React.ReactNode;\n disabled?: boolean;\n className?: string;\n onSelect?: (value: string, label: React.ReactNode) => void;\n selectedValue?: string;\n}\n\ninterface SelectLabelProps {\n children: React.ReactNode;\n className?: string;\n}\n\ninterface SelectGroupProps {\n children: React.ReactNode;\n className?: string;\n}\n\ninterface SelectSeparatorProps {\n className?: string;\n}\n\nconst Select = React.forwardRef(\n (\n {\n value,\n onValueChange,\n placeholder,\n disabled = false,\n className,\n triggerClassName,\n contentClassName,\n snapPoints = [0.5, 0.8],\n initialSnapIndex = 0,\n avoidKeyboard = true,\n children,\n },\n ref\n ) => {\n const [open, setOpen] = React.useState(false);\n const [selectedValue, setSelectedValue] = React.useState(value);\n const [selectedLabel, setSelectedLabel] =\n React.useState(\"\");\n\n React.useEffect(() => {\n setSelectedValue(value);\n }, [value]);\n\n React.useEffect(() => {\n if (selectedValue === undefined) return;\n\n let found = false;\n\n const findLabel = (child: React.ReactNode) => {\n if (!React.isValidElement(child)) return;\n\n const childElement = child as React.ReactElement;\n\n if (\n childElement.type === SelectItem &&\n childElement.props.value === selectedValue\n ) {\n setSelectedLabel(childElement.props.children);\n found = true;\n return;\n }\n\n if (childElement.type === SelectGroup) {\n React.Children.forEach(childElement.props.children, findLabel);\n }\n };\n\n React.Children.forEach(children, findLabel);\n\n if (!found) {\n setSelectedLabel(\"\");\n }\n }, [selectedValue, children]);\n\n const handleSelect = (value: string, label: React.ReactNode) => {\n if (onValueChange) {\n onValueChange(value);\n }\n\n if (!onValueChange) {\n setSelectedValue(value);\n setSelectedLabel(label);\n }\n\n setTimeout(() => {\n setOpen(false);\n }, 300);\n };\n\n const enhancedChildren = React.Children.map(children, (child) => {\n if (!React.isValidElement(child)) return child;\n\n const childElement = child as React.ReactElement;\n\n if (childElement.type === SelectItem) {\n return React.cloneElement(childElement, {\n onSelect: handleSelect,\n selectedValue,\n });\n }\n\n if (childElement.type === SelectGroup) {\n const groupChildren = React.Children.map(\n childElement.props.children,\n (groupChild) => {\n if (\n React.isValidElement(groupChild) &&\n (groupChild as React.ReactElement).type === SelectItem\n ) {\n return React.cloneElement(groupChild as React.ReactElement, {\n onSelect: handleSelect,\n selectedValue,\n });\n }\n return groupChild;\n }\n );\n return React.cloneElement(childElement, {}, groupChildren);\n }\n\n return child;\n });\n\n return (\n \n setOpen(true)}\n className={cn(\n \"flex-row h-12 items-center justify-between rounded-md border border-input bg-transparent px-3 py-2\",\n \"shadow-sm\",\n \"active:opacity-70\",\n disabled && \"opacity-50\",\n Platform.OS === \"ios\"\n ? \"ios:shadow-sm ios:shadow-foreground/10\"\n : \"android:elevation-1\",\n triggerClassName\n )}\n >\n \n {selectedValue ? selectedLabel : placeholder || \"Select an option\"}\n \n\n \n \n\n setOpen(false)}\n title={placeholder || \"Select an option\"}\n snapPoints={snapPoints}\n initialSnapIndex={initialSnapIndex}\n contentClassName={contentClassName}\n avoidKeyboard={avoidKeyboard}\n closeOnBackdropPress={true}\n >\n \n {enhancedChildren}\n \n \n \n );\n }\n);\n\nSelect.displayName = \"Select\";\n\nconst SelectGroup = React.forwardRef(\n ({ className, children, ...props }, ref) => {\n return (\n \n {children}\n \n );\n }\n);\n\nSelectGroup.displayName = \"SelectGroup\";\n\nconst SelectItem = React.forwardRef(\n (\n { className, children, value, disabled, onSelect, selectedValue, ...props },\n ref\n ) => {\n const isSelected = selectedValue === value;\n const drawer = useDrawer();\n\n return (\n {\n if (onSelect) {\n onSelect(value, children);\n }\n\n drawer.animateClose();\n }}\n className={cn(\n \"flex-row h-14 items-center justify-between px-4 py-2 active:bg-accent/50\",\n isSelected ? \"bg-accent\" : \"\",\n disabled && \"opacity-50\",\n className\n )}\n {...props}\n >\n \n {children}\n \n\n {isSelected && }\n \n );\n }\n);\n\nSelectItem.displayName = \"SelectItem\";\n\nconst SelectLabel = React.forwardRef(\n ({ className, children, ...props }, ref) => {\n return (\n \n {children}\n \n );\n }\n);\n\nSelectLabel.displayName = \"SelectLabel\";\n\nconst SelectSeparator = React.forwardRef(\n ({ className, ...props }, ref) => {\n return (\n \n );\n }\n);\n\nSelectSeparator.displayName = \"SelectSeparator\";\n\nexport { Select, SelectGroup, SelectItem, SelectLabel, SelectSeparator };\n", "type": "registry:component" } ] diff --git a/registry.json b/registry.json index 63310fb..74d1ea1 100644 --- a/registry.json +++ b/registry.json @@ -198,6 +198,23 @@ ], "dependencies": ["react-native"], "registryDependencies": [] + }, + { + "name": "combobox", + "type": "registry:component", + "title": "Combobox", + "description": "A combobox component for React Native applications.", + "files": [ + { + "path": "registry/combobox/combobox.tsx", + "type": "registry:component" + } + ], + "dependencies": ["react-native", "@expo/vector-icons"], + "registryDependencies": [ + "https://nativeui.io/registry/drawer.json", + "https://nativeui.io/registry/input.json" + ] } ] } diff --git a/registry/combobox/combobox.tsx b/registry/combobox/combobox.tsx new file mode 100644 index 0000000..cce264d --- /dev/null +++ b/registry/combobox/combobox.tsx @@ -0,0 +1,402 @@ +import * as React from "react"; +import { + View, + Text, + Pressable, + Platform, + FlatList, + TextInput, +} from "react-native"; +import { Ionicons } from "@expo/vector-icons"; +import { cn } from "@/lib/utils"; +import { Drawer, useDrawer } from "@/components/ui/drawer"; +import { Input } from "@/components/ui/input"; + +interface ComboboxProps { + value?: string; + onValueChange?: (value: string) => void; + placeholder?: string; + searchPlaceholder?: string; + disabled?: boolean; + className?: string; + triggerClassName?: string; + contentClassName?: string; + items: { + value: string; + label: string; + disabled?: boolean; + }[]; + filter?: (value: string, search: string) => boolean; + emptyText?: string; +} + +interface ComboboxItemProps { + value: string; + children: React.ReactNode; + disabled?: boolean; + className?: string; + onSelect?: (value: string, label: React.ReactNode) => void; + selectedValue?: string; +} + +interface ComboboxLabelProps { + children: React.ReactNode; + className?: string; +} + +interface ComboboxGroupProps { + children: React.ReactNode; + className?: string; +} + +interface ComboboxSeparatorProps { + className?: string; +} + +const searchState = { + value: "", + listeners: new Set<() => void>(), + + setValue(newValue: string) { + this.value = newValue; + this.notifyListeners(); + }, + + addListener(listener: () => void) { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + }, + + notifyListeners() { + this.listeners.forEach((listener) => listener()); + }, +}; + +const ComboboxSearchInput = () => { + const [localValue, setLocalValue] = React.useState(searchState.value); + const inputRef = React.useRef(null); + + React.useEffect(() => { + const unsubscribe = searchState.addListener(() => { + setLocalValue(searchState.value); + }); + return unsubscribe; + }, []); + + const handleChangeText = (text: string) => { + setLocalValue(text); + searchState.setValue(text); + }; + + const handleClear = () => { + setLocalValue(""); + searchState.setValue(""); + inputRef.current?.clear(); + }; + + return ( + + + + + + + {localValue.length > 0 && ( + + + + + + )} + + + ); +}; + +const ComboboxItemsList = ({ + items, + selectedValue, + onSelect, + filter, + emptyText, +}: { + items: ComboboxProps["items"]; + selectedValue?: string; + onSelect: (value: string) => void; + filter: (value: string, search: string) => boolean; + emptyText: string; +}) => { + const [filteredItems, setFilteredItems] = React.useState(items); + + React.useEffect(() => { + const updateFilter = () => { + if (!searchState.value) { + setFilteredItems(items); + } else { + setFilteredItems( + items.filter((item) => filter(item.value, searchState.value)) + ); + } + }; + + updateFilter(); + + const unsubscribe = searchState.addListener(updateFilter); + return unsubscribe; + }, [items, filter]); + + if (filteredItems.length === 0) { + return ( + + {emptyText} + + ); + } + + return ( + item.value} + keyboardShouldPersistTaps="handled" + nestedScrollEnabled={true} + renderItem={({ item }) => ( + + {item.label} + + )} + contentContainerStyle={{ paddingBottom: 20 }} + /> + ); +}; + +const Combobox = React.forwardRef( + ( + { + value, + onValueChange, + placeholder = "Select an option", + searchPlaceholder = "Search...", + disabled = false, + className, + triggerClassName, + contentClassName, + items = [], + filter, + emptyText = "No results found.", + }, + ref + ) => { + const [isOpen, setIsOpen] = React.useState(false); + const [selectedValue, setSelectedValue] = React.useState(value); + + React.useEffect(() => { + if (!isOpen) { + setTimeout(() => { + searchState.setValue(""); + }, 100); + } + }, [isOpen]); + + React.useEffect(() => { + setSelectedValue(value); + }, [value]); + + const defaultFilter = React.useCallback( + (itemValue: string, search: string) => { + const label = + items.find((item) => item.value === itemValue)?.label || ""; + return label.toLowerCase().includes(search.toLowerCase()); + }, + [items] + ); + + const filterFn = filter || defaultFilter; + + const selectedLabel = React.useMemo(() => { + if (!selectedValue) return ""; + return items.find((item) => item.value === selectedValue)?.label || ""; + }, [selectedValue, items]); + + const handleSelect = React.useCallback( + (itemValue: string) => { + setSelectedValue(itemValue); + if (onValueChange) { + onValueChange(itemValue); + } + }, + [onValueChange] + ); + + return ( + + setIsOpen(true)} + className={cn( + "flex-row h-12 items-center justify-between rounded-md border border-input bg-transparent px-3 py-2", + "shadow-sm", + "active:opacity-70", + disabled && "opacity-50", + Platform.OS === "ios" + ? "ios:shadow-sm ios:shadow-foreground/10" + : "android:elevation-1", + triggerClassName + )} + > + + {selectedValue ? selectedLabel : placeholder} + + + + + + setIsOpen(false)} + title={placeholder} + snapPoints={[0.5, 0.8]} + initialSnapIndex={0} + contentClassName={contentClassName} + > + + + + + ); + } +); + +Combobox.displayName = "Combobox"; + +const ComboboxGroup = React.forwardRef( + ({ className, children, ...props }, ref) => { + return ( + + {children} + + ); + } +); + +ComboboxGroup.displayName = "ComboboxGroup"; + +const ComboboxItem = React.forwardRef( + ( + { className, children, value, disabled, onSelect, selectedValue, ...props }, + ref + ) => { + const isSelected = selectedValue === value; + const drawer = useDrawer(); + + return ( + { + if (onSelect) { + onSelect(value, children); + } + drawer.animateClose(); + }} + className={cn( + "flex-row h-14 items-center justify-between px-4 py-2 active:bg-accent/50", + isSelected ? "bg-accent" : "", + disabled && "opacity-50", + className + )} + {...props} + > + + {children} + + + {isSelected && } + + ); + } +); + +ComboboxItem.displayName = "ComboboxItem"; + +const ComboboxLabel = React.forwardRef( + ({ className, children, ...props }, ref) => { + return ( + + {children} + + ); + } +); + +ComboboxLabel.displayName = "ComboboxLabel"; + +const ComboboxSeparator = React.forwardRef( + ({ className, ...props }, ref) => { + return ( + + ); + } +); + +ComboboxSeparator.displayName = "ComboboxSeparator"; + +export { + Combobox, + ComboboxGroup, + ComboboxItem, + ComboboxLabel, + ComboboxSeparator, +}; diff --git a/registry/drawer/drawer.tsx b/registry/drawer/drawer.tsx index c21f2bc..fa48f15 100644 --- a/registry/drawer/drawer.tsx +++ b/registry/drawer/drawer.tsx @@ -10,7 +10,9 @@ import { Dimensions, StyleSheet, Easing, + KeyboardAvoidingView, } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; import { cn } from "@/lib/utils"; interface DrawerProps { @@ -22,6 +24,9 @@ interface DrawerProps { initialSnapIndex?: number; className?: string; contentClassName?: string; + avoidKeyboard?: boolean; + closeOnBackdropPress?: boolean; + disableBackHandler?: boolean; } const { height: SCREEN_HEIGHT } = Dimensions.get("window"); @@ -44,9 +49,13 @@ const Drawer = React.forwardRef( initialSnapIndex = 0, className, contentClassName, + avoidKeyboard = true, + closeOnBackdropPress = true, + disableBackHandler = false, }, ref ) => { + const [isVisible, setIsVisible] = React.useState(false); const snapPointsPixels = snapPoints.map( (point) => SCREEN_HEIGHT - SCREEN_HEIGHT * point ); @@ -99,23 +108,21 @@ const Drawer = React.forwardRef( useNativeDriver: true, delay: 100, }).start(() => { - onClose(); + setIsVisible(false); isClosing.current = false; + onClose(); }); }, [backdropOpacity, translateY, onClose]); React.useEffect(() => { - if (open && !isClosing.current) { + if (open && !isVisible) { + setIsVisible(true); + } else if (open && !isClosing.current) { animateOpen(); - } - }, [open, animateOpen]); - - // Ensure drawer animates close when open becomes false - React.useEffect(() => { - if (!open && !isClosing.current) { + } else if (!open && isVisible && !isClosing.current) { animateClose(); } - }, [open, animateClose]); + }, [open, isVisible, animateOpen, animateClose, isClosing]); const animateToSnapPoint = (index: number, velocity = 0) => { if (index < 0 || index >= snapPointsPixels.length) return; @@ -227,57 +234,71 @@ const Drawer = React.forwardRef( } }, }); - }, [snapPointsPixels, onClose, translateY, animateClose]); + }, [snapPointsPixels, animateClose]); + + if (!isVisible) return null; + + const renderContent = () => ( + + + {closeOnBackdropPress && ( + + + + )} + + + + + + + + + {title && ( + + + {title} + + + )} + - if (!open) return null; + + + {children} + + + + + ); return ( - - - - - - - - - - - - - - {title && ( - - - {title} - - - )} - - - - {children} - - - + {renderContent()} + + ) : ( + renderContent() + )} ); diff --git a/registry/select/select.tsx b/registry/select/select.tsx index 6ec9304..94ad47b 100644 --- a/registry/select/select.tsx +++ b/registry/select/select.tsx @@ -12,6 +12,9 @@ interface SelectProps { className?: string; triggerClassName?: string; contentClassName?: string; + snapPoints?: number[]; + initialSnapIndex?: number; + avoidKeyboard?: boolean; children: React.ReactNode; } @@ -48,6 +51,9 @@ const Select = React.forwardRef( className, triggerClassName, contentClassName, + snapPoints = [0.5, 0.8], + initialSnapIndex = 0, + avoidKeyboard = true, children, }, ref @@ -58,49 +64,53 @@ const Select = React.forwardRef( React.useState(""); React.useEffect(() => { - if (value === undefined) return; + setSelectedValue(value); + }, [value]); + + React.useEffect(() => { + if (selectedValue === undefined) return; + + let found = false; - React.Children.forEach(children, (child) => { + const findLabel = (child: React.ReactNode) => { if (!React.isValidElement(child)) return; const childElement = child as React.ReactElement; if ( childElement.type === SelectItem && - childElement.props.value === value + childElement.props.value === selectedValue ) { setSelectedLabel(childElement.props.children); - setSelectedValue(value); + found = true; return; } if (childElement.type === SelectGroup) { - React.Children.forEach(childElement.props.children, (groupChild) => { - if ( - React.isValidElement(groupChild) && - (groupChild as React.ReactElement).type === SelectItem && - (groupChild as React.ReactElement).props.value === value - ) { - setSelectedLabel( - (groupChild as React.ReactElement).props.children - ); - setSelectedValue(value); - } - }); + React.Children.forEach(childElement.props.children, findLabel); } - }); - }, [value, children]); + }; + + React.Children.forEach(children, findLabel); + + if (!found) { + setSelectedLabel(""); + } + }, [selectedValue, children]); const handleSelect = (value: string, label: React.ReactNode) => { - setSelectedValue(value); - setSelectedLabel(label); if (onValueChange) { onValueChange(value); } + if (!onValueChange) { + setSelectedValue(value); + setSelectedLabel(label); + } + setTimeout(() => { setOpen(false); - }, 300); // Delay setting open to false until after the animation completes + }, 300); }; const enhancedChildren = React.Children.map(children, (child) => { @@ -176,11 +186,19 @@ const Select = React.forwardRef( open={open} onClose={() => setOpen(false)} title={placeholder || "Select an option"} - snapPoints={[0.5, 0.8]} - initialSnapIndex={0} + snapPoints={snapPoints} + initialSnapIndex={initialSnapIndex} contentClassName={contentClassName} + avoidKeyboard={avoidKeyboard} + closeOnBackdropPress={true} > - {enhancedChildren} + + {enhancedChildren} + );