'use client' import { useCallback, useEffect, useId, useRef, useState, type ReactNode } from 'react' import { cn } from '../../lib/cn' import { useOverlayLayer } from '../../lib/overlay' import { FocusTrap } from '../FocusTrap' import { Portal } from '../Portal' import { IconButton } from '../IconButton' import { Text } from '../Text' import { usePrefersReducedMotion } from '../../lib/motion' import type { IconComponent } from '../../lib/types' /** Minimal X, so the component carries no icon-library dependency. */ const CloseGlyph: IconComponent = ({ size = 16, strokeWidth = 2.25 }) => { return ( ) } export interface MorphDialogProps { /** The closed state — a card, a tile, a row. Clicking it opens the dialog. */ trigger: ReactNode /** Dialog body. */ children: ReactNode /** Dialog heading. Becomes the accessible name. */ title: string /** Supporting line, wired to aria-describedby. */ description?: string /** Dialog width in pixels, capped to the viewport. */ width?: number /** Accessible name for the trigger, when the trigger is not self-describing. */ triggerLabel?: string onOpenChange?: (open: boolean) => void /** Merged last, so it wins. */ className?: string /** Classes for the opened panel rather than for the trigger. */ dialogClassName?: string } const DURATION = 340 const EASING = 'cubic-bezier(0.32, 0.72, 0, 1)' /** * A card that expands into a dialog from exactly where it sits, and collapses * back into it. * * This is a FLIP animation. The dialog is rendered at its final size and * position, then transformed back onto the trigger rectangle for one frame and * released — so the browser animates a single `transform`, never width, height, * top or left. Animating the box itself would relayout the dialog contents on * every frame of the open. * * The connection between the two is the point. A dialog that fades in from * nowhere makes the reader find their place again; one that grows out of the * thing they clicked tells them where they are and where they will be returned * to. Under `prefers-reduced-motion` it simply appears — the spatial cue is a * nicety, the dialog is not. * * Everything else is an ordinary dialog: portalled out of the layout, focus * trapped and restored, Escape and scrim both close, and the page behind it * cannot scroll while it is open. */ export function MorphDialog({ trigger, children, title, description, width = 560, triggerLabel, onOpenChange, className, dialogClassName, }: MorphDialogProps) { const id = useId() const triggerRef = useRef(null) const panelRef = useRef(null) const closeTimer = useRef(undefined) const [open, setOpen] = useState(false) /** Kept mounted through the closing animation. */ const [mounted, setMounted] = useState(false) const reducedMotion = usePrefersReducedMotion() /** The transform that puts the panel exactly over the trigger. */ const transformToTrigger = useCallback(() => { const panel = panelRef.current const origin = triggerRef.current?.getBoundingClientRect() if (!panel || !origin) return '' const box = panel.getBoundingClientRect() const scaleX = origin.width / box.width const scaleY = origin.height / box.height const dx = origin.left + origin.width / 2 - (box.left + box.width / 2) const dy = origin.top + origin.height / 2 - (box.top + box.height / 2) return `translate(${dx}px, ${dy}px) scale(${scaleX}, ${scaleY})` }, []) const hide = useCallback(() => { const panel = panelRef.current setOpen(false) onOpenChange?.(false) if (!panel || reducedMotion) { setMounted(false) return } panel.style.transition = `transform ${DURATION}ms ${EASING}, opacity ${Math.round(DURATION * 0.6)}ms linear` panel.style.transform = transformToTrigger() panel.style.opacity = '0' closeTimer.current = window.setTimeout(() => setMounted(false), DURATION) }, [onOpenChange, reducedMotion, transformToTrigger]) // Unmounting mid-close must not leave a timer to set state on nothing. useEffect(() => () => window.clearTimeout(closeTimer.current), []) // First frame after mount: pin the panel onto the trigger, then release it. useEffect(() => { if (!mounted) return const panel = panelRef.current if (!panel) return if (reducedMotion) { setOpen(true) return } panel.style.transition = 'none' panel.style.transformOrigin = 'center center' panel.style.transform = transformToTrigger() panel.style.opacity = '0.35' const frame = requestAnimationFrame(() => { panel.style.transition = `transform ${DURATION}ms ${EASING}, opacity ${Math.round(DURATION * 0.5)}ms linear` panel.style.transform = 'translate(0, 0) scale(1)' panel.style.opacity = '1' setOpen(true) }) return () => cancelAnimationFrame(frame) }, [mounted, reducedMotion, transformToTrigger]) // The layer lasts as long as the panel is on screen, exit flight included, so // the page stays locked until it has gone. Escape only counts while it is // actually open: a second press mid-flight used to report the close twice. const { zIndex } = useOverlayLayer({ open: mounted, onDismiss: hide, dismissible: open }) return ( <> {mounted && (
)} ) }