Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
332 changes: 332 additions & 0 deletions app/(site)/docs/components/popover/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,332 @@
import { ComponentPreview } from "@/components/docs/component-preview";

export default function PopoverPage() {
return (
<ComponentPreview
name="Popover"
description="A popover component for React Native applications."
examples={[
{
"title": "Default",
"value": "default",
"content": "import { Popover } from \"@nativeui/ui\";\n\nexport default function PopoverDemo() {\n return (\n <Popover>\n Click me\n </Popover>\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<React.SetStateAction<boolean>>;
triggerRef: React.RefObject<View>;
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<React.SetStateAction<boolean>>;
}>({
open: false,
setOpen: () => {},
triggerRef: { current: null },
triggerLayout: null,
setTriggerLayout: () => {},
contentLayout: null,
setContentLayout: () => {},
isAnimating: false,
setIsAnimating: () => {},
});

const Popover = React.forwardRef<View, PopoverProps>(
({ children, className, ...props }, ref) => {
const [open, setOpen] = React.useState(false);
const triggerRef = React.useRef<View>(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 (
<PopoverContext.Provider
value={{
open,
setOpen,
triggerRef,
triggerLayout,
setTriggerLayout,
contentLayout,
setContentLayout,
isAnimating,
setIsAnimating,
}}
>
<View ref={ref} className={cn("", className)} {...props}>
{children}
</View>
</PopoverContext.Provider>
);
}
);

Popover.displayName = "Popover";

const PopoverTrigger = React.forwardRef<View, PopoverTriggerProps>(
({ 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 (
<Pressable
ref={triggerRef as any}
className={cn("", className)}
disabled={disabled || isAnimating}
onPress={() => {
if (open) {
setOpen(false);
} else {
measureTrigger();
setOpen(true);
}
}}
accessibilityRole="button"
{...props}
>
{children}
</Pressable>
);
}
);

PopoverTrigger.displayName = "PopoverTrigger";

const PopoverAnchor = React.forwardRef<View, PopoverAnchorProps>(
({ children, className, ...props }, ref) => {
return (
<View ref={ref} className={cn("", className)} {...props}>
{children}
</View>
);
}
);

PopoverAnchor.displayName = "PopoverAnchor";

const PopoverContent = React.forwardRef<View, PopoverContentProps>(
(
{
children,
className,
align = "center",
side = "bottom",
sideOffset = 8,
...props
},
ref
) => {
const {
open,
setOpen,
triggerLayout,
contentLayout,
setContentLayout,
setIsAnimating,
} = React.useContext(PopoverContext);

const contentRef = React.useRef<View>(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 (
<Modal
visible={open}
transparent
animationType="none"
onRequestClose={closePopover}
>
<TouchableWithoutFeedback onPress={closePopover}>
<View className="flex-1">
<TouchableWithoutFeedback>
<Animated.View
ref={contentRef}
style={[getPosition(), { opacity: fadeAnim }]}
className={cn(
"absolute rounded-md border border-border bg-popover p-4",
"shadow-lg min-w-[200px] max-w-[90%]",
Platform.OS === "ios"
? "ios:shadow-lg"
: "android:elevation-4",
className
)}
{...props}
>
{children}
</Animated.View>
</TouchableWithoutFeedback>
</View>
</TouchableWithoutFeedback>
</Modal>
);
}
);

PopoverContent.displayName = "PopoverContent";

export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
`}
previewCode={`import { Popover } from "@nativeui/ui";

export default function PopoverDemo() {
return (
<div className="flex flex-col gap-4">
<Popover>Default Popover</Popover>
<Popover variant="destructive">Delete</Popover>
<Popover variant="outline">Outline</Popover>
<Popover variant="secondary">Secondary</Popover>
<Popover variant="ghost">Ghost</Popover>
<Popover variant="link">Link</Popover>
</div>
);
}`}
registryName="popover"
packageName="@nativeui/ui"
/>
);
}
18 changes: 18 additions & 0 deletions public/r/popover.json
Original file line number Diff line number Diff line change
@@ -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<React.SetStateAction<boolean>>;\n triggerRef: React.RefObject<View>;\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<React.SetStateAction<boolean>>;\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<View, PopoverProps>(\n ({ children, className, ...props }, ref) => {\n const [open, setOpen] = React.useState(false);\n const triggerRef = React.useRef<View>(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 <PopoverContext.Provider\n value={{\n open,\n setOpen,\n triggerRef,\n triggerLayout,\n setTriggerLayout,\n contentLayout,\n setContentLayout,\n isAnimating,\n setIsAnimating,\n }}\n >\n <View ref={ref} className={cn(\"\", className)} {...props}>\n {children}\n </View>\n </PopoverContext.Provider>\n );\n }\n);\n\nPopover.displayName = \"Popover\";\n\nconst PopoverTrigger = React.forwardRef<View, PopoverTriggerProps>(\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 <Pressable\n ref={triggerRef as any}\n className={cn(\"\", className)}\n disabled={disabled || isAnimating}\n onPress={() => {\n if (open) {\n setOpen(false);\n } else {\n measureTrigger();\n setOpen(true);\n }\n }}\n accessibilityRole=\"button\"\n {...props}\n >\n {children}\n </Pressable>\n );\n }\n);\n\nPopoverTrigger.displayName = \"PopoverTrigger\";\n\nconst PopoverAnchor = React.forwardRef<View, PopoverAnchorProps>(\n ({ children, className, ...props }, ref) => {\n return (\n <View ref={ref} className={cn(\"\", className)} {...props}>\n {children}\n </View>\n );\n }\n);\n\nPopoverAnchor.displayName = \"PopoverAnchor\";\n\nconst PopoverContent = React.forwardRef<View, PopoverContentProps>(\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<View>(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 <Modal\n visible={open}\n transparent\n animationType=\"none\"\n onRequestClose={closePopover}\n >\n <TouchableWithoutFeedback onPress={closePopover}>\n <View className=\"flex-1\">\n <TouchableWithoutFeedback>\n <Animated.View\n ref={contentRef}\n style={[getPosition(), { opacity: fadeAnim }]}\n className={cn(\n \"absolute rounded-md border border-border bg-popover p-4\",\n \"shadow-lg min-w-[200px] max-w-[90%]\",\n Platform.OS === \"ios\"\n ? \"ios:shadow-lg\"\n : \"android:elevation-4\",\n className\n )}\n {...props}\n >\n {children}\n </Animated.View>\n </TouchableWithoutFeedback>\n </View>\n </TouchableWithoutFeedback>\n </Modal>\n );\n }\n);\n\nPopoverContent.displayName = \"PopoverContent\";\n\nexport { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };\n",
"type": "registry:component"
}
]
}
16 changes: 15 additions & 1 deletion registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": []
}
]
}
}
Loading