"use client"; import { useId, useRef, type ReactNode, type RefObject, type FormEvent } from "react"; import { createPortal } from "react-dom"; import { useTranslations } from "next-intl"; import { useFocusTrap } from "@/hooks/use-focus-trap"; import { IconButton } from "@/components/ui/icon-button"; import { X } from "@/components/icons"; import { cn } from "@/lib/utils"; export type DialogSize = "sm" | "md" | "lg" | "full-on-phone"; export interface DialogProps { isOpen: boolean; /** Called by Escape, the close X and an outside click. Ask about unsaved input here (R5). */ onClose: () => void; title: ReactNode; /** One line under the title; becomes the dialog's description. */ description?: ReactNode; /** The body, under the title and description. */ children?: ReactNode; /** The buttons: Cancel (outline) first, then the primary. Laid out at the end. */ footer?: ReactNode; /** `alertdialog` for confirmations. */ role?: "dialog" | "alertdialog"; /** sm 384px, md 448px (default), lg 672px; full-on-phone fills a phone screen and is lg above. */ size?: DialogSize; /** Drawn before the title, e.g. the warning icon of a destructive confirm. */ icon?: ReactNode; /** The close X at the top end. Default true. */ showClose?: boolean; /** Ignore clicks outside, e.g. while the dialog holds unsaved input. */ preventOutsideClose?: boolean; /** Same as preventOutsideClose: the dialog holds unsaved input. */ dirty?: boolean; /** Where the focus goes on open. Default: the first field, else the first control. */ initialFocus?: RefObject; /** Wraps body and footer in a form, so Enter in a field submits. */ onSubmit?: (e: FormEvent) => void; /** Extra classes for the panel. */ className?: string; /** Extra classes for the body. */ bodyClassName?: string; /** Test hook on the panel. */ testId?: string; } const SIZES: Record = { sm: "max-w-sm", md: "max-w-md", lg: "max-w-2xl", "full-on-phone": "max-w-2xl max-sm:max-w-none max-sm:h-full max-sm:max-h-none max-sm:rounded-none max-sm:border-0", }; const FIELD_SELECTOR = 'input:not([type="hidden"]):not([type="button"]):not([type="submit"]):not([type="checkbox"]):not([type="radio"]):not(:disabled), textarea:not(:disabled), select:not(:disabled), [contenteditable="true"]'; /** * The one dialog shell (R4): a portal on at z-[60] over a dimmed * backdrop, labelled by its title, focus moved in and given back, Escape * from anywhere inside, outside click closes unless `dirty`. * * ```tsx * }> * * * ``` */ export function Dialog({ isOpen, onClose, title, description, children, footer, role = "dialog", size = "md", icon, showClose = true, preventOutsideClose = false, dirty = false, initialFocus, onSubmit, className, bodyClassName, testId, }: DialogProps) { const tCommon = useTranslations("common"); const id = useId(); const titleId = `${id}-title`; const descriptionId = `${id}-description`; // The focus starts on an explicit target, else the first field, else // the first control. const panelRef = useFocusTrap({ isActive: isOpen, onEscape: onClose, restoreFocus: true, initialFocus: () => initialFocus?.current ?? panelRef.current?.querySelector(FIELD_SELECTOR), }); // A press that started on the backdrop, so a text selection dragged out of // a field and released outside doesn't count as an outside click. const pressedOnBackdrop = useRef(false); if (!isOpen || typeof document === "undefined") return null; const blockOutside = preventOutsideClose || dirty; const heading = ( <>

{title}

{description && (

{description}

)} ); const footerRow = footer && (
{footer}
); // With an icon the title, text and body sit in one column beside it (a // destructive confirm). Without one, the title stays put while a long // body scrolls under it. The spacing is the same either way. const body = icon ? ( <>
{icon}
{heading} {children}
{footerRow} ) : ( <>
{heading}
{children}
{footerRow} ); return createPortal(
{ pressedOnBackdrop.current = e.target === e.currentTarget; }} onClick={(e) => { const outside = pressedOnBackdrop.current && e.target === e.currentTarget; pressedOnBackdrop.current = false; if (outside && !blockOutside) onClose(); }} >
{onSubmit ? (
{body}
) : ( body )} {showClose && ( // Last in the tab order, so the focus starts in the content. )}
, document.body ); }