import React, { forwardRef } from "react"; import { BoxNew, type BoxNewProps } from "../../primitives/box"; import { DismissableLayer } from "../../utils/components/dismissablelayer/DismissableLayer"; import { FocusBoundary } from "../../utils/components/focus-boundary/FocusBoundary"; import { FocusGuards } from "../../utils/components/focus-guards/FocusGuards"; import { cl } from "../../utils/helpers"; import { createTransitionStatusAttribute, useMergeRefs, useOpenChangeAnimationComplete, useScrollLock, } from "../../utils/hooks"; import { useDialogContext } from "../root/DialogRoot.context"; type DialogPosition = "center" | "bottom" | "left" | "right" | "fullscreen"; interface DialogPopupInternalProps extends React.HTMLAttributes { /** * Determines if the dialog enters a modal state when open. * - `true`: user interaction is limited to just the dialog: focus is trapped, document page scroll is locked, and pointer interactions on outside elements are disabled. * - `'trap-focus'`: focus is trapped inside the dialog, but document page scroll is not locked and pointer interactions outside of it remain enabled. * @default true */ modal?: true | "trap-focus"; /** * Determines if the dialog should close on outside clicks. * @default true */ closeOnOutsideClick?: boolean; /** * Will try to focus the given element on mount. * * If not provided, Dialog will focus the popup itself. */ initialFocusTo?: | React.RefObject | (() => HTMLElement | null | undefined); /** * Will try to focus the given element on unmount. * * If not provided, Dialog will try to focus the `Dialog.Trigger` element * or the last focused element before the dialog opened. */ returnFocusTo?: | React.RefObject | (() => HTMLElement | null | undefined); /** * The position of the dialog relative to the viewport. * @default "center" */ position?: "center" | "bottom" | "left" | "right" | "fullscreen"; /** * CSS `width` * * Has no effect when `position` is set to `fullscreen`. * * @default "medium" */ width?: (string & {}) | "small" | "medium" | "large"; /** * CSS `height` * * Has no effect when `position` is set to `fullscreen`, `left` or `right`. */ height?: (string & {}) | "small" | "medium" | "large"; /** * Adds a backdrop behind the dialog popup. * @default true if `modal={true}`, else `false` */ withBackdrop?: boolean; /** * ARIA role for the dialog popup. * @default "dialog" */ role?: "dialog" | "alertdialog"; } const DialogPopupInternal = forwardRef< HTMLDivElement, DialogPopupInternalProps >( ( { className, modal, closeOnOutsideClick = true, initialFocusTo, returnFocusTo, position = "center", width = "medium", height, id, style, "aria-labelledby": ariaLabelledbyProp, withBackdrop, role = "dialog", ...restProps }, forwardedRef, ) => { const { mounted, popupRef, setPopupElement, triggerElement, setOpen, open, transitionStatus, popupElement, nestedOpenDialogCount: nestedOpenDialogCountProp, nested, size, titleId, popupId, onOpenChangeComplete, setMounted, } = useDialogContext(); const mergedRefs = useMergeRefs(forwardedRef, popupRef, setPopupElement); useScrollLock({ enabled: open && modal === true, mounted, open, referenceElement: popupElement, }); useOpenChangeAnimationComplete({ open, ref: popupRef, onComplete() { if (open) { onOpenChangeComplete?.(true); } else { setMounted(false); onOpenChangeComplete?.(false); } }, }); const resolvedInitialFocus = initialFocusTo ?? popupRef; const resolvedReturnFocus = () => { if (returnFocusTo) { return typeof returnFocusTo === "function" ? returnFocusTo() : returnFocusTo.current; } if (isFocusable(triggerElement)) { return triggerElement; } return true; }; return ( { open && setOpen(false, event); }} disableOutsidePointerEvents={modal === true || withBackdrop} onInteractOutside={(event) => { /** * Since trigger might be set up to close the dialog on click, * we need to prevent dismissing when clicking the trigger to avoid double close events (potentially re-triggering open) */ const target = event.target as HTMLElement; const targetIsTrigger = triggerElement?.contains(target); if (targetIsTrigger) { event.preventDefault(); } }} /** * Only close dialog on pointerUp pointerEvents */ onPointerDownOutside={(event) => { event.preventDefault(); }} onFocusOutside={(event) => { /** * Focus-events are tricky when dealing with portals and nested dialogs. * If multiple dialogs are open, initial auto-focus might cause * onFocusOutside to trigger on the parent dialog when focusing the child dialog. */ event.preventDefault(); }} enablePointerUpOutside onPointerUpOutside={(event) => { !closeOnOutsideClick && event.preventDefault(); }} > ); }, ); function translateWidth( width: DialogPopupInternalProps["width"], position: DialogPosition, ): BoxNewProps["width"] { if (position === "fullscreen") { return undefined; } switch (width) { case "small": return "480px"; case "medium": return "640px"; case "large": return "800px"; default: return width; } } function translateHeight( height: DialogPopupInternalProps["height"], position: DialogPosition, ): BoxNewProps["height"] { if ( position === "fullscreen" || position === "left" || position === "right" ) { return undefined; } return height; } function isFocusable(element: Element | null) { if (!element || !element.isConnected) { return false; } /** * Baselined in 2024 * @see https://caniuse.com/?search=checkvisibility */ if (typeof element.checkVisibility === "function") { return element.checkVisibility(); } return getComputedStyle(element).display !== "none"; } export { DialogPopupInternal }; export type { DialogPopupInternalProps };