feat: implement ExpandableScreen component with trigger and content for interactive UI
This commit is contained in:
parent
7763aac01c
commit
cf35fac146
2 changed files with 257 additions and 1 deletions
228
apps/infoalloggi/src/components/expandable_screen.tsx
Normal file
228
apps/infoalloggi/src/components/expandable_screen.tsx
Normal file
|
|
@ -0,0 +1,228 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
type ReactNode,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
// 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={`absolute top-6 right-6 z-30 flex h-10 w-10 cursor-pointer items-center justify-center rounded-full transition-colors ${
|
||||||
|
closeButtonClassName ||
|
||||||
|
"bg-transparent text-white hover:bg-white/10 dark:text-primary-foreground hover:dark:bg-primary-foreground/10"
|
||||||
|
}`}
|
||||||
|
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 };
|
||||||
|
|
@ -1,7 +1,35 @@
|
||||||
|
import {
|
||||||
|
ExpandableScreen,
|
||||||
|
ExpandableScreenContent,
|
||||||
|
ExpandableScreenTrigger,
|
||||||
|
} from "~/components/expandable_screen";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
import type { NextPageWithLayout } from "./_app";
|
import type { NextPageWithLayout } from "./_app";
|
||||||
|
|
||||||
const Test: NextPageWithLayout = () => {
|
const Test: NextPageWithLayout = () => {
|
||||||
return <main className="flex flex-col gap-4 p-8">asdsad</main>;
|
return (
|
||||||
|
<main className="flex flex-col gap-4 p-8">
|
||||||
|
<ExpandableScreen
|
||||||
|
contentRadius="14px"
|
||||||
|
layoutId="cta-card"
|
||||||
|
triggerRadius="100px"
|
||||||
|
>
|
||||||
|
<div className="flex min-h-screen items-center justify-center">
|
||||||
|
<ExpandableScreenTrigger>
|
||||||
|
<Button>Open Screen</Button>
|
||||||
|
</ExpandableScreenTrigger>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ExpandableScreenContent className="bg-primary">
|
||||||
|
<div className="flex h-full items-center justify-center p-8">
|
||||||
|
<h2 className="text-4xl text-primary-foreground">
|
||||||
|
Full Screen Content
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
</ExpandableScreenContent>
|
||||||
|
</ExpandableScreen>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Test;
|
export default Test;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue