'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 && (