Skip to content
Open
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
302 changes: 302 additions & 0 deletions components/motion/popover.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,302 @@
"use client";

import { AnimatePresence, motion, useReducedMotion, type Variants } from "motion/react";
import {
cloneElement,
isValidElement,
useCallback,
useEffect,
useId,
useRef,
useState,
type KeyboardEvent as ReactKeyboardEvent,
type MouseEvent as ReactMouseEvent,
type ReactElement,
type ReactNode,
} from "react";
import { EASE_OUT, SPRING_PANEL } from "@/lib/ease";
import { cn } from "@/lib/utils";

type Side = "top" | "right" | "bottom" | "left";
type Align = "start" | "center" | "end";
type AnimationStyle = "spring" | "soft" | "drift";

const sideClass: Record<Side, string> = {
top: "bottom-full",
right: "left-full",
bottom: "top-full",
left: "right-full",
};

const alignClass: Record<Side, Record<Align, string>> = {
top: {
start: "left-0",
center: "left-1/2 -translate-x-1/2",
end: "right-0",
},
bottom: {
start: "left-0",
center: "left-1/2 -translate-x-1/2",
end: "right-0",
},
left: {
start: "top-0",
center: "top-1/2 -translate-y-1/2",
end: "bottom-0",
},
right: {
start: "top-0",
center: "top-1/2 -translate-y-1/2",
end: "bottom-0",
},
};

const transformOrigin: Record<Side, Record<Align, string>> = {
top: {
start: "left bottom",
center: "center bottom",
end: "right bottom",
},
right: {
start: "left top",
center: "left center",
end: "left bottom",
},
bottom: {
start: "left top",
center: "center top",
end: "right top",
},
left: {
start: "right top",
center: "right center",
end: "right bottom",
},
};

function axisOffset(side: Side, distance: number) {
return {
yOffset: side === "top" ? distance : side === "bottom" ? -distance : 0,
xOffset: side === "left" ? distance : side === "right" ? -distance : 0,
};
}

function buildMotionVariants(side: Side, animationStyle: AnimationStyle): Variants {
const offsetDistance = animationStyle === "drift" ? 12 : animationStyle === "soft" ? 6 : 8;
const { xOffset, yOffset } = axisOffset(side, offsetDistance);
const startScale = animationStyle === "soft" ? 0.97 : animationStyle === "drift" ? 0.99 : 0.94;
const startBlur = animationStyle === "soft" ? 4 : animationStyle === "drift" ? 6 : 8;
const exitScale = animationStyle === "drift" ? 0.99 : 0.97;

return {
initial: {
opacity: 0,
scale: startScale,
filter: `blur(${startBlur}px)`,
x: xOffset,
y: yOffset,
},
animate: {
opacity: 1,
scale: 1,
filter: "blur(0px)",
x: 0,
y: 0,
transition: {
...SPRING_PANEL,
opacity: { duration: 0.18, ease: EASE_OUT },
filter: { duration: 0.22, ease: EASE_OUT },
},
},
exit: {
opacity: 0,
scale: exitScale,
filter: "blur(4px)",
x: xOffset * 0.45,
y: yOffset * 0.45,
transition: { duration: 0.12, ease: EASE_OUT },
},
};
}

const CONTENT_VARIANTS: Variants = {
initial: { opacity: 0, y: 4 },
animate: {
opacity: 1,
y: 0,
transition: {
duration: 0.18,
ease: EASE_OUT,
delay: 0.03,
},
},
exit: {
opacity: 0,
y: 2,
transition: {
duration: 0.1,
ease: EASE_OUT,
},
},
};

const REDUCED_VARIANTS: Variants = {
initial: { opacity: 0 },
animate: { opacity: 1, transition: { duration: 0.14, ease: EASE_OUT } },
exit: { opacity: 0, transition: { duration: 0.1, ease: EASE_OUT } },
};

export interface PopoverProps {
trigger: ReactElement;
children: ReactNode;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
side?: Side;
align?: Align;
className?: string;
contentClassName?: string;
/** Additional offset in pixels, applied from the trigger edge. */
sideOffset?: number;
/** Close when pressing escape or clicking outside. Default true. */
dismissable?: boolean;
/** Close when panel content is clicked (useful for action menus). Default false. */
closeOnContentClick?: boolean;
/** Motion personality for the panel shell. Default "spring". */
animationStyle?: AnimationStyle;
/** Optional fixed width utility classes for the panel (default w-72). */
widthClassName?: string;
}

export function Popover({
trigger,
children,
open,
defaultOpen = false,
onOpenChange,
side = "bottom",
align = "center",
className,
contentClassName,
sideOffset = 8,
dismissable = true,
closeOnContentClick = false,
animationStyle = "spring",
widthClassName = "w-72",
}: PopoverProps) {
const reduce = useReducedMotion();
const contentId = useId();
const wrapperRef = useRef<HTMLDivElement>(null);
const isControlled = open !== undefined;
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
const isOpen = isControlled ? open : uncontrolledOpen;

const setOpen = useCallback(
(nextOpen: boolean) => {
if (!isControlled) setUncontrolledOpen(nextOpen);
onOpenChange?.(nextOpen);
},
[isControlled, onOpenChange],
);

useEffect(() => {
if (!isOpen || !dismissable) return;
const onPointerDown = (event: PointerEvent) => {
const target = event.target;
if (!(target instanceof Node)) return;
if (!wrapperRef.current?.contains(target)) {
setOpen(false);
}
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setOpen(false);
};
document.addEventListener("pointerdown", onPointerDown);
window.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("pointerdown", onPointerDown);
window.removeEventListener("keydown", onKeyDown);
};
}, [dismissable, isOpen, setOpen]);

if (!isValidElement(trigger)) return null;

const triggerElement = cloneElement(trigger as ReactElement<Record<string, unknown>>, {
"aria-expanded": isOpen,
"aria-haspopup": "dialog",
"aria-controls": contentId,
onKeyDown: (event: ReactKeyboardEvent) => {
const triggerProps = trigger.props as {
onKeyDown?: (ev: ReactKeyboardEvent) => void;
};
triggerProps.onKeyDown?.(event);
if (event.defaultPrevented) return;
if (event.key === "Escape" && isOpen) {
setOpen(false);
}
},
onClick: (event: ReactMouseEvent) => {
const triggerProps = trigger.props as {
onClick?: (ev: ReactMouseEvent) => void;
};
triggerProps.onClick?.(event);
if (!event.defaultPrevented) setOpen(!isOpen);
},
});

const variants = reduce ? REDUCED_VARIANTS : buildMotionVariants(side, animationStyle);

return (
<div ref={wrapperRef} className={cn("relative inline-flex", className)}>
{triggerElement}
<AnimatePresence>
{isOpen ? (
<div
className={cn(
"absolute z-50",
sideClass[side],
alignClass[side][align],
)}
style={
side === "top" || side === "bottom"
? { marginTop: side === "bottom" ? sideOffset : undefined, marginBottom: side === "top" ? sideOffset : undefined }
: { marginLeft: side === "right" ? sideOffset : undefined, marginRight: side === "left" ? sideOffset : undefined }
}
>
<motion.div
id={contentId}
role="dialog"
aria-modal="false"
initial="initial"
animate="animate"
exit="exit"
variants={variants}
style={{
transformOrigin: transformOrigin[side][align],
willChange: "transform, opacity, filter",
}}
data-state={isOpen ? "open" : "closed"}
className={cn(
"rounded-2xl border border-border/70 bg-popover/88 p-4 text-popover-foreground shadow-2xl backdrop-blur-xl",
widthClassName,
contentClassName,
)}
onClick={() => {
if (closeOnContentClick) setOpen(false);
}}
>
<motion.div
variants={reduce ? REDUCED_VARIANTS : CONTENT_VARIANTS}
initial="initial"
animate="animate"
exit="exit"
>
{children}
</motion.div>
</motion.div>
</div>
) : null}
</AnimatePresence>
</div>
);
}
3 changes: 3 additions & 0 deletions components/previews/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,9 @@ export const previews: Record<string, ComponentType> = {
"motion/tooltip": dynamic(() =>
import("./motion/tooltip.preview").then((m) => m.TooltipPreview),
),
"motion/popover": dynamic(() =>
import("./motion/popover.preview").then((m) => m.PopoverPreview),
),
"motion/morphing-modal": dynamic(() =>
import("./motion/morphing-modal.preview").then((m) => m.MorphingModalPreview),
),
Expand Down
Loading