import { useFloatingPortalNode } from "@floating-ui/react"; import React, { forwardRef, useEffect, useRef } from "react"; import { createPortal } from "react-dom"; import { useDateInputContext } from "../date/Date.Input"; import { useProvider } from "../provider/Provider"; import { Detail, Heading } from "../typography"; import { useId } from "../utils-external"; import { cl, composeEventHandlers } from "../utils/helpers"; import { useMergeRefs, useScrollLock } from "../utils/hooks"; import { ModalContextProvider, useModalContext } from "./Modal.context"; import ModalBody from "./ModalBody"; import ModalFooter from "./ModalFooter"; import ModalHeader from "./ModalHeader"; import { MouseCoordinates, coordsAreInside, getCloseHandler, useIsModalOpen, } from "./ModalUtils"; import dialogPolyfill, { needPolyfill } from "./dialog-polyfill"; import { ModalProps } from "./types"; const polyfillClassName = "aksel-modal--polyfilled"; interface ModalComponent extends React.ForwardRefExoticComponent< ModalProps & React.RefAttributes > { Header: typeof ModalHeader; Body: typeof ModalBody; Footer: typeof ModalFooter; } /** * A component that displays a modal dialog. * * @see [📝 Documentation](https://aksel.nav.no/komponenter/core/modal) * @see 🏷️ {@link ModalProps} * * @example * State change with `useRef` * ```jsx * const ref = useRef(null); * * , * heading: "My heading", * }} * > * * Hello world * * * * * * * ``` * @example * State change with `useState` * ```jsx * const [open, setOpen] = useState(false); * setOpen(false)} * aria-labelledby="modal-heading" * > * * My heading * * * Hello world * * * ``` */ export const Modal = forwardRef( ( { header, children, open, onBeforeClose, onCancel, closeOnBackdropClick, width, placement, portal, className, "aria-labelledby": ariaLabelledby, style, onClick, onMouseDown, ...rest }: ModalProps, ref, ) => { const modalRef = useRef(null); const mergedRef = useMergeRefs(modalRef, ref); const ariaLabelId = useId(); const rootElement = useProvider()?.rootElement; const portalNode = useFloatingPortalNode({ root: rootElement }); const dateContext = useDateInputContext(false); const isNested = useModalContext(false) !== undefined; const isModalOpen = useIsModalOpen(modalRef.current); if (isNested && !dateContext) { console.error("Modals should not be nested"); } useEffect(() => { // If using portal, modalRef.current will not be set before portalNode is set. // If not using portal, modalRef.current is available first. // We check both to avoid activating polyfill twice when not using portal. if (needPolyfill && modalRef.current && portalNode) { dialogPolyfill.registerDialog(modalRef.current); // Force-add the "polyfilled" class in case of SSR (needPolyfill will always be false on the server) modalRef.current.classList.add(polyfillClassName); } // We set autofocus on the dialog element to prevent the default behavior where first focusable element gets focus when modal is opened. // This is mainly to fix an edge case where having a Tooltip as the first focusable element would make it activate when you open the modal. // We have to use JS because it doesn't work to set it with a prop (React bug?) // Currently doesn't seem to work in Chrome. See also Tooltip.tsx if (modalRef.current && portalNode) modalRef.current.autofocus = true; }, [portalNode]); useEffect(() => { // We need to have this in a useEffect so that the content renders before the modal is displayed, // and in case `open` is true initially. // We need to check both modalRef.current and portalNode to make sure the polyfill has been activated. if (modalRef.current && portalNode && open !== undefined) { if (open && !modalRef.current.open) { modalRef.current.showModal(); } else if (!open && modalRef.current.open) { modalRef.current.close(); } } }, [portalNode, open]); useScrollLock({ enabled: isModalOpen, mounted: isModalOpen, open: isModalOpen, referenceElement: modalRef.current, }); const isWidthPreset = typeof width === "string" && ["small", "medium"].includes(width); const mergedClassName = cl("aksel-modal", className, { [polyfillClassName]: needPolyfill, "aksel-modal--autowidth": !width, [`aksel-modal--${width}`]: isWidthPreset, "aksel-modal--top": placement === "top" && !needPolyfill, }); const mergedStyle = { ...style, ...(!isWidthPreset ? { width } : {}), }; const mouseClickStart = useRef({ clientX: 0, clientY: 0, }); const handleModalMouseDown: React.MouseEventHandler = ( event, ) => { mouseClickStart.current = event; }; const shouldHandleModalClick = closeOnBackdropClick && !needPolyfill; /** * `closeOnBackdropClick` has issues on polyfill when nesting modals (DatePicker) */ const handleModalClick = ( endEvent: React.MouseEvent, ) => { if (endEvent.target !== modalRef.current) { return; } const modalRect = modalRef.current.getBoundingClientRect(); if ( coordsAreInside(mouseClickStart.current, modalRect) || coordsAreInside(endEvent, modalRect) ) { return; } if (onBeforeClose !== undefined && onBeforeClose() === false) { return; } modalRef.current.close(); }; /** * onCancel fires when you press `Esc` */ const handleModalCancel = ( event: React.SyntheticEvent, ) => { onBeforeClose && onBeforeClose() === false && event.preventDefault(); }; const mergedAriaLabelledBy = !ariaLabelledby && !rest["aria-label"] && header ? ariaLabelId : ariaLabelledby; const component = ( // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions { /** * Stops propagation of Escape key to prevent closing parent modals/dialogs */ if (e.key === "Escape") { e.stopPropagation(); } }} > {header && ( {header.label && ( {header.label} )} {header.icon && ( {header.icon} )} {header.heading} )} {children} ); if (portal) { if (portalNode) return createPortal(component, portalNode); return null; } return component; }, ) as ModalComponent; Modal.Header = ModalHeader; Modal.Body = ModalBody; Modal.Footer = ModalFooter; export default Modal;