Files
Trilium/apps/client/src/widgets/react/Modal.tsx

188 lines
7.5 KiB
TypeScript
Raw Normal View History

import { useContext, useEffect, useRef, useMemo, useCallback } from "preact/hooks";
2025-08-03 15:29:57 +03:00
import { t } from "../../services/i18n";
import { ComponentChildren } from "preact";
2025-08-05 19:06:47 +03:00
import type { CSSProperties, RefObject } from "preact/compat";
import { openDialog } from "../../services/dialog";
import { ParentComponent } from "./react_utils";
import { Modal as BootstrapModal } from "bootstrap";
import { memo } from "preact/compat";
2025-08-03 15:29:57 +03:00
interface ModalProps {
className: string;
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;
/**
* 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";
2025-08-05 20:35:53 +03:00
minWidth?: string;
2025-08-03 21:39:25 +03:00
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;
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;
/**
* 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;
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>;
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;
/**
* By default displaying a modal will close all existing modals. Set this to true to keep the existing modals open instead. This is useful for confirmation modals.
*/
stackable?: boolean;
2025-08-03 15:29:57 +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, show, stackable }: ModalProps) {
2025-08-05 19:06:47 +03:00
const modalRef = _modalRef ?? useRef<HTMLDivElement>(null);
const modalInstanceRef = useRef<BootstrapModal>();
2025-08-05 19:06:47 +03:00
const formRef = _formRef ?? useRef<HTMLFormElement>(null);
const parentWidget = useContext(ParentComponent);
const elementToFocus = useRef<Element | null>();
2025-08-03 15:29:57 +03:00
useEffect(() => {
const modalElement = modalRef.current;
if (!modalElement) {
return;
}
if (onShown) {
modalElement.addEventListener("shown.bs.modal", onShown);
}
modalElement.addEventListener("hidden.bs.modal", () => {
onHidden();
if (elementToFocus.current && "focus" in elementToFocus.current) {
(elementToFocus.current as HTMLElement).focus();
}
});
return () => {
if (onShown) {
modalElement.removeEventListener("shown.bs.modal", onShown);
}
modalElement.removeEventListener("hidden.bs.modal", onHidden);
};
}, [ onShown, onHidden ]);
2025-08-03 15:29:57 +03:00
useEffect(() => {
if (!parentWidget) {
return;
}
if (show) {
elementToFocus.current = document.activeElement;
openDialog(parentWidget.$widget, !stackable).then(($widget) => {
modalInstanceRef.current = BootstrapModal.getOrCreateInstance($widget[0]);
})
} else {
modalInstanceRef.current?.hide();
}
}, [ show ]);
// Memoize styles to prevent recreation on every render
const dialogStyle = useMemo<CSSProperties>(() => {
const style: CSSProperties = {};
if (zIndex) {
style.zIndex = zIndex;
}
return style;
}, [zIndex]);
const documentStyle = useMemo<CSSProperties>(() => {
const style: CSSProperties = {};
if (maxWidth) {
style.maxWidth = `${maxWidth}px`;
}
if (minWidth) {
style.minWidth = minWidth;
}
return style;
}, [maxWidth, minWidth]);
2025-08-03 21:39:25 +03:00
return (
<div className={`modal fade mx-auto ${className}`} tabIndex={-1} style={dialogStyle} role="dialog" ref={modalRef}>
{show && <div className={`modal-dialog modal-${size} ${scrollable ? "modal-dialog-scrollable" : ""}`} style={documentStyle} role="document">
<div className="modal-content">
2025-08-03 15:29:57 +03:00
<div className="modal-header">
{!title || typeof title === "string" ? (
<h5 className="modal-title">{title ?? <>&nbsp;</>}</h5>
) : (
title
)}
{header}
{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 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 ? (
<form ref={formRef} onSubmit={useCallback((e) => {
2025-08-03 19:44:15 +03:00
e.preventDefault();
onSubmit();
}, [onSubmit])}>
<ModalInner footer={footer} bodyStyle={bodyStyle} footerStyle={footerStyle} footerAlignment={footerAlignment}>{children}</ModalInner>
2025-08-03 19:44:15 +03:00
</form>
) : (
<ModalInner footer={footer} bodyStyle={bodyStyle} footerStyle={footerStyle} footerAlignment={footerAlignment}>
2025-08-03 19:44:15 +03:00
{children}
</ModalInner>
)}
</div>
</div>}
</div>
);
2025-08-03 19:44:15 +03:00
}
const ModalInner = memo(({ children, footer, footerAlignment, bodyStyle, footerStyle: _footerStyle }: Pick<ModalProps, "children" | "footer" | "footerAlignment" | "bodyStyle" | "footerStyle">) => {
// Memoize footer style
const footerStyle = useMemo<CSSProperties>(() => {
const style: CSSProperties = _footerStyle ?? {};
if (footerAlignment === "between") {
style.justifyContent = "space-between";
}
return style;
}, [_footerStyle, footerAlignment]);
2025-08-03 19:44:15 +03:00
return (
<>
<div className="modal-body" style={bodyStyle}>
2025-08-03 19:44:15 +03:00
{children}
</div>
{footer && (
<div className="modal-footer" style={footerStyle}>
2025-08-03 19:44:15 +03:00
{footer}
</div>
)}
</>
);
});