import { DIALOG, DIALOG_MOTION_PRESETS, NO_MOTION_TRANSITION } from "$constants"; import { useSkipAnimation } from "$hooks/useMotion"; import { DialogMotionPreset } from "$types"; import { cn } from "$utils/tw"; import { AnimatePresence, motion } from "motion/react"; import { useEffect, useMemo, useRef } from "react"; import type { CSSProperties, ReactNode } from "react"; type DialogProps = { isOpen: boolean; children: ReactNode; onClose?: () => void; ariaLabel?: string; showBackdrop?: boolean; closeOnBackdrop?: boolean; backdropClassName?: string; containerClassName?: string; containerStyle?: CSSProperties; panelClassName?: string; panelStyle?: CSSProperties; motionPreset?: DialogMotionPreset; }; const FOCUSABLE_SELECTOR = "button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex='-1'])"; function getFocusableElements(root: HTMLElement): HTMLElement[] { return Array.from(root.querySelectorAll(FOCUSABLE_SELECTOR)).filter((element) => !element.hasAttribute("disabled") && element.getAttribute("aria-hidden") !== "true" ); } export function Dialog( { isOpen, children, onClose, ariaLabel, showBackdrop = true, closeOnBackdrop = true, backdropClassName, containerClassName, containerStyle, panelClassName, panelStyle, motionPreset = "scale", }: DialogProps, ) { const skipAnimation = useSkipAnimation(); const motionConfig = DIALOG_MOTION_PRESETS[motionPreset]; const panelRef = useRef(null); const backdropTransition = useMemo(() => skipAnimation ? NO_MOTION_TRANSITION : DIALOG.BACKDROP.FADE_TRANSITION, [ skipAnimation, ]); const surfaceTransition = useMemo(() => skipAnimation ? NO_MOTION_TRANSITION : DIALOG.SURFACE.TRANSITION, [ skipAnimation, ]); const handleBackdropClick = closeOnBackdrop ? onClose : undefined; useEffect(() => { if (!isOpen || typeof globalThis.addEventListener !== "function") { return; } const panel = panelRef.current; if (!panel) { return; } const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null; const focusable = getFocusableElements(panel); const firstFocusable = focusable[0]; const fallbackFocusable = panel; (firstFocusable ?? fallbackFocusable).focus(); const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape" && onClose) { onClose(); return; } if (event.key !== "Tab") { return; } const activePanel = panelRef.current; if (!activePanel) { return; } const cycleTargets = getFocusableElements(activePanel); if (cycleTargets.length === 0) { event.preventDefault(); activePanel.focus(); return; } const first = cycleTargets[0]; const last = cycleTargets.at(-1); const activeElement = document.activeElement; if (event.shiftKey && activeElement === first) { event.preventDefault(); last?.focus(); } else if (!event.shiftKey && activeElement === last) { event.preventDefault(); first.focus(); } }; globalThis.addEventListener("keydown", handleKeyDown); return () => { globalThis.removeEventListener("keydown", handleKeyDown); previousFocus?.focus(); }; }, [isOpen, onClose]); return ( {isOpen && (
{showBackdrop && ( )} {children}
)}
); }