import shim from '../shim'; import { blur, focus } from '../utils/focusHandler'; import useDocument from '../hooks/dom/useDocument'; import type { FC, CSSProperties, ReactNode } from 'react'; const React = shim.react(); const { useEffect, useRef, useState } = shim.react(); type OnCancelListener = ()=> void; // Closes made by the app, not the user, so the close event must not report them as a cancel. const removedReturnValue = 'removed-from-dom'; const hiddenReturnValue = 'hidden'; type OnShowListener = ()=> void; type OnBeforeShowListener = (dialog: HTMLDialogElement)=> void; interface Props { className?: string; onCancel?: OnCancelListener; onShow?: OnShowListener; onBeforeShow?: OnBeforeShowListener; contentStyle?: CSSProperties; open?: boolean; preventAutoCloseOnCancel?: boolean; contentFillsScreen?: boolean; children: ReactNode; } const Dialog: FC = props => { const [containerElement, setContainerElement] = useState(null); const containerDocument = useDocument(containerElement); // For correct focus handling, the dialog element needs to be managed separately from React. In particular, // just after creating the dialog, we need to call .showModal() and just **before** closing the dialog, we // need to call .close(). This second requirement is particularly difficult, as this needs to happen even // if the dialog is closed by removing its parent from the React DOM. // // Because useEffect cleanup can happen after an element is removed from the HTML DOM, the dialog is managed // using native HTML APIs. This allows us to call .close() while the dialog is still attached to the DOM, which // allows the browser to restore the focus from before the dialog was opened. const dialogElement = useDialogElement(containerDocument, props.onCancel, props.preventAutoCloseOnCancel ?? false); useDialogClassNames(dialogElement, props.className); const [contentRendered, setContentRendered] = useState(false); const propsRef = useRef(props); propsRef.current = props; useEffect(() => { if (!dialogElement || !contentRendered) return; const open = props.open ?? true; if (!dialogElement.open && open) { propsRef.current.onBeforeShow?.(dialogElement); dialogElement.showModal(); propsRef.current.onShow?.(); } else if (dialogElement.open && !open) { // The close event fires later, when a dialog shown next may already own onCancel. dialogElement.close(hiddenReturnValue); } }, [dialogElement, contentRendered, props.open]); useEffect(() => { if (!dialogElement) return; if (props.contentFillsScreen) { dialogElement.classList.add('-fullscreen'); } else { dialogElement.classList.remove('-fullscreen'); } }, [props.contentFillsScreen, dialogElement]); if (dialogElement && !contentRendered) { setContentRendered(true); } const content = (
{props.children}
); return
{dialogElement && shim.reactDom().createPortal(content, dialogElement) as ReactNode}
; }; // We keep track of the mouse events to allow the action to be cancellable on the mouseup // If dialogElement is the source of the mouse event it means // that the user clicked in the dimmed background and not in the content of the dialog const useClickedOutsideContent = (dialogElement: HTMLDialogElement|null) => { const mouseDownOutsideContent = useRef(false); mouseDownOutsideContent.current = false; const [clickedOutsideContent, setClickedOutsideContent] = useState(false); useEffect(() => { if (!dialogElement) return () => {}; const mouseDownListener = (event: MouseEvent) => { if (event.target === dialogElement) { mouseDownOutsideContent.current = true; } else { mouseDownOutsideContent.current = false; } }; const mouseUpListener = (event: MouseEvent) => { if (!mouseDownOutsideContent.current) return; if (mouseDownOutsideContent.current && event.target === dialogElement) { setClickedOutsideContent(true); mouseDownOutsideContent.current = false; } else { setClickedOutsideContent(false); mouseDownOutsideContent.current = false; } }; dialogElement.addEventListener('mousedown', mouseDownListener); dialogElement.addEventListener('mouseup', mouseUpListener); return () => { dialogElement.removeEventListener('mousedown', mouseDownListener); dialogElement.removeEventListener('mouseup', mouseUpListener); }; }, [dialogElement]); return [clickedOutsideContent, setClickedOutsideContent] as const; }; const useDialogElement = (containerDocument: Document, onCancel: undefined|OnCancelListener, preventAutoCloseOnCancel: boolean) => { const [dialogElement, setDialogElement] = useState(null); const onCancelRef = useRef(onCancel); onCancelRef.current = onCancel; const skipCloseCancelRef = useRef(false); const [clickedOutsideContent, setClickedOutsideContent] = useClickedOutsideContent(dialogElement); useEffect(() => { if (clickedOutsideContent) { const onCancel = onCancelRef.current; if (onCancel) { if (preventAutoCloseOnCancel) { skipCloseCancelRef.current = true; } onCancel(); } else { setClickedOutsideContent(false); } } }, [clickedOutsideContent, setClickedOutsideContent, preventAutoCloseOnCancel]); useEffect(() => { if (!containerDocument) return () => {}; const dialog = containerDocument.createElement('dialog'); dialog.classList.add('dialog-modal-layer'); dialog.addEventListener('cancel', event => { if (preventAutoCloseOnCancel) { // Prevent the native dialog element from auto-closing so the app can // decide whether to close (for example, to confirm unsaved changes). event.preventDefault(); skipCloseCancelRef.current = true; onCancelRef.current?.(); return; } const canCancel = !!onCancelRef.current; if (!canCancel) { // Prevents [Escape] from closing the dialog. In many places, this is handled // by external logic. // See https://stackoverflow.com/a/61021326 event.preventDefault(); } }); dialog.addEventListener('close', () => { const closedByCancel = dialog.returnValue !== removedReturnValue && dialog.returnValue !== hiddenReturnValue; // A cancel leaves the return value as it was, so it is cleared here rather than on show, which can // happen before this event arrives. dialog.returnValue = ''; if (closedByCancel) { if (!preventAutoCloseOnCancel || !skipCloseCancelRef.current) { onCancelRef.current?.(); } } skipCloseCancelRef.current = false; // Work around what seems to be an Electron bug -- if an input or contenteditable region is refocused after // dismissing a dialog, it won't be editable. // Note: While this addresses the issue in the note title input, it does not address the issue in the Rich Text Editor. if (containerDocument.activeElement?.tagName === 'INPUT') { const element = containerDocument.activeElement as HTMLElement; blur('Dialog', element); focus('Dialog', element); } }); containerDocument.body.appendChild(dialog); setDialogElement(dialog); return () => { if (dialog.open) { // .close: Instructs the browser to restore keyboard focus to whatever was focused // before the dialog. dialog.close(removedReturnValue); } dialog.remove(); }; }, [containerDocument, preventAutoCloseOnCancel]); return dialogElement; }; const useDialogClassNames = (dialogElement: HTMLElement|null, classNames: undefined|string) => { useEffect(() => { if (!dialogElement || !classNames) { return () => {}; } // The React className prop can include multiple space-separated classes const newClassNames = classNames .split(/\s+/) .filter(name => name.length && !dialogElement.classList.contains(name)); dialogElement.classList.add(...newClassNames); return () => { dialogElement.classList.remove(...newClassNames); }; }, [dialogElement, classNames]); }; export default Dialog;