infoalloggi-monorepo/apps/infoalloggi/src/components/expandable_screen.tsx

231 lines
5.2 KiB
TypeScript
Raw Normal View History

"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 };