2025-08-03 15:29:57 +03:00
|
|
|
import { useEffect, useRef } from "preact/hooks";
|
|
|
|
|
import { t } from "../../services/i18n";
|
|
|
|
|
import { ComponentChildren } from "preact";
|
2025-08-05 19:06:47 +03:00
|
|
|
import type { CSSProperties, RefObject } from "preact/compat";
|
2025-08-03 15:29:57 +03:00
|
|
|
|
|
|
|
|
interface ModalProps {
|
|
|
|
|
className: string;
|
2025-08-04 18:54:17 +03:00
|
|
|
title: string | ComponentChildren;
|
2025-08-05 18:05:41 +03:00
|
|
|
size: "xl" | "lg" | "md" | "sm";
|
2025-08-03 15:29:57 +03:00
|
|
|
children: ComponentChildren;
|
2025-08-06 16:16:30 +03:00
|
|
|
/**
|
|
|
|
|
* Items to display in the modal header, apart from the title itself which is handled separately.
|
|
|
|
|
*/
|
|
|
|
|
header?: ComponentChildren;
|
2025-08-03 17:23:47 +03:00
|
|
|
footer?: ComponentChildren;
|
2025-08-06 18:01:26 +03:00
|
|
|
footerStyle?: CSSProperties;
|
2025-08-05 15:39:49 +03:00
|
|
|
footerAlignment?: "right" | "between";
|
2025-08-05 20:35:53 +03:00
|
|
|
minWidth?: string;
|
2025-08-03 21:39:25 +03:00
|
|
|
maxWidth?: number;
|
2025-08-05 14:12:51 +03:00
|
|
|
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;
|
2025-08-03 19:44:15 +03:00
|
|
|
/**
|
|
|
|
|
* 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;
|
2025-08-03 23:20:32 +03:00
|
|
|
/** Called when the modal is shown. */
|
2025-08-03 15:29:57 +03:00
|
|
|
onShown?: () => void;
|
2025-08-03 23:20:32 +03:00
|
|
|
/** Called when the modal is hidden, either via close button, backdrop click or submit. */
|
|
|
|
|
onHidden?: () => void;
|
2025-08-03 19:48:44 +03:00
|
|
|
helpPageId?: string;
|
2025-08-05 19:06:47 +03:00
|
|
|
/**
|
|
|
|
|
* Gives access to the underlying modal element. This is useful for manipulating the modal directly
|
|
|
|
|
* or for attaching event listeners.
|
|
|
|
|
*/
|
|
|
|
|
modalRef?: RefObject<HTMLDivElement>;
|
|
|
|
|
/**
|
|
|
|
|
* Gives access to the underlying form element of the modal. This is only set if `onSubmit` is provided.
|
|
|
|
|
*/
|
|
|
|
|
formRef?: RefObject<HTMLFormElement>;
|
2025-08-06 16:16:30 +03:00
|
|
|
bodyStyle?: CSSProperties;
|
2025-08-03 15:29:57 +03:00
|
|
|
}
|
|
|
|
|
|
2025-08-06 18:01:26 +03:00
|
|
|
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 }: ModalProps) {
|
2025-08-05 19:06:47 +03:00
|
|
|
const modalRef = _modalRef ?? useRef<HTMLDivElement>(null);
|
|
|
|
|
const formRef = _formRef ?? useRef<HTMLFormElement>(null);
|
2025-08-03 15:29:57 +03:00
|
|
|
|
2025-08-03 23:20:32 +03:00
|
|
|
if (onShown || onHidden) {
|
2025-08-03 15:29:57 +03:00
|
|
|
useEffect(() => {
|
|
|
|
|
const modalElement = modalRef.current;
|
2025-08-04 19:54:59 +03:00
|
|
|
if (!modalElement) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (onShown) {
|
|
|
|
|
modalElement.addEventListener("shown.bs.modal", onShown);
|
|
|
|
|
}
|
|
|
|
|
if (onHidden) {
|
|
|
|
|
modalElement.addEventListener("hidden.bs.modal", onHidden);
|
|
|
|
|
}
|
|
|
|
|
return () => {
|
2025-08-03 23:20:32 +03:00
|
|
|
if (onShown) {
|
2025-08-04 19:54:59 +03:00
|
|
|
modalElement.removeEventListener("shown.bs.modal", onShown);
|
2025-08-03 23:20:32 +03:00
|
|
|
}
|
|
|
|
|
if (onHidden) {
|
2025-08-04 19:54:59 +03:00
|
|
|
modalElement.removeEventListener("hidden.bs.modal", onHidden);
|
2025-08-03 23:20:32 +03:00
|
|
|
}
|
2025-08-04 19:54:59 +03:00
|
|
|
};
|
2025-08-06 20:54:29 +03:00
|
|
|
}, [ ]);
|
2025-08-04 12:58:42 +03:00
|
|
|
}
|
2025-08-03 15:29:57 +03:00
|
|
|
|
2025-08-05 14:12:51 +03:00
|
|
|
const dialogStyle: CSSProperties = {};
|
|
|
|
|
if (zIndex) {
|
|
|
|
|
dialogStyle.zIndex = zIndex;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const documentStyle: CSSProperties = {};
|
2025-08-03 21:39:25 +03:00
|
|
|
if (maxWidth) {
|
2025-08-05 14:12:51 +03:00
|
|
|
documentStyle.maxWidth = `${maxWidth}px`;
|
2025-08-03 21:39:25 +03:00
|
|
|
}
|
2025-08-05 20:35:53 +03:00
|
|
|
if (minWidth) {
|
|
|
|
|
documentStyle.minWidth = minWidth;
|
|
|
|
|
}
|
2025-08-03 21:39:25 +03:00
|
|
|
|
2025-08-03 13:39:23 +03:00
|
|
|
return (
|
2025-08-05 14:12:51 +03:00
|
|
|
<div className={`modal fade mx-auto ${className}`} tabIndex={-1} style={dialogStyle} role="dialog" ref={modalRef}>
|
|
|
|
|
<div className={`modal-dialog modal-${size} ${scrollable ? "modal-dialog-scrollable" : ""}`} style={documentStyle} role="document">
|
2025-08-03 13:39:23 +03:00
|
|
|
<div className="modal-content">
|
2025-08-03 15:29:57 +03:00
|
|
|
<div className="modal-header">
|
2025-08-05 14:12:51 +03:00
|
|
|
{!title || typeof title === "string" ? (
|
|
|
|
|
<h5 className="modal-title">{title ?? <> </>}</h5>
|
2025-08-04 18:54:17 +03:00
|
|
|
) : (
|
|
|
|
|
title
|
|
|
|
|
)}
|
2025-08-06 16:16:30 +03:00
|
|
|
{header}
|
2025-08-03 19:48:44 +03:00
|
|
|
{helpPageId && (
|
2025-08-04 12:58:42 +03:00
|
|
|
<button className="help-button" type="button" data-in-app-help={helpPageId} title={t("modal.help_title")}>?</button>
|
2025-08-03 19:48:44 +03:00
|
|
|
)}
|
2025-08-03 15:29:57 +03:00
|
|
|
<button type="button" className="btn-close" data-bs-dismiss="modal" aria-label={t("modal.close")}></button>
|
|
|
|
|
</div>
|
|
|
|
|
|
2025-08-03 19:44:15 +03:00
|
|
|
{onSubmit ? (
|
2025-08-05 19:06:47 +03:00
|
|
|
<form ref={formRef} onSubmit={(e) => {
|
2025-08-03 19:44:15 +03:00
|
|
|
e.preventDefault();
|
|
|
|
|
onSubmit();
|
|
|
|
|
}}>
|
2025-08-06 18:01:26 +03:00
|
|
|
<ModalInner footer={footer} bodyStyle={bodyStyle} footerStyle={footerStyle} footerAlignment={footerAlignment}>{children}</ModalInner>
|
2025-08-03 19:44:15 +03:00
|
|
|
</form>
|
|
|
|
|
) : (
|
2025-08-06 18:01:26 +03:00
|
|
|
<ModalInner footer={footer} bodyStyle={bodyStyle} footerStyle={footerStyle} footerAlignment={footerAlignment}>
|
2025-08-03 19:44:15 +03:00
|
|
|
{children}
|
|
|
|
|
</ModalInner>
|
2025-08-03 17:23:47 +03:00
|
|
|
)}
|
2025-08-03 13:39:23 +03:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
2025-08-03 19:44:15 +03:00
|
|
|
}
|
|
|
|
|
|
2025-08-06 18:01:26 +03:00
|
|
|
function ModalInner({ children, footer, footerAlignment, bodyStyle, footerStyle: _footerStyle }: Pick<ModalProps, "children" | "footer" | "footerAlignment" | "bodyStyle" | "footerStyle">) {
|
|
|
|
|
const footerStyle: CSSProperties = _footerStyle ?? {};
|
2025-08-05 15:39:49 +03:00
|
|
|
if (footerAlignment === "between") {
|
|
|
|
|
footerStyle.justifyContent = "space-between";
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-03 19:44:15 +03:00
|
|
|
return (
|
|
|
|
|
<>
|
2025-08-06 16:16:30 +03:00
|
|
|
<div className="modal-body" style={bodyStyle}>
|
2025-08-03 19:44:15 +03:00
|
|
|
{children}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{footer && (
|
2025-08-05 15:39:49 +03:00
|
|
|
<div className="modal-footer" style={footerStyle}>
|
2025-08-03 19:44:15 +03:00
|
|
|
{footer}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
);
|
2025-08-03 13:39:23 +03:00
|
|
|
}
|