'use client';
import {
useState,
useId,
useRef,
useEffect,
createContext,
useContext,
isValidElement,
} from 'react';
import {
AnimatePresence,
MotionConfig,
motion,
Transition,
Variants,
} from 'motion/react';
import useClickOutside from '@/hooks/useClickOutside';
import { cn } from '@/lib/utils';
const TRANSITION = {
type: 'spring',
bounce: 0.1,
duration: 0.4,
};
type MorphingPopoverContextValue = {
isOpen: boolean;
open: () => void;
close: () => void;
uniqueId: string;
variants?: Variants;
};
const MorphingPopoverContext =
createContext<MorphingPopoverContextValue | null>(null);
function usePopoverLogic({
defaultOpen = false,
open: controlledOpen,
onOpenChange,
}: {
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
} = {}) {
const uniqueId = useId();
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
const isOpen = controlledOpen ?? uncontrolledOpen;
const open = () => {
if (controlledOpen === undefined) {
setUncontrolledOpen(true);
}
onOpenChange?.(true);
};
const close = () => {
if (controlledOpen === undefined) {
setUncontrolledOpen(false);
}
onOpenChange?.(false);
};
return { isOpen, open, close, uniqueId };
}
export type MorphingPopoverProps = {
children: React.ReactNode;
transition?: Transition;
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
variants?: Variants;
className?: string;
} & React.ComponentProps<'div'>;
function MorphingPopover({
children,
transition = TRANSITION,
defaultOpen,
open,
onOpenChange,
variants,
className,
...props
}: MorphingPopoverProps) {
const popoverLogic = usePopoverLogic({ defaultOpen, open, onOpenChange });
return (
<MorphingPopoverContext.Provider value={{ ...popoverLogic, variants }}>
<MotionConfig transition={transition}>
<div
className={cn('relative flex items-center justify-center', className)}
key={popoverLogic.uniqueId}
{...props}
>
{children}
</div>
</MotionConfig>
</MorphingPopoverContext.Provider>
);
}
export type MorphingPopoverTriggerProps = {
asChild?: boolean;
children: React.ReactNode;
className?: string;
} & React.ComponentProps<typeof motion.button>;
function MorphingPopoverTrigger({
children,
className,
asChild = false,
...props
}: MorphingPopoverTriggerProps) {
const context = useContext(MorphingPopoverContext);
if (!context) {
throw new Error(
'MorphingPopoverTrigger must be used within MorphingPopover'
);
}
if (asChild && isValidElement(children)) {
const MotionComponent = motion.create(
children.type as React.ForwardRefExoticComponent<any>
);
const childProps = children.props as Record<string, unknown>;
return (
<MotionComponent
{...childProps}
onClick={context.open}
layoutId={`popover-trigger-${context.uniqueId}`}
className={childProps.className}
key={context.uniqueId}
aria-expanded={context.isOpen}
aria-controls={`popover-content-${context.uniqueId}`}
/>
);
}
return (
<motion.div
key={context.uniqueId}
layoutId={`popover-trigger-${context.uniqueId}`}
onClick={context.open}
>
<motion.button
{...props}
layoutId={`popover-label-${context.uniqueId}`}
key={context.uniqueId}
className={className}
aria-expanded={context.isOpen}
aria-controls={`popover-content-${context.uniqueId}`}
>
{children}
</motion.button>
</motion.div>
);
}
export type MorphingPopoverContentProps = {
children: React.ReactNode;
className?: string;
} & React.ComponentProps<typeof motion.div>;
function MorphingPopoverContent({
children,
className,
...props
}: MorphingPopoverContentProps) {
const context = useContext(MorphingPopoverContext);
if (!context)
throw new Error(
'MorphingPopoverContent must be used within MorphingPopover'
);
const ref = useRef<HTMLDivElement>(null);
useClickOutside(ref, context.close);
useEffect(() => {
if (!context.isOpen) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') context.close();
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [context.isOpen, context.close]);
return (
<AnimatePresence>
{context.isOpen && (
<>
<motion.div
{...props}
ref={ref}
layoutId={`popover-trigger-${context.uniqueId}`}
key={context.uniqueId}
id={`popover-content-${context.uniqueId}`}
role='dialog'
aria-modal='true'
className={cn(
'absolute overflow-hidden rounded-md border border-zinc-950/10 bg-white p-2 text-zinc-950 shadow-md dark:border-zinc-50/10 dark:bg-zinc-700 dark:text-zinc-50',
className
)}
initial='initial'
animate='animate'
exit='exit'
variants={context.variants}
>
{children}
</motion.div>
</>
)}
</AnimatePresence>
);
}
export { MorphingPopover, MorphingPopoverTrigger, MorphingPopoverContent };