230 lines
5.2 KiB
TypeScript
230 lines
5.2 KiB
TypeScript
"use client";
|
|
|
|
import { AnimatePresence, motion } from "framer-motion";
|
|
import { X } from "lucide-react";
|
|
import {
|
|
createContext,
|
|
type ReactNode,
|
|
useContext,
|
|
useEffect,
|
|
useState,
|
|
} from "react";
|
|
import { cn } from "~/lib/utils";
|
|
|
|
// Context
|
|
interface ExpandableScreenContextValue {
|
|
isExpanded: boolean;
|
|
expand: () => void;
|
|
collapse: () => void;
|
|
layoutId: string;
|
|
triggerRadius: string;
|
|
contentRadius: string;
|
|
animationDuration: number;
|
|
}
|
|
|
|
const ExpandableScreenContext =
|
|
createContext<ExpandableScreenContextValue | null>(null);
|
|
|
|
function useExpandableScreen() {
|
|
const context = useContext(ExpandableScreenContext);
|
|
if (!context) {
|
|
throw new Error(
|
|
"useExpandableScreen must be used within an ExpandableScreen",
|
|
);
|
|
}
|
|
return context;
|
|
}
|
|
|
|
// Root Component
|
|
interface ExpandableScreenProps {
|
|
children: ReactNode;
|
|
defaultExpanded?: boolean;
|
|
onExpandChange?: (expanded: boolean) => void;
|
|
layoutId?: string;
|
|
triggerRadius?: string;
|
|
contentRadius?: string;
|
|
animationDuration?: number;
|
|
lockScroll?: boolean;
|
|
}
|
|
|
|
/**
|
|
* @link https://www.cult-ui.com/docs/components/expandable-screen
|
|
* */
|
|
export function ExpandableScreen({
|
|
children,
|
|
defaultExpanded = false,
|
|
onExpandChange,
|
|
layoutId = "expandable-card",
|
|
triggerRadius = "100px",
|
|
contentRadius = "24px",
|
|
animationDuration = 0.3,
|
|
lockScroll = true,
|
|
}: ExpandableScreenProps) {
|
|
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
|
|
|
const expand = () => {
|
|
setIsExpanded(true);
|
|
onExpandChange?.(true);
|
|
};
|
|
|
|
const collapse = () => {
|
|
setIsExpanded(false);
|
|
onExpandChange?.(false);
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (lockScroll) {
|
|
if (isExpanded) {
|
|
document.body.style.overflow = "hidden";
|
|
} else {
|
|
document.body.style.overflow = "unset";
|
|
}
|
|
}
|
|
}, [isExpanded, lockScroll]);
|
|
|
|
return (
|
|
<ExpandableScreenContext.Provider
|
|
value={{
|
|
isExpanded,
|
|
expand,
|
|
collapse,
|
|
layoutId,
|
|
triggerRadius,
|
|
contentRadius,
|
|
animationDuration,
|
|
}}
|
|
>
|
|
{children}
|
|
</ExpandableScreenContext.Provider>
|
|
);
|
|
}
|
|
|
|
// Trigger Component
|
|
interface ExpandableScreenTriggerProps {
|
|
children: ReactNode;
|
|
className?: string;
|
|
}
|
|
|
|
export function ExpandableScreenTrigger({
|
|
children,
|
|
className = "",
|
|
}: ExpandableScreenTriggerProps) {
|
|
const { isExpanded, expand, layoutId, triggerRadius } = useExpandableScreen();
|
|
|
|
return (
|
|
<AnimatePresence initial={false}>
|
|
{!isExpanded && (
|
|
<motion.div className={`relative inline-block ${className}`}>
|
|
{/* Background layer with shared layoutId for morphing */}
|
|
<motion.div
|
|
className="absolute inset-0 transform-gpu will-change-transform"
|
|
layout
|
|
layoutId={layoutId}
|
|
style={{
|
|
borderRadius: triggerRadius,
|
|
}}
|
|
/>
|
|
{/* Content layer that fades out on expand */}
|
|
<motion.div
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
className="relative cursor-pointer"
|
|
exit={{ opacity: 0, scale: 0.8 }}
|
|
initial={{ opacity: 0, scale: 0.8 }}
|
|
layout={false}
|
|
onClick={expand}
|
|
transition={{ delay: 0.2 }}
|
|
>
|
|
{children}
|
|
</motion.div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
);
|
|
}
|
|
|
|
// Content Component
|
|
interface ExpandableScreenContentProps {
|
|
children: ReactNode;
|
|
className?: string;
|
|
showCloseButton?: boolean;
|
|
closeButtonClassName?: string;
|
|
}
|
|
|
|
export function ExpandableScreenContent({
|
|
children,
|
|
className = "",
|
|
showCloseButton = true,
|
|
closeButtonClassName = "",
|
|
}: ExpandableScreenContentProps) {
|
|
const { isExpanded, collapse, layoutId, contentRadius, animationDuration } =
|
|
useExpandableScreen();
|
|
|
|
return (
|
|
<AnimatePresence initial={false}>
|
|
{isExpanded && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-2">
|
|
{/* Morphing background with shared layoutId */}
|
|
<motion.div
|
|
className={`relative flex h-full w-full transform-gpu overflow-y-auto will-change-transform ${className}`}
|
|
layout
|
|
layoutId={layoutId}
|
|
style={{
|
|
borderRadius: contentRadius,
|
|
}}
|
|
transition={{ duration: animationDuration }}
|
|
>
|
|
<motion.div
|
|
animate={{ opacity: 1 }}
|
|
className="relative z-20 w-full"
|
|
initial={{ opacity: 0 }}
|
|
transition={{ delay: 0.15, duration: 0.4 }}
|
|
>
|
|
{children}
|
|
</motion.div>
|
|
|
|
{showCloseButton && (
|
|
<motion.button
|
|
aria-label="Close"
|
|
className={cn(
|
|
"absolute top-6 right-6 z-30 flex h-10 w-10 cursor-pointer items-center justify-center rounded-full transition-colors",
|
|
"bg-transparent text-white hover:bg-white/10 dark:text-primary-foreground hover:dark:bg-primary-foreground/10",
|
|
closeButtonClassName,
|
|
)}
|
|
onClick={collapse}
|
|
>
|
|
<X className="h-5 w-5" />
|
|
</motion.button>
|
|
)}
|
|
</motion.div>
|
|
</div>
|
|
)}
|
|
</AnimatePresence>
|
|
);
|
|
}
|
|
|
|
// Background Component (optional)
|
|
interface ExpandableScreenBackgroundProps {
|
|
trigger?: ReactNode;
|
|
content?: ReactNode;
|
|
className?: string;
|
|
}
|
|
|
|
export function ExpandableScreenBackground({
|
|
trigger,
|
|
content,
|
|
className = "",
|
|
}: ExpandableScreenBackgroundProps) {
|
|
const { isExpanded } = useExpandableScreen();
|
|
|
|
if (isExpanded && content) {
|
|
return <div className={className}>{content}</div>;
|
|
}
|
|
|
|
if (!isExpanded && trigger) {
|
|
return <div className={className}>{trigger}</div>;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export { useExpandableScreen };
|