import { useContext, useEffect, useRef, useMemo, useCallback } from "preact/hooks"; import { t } from "../../services/i18n"; import { ComponentChildren } from "preact"; import type { CSSProperties, RefObject } from "preact/compat"; import { openDialog } from "../../services/dialog"; import { ParentComponent } from "./ReactBasicWidget"; import { Modal as BootstrapModal } from "bootstrap"; import { memo } from "preact/compat"; interface ModalProps { className: string; title: string | ComponentChildren; size: "xl" | "lg" | "md" | "sm"; children: ComponentChildren; /** * Items to display in the modal header, apart from the title itself which is handled separately. */ header?: ComponentChildren; footer?: ComponentChildren; footerStyle?: CSSProperties; footerAlignment?: "right" | "between"; minWidth?: string; maxWidth?: number; zIndex?: number; /** * If true, the modal body will be scrollable if the content overflows. * This is useful for larger modals where you want to keep the header and footer visible * while allowing the body content to scroll. * Defaults to false. */ scrollable?: boolean; /** * If set, the modal body and footer will be wrapped in a form and the submit event will call this function. * Especially useful for user input that can be submitted with Enter key. */ onSubmit?: () => void; /** Called when the modal is shown. */ onShown?: () => void; /** * Called when the modal is hidden, either via close button, backdrop click or submit. * * Here it's generally a good idea to set `show` to false to reflect the actual state of the modal. */ onHidden: () => void; helpPageId?: string; /** * Gives access to the underlying modal element. This is useful for manipulating the modal directly * or for attaching event listeners. */ modalRef?: RefObject; /** * Gives access to the underlying form element of the modal. This is only set if `onSubmit` is provided. */ formRef?: RefObject; bodyStyle?: CSSProperties; /** * Controls whether the modal is shown. Setting it to `true` will trigger the modal to be displayed to the user, whereas setting it to `false` will hide the modal. * This method must generally be coupled with `onHidden` in order to detect when the modal was closed externally (e.g. by the user clicking on the backdrop or on the close button). */ show: boolean; } export default function Modal({ children, className, size, title, header, footer, footerStyle, footerAlignment, onShown, onSubmit, helpPageId, minWidth, maxWidth, zIndex, scrollable, onHidden: onHidden, modalRef: _modalRef, formRef: _formRef, bodyStyle, show }: ModalProps) { const modalRef = _modalRef ?? useRef(null); const modalInstanceRef = useRef(); const formRef = _formRef ?? useRef(null); const parentWidget = useContext(ParentComponent); if (onShown || onHidden) { useEffect(() => { const modalElement = modalRef.current; if (!modalElement) { return; } if (onShown) { modalElement.addEventListener("shown.bs.modal", onShown); } if (onHidden) { modalElement.addEventListener("hidden.bs.modal", onHidden); } return () => { if (onShown) { modalElement.removeEventListener("shown.bs.modal", onShown); } if (onHidden) { modalElement.removeEventListener("hidden.bs.modal", onHidden); } }; }, [ ]); } useEffect(() => { if (!parentWidget) { return; } if (show) { openDialog(parentWidget.$widget).then(($widget) => { modalInstanceRef.current = BootstrapModal.getOrCreateInstance($widget[0]); }) } else { modalInstanceRef.current?.hide(); } }, [ show ]); // Memoize styles to prevent recreation on every render const dialogStyle = useMemo(() => { const style: CSSProperties = {}; if (zIndex) { style.zIndex = zIndex; } return style; }, [zIndex]); const documentStyle = useMemo(() => { const style: CSSProperties = {}; if (maxWidth) { style.maxWidth = `${maxWidth}px`; } if (minWidth) { style.minWidth = minWidth; } return style; }, [maxWidth, minWidth]); return (
{show &&
{!title || typeof title === "string" ? (
{title ?? <> }
) : ( title )} {header} {helpPageId && ( )}
{onSubmit ? (
{ e.preventDefault(); onSubmit(); }, [onSubmit])}> {children}
) : ( {children} )}
}
); } const ModalInner = memo(({ children, footer, footerAlignment, bodyStyle, footerStyle: _footerStyle }: Pick) => { // Memoize footer style const footerStyle = useMemo(() => { const style: CSSProperties = _footerStyle ?? {}; if (footerAlignment === "between") { style.justifyContent = "space-between"; } return style; }, [_footerStyle, footerAlignment]); return ( <>
{children}
{footer && (
{footer}
)} ); });