mirror of
				https://github.com/zadam/trilium.git
				synced 2025-10-31 18:36:30 +01:00 
			
		
		
		
	
		
			
				
	
	
		
			188 lines
		
	
	
		
			7.5 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			188 lines
		
	
	
		
			7.5 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
| 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 "./react_utils";
 | |
| 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<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;
 | |
| }
 | |
| 
 | |
| 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) {
 | |
|     const modalRef = _modalRef ?? useRef<HTMLDivElement>(null);
 | |
|     const modalInstanceRef = useRef<BootstrapModal>();
 | |
|     const formRef = _formRef ?? useRef<HTMLFormElement>(null);
 | |
|     const parentWidget = useContext(ParentComponent);
 | |
|     const elementToFocus = useRef<Element | null>();
 | |
| 
 | |
|     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 ]);
 | |
| 
 | |
|     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]);
 | |
| 
 | |
|     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">
 | |
|                     <div className="modal-header">
 | |
|                         {!title || typeof title === "string" ? (
 | |
|                             <h5 className="modal-title">{title ?? <> </>}</h5>
 | |
|                         ) : (
 | |
|                             title
 | |
|                         )}
 | |
|                         {header}
 | |
|                         {helpPageId && (
 | |
|                             <button className="help-button" type="button" data-in-app-help={helpPageId} title={t("modal.help_title")}>?</button>
 | |
|                         )}
 | |
|                         <button type="button" className="btn-close" data-bs-dismiss="modal" aria-label={t("modal.close")}></button>
 | |
|                     </div>
 | |
| 
 | |
|                     {onSubmit ? (
 | |
|                         <form ref={formRef} onSubmit={useCallback((e) => {
 | |
|                             e.preventDefault();
 | |
|                             onSubmit();
 | |
|                         }, [onSubmit])}>
 | |
|                             <ModalInner footer={footer} bodyStyle={bodyStyle} footerStyle={footerStyle} footerAlignment={footerAlignment}>{children}</ModalInner>
 | |
|                         </form>
 | |
|                     ) : (
 | |
|                         <ModalInner footer={footer} bodyStyle={bodyStyle} footerStyle={footerStyle} footerAlignment={footerAlignment}>
 | |
|                             {children}
 | |
|                         </ModalInner>
 | |
|                     )}
 | |
|                 </div>
 | |
|             </div>}
 | |
|         </div>
 | |
|     );
 | |
| }
 | |
| 
 | |
| 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]);
 | |
| 
 | |
|     return (
 | |
|         <>
 | |
|             <div className="modal-body" style={bodyStyle}>
 | |
|                 {children}
 | |
|             </div>
 | |
| 
 | |
|             {footer && (
 | |
|                 <div className="modal-footer" style={footerStyle}>
 | |
|                     {footer}
 | |
|                 </div>
 | |
|             )}
 | |
|         </>
 | |
|     );
 | |
| }); |