diff --git a/app/(site)/docs/components/carousel/page.tsx b/app/(site)/docs/components/carousel/page.tsx new file mode 100644 index 0000000..2000de4 --- /dev/null +++ b/app/(site)/docs/components/carousel/page.tsx @@ -0,0 +1,465 @@ +import { ComponentPreview } from "@/components/docs/component-preview"; + +export default function CarouselPage() { + return ( + \n Click me\n \n );\n}", + "language": "tsx" + } +]} + componentCode={`import * as React from "react"; +import { + View, + Text, + Pressable, + Dimensions, + ScrollView, + AccessibilityInfo, +} from "react-native"; +import { cn } from "@/lib/utils"; +import { Ionicons } from "@expo/vector-icons"; + +type CarouselContextProps = { + scrollViewRef: React.RefObject; + currentIndex: number; + scrollTo: (index: number) => void; + canScrollPrev: boolean; + canScrollNext: boolean; + itemsCount: number; + orientation?: "horizontal" | "vertical"; +}; + +const CarouselContext = React.createContext(null); + +function useCarousel() { + const context = React.useContext(CarouselContext); + if (!context) { + throw new Error("useCarousel must be used within a "); + } + return context; +} + +interface CarouselProps { + children: React.ReactNode; + className?: string; + orientation?: "horizontal" | "vertical"; + showControls?: boolean; + showIndicators?: boolean; + autoPlay?: boolean; + autoPlayInterval?: number; + loop?: boolean; + indicatorStyle?: "dots" | "lines" | "numbers"; + onIndexChange?: (index: number) => void; +} + +const Carousel = React.forwardRef( + ( + { + children, + className, + orientation = "horizontal", + showControls = true, + showIndicators = true, + autoPlay = false, + autoPlayInterval = 3000, + loop = true, + indicatorStyle = "dots", + onIndexChange, + ...props + }, + ref + ) => { + const scrollViewRef = React.useRef(null); + const [currentIndex, setCurrentIndex] = React.useState(0); + const [itemsCount, setItemsCount] = React.useState(0); + const dimensions = { + width: Dimensions.get("window").width, + height: Dimensions.get("window").height, + }; + + const canScrollPrev = currentIndex > 0 || loop; + const canScrollNext = currentIndex < itemsCount - 1 || loop; + + const scrollTo = React.useCallback( + (index: number) => { + if (!scrollViewRef.current) return; + + let targetIndex = index; + if (index < 0) { + targetIndex = loop ? itemsCount - 1 : 0; + } else if (index >= itemsCount) { + targetIndex = loop ? 0 : itemsCount - 1; + } + + const offset = + orientation === "horizontal" + ? targetIndex * dimensions.width + : targetIndex * dimensions.height; + + scrollViewRef.current.scrollTo({ + [orientation === "horizontal" ? "x" : "y"]: offset, + animated: true, + }); + + setCurrentIndex(targetIndex); + onIndexChange?.(targetIndex); + AccessibilityInfo.announceForAccessibility( + \`Image "" of ""\` + ); + }, + [orientation, dimensions, itemsCount, onIndexChange, loop] + ); + + const handleScroll = React.useCallback( + (event: any) => { + const { + nativeEvent: { contentOffset, layoutMeasurement }, + } = event; + const offset = + orientation === "horizontal" ? contentOffset.x : contentOffset.y; + const size = + orientation === "horizontal" + ? layoutMeasurement.width + : layoutMeasurement.height; + const index = Math.round(offset / size); + + if (index !== currentIndex) { + setCurrentIndex(index); + onIndexChange?.(index); + } + }, + [orientation, currentIndex, onIndexChange] + ); + + React.useEffect(() => { + if (autoPlay && canScrollNext) { + const interval = setInterval(() => { + if (currentIndex < itemsCount - 1) { + scrollTo(currentIndex + 1); + } else if (loop) { + scrollTo(0); + } + }, autoPlayInterval); + + return () => clearInterval(interval); + } + }, [currentIndex, autoPlay, autoPlayInterval, loop, itemsCount, scrollTo]); + + const renderIndicator = () => { + switch (indicatorStyle) { + case "lines": + return ( + + {Array.from({ length: itemsCount }).map((_, index) => ( + scrollTo(index)} + accessibilityRole="button" + accessibilityLabel={\`Go to image ""\`} + accessibilityState={{ selected: currentIndex === index }} + style={[ + { + height: orientation === "horizontal" ? 2 : 16, + width: + orientation === "horizontal" + ? currentIndex === index + ? 16 + : 8 + : 2, + borderRadius: 2, + backgroundColor: + currentIndex === index + ? "#3b82f6" + : "rgba(255, 255, 255, 0.5)", + }, + ]} + /> + ))} + + ); + case "numbers": + return ( + + + {currentIndex + 1} / {itemsCount} + + + ); + default: + return ( + + {Array.from({ length: itemsCount }).map((_, index) => ( + scrollTo(index)} + accessibilityRole="button" + accessibilityLabel={\`Go to image ""\`} + accessibilityState={{ selected: currentIndex === index }} + style={[ + { + width: 8, + height: 8, + borderRadius: 4, + transform: [{ scale: currentIndex === index ? 1.25 : 1 }], + backgroundColor: + currentIndex === index + ? "#3b82f6" + : "rgba(255, 255, 255, 0.5)", + }, + ]} + /> + ))} + + ); + } + }; + + return ( + + + { + setItemsCount( + Math.ceil( + (orientation === "horizontal" ? w : h) / + (orientation === "horizontal" + ? dimensions.width + : dimensions.height) + ) + ); + }} + > + {children} + + + {showControls && ( + <> + + + + )} + + {showIndicators && renderIndicator()} + + + ); + } +); + +Carousel.displayName = "Carousel"; + +const CarouselContent = React.forwardRef< + View, + React.ComponentProps +>(({ className, children, ...props }, ref) => { + const { orientation } = useCarousel(); + + return ( + + {children} + + ); +}); + +CarouselContent.displayName = "CarouselContent"; + +const CarouselItem = React.forwardRef>( + ({ className, children, ...props }, ref) => { + const { orientation } = useCarousel(); + const dimensions = Dimensions.get("window"); + + return ( + + {children} + + ); + } +); + +CarouselItem.displayName = "CarouselItem"; + +const CarouselPrevious = React.forwardRef< + View, + React.ComponentProps +>(({ className, ...props }, ref) => { + const { scrollTo, currentIndex, canScrollPrev, orientation } = useCarousel(); + + if (!canScrollPrev) return null; + + return ( + scrollTo(currentIndex - 1)} + className={cn( + "absolute z-10 p-3 rounded-full bg-background/50 backdrop-blur-sm", + orientation === "horizontal" + ? "left-4 top-1/2 -translate-y-1/2" + : "left-1/2 -translate-x-1/2 top-4", + className + )} + accessibilityRole="button" + accessibilityLabel="Previous image" + {...props} + > + + + ); +}); + +CarouselPrevious.displayName = "CarouselPrevious"; + +const CarouselNext = React.forwardRef>( + ({ className, ...props }, ref) => { + const { scrollTo, currentIndex, canScrollNext, orientation } = + useCarousel(); + + if (!canScrollNext) return null; + + return ( + scrollTo(currentIndex + 1)} + className={cn( + "absolute z-10 p-3 rounded-full bg-background/50 backdrop-blur-sm", + orientation === "horizontal" + ? "right-4 top-1/2 -translate-y-1/2" + : "left-1/2 -translate-x-1/2 bottom-4", + className + )} + accessibilityRole="button" + accessibilityLabel="Next image" + {...props} + > + + + ); + } +); + +CarouselNext.displayName = "CarouselNext"; + +export { + type CarouselProps, + Carousel, + CarouselContent, + CarouselItem, + CarouselPrevious, + CarouselNext, +}; +`} + previewCode={`import { Carousel } from "@nativeui/ui"; + +export default function CarouselDemo() { + return ( +
+ Default Carousel + Delete + Outline + Secondary + Ghost + Link +
+ ); +}`} + registryName="carousel" + packageName="@nativeui/ui" + /> + ); +} diff --git a/public/r/carousel.json b/public/r/carousel.json new file mode 100644 index 0000000..74e2608 --- /dev/null +++ b/public/r/carousel.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "carousel", + "type": "registry:component", + "title": "Carousel", + "description": "A carousel component for React Native applications.", + "dependencies": [ + "react-native", + "@expo/vector-icons" + ], + "registryDependencies": [], + "files": [ + { + "path": "registry/carousel/carousel.tsx", + "content": "import * as React from \"react\";\nimport {\n View,\n Text,\n Pressable,\n Dimensions,\n ScrollView,\n AccessibilityInfo,\n} from \"react-native\";\nimport { cn } from \"@/lib/utils\";\nimport { Ionicons } from \"@expo/vector-icons\";\n\ntype CarouselContextProps = {\n scrollViewRef: React.RefObject;\n currentIndex: number;\n scrollTo: (index: number) => void;\n canScrollPrev: boolean;\n canScrollNext: boolean;\n itemsCount: number;\n orientation?: \"horizontal\" | \"vertical\";\n};\n\nconst CarouselContext = React.createContext(null);\n\nfunction useCarousel() {\n const context = React.useContext(CarouselContext);\n if (!context) {\n throw new Error(\"useCarousel must be used within a \");\n }\n return context;\n}\n\ninterface CarouselProps {\n children: React.ReactNode;\n className?: string;\n orientation?: \"horizontal\" | \"vertical\";\n showControls?: boolean;\n showIndicators?: boolean;\n autoPlay?: boolean;\n autoPlayInterval?: number;\n loop?: boolean;\n indicatorStyle?: \"dots\" | \"lines\" | \"numbers\";\n onIndexChange?: (index: number) => void;\n}\n\nconst Carousel = React.forwardRef(\n (\n {\n children,\n className,\n orientation = \"horizontal\",\n showControls = true,\n showIndicators = true,\n autoPlay = false,\n autoPlayInterval = 3000,\n loop = true,\n indicatorStyle = \"dots\",\n onIndexChange,\n ...props\n },\n ref\n ) => {\n const scrollViewRef = React.useRef(null);\n const [currentIndex, setCurrentIndex] = React.useState(0);\n const [itemsCount, setItemsCount] = React.useState(0);\n const dimensions = {\n width: Dimensions.get(\"window\").width,\n height: Dimensions.get(\"window\").height,\n };\n\n const canScrollPrev = currentIndex > 0 || loop;\n const canScrollNext = currentIndex < itemsCount - 1 || loop;\n\n const scrollTo = React.useCallback(\n (index: number) => {\n if (!scrollViewRef.current) return;\n\n let targetIndex = index;\n if (index < 0) {\n targetIndex = loop ? itemsCount - 1 : 0;\n } else if (index >= itemsCount) {\n targetIndex = loop ? 0 : itemsCount - 1;\n }\n\n const offset =\n orientation === \"horizontal\"\n ? targetIndex * dimensions.width\n : targetIndex * dimensions.height;\n\n scrollViewRef.current.scrollTo({\n [orientation === \"horizontal\" ? \"x\" : \"y\"]: offset,\n animated: true,\n });\n\n setCurrentIndex(targetIndex);\n onIndexChange?.(targetIndex);\n AccessibilityInfo.announceForAccessibility(\n `Image ${targetIndex + 1} of ${itemsCount}`\n );\n },\n [orientation, dimensions, itemsCount, onIndexChange, loop]\n );\n\n const handleScroll = React.useCallback(\n (event: any) => {\n const {\n nativeEvent: { contentOffset, layoutMeasurement },\n } = event;\n const offset =\n orientation === \"horizontal\" ? contentOffset.x : contentOffset.y;\n const size =\n orientation === \"horizontal\"\n ? layoutMeasurement.width\n : layoutMeasurement.height;\n const index = Math.round(offset / size);\n\n if (index !== currentIndex) {\n setCurrentIndex(index);\n onIndexChange?.(index);\n }\n },\n [orientation, currentIndex, onIndexChange]\n );\n\n React.useEffect(() => {\n if (autoPlay && canScrollNext) {\n const interval = setInterval(() => {\n if (currentIndex < itemsCount - 1) {\n scrollTo(currentIndex + 1);\n } else if (loop) {\n scrollTo(0);\n }\n }, autoPlayInterval);\n\n return () => clearInterval(interval);\n }\n }, [currentIndex, autoPlay, autoPlayInterval, loop, itemsCount, scrollTo]);\n\n const renderIndicator = () => {\n switch (indicatorStyle) {\n case \"lines\":\n return (\n \n {Array.from({ length: itemsCount }).map((_, index) => (\n scrollTo(index)}\n accessibilityRole=\"button\"\n accessibilityLabel={`Go to image ${index + 1}`}\n accessibilityState={{ selected: currentIndex === index }}\n style={[\n {\n height: orientation === \"horizontal\" ? 2 : 16,\n width:\n orientation === \"horizontal\"\n ? currentIndex === index\n ? 16\n : 8\n : 2,\n borderRadius: 2,\n backgroundColor:\n currentIndex === index\n ? \"#3b82f6\"\n : \"rgba(255, 255, 255, 0.5)\",\n },\n ]}\n />\n ))}\n \n );\n case \"numbers\":\n return (\n \n \n {currentIndex + 1} / {itemsCount}\n \n \n );\n default:\n return (\n \n {Array.from({ length: itemsCount }).map((_, index) => (\n scrollTo(index)}\n accessibilityRole=\"button\"\n accessibilityLabel={`Go to image ${index + 1}`}\n accessibilityState={{ selected: currentIndex === index }}\n style={[\n {\n width: 8,\n height: 8,\n borderRadius: 4,\n transform: [{ scale: currentIndex === index ? 1.25 : 1 }],\n backgroundColor:\n currentIndex === index\n ? \"#3b82f6\"\n : \"rgba(255, 255, 255, 0.5)\",\n },\n ]}\n />\n ))}\n \n );\n }\n };\n\n return (\n \n \n {\n setItemsCount(\n Math.ceil(\n (orientation === \"horizontal\" ? w : h) /\n (orientation === \"horizontal\"\n ? dimensions.width\n : dimensions.height)\n )\n );\n }}\n >\n {children}\n \n\n {showControls && (\n <>\n \n \n \n )}\n\n {showIndicators && renderIndicator()}\n \n \n );\n }\n);\n\nCarousel.displayName = \"Carousel\";\n\nconst CarouselContent = React.forwardRef<\n View,\n React.ComponentProps\n>(({ className, children, ...props }, ref) => {\n const { orientation } = useCarousel();\n\n return (\n \n {children}\n \n );\n});\n\nCarouselContent.displayName = \"CarouselContent\";\n\nconst CarouselItem = React.forwardRef>(\n ({ className, children, ...props }, ref) => {\n const { orientation } = useCarousel();\n const dimensions = Dimensions.get(\"window\");\n\n return (\n \n {children}\n \n );\n }\n);\n\nCarouselItem.displayName = \"CarouselItem\";\n\nconst CarouselPrevious = React.forwardRef<\n View,\n React.ComponentProps\n>(({ className, ...props }, ref) => {\n const { scrollTo, currentIndex, canScrollPrev, orientation } = useCarousel();\n\n if (!canScrollPrev) return null;\n\n return (\n scrollTo(currentIndex - 1)}\n className={cn(\n \"absolute z-10 p-3 rounded-full bg-background/50 backdrop-blur-sm\",\n orientation === \"horizontal\"\n ? \"left-4 top-1/2 -translate-y-1/2\"\n : \"left-1/2 -translate-x-1/2 top-4\",\n className\n )}\n accessibilityRole=\"button\"\n accessibilityLabel=\"Previous image\"\n {...props}\n >\n \n \n );\n});\n\nCarouselPrevious.displayName = \"CarouselPrevious\";\n\nconst CarouselNext = React.forwardRef>(\n ({ className, ...props }, ref) => {\n const { scrollTo, currentIndex, canScrollNext, orientation } =\n useCarousel();\n\n if (!canScrollNext) return null;\n\n return (\n scrollTo(currentIndex + 1)}\n className={cn(\n \"absolute z-10 p-3 rounded-full bg-background/50 backdrop-blur-sm\",\n orientation === \"horizontal\"\n ? \"right-4 top-1/2 -translate-y-1/2\"\n : \"left-1/2 -translate-x-1/2 bottom-4\",\n className\n )}\n accessibilityRole=\"button\"\n accessibilityLabel=\"Next image\"\n {...props}\n >\n \n \n );\n }\n);\n\nCarouselNext.displayName = \"CarouselNext\";\n\nexport {\n type CarouselProps,\n Carousel,\n CarouselContent,\n CarouselItem,\n CarouselPrevious,\n CarouselNext,\n};\n", + "type": "registry:component" + } + ] +} \ No newline at end of file diff --git a/registry.json b/registry.json index 825c44b..0d0c0de 100644 --- a/registry.json +++ b/registry.json @@ -455,6 +455,20 @@ ], "dependencies": ["react-native"], "registryDependencies": [] + }, + { + "name": "carousel", + "type": "registry:component", + "title": "Carousel", + "description": "A carousel component for React Native applications.", + "files": [ + { + "path": "registry/carousel/carousel.tsx", + "type": "registry:component" + } + ], + "dependencies": ["react-native", "@expo/vector-icons"], + "registryDependencies": [] } ] } diff --git a/registry/carousel/carousel.tsx b/registry/carousel/carousel.tsx new file mode 100644 index 0000000..f53d111 --- /dev/null +++ b/registry/carousel/carousel.tsx @@ -0,0 +1,430 @@ +import * as React from "react"; +import { + View, + Text, + Pressable, + Dimensions, + ScrollView, + AccessibilityInfo, +} from "react-native"; +import { cn } from "@/lib/utils"; +import { Ionicons } from "@expo/vector-icons"; + +type CarouselContextProps = { + scrollViewRef: React.RefObject; + currentIndex: number; + scrollTo: (index: number) => void; + canScrollPrev: boolean; + canScrollNext: boolean; + itemsCount: number; + orientation?: "horizontal" | "vertical"; +}; + +const CarouselContext = React.createContext(null); + +function useCarousel() { + const context = React.useContext(CarouselContext); + if (!context) { + throw new Error("useCarousel must be used within a "); + } + return context; +} + +interface CarouselProps { + children: React.ReactNode; + className?: string; + orientation?: "horizontal" | "vertical"; + showControls?: boolean; + showIndicators?: boolean; + autoPlay?: boolean; + autoPlayInterval?: number; + loop?: boolean; + indicatorStyle?: "dots" | "lines" | "numbers"; + onIndexChange?: (index: number) => void; +} + +const Carousel = React.forwardRef( + ( + { + children, + className, + orientation = "horizontal", + showControls = true, + showIndicators = true, + autoPlay = false, + autoPlayInterval = 3000, + loop = true, + indicatorStyle = "dots", + onIndexChange, + ...props + }, + ref + ) => { + const scrollViewRef = React.useRef(null); + const [currentIndex, setCurrentIndex] = React.useState(0); + const [itemsCount, setItemsCount] = React.useState(0); + const dimensions = { + width: Dimensions.get("window").width, + height: Dimensions.get("window").height, + }; + + const canScrollPrev = currentIndex > 0 || loop; + const canScrollNext = currentIndex < itemsCount - 1 || loop; + + const scrollTo = React.useCallback( + (index: number) => { + if (!scrollViewRef.current) return; + + let targetIndex = index; + if (index < 0) { + targetIndex = loop ? itemsCount - 1 : 0; + } else if (index >= itemsCount) { + targetIndex = loop ? 0 : itemsCount - 1; + } + + const offset = + orientation === "horizontal" + ? targetIndex * dimensions.width + : targetIndex * dimensions.height; + + scrollViewRef.current.scrollTo({ + [orientation === "horizontal" ? "x" : "y"]: offset, + animated: true, + }); + + setCurrentIndex(targetIndex); + onIndexChange?.(targetIndex); + AccessibilityInfo.announceForAccessibility( + `Image ${targetIndex + 1} of ${itemsCount}` + ); + }, + [orientation, dimensions, itemsCount, onIndexChange, loop] + ); + + const handleScroll = React.useCallback( + (event: any) => { + const { + nativeEvent: { contentOffset, layoutMeasurement }, + } = event; + const offset = + orientation === "horizontal" ? contentOffset.x : contentOffset.y; + const size = + orientation === "horizontal" + ? layoutMeasurement.width + : layoutMeasurement.height; + const index = Math.round(offset / size); + + if (index !== currentIndex) { + setCurrentIndex(index); + onIndexChange?.(index); + } + }, + [orientation, currentIndex, onIndexChange] + ); + + React.useEffect(() => { + if (autoPlay && canScrollNext) { + const interval = setInterval(() => { + if (currentIndex < itemsCount - 1) { + scrollTo(currentIndex + 1); + } else if (loop) { + scrollTo(0); + } + }, autoPlayInterval); + + return () => clearInterval(interval); + } + }, [currentIndex, autoPlay, autoPlayInterval, loop, itemsCount, scrollTo]); + + const renderIndicator = () => { + switch (indicatorStyle) { + case "lines": + return ( + + {Array.from({ length: itemsCount }).map((_, index) => ( + scrollTo(index)} + accessibilityRole="button" + accessibilityLabel={`Go to image ${index + 1}`} + accessibilityState={{ selected: currentIndex === index }} + style={[ + { + height: orientation === "horizontal" ? 2 : 16, + width: + orientation === "horizontal" + ? currentIndex === index + ? 16 + : 8 + : 2, + borderRadius: 2, + backgroundColor: + currentIndex === index + ? "#3b82f6" + : "rgba(255, 255, 255, 0.5)", + }, + ]} + /> + ))} + + ); + case "numbers": + return ( + + + {currentIndex + 1} / {itemsCount} + + + ); + default: + return ( + + {Array.from({ length: itemsCount }).map((_, index) => ( + scrollTo(index)} + accessibilityRole="button" + accessibilityLabel={`Go to image ${index + 1}`} + accessibilityState={{ selected: currentIndex === index }} + style={[ + { + width: 8, + height: 8, + borderRadius: 4, + transform: [{ scale: currentIndex === index ? 1.25 : 1 }], + backgroundColor: + currentIndex === index + ? "#3b82f6" + : "rgba(255, 255, 255, 0.5)", + }, + ]} + /> + ))} + + ); + } + }; + + return ( + + + { + setItemsCount( + Math.ceil( + (orientation === "horizontal" ? w : h) / + (orientation === "horizontal" + ? dimensions.width + : dimensions.height) + ) + ); + }} + > + {children} + + + {showControls && ( + <> + + + + )} + + {showIndicators && renderIndicator()} + + + ); + } +); + +Carousel.displayName = "Carousel"; + +const CarouselContent = React.forwardRef< + View, + React.ComponentProps +>(({ className, children, ...props }, ref) => { + const { orientation } = useCarousel(); + + return ( + + {children} + + ); +}); + +CarouselContent.displayName = "CarouselContent"; + +const CarouselItem = React.forwardRef>( + ({ className, children, ...props }, ref) => { + const { orientation } = useCarousel(); + const dimensions = Dimensions.get("window"); + + return ( + + {children} + + ); + } +); + +CarouselItem.displayName = "CarouselItem"; + +const CarouselPrevious = React.forwardRef< + View, + React.ComponentProps +>(({ className, ...props }, ref) => { + const { scrollTo, currentIndex, canScrollPrev, orientation } = useCarousel(); + + if (!canScrollPrev) return null; + + return ( + scrollTo(currentIndex - 1)} + className={cn( + "absolute z-10 p-3 rounded-full bg-background/50 backdrop-blur-sm", + orientation === "horizontal" + ? "left-4 top-1/2 -translate-y-1/2" + : "left-1/2 -translate-x-1/2 top-4", + className + )} + accessibilityRole="button" + accessibilityLabel="Previous image" + {...props} + > + + + ); +}); + +CarouselPrevious.displayName = "CarouselPrevious"; + +const CarouselNext = React.forwardRef>( + ({ className, ...props }, ref) => { + const { scrollTo, currentIndex, canScrollNext, orientation } = + useCarousel(); + + if (!canScrollNext) return null; + + return ( + scrollTo(currentIndex + 1)} + className={cn( + "absolute z-10 p-3 rounded-full bg-background/50 backdrop-blur-sm", + orientation === "horizontal" + ? "right-4 top-1/2 -translate-y-1/2" + : "left-1/2 -translate-x-1/2 bottom-4", + className + )} + accessibilityRole="button" + accessibilityLabel="Next image" + {...props} + > + + + ); + } +); + +CarouselNext.displayName = "CarouselNext"; + +export { + type CarouselProps, + Carousel, + CarouselContent, + CarouselItem, + CarouselPrevious, + CarouselNext, +};