diff --git a/app/(site)/docs/components/popover/page.tsx b/app/(site)/docs/components/popover/page.tsx new file mode 100644 index 0000000..aeb9001 --- /dev/null +++ b/app/(site)/docs/components/popover/page.tsx @@ -0,0 +1,332 @@ +import { ComponentPreview } from "@/components/docs/component-preview"; + +export default function PopoverPage() { + return ( + \n Click me\n \n );\n}", + "language": "tsx" + } +]} + componentCode={`import * as React from "react"; +import { + View, + Text, + Pressable, + Modal, + TouchableWithoutFeedback, + Platform, + Animated, +} from "react-native"; +import { cn } from "@/lib/utils"; + +interface PopoverProps { + children: React.ReactNode; + className?: string; +} + +interface PopoverTriggerProps { + children: React.ReactNode; + className?: string; + disabled?: boolean; + asChild?: boolean; +} + +interface PopoverAnchorProps { + children: React.ReactNode; + className?: string; +} + +interface PopoverContentProps { + children: React.ReactNode; + className?: string; + align?: "start" | "center" | "end"; + side?: "top" | "right" | "bottom" | "left"; + sideOffset?: number; +} + +const PopoverContext = React.createContext<{ + open: boolean; + setOpen: React.Dispatch>; + triggerRef: React.RefObject; + triggerLayout: { x: number; y: number; width: number; height: number } | null; + setTriggerLayout: React.Dispatch< + React.SetStateAction<{ + x: number; + y: number; + width: number; + height: number; + } | null> + >; + contentLayout: { width: number; height: number } | null; + setContentLayout: React.Dispatch< + React.SetStateAction<{ width: number; height: number } | null> + >; + isAnimating: boolean; + setIsAnimating: React.Dispatch>; +}>({ + open: false, + setOpen: () => {}, + triggerRef: { current: null }, + triggerLayout: null, + setTriggerLayout: () => {}, + contentLayout: null, + setContentLayout: () => {}, + isAnimating: false, + setIsAnimating: () => {}, +}); + +const Popover = React.forwardRef( + ({ children, className, ...props }, ref) => { + const [open, setOpen] = React.useState(false); + const triggerRef = React.useRef(null); + const [triggerLayout, setTriggerLayout] = React.useState<{ + x: number; + y: number; + width: number; + height: number; + } | null>(null); + const [contentLayout, setContentLayout] = React.useState<{ + width: number; + height: number; + } | null>(null); + const [isAnimating, setIsAnimating] = React.useState(false); + + return ( + + + {children} + + + ); + } +); + +Popover.displayName = "Popover"; + +const PopoverTrigger = React.forwardRef( + ({ children, className, disabled = false, ...props }, ref) => { + const { setOpen, open, triggerRef, setTriggerLayout, isAnimating } = + React.useContext(PopoverContext); + + const measureTrigger = () => { + if (triggerRef.current) { + triggerRef.current.measureInWindow((x, y, width, height) => { + setTriggerLayout({ x, y, width, height }); + }); + } + }; + + return ( + { + if (open) { + setOpen(false); + } else { + measureTrigger(); + setOpen(true); + } + }} + accessibilityRole="button" + {...props} + > + {children} + + ); + } +); + +PopoverTrigger.displayName = "PopoverTrigger"; + +const PopoverAnchor = React.forwardRef( + ({ children, className, ...props }, ref) => { + return ( + + {children} + + ); + } +); + +PopoverAnchor.displayName = "PopoverAnchor"; + +const PopoverContent = React.forwardRef( + ( + { + children, + className, + align = "center", + side = "bottom", + sideOffset = 8, + ...props + }, + ref + ) => { + const { + open, + setOpen, + triggerLayout, + contentLayout, + setContentLayout, + setIsAnimating, + } = React.useContext(PopoverContext); + + const contentRef = React.useRef(null); + const fadeAnim = React.useRef(new Animated.Value(0)).current; + + React.useEffect(() => { + if (open) { + setIsAnimating(true); + if (contentRef.current) { + setTimeout(() => { + contentRef.current?.measure((_x, _y, width, height) => { + setContentLayout({ width, height }); + Animated.timing(fadeAnim, { + toValue: 1, + duration: 150, // Animation duration + useNativeDriver: true, + }).start(() => { + setIsAnimating(false); + }); + }); + }, 10); + } + } else { + // Reset fadeAnim when closed + fadeAnim.setValue(0); + } + + // Cleanup animation when component unmounts + return () => { + fadeAnim.setValue(0); + }; + }, [open, setContentLayout, fadeAnim, setIsAnimating]); + + const closePopover = React.useCallback(() => { + setIsAnimating(true); + Animated.timing(fadeAnim, { + toValue: 0, + duration: 100, + useNativeDriver: true, + }).start(() => { + setOpen(false); + setIsAnimating(false); + }); + }, [fadeAnim, setOpen, setIsAnimating]); + + if (!open) return null; + + const getPosition = () => { + if (!triggerLayout || !contentLayout) return {}; + + let left = 0; + let top = 0; + + // Handle horizontal alignment + if (align === "start") { + left = triggerLayout.x; + } else if (align === "center") { + left = + triggerLayout.x + triggerLayout.width / 2 - contentLayout.width / 2; + } else if (align === "end") { + left = triggerLayout.x + triggerLayout.width - contentLayout.width; + } + + // Handle vertical positioning + if (side === "top") { + top = triggerLayout.y - contentLayout.height - sideOffset; + } else if (side === "bottom") { + top = triggerLayout.y + triggerLayout.height + sideOffset; + } else if (side === "left") { + left = triggerLayout.x - contentLayout.width - sideOffset; + top = + triggerLayout.y + triggerLayout.height / 2 - contentLayout.height / 2; + } else if (side === "right") { + left = triggerLayout.x + triggerLayout.width + sideOffset; + top = + triggerLayout.y + triggerLayout.height / 2 - contentLayout.height / 2; + } + + // Ensure the popover stays within screen bounds + left = Math.max(16, left); + top = Math.max(50, top); + + return { left, top }; + }; + + return ( + + + + + + {children} + + + + + + ); + } +); + +PopoverContent.displayName = "PopoverContent"; + +export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }; +`} + previewCode={`import { Popover } from "@nativeui/ui"; + +export default function PopoverDemo() { + return ( +
+ Default Popover + Delete + Outline + Secondary + Ghost + Link +
+ ); +}`} + registryName="popover" + packageName="@nativeui/ui" + /> + ); +} diff --git a/public/r/popover.json b/public/r/popover.json new file mode 100644 index 0000000..db5e0e6 --- /dev/null +++ b/public/r/popover.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "popover", + "type": "registry:component", + "title": "Popover", + "description": "A popover component for React Native applications.", + "dependencies": [ + "react-native" + ], + "registryDependencies": [], + "files": [ + { + "path": "registry/popover/popover.tsx", + "content": "import * as React from \"react\";\nimport {\n View,\n Text,\n Pressable,\n Modal,\n TouchableWithoutFeedback,\n Platform,\n Animated,\n} from \"react-native\";\nimport { cn } from \"@/lib/utils\";\n\ninterface PopoverProps {\n children: React.ReactNode;\n className?: string;\n}\n\ninterface PopoverTriggerProps {\n children: React.ReactNode;\n className?: string;\n disabled?: boolean;\n asChild?: boolean;\n}\n\ninterface PopoverAnchorProps {\n children: React.ReactNode;\n className?: string;\n}\n\ninterface PopoverContentProps {\n children: React.ReactNode;\n className?: string;\n align?: \"start\" | \"center\" | \"end\";\n side?: \"top\" | \"right\" | \"bottom\" | \"left\";\n sideOffset?: number;\n}\n\nconst PopoverContext = React.createContext<{\n open: boolean;\n setOpen: React.Dispatch>;\n triggerRef: React.RefObject;\n triggerLayout: { x: number; y: number; width: number; height: number } | null;\n setTriggerLayout: React.Dispatch<\n React.SetStateAction<{\n x: number;\n y: number;\n width: number;\n height: number;\n } | null>\n >;\n contentLayout: { width: number; height: number } | null;\n setContentLayout: React.Dispatch<\n React.SetStateAction<{ width: number; height: number } | null>\n >;\n isAnimating: boolean;\n setIsAnimating: React.Dispatch>;\n}>({\n open: false,\n setOpen: () => {},\n triggerRef: { current: null },\n triggerLayout: null,\n setTriggerLayout: () => {},\n contentLayout: null,\n setContentLayout: () => {},\n isAnimating: false,\n setIsAnimating: () => {},\n});\n\nconst Popover = React.forwardRef(\n ({ children, className, ...props }, ref) => {\n const [open, setOpen] = React.useState(false);\n const triggerRef = React.useRef(null);\n const [triggerLayout, setTriggerLayout] = React.useState<{\n x: number;\n y: number;\n width: number;\n height: number;\n } | null>(null);\n const [contentLayout, setContentLayout] = React.useState<{\n width: number;\n height: number;\n } | null>(null);\n const [isAnimating, setIsAnimating] = React.useState(false);\n\n return (\n \n \n {children}\n \n \n );\n }\n);\n\nPopover.displayName = \"Popover\";\n\nconst PopoverTrigger = React.forwardRef(\n ({ children, className, disabled = false, ...props }, ref) => {\n const { setOpen, open, triggerRef, setTriggerLayout, isAnimating } =\n React.useContext(PopoverContext);\n\n const measureTrigger = () => {\n if (triggerRef.current) {\n triggerRef.current.measureInWindow((x, y, width, height) => {\n setTriggerLayout({ x, y, width, height });\n });\n }\n };\n\n return (\n {\n if (open) {\n setOpen(false);\n } else {\n measureTrigger();\n setOpen(true);\n }\n }}\n accessibilityRole=\"button\"\n {...props}\n >\n {children}\n \n );\n }\n);\n\nPopoverTrigger.displayName = \"PopoverTrigger\";\n\nconst PopoverAnchor = React.forwardRef(\n ({ children, className, ...props }, ref) => {\n return (\n \n {children}\n \n );\n }\n);\n\nPopoverAnchor.displayName = \"PopoverAnchor\";\n\nconst PopoverContent = React.forwardRef(\n (\n {\n children,\n className,\n align = \"center\",\n side = \"bottom\",\n sideOffset = 8,\n ...props\n },\n ref\n ) => {\n const {\n open,\n setOpen,\n triggerLayout,\n contentLayout,\n setContentLayout,\n setIsAnimating,\n } = React.useContext(PopoverContext);\n\n const contentRef = React.useRef(null);\n const fadeAnim = React.useRef(new Animated.Value(0)).current;\n\n React.useEffect(() => {\n if (open) {\n setIsAnimating(true);\n if (contentRef.current) {\n setTimeout(() => {\n contentRef.current?.measure((_x, _y, width, height) => {\n setContentLayout({ width, height });\n Animated.timing(fadeAnim, {\n toValue: 1,\n duration: 150, // Animation duration\n useNativeDriver: true,\n }).start(() => {\n setIsAnimating(false);\n });\n });\n }, 10);\n }\n } else {\n // Reset fadeAnim when closed\n fadeAnim.setValue(0);\n }\n\n // Cleanup animation when component unmounts\n return () => {\n fadeAnim.setValue(0);\n };\n }, [open, setContentLayout, fadeAnim, setIsAnimating]);\n\n const closePopover = React.useCallback(() => {\n setIsAnimating(true);\n Animated.timing(fadeAnim, {\n toValue: 0,\n duration: 100,\n useNativeDriver: true,\n }).start(() => {\n setOpen(false);\n setIsAnimating(false);\n });\n }, [fadeAnim, setOpen, setIsAnimating]);\n\n if (!open) return null;\n\n const getPosition = () => {\n if (!triggerLayout || !contentLayout) return {};\n\n let left = 0;\n let top = 0;\n\n // Handle horizontal alignment\n if (align === \"start\") {\n left = triggerLayout.x;\n } else if (align === \"center\") {\n left =\n triggerLayout.x + triggerLayout.width / 2 - contentLayout.width / 2;\n } else if (align === \"end\") {\n left = triggerLayout.x + triggerLayout.width - contentLayout.width;\n }\n\n // Handle vertical positioning\n if (side === \"top\") {\n top = triggerLayout.y - contentLayout.height - sideOffset;\n } else if (side === \"bottom\") {\n top = triggerLayout.y + triggerLayout.height + sideOffset;\n } else if (side === \"left\") {\n left = triggerLayout.x - contentLayout.width - sideOffset;\n top =\n triggerLayout.y + triggerLayout.height / 2 - contentLayout.height / 2;\n } else if (side === \"right\") {\n left = triggerLayout.x + triggerLayout.width + sideOffset;\n top =\n triggerLayout.y + triggerLayout.height / 2 - contentLayout.height / 2;\n }\n\n // Ensure the popover stays within screen bounds\n left = Math.max(16, left);\n top = Math.max(50, top);\n\n return { left, top };\n };\n\n return (\n \n \n \n \n \n {children}\n \n \n \n \n \n );\n }\n);\n\nPopoverContent.displayName = \"PopoverContent\";\n\nexport { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };\n", + "type": "registry:component" + } + ] +} \ No newline at end of file diff --git a/registry.json b/registry.json index 293ee40..0cc0cec 100644 --- a/registry.json +++ b/registry.json @@ -170,6 +170,20 @@ ], "dependencies": ["react-native", "@expo/vector-icons"], "registryDependencies": [] + }, + { + "name": "popover", + "type": "registry:component", + "title": "Popover", + "description": "A popover component for React Native applications.", + "files": [ + { + "path": "registry/popover/popover.tsx", + "type": "registry:component" + } + ], + "dependencies": ["react-native"], + "registryDependencies": [] } ] -} +} diff --git a/registry/popover/popover.tsx b/registry/popover/popover.tsx new file mode 100644 index 0000000..dc9b989 --- /dev/null +++ b/registry/popover/popover.tsx @@ -0,0 +1,297 @@ +import * as React from "react"; +import { + View, + Text, + Pressable, + Modal, + TouchableWithoutFeedback, + Platform, + Animated, +} from "react-native"; +import { cn } from "@/lib/utils"; + +interface PopoverProps { + children: React.ReactNode; + className?: string; +} + +interface PopoverTriggerProps { + children: React.ReactNode; + className?: string; + disabled?: boolean; + asChild?: boolean; +} + +interface PopoverAnchorProps { + children: React.ReactNode; + className?: string; +} + +interface PopoverContentProps { + children: React.ReactNode; + className?: string; + align?: "start" | "center" | "end"; + side?: "top" | "right" | "bottom" | "left"; + sideOffset?: number; +} + +const PopoverContext = React.createContext<{ + open: boolean; + setOpen: React.Dispatch>; + triggerRef: React.RefObject; + triggerLayout: { x: number; y: number; width: number; height: number } | null; + setTriggerLayout: React.Dispatch< + React.SetStateAction<{ + x: number; + y: number; + width: number; + height: number; + } | null> + >; + contentLayout: { width: number; height: number } | null; + setContentLayout: React.Dispatch< + React.SetStateAction<{ width: number; height: number } | null> + >; + isAnimating: boolean; + setIsAnimating: React.Dispatch>; +}>({ + open: false, + setOpen: () => {}, + triggerRef: { current: null }, + triggerLayout: null, + setTriggerLayout: () => {}, + contentLayout: null, + setContentLayout: () => {}, + isAnimating: false, + setIsAnimating: () => {}, +}); + +const Popover = React.forwardRef( + ({ children, className, ...props }, ref) => { + const [open, setOpen] = React.useState(false); + const triggerRef = React.useRef(null); + const [triggerLayout, setTriggerLayout] = React.useState<{ + x: number; + y: number; + width: number; + height: number; + } | null>(null); + const [contentLayout, setContentLayout] = React.useState<{ + width: number; + height: number; + } | null>(null); + const [isAnimating, setIsAnimating] = React.useState(false); + + return ( + + + {children} + + + ); + } +); + +Popover.displayName = "Popover"; + +const PopoverTrigger = React.forwardRef( + ({ children, className, disabled = false, ...props }, ref) => { + const { setOpen, open, triggerRef, setTriggerLayout, isAnimating } = + React.useContext(PopoverContext); + + const measureTrigger = () => { + if (triggerRef.current) { + triggerRef.current.measureInWindow((x, y, width, height) => { + setTriggerLayout({ x, y, width, height }); + }); + } + }; + + return ( + { + if (open) { + setOpen(false); + } else { + measureTrigger(); + setOpen(true); + } + }} + accessibilityRole="button" + {...props} + > + {children} + + ); + } +); + +PopoverTrigger.displayName = "PopoverTrigger"; + +const PopoverAnchor = React.forwardRef( + ({ children, className, ...props }, ref) => { + return ( + + {children} + + ); + } +); + +PopoverAnchor.displayName = "PopoverAnchor"; + +const PopoverContent = React.forwardRef( + ( + { + children, + className, + align = "center", + side = "bottom", + sideOffset = 8, + ...props + }, + ref + ) => { + const { + open, + setOpen, + triggerLayout, + contentLayout, + setContentLayout, + setIsAnimating, + } = React.useContext(PopoverContext); + + const contentRef = React.useRef(null); + const fadeAnim = React.useRef(new Animated.Value(0)).current; + + React.useEffect(() => { + if (open) { + setIsAnimating(true); + if (contentRef.current) { + setTimeout(() => { + contentRef.current?.measure((_x, _y, width, height) => { + setContentLayout({ width, height }); + Animated.timing(fadeAnim, { + toValue: 1, + duration: 150, // Animation duration + useNativeDriver: true, + }).start(() => { + setIsAnimating(false); + }); + }); + }, 10); + } + } else { + // Reset fadeAnim when closed + fadeAnim.setValue(0); + } + + // Cleanup animation when component unmounts + return () => { + fadeAnim.setValue(0); + }; + }, [open, setContentLayout, fadeAnim, setIsAnimating]); + + const closePopover = React.useCallback(() => { + setIsAnimating(true); + Animated.timing(fadeAnim, { + toValue: 0, + duration: 100, + useNativeDriver: true, + }).start(() => { + setOpen(false); + setIsAnimating(false); + }); + }, [fadeAnim, setOpen, setIsAnimating]); + + if (!open) return null; + + const getPosition = () => { + if (!triggerLayout || !contentLayout) return {}; + + let left = 0; + let top = 0; + + // Handle horizontal alignment + if (align === "start") { + left = triggerLayout.x; + } else if (align === "center") { + left = + triggerLayout.x + triggerLayout.width / 2 - contentLayout.width / 2; + } else if (align === "end") { + left = triggerLayout.x + triggerLayout.width - contentLayout.width; + } + + // Handle vertical positioning + if (side === "top") { + top = triggerLayout.y - contentLayout.height - sideOffset; + } else if (side === "bottom") { + top = triggerLayout.y + triggerLayout.height + sideOffset; + } else if (side === "left") { + left = triggerLayout.x - contentLayout.width - sideOffset; + top = + triggerLayout.y + triggerLayout.height / 2 - contentLayout.height / 2; + } else if (side === "right") { + left = triggerLayout.x + triggerLayout.width + sideOffset; + top = + triggerLayout.y + triggerLayout.height / 2 - contentLayout.height / 2; + } + + // Ensure the popover stays within screen bounds + left = Math.max(16, left); + top = Math.max(50, top); + + return { left, top }; + }; + + return ( + + + + + + {children} + + + + + + ); + } +); + +PopoverContent.displayName = "PopoverContent"; + +export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };