UNPKG

@reusable-ui/modal

Version:

Overlays a dialog to the entire site's page.

506 lines (505 loc) 23 kB
// react: import { // react: default as React, // hooks: useRef, useEffect, useState, } from 'react'; import { createPortal, } from 'react-dom'; // cssfn: import { // style sheets: dynamicStyleSheet, dynamicStyleSheets, } from '@cssfn/cssfn-react'; // writes css in react hook // reusable-ui core: import { // a set of React node utility functions: isReusableUiComponent, // focusing functions: setFocusNext, // react helper hooks: useIsomorphicLayoutEffect, useEvent, useMergeEvents, useMergeRefs, useMergeClasses, useGlobalStackable, useAutoFocusable, useCollapsible, useCollapsibleEvent, useExcitable, useControllableExcitable, } from '@reusable-ui/core'; // a set of reusable-ui packages which are responsible for building any component // reusable-ui components: import { Generic, } from '@reusable-ui/generic'; // a generic component // internals: import { // features: usesBackdrop, } from './features/backdrop.js'; import { useBackdropVariant, } from './variants/BackdropVariant.js'; import { backdropCounterMap, } from './backdrop-counter.js'; // styles: export const useModalUiStyleSheet = dynamicStyleSheet(() => import(/* webpackPrefetch: true */ './styles/modalUiStyles.js'), { specificityWeight: 0, id: 'u4teynvq1y' }); // a unique salt for SSR support, ensures the server-side & client-side have the same generated class names export const useBackdropStyleSheet = dynamicStyleSheet(() => import(/* webpackPrefetch: true */ './styles/backdropStyles.js'), { id: 'z26pqrin5i' }); // a unique salt for SSR support, ensures the server-side & client-side have the same generated class names export const useNoScrollbarStyleSheet = dynamicStyleSheets(() => import(/* webpackPrefetch: true */ './styles/noScrollbarStyles.js'), { id: 'cys5kcu257' }); // a unique salt for SSR support, ensures the server-side & client-side have the same generated class names const Modal = (props) => { // styles: const styleSheet = useBackdropStyleSheet(); const uiStyleSheet = useModalUiStyleSheet(); const noScrollbarStyleSheet = useNoScrollbarStyleSheet(); // variants: const backdropVariant = useBackdropVariant(props); // refs: const modalUiRefInternal = useRef(null); // rest props: const { // refs: elmRef, // variants: backdropStyle = 'regular', // behaviors: lazy = false, // states: expanded: _expanded, // remove onExpandStart: _onExpandStart, // remove onCollapseStart: _onCollapseStart, // remove onExpandEnd: _onExpandEnd, // remove onCollapseEnd: _onCollapseEnd, // remove onExpandedChange, // global stackable: viewport: _viewport, // remove // auto focusable: autoFocusOn = modalUiRefInternal.current, // take restoreFocusOn = 'auto', // take autoFocus = true, // take restoreFocus = true, // take autoFocusScroll = false, // take restoreFocusScroll = false, // take // components: tabIndex, children: modalUiComponent, ...restGenericProps } = props; // states: const collapsibleState = useCollapsible(props); const isVisible = collapsibleState.isVisible; // visible = showing, shown, hidding ; !visible = hidden const isExpanded = collapsibleState.expanded; const isModal = isVisible && !['hidden', 'interactive'].includes(backdropStyle); useCollapsibleEvent(props, collapsibleState); const [excitedDn, setExcitedDn] = useState(false); const handleExcitedChange = useEvent((event) => { setExcitedDn(event.excited); }); const excitableState = useExcitable({ excited: excitedDn }); useControllableExcitable({ onExcitedChange: handleExcitedChange }, excitableState); // capabilities: const { viewportElm, portalElm, ensureTopMost } = useGlobalStackable(props); useAutoFocusable({ autoFocusOn, restoreFocusOn, autoFocus, restoreFocus, autoFocusScroll, restoreFocusScroll, }, collapsibleState); // verifies: React.Children.only(modalUiComponent); const isReusableUiModalComponent = isReusableUiComponent(modalUiComponent); if (!isReusableUiModalComponent && !React.isValidElement(modalUiComponent)) throw Error('Invalid child element.'); // refs: const backdropRefInternal = useRef(null); const mergedBackdropRef = useMergeRefs( // preserves the original `elmRef` from `props`: elmRef, backdropRefInternal); const mergedModalUiRef = useMergeRefs( // preserves the original `ref` from `modalUiComponent`: (isReusableUiModalComponent ? modalUiComponent.props.elmRef : modalUiComponent.props.ref), modalUiRefInternal); // const noParentScrollRefInternal = useRef<HTMLDivElement|null>(null); // classes: const variantClasses = useMergeClasses( // preserves the original `variantClasses`: props.variantClasses, // variants: backdropVariant.class); const stateClasses = useMergeClasses( // preserves the original `stateClasses`: props.stateClasses, // states: collapsibleState.class); const modalUiClasses = useMergeClasses( // preserves the original `classes` from `modalUiComponent`: (isReusableUiModalComponent ? modalUiComponent.props.classes : (modalUiComponent.props.className ?? '').split(' ')), // styles: uiStyleSheet.main, // states: excitableState.class); // handlers: const handleExpandedChange = onExpandedChange; const handleKeyDownInternal = useEvent((event) => { // conditions: if (event.defaultPrevented) return; // the event was already handled by user => nothing to do /* note: the `code` may `undefined` on autoComplete */ const keyCode = event.code?.toLowerCase(); if (!keyCode) return; // ignores [unidentified] key if ((() => { if (!isModal) { return false; // interactive|hidden => do not trap the [tab] } else if (isModal && (keyCode === 'tab')) { if (event.altKey || event.ctrlKey || event.shiftKey || event.metaKey) return false; // do not handle [tab] key if [alt][ctrl][shift][win] keys are also pressed setFocusNext(event.currentTarget); } else if ((keyCode === 'pagedown') || (keyCode === 'pageup')) { // do nothing // do not scroll the page } else if ( // navigation keys: (keyCode === 'home') || (keyCode === 'end') || (keyCode === 'arrowdown') || (keyCode === 'arrowup') || (keyCode === 'arrowleft') || (keyCode === 'arrowright')) { if ( //#region list of controls that internally HANDLE [navigation] keys // <CustomEditor>: event.target?.isContentEditable || // <TextEditorMultiline>: (event.target?.tagName === 'TEXTAREA') || // <TextEditorLike>, <Range>: ((event.target?.tagName === 'INPUT') && // controls that NOT handle [navigation] keys: !['color', 'file'].includes(event.target?.type ?? '')) //#endregion list of controls that internally HANDLE [navigation] keys ) { return false; // do not handle [navigation] keys if the event coming from <CustomEditor>|<TextEditor>|<Range> } // if // do nothing // do not scroll the page } else if ( // special keys: (keyCode === 'space')) { if ( //#region list of controls that internally HANDLE [space] key // <CustomEditor>: event.target?.isContentEditable || // <TextEditorMultiline>: (event.target?.tagName === 'TEXTAREA') || // <TextEditorLike> (excluding <Range>): ((event.target?.tagName === 'INPUT') && // controls that NOT handle [space] key: !['color', 'file', 'range'].includes(event.target?.type ?? '')) //#endregion list of controls that internally HANDLE [space] key ) { return false; // do not handle [space] key if the event coming from <CustomEditor>|<TextEditor> (excluding <Range>) } // if // do nothing // do not scroll the page } else return false; // not handled return true; // handled })()) { event.preventDefault(); // prevents the whole page from scrolling when the user press the [up],[down],[left],[right],[pg up],[pg down],[home],[end] } // if }); const handleKeyDown = useMergeEvents( // preserves the original `onKeyDown`: props.onKeyDown, // actions: handleKeyDownInternal); const handleMouseDownInternal = useEvent((event) => { // conditions: if (event.defaultPrevented) return; // the event was already handled by user => nothing to do if ( // ignore bubbling from NON <Backdrop>: (event.target !== event.currentTarget) // && // // // ignore bubbling from NON <NoParentScroll> (if PRESENT): // ( // // <NoParentScroll> is NOT PRESENT (NO modal (blocking) mode) => nothing to ignore: // !noParentScrollRefInternal.current // || // // <NoParentScroll> is PRESENT and MATCH to target event => ignore bubbling from <NoParentScroll> // (event.target !== noParentScrollRefInternal.current) // ) ) return; // ignore bubbling from NON <Backdrop> and NON <NoParentScroll>, assumes the elements OTHER THAN <Backdrop>|<NoParentScroll> is <ModalUi> // actions: if (backdropStyle === 'static') { setExcitedDn(true); // make <ModalUi> blinking const autoFocusElm = ((autoFocusOn instanceof Element) ? autoFocusOn : autoFocusOn?.current); if (autoFocus && autoFocusElm && autoFocusElm.focus) { setTimeout(() => { requestAnimationFrame(() => { autoFocusElm.focus({ preventScroll: !autoFocusScroll }); // re-focus to the <FocusableTarget> of <Modal>, so the focus is trapped inside the <Modal> }); // wait until mouseup|keyup fired of the <TriggerButton> (if any) }, 0); // wait until mouseup|keyup fired of the <TriggerButton> (if any) } // if } else { // backdrop clicked => request to hide the <Modal>: handleExpandedChange?.({ expanded: false, actionType: 'backdrop' }); } // if if (event.type !== 'touchstart') event.preventDefault(); // handled }); const handleMouseDown = useMergeEvents( // preserves the original `onMouseDown` from `props`: props.onMouseDown, // actions: handleMouseDownInternal); const handleTouchStart = useMergeEvents( // preserves the original `onTouchStart` from `props`: props.onTouchStart, // actions: handleMouseDownInternal); const handleContextMenuInternal = useEvent((event) => { // conditions: if (event.defaultPrevented) return; // the event was already handled by user => nothing to do if (event.target !== event.currentTarget) return; // only cancels the contextMenu at the <overlay>, allows at the <ModalUi> // actions: // cancel the contextMenu: event.preventDefault(); // handled }); const handleContextMenu = useMergeEvents( // preserves the original `onContextMenu` from `props`: props.onContextMenu, // actions: handleContextMenuInternal); const handleAnimationStart = useMergeEvents( // preserves the original `onAnimationStart`: props.onAnimationStart, // states: collapsibleState.handleAnimationStart); const handleAnimationEnd = useMergeEvents( // preserves the original `onAnimationEnd`: props.onAnimationEnd, // states: collapsibleState.handleAnimationEnd); const handleModalUiAnimationStart = useMergeEvents( // preserves the original `onAnimationStart` from `modalUiComponent`: modalUiComponent.props.onAnimationStart, // states: excitableState.handleAnimationStart); const handleModalUiAnimationEnd = useMergeEvents( // preserves the original `onAnimationEnd` from `modalUiComponent`: modalUiComponent.props.onAnimationEnd, // states: excitableState.handleAnimationEnd); // dom effects: // make sure the <Modal> is top_most (if there is multiple <Modal>s shown at the same time): useIsomorphicLayoutEffect(() => { // conditions: if (!isExpanded) return; // <Modal> is not expanded => ignore // actions: ensureTopMost(); }, [isExpanded]); // OBSOLETE: NOT RELIABLE: // // prevents the <viewport> from scrolling when in modal (blocking) mode: // useEffect(() => { // // conditions: // if (!viewportElm) return; // if undefined => server side => ignore // if (!isModal) return; // only modal (blocking) mode // // // // // setups: // const normalizedViewportElm = (viewportElm === document.body) ? (document.scrollingElement ?? document.documentElement) : viewportElm; // const scrollableElm = normalizedViewportElm; // const scrollableEvent = (normalizedViewportElm === document.scrollingElement) ? document : normalizedViewportElm; // // const currentScrollTop = scrollableElm.scrollTop; // const currentScrollLeft = scrollableElm.scrollLeft; // // const handlePreventScroll = (event: Event) => { // if (event.target === scrollableEvent) { // only handle scroll on the viewport, ignores scroll bubbling from the children // scrollableElm.scrollTop = currentScrollTop; // prevent from scrolling by keeping the initial scroll position // scrollableElm.scrollLeft = currentScrollLeft; // prevent from scrolling by keeping the initial scroll position // } // if // }; // // scrollableEvent.addEventListener('scroll', handlePreventScroll); // // // // // cleanups: // return () => { // scrollableEvent.removeEventListener('scroll', handlePreventScroll); // }; // }, [isModal, viewportElm]); // UPDATE: MORE RELIABLE: // prevents the <viewport> from scrolling when in modal (blocking) mode: useEffect(() => { // conditions: if (!viewportElm) return; // if undefined => server side => ignore if (!isModal) return; // only modal (blocking) mode // setups: const normalizedViewportElm = (viewportElm === document.body) ? (document.scrollingElement ?? document.documentElement) : viewportElm; const scrollableElm = normalizedViewportElm; const [isScrollableInline, isScrollableBlock] = (() => { if (scrollableElm === document.scrollingElement) { return [true, true]; } else { const computedStyle = getComputedStyle(scrollableElm); return [ ['auto', 'scroll'].includes(computedStyle.overflowX), ['auto', 'scroll'].includes(computedStyle.overflowY), ]; } // if })(); const classList = scrollableElm.classList; const backdropCounter = backdropCounterMap.get(scrollableElm) ?? (() => { const newBackdropCounter = { inline: 0, block: 0 }; backdropCounterMap.set(scrollableElm, newBackdropCounter); return newBackdropCounter; })(); // register the backdrop existance of current scrollableElm: if (isScrollableInline) backdropCounter.inline++; if (isScrollableBlock) backdropCounter.block++; // applies the stylesheets when the counter is exactly 1: if (backdropCounter.inline === 1) classList.add(noScrollbarStyleSheet.noScrollbarInline, 'no-scrollbar-inline'); if (backdropCounter.block === 1) classList.add(noScrollbarStyleSheet.noScrollbarBlock, 'no-scrollbar-block'); // cleanups: return () => { // un-register the backdrop existance of current scrollableElm: if (isScrollableInline && (backdropCounter.inline >= 1)) backdropCounter.inline--; if (isScrollableBlock && (backdropCounter.block >= 1)) backdropCounter.block--; // un-applies the stylesheets when the counter is exactly 0: if (backdropCounter.inline === 0) classList.remove(noScrollbarStyleSheet.noScrollbarInline, 'no-scrollbar-inline'); if (backdropCounter.block === 0) classList.remove(noScrollbarStyleSheet.noScrollbarBlock, 'no-scrollbar-block'); }; }, [isModal, viewportElm]); // watchdog update of <viewport>'s scrolling position to compensate <Backdrop> shifting: // TODO: the backdrop having a "jitter effect" when the <viewport> is scrolled quickly, it need to be fixed. // features: const { backdropVars } = usesBackdrop(); useEffect(() => { // conditions: if (!viewportElm) return; // if undefined => server side => ignore if (!isModal) return; // only modal (blocking) mode const backdropElm = backdropRefInternal.current; if (!backdropElm) return; // no backdrop => ignore // setups: const normalizedViewportElm = (viewportElm === document.body) ? (document.scrollingElement ?? document.documentElement) : viewportElm; const scrollableElm = normalizedViewportElm; const scrollableEvent = (normalizedViewportElm === document.scrollingElement) ? document : normalizedViewportElm; const isLocalBackdrop = (scrollableElm !== document.scrollingElement); const backdropStyle = backdropElm.style; const handleUpdateScroll = (event) => { // conditions: if (event && (event.target !== scrollableEvent)) return; // ignores scroll bubbling from the children // actions: const scrollTop = isLocalBackdrop ? scrollableElm.scrollTop : 0; const scrollLeft = isLocalBackdrop ? scrollableElm.scrollLeft : 0; backdropStyle.setProperty(backdropVars.scrollTop .slice(4, -1) // fix: var(--customProp) => --customProp , `${scrollTop}px`); backdropStyle.setProperty(backdropVars.scrollLeft .slice(4, -1) // fix: var(--customProp) => --customProp , `${scrollLeft}px`); }; handleUpdateScroll(); // the initial update if (isLocalBackdrop) scrollableEvent.addEventListener('scroll', handleUpdateScroll); // cleanups: return () => { if (isLocalBackdrop) scrollableEvent.removeEventListener('scroll', handleUpdateScroll); }; }, [isModal, viewportElm]); // stops the excited state when modal is closed: useEffect(() => { // conditions: if (isExpanded) return; // <Modal> is still shown => ignore if (!excitedDn) return; // <Modal> is not excited => ignore // actions: setExcitedDn(false); }, [isExpanded, excitedDn]); // watch [esc] key globally, when non modal (interactive|hidden) mode: useEffect(() => { // conditions: if (!isExpanded) return; // <Modal> is not expanded => ignore // handlers: const handleKeyDown = (event) => { // conditions: if (event.defaultPrevented) return; // the event was already handled by user => nothing to do /* note: the `code` may `undefined` on autoComplete */ const keyCode = event.code?.toLowerCase(); if (!keyCode) return; // ignores [unidentified] key if ((keyCode === 'escape')) { // [esc] key pressed => request to hide the <Modal>: handleExpandedChange?.({ expanded: false, actionType: 'shortcut' }); // event.preventDefault(); // no need to mark as handled, because it's a global event } // if }; // setups: document.addEventListener('keydown', handleKeyDown); // cleanups: return () => { document.removeEventListener('keydown', handleKeyDown); }; }, [isExpanded, handleExpandedChange]); // jsx: if (!portalElm) return null; // server side -or- client side but not already hydrated => nothing to render return createPortal(// workaround for zIndex stacking context React.createElement(Generic, { ...restGenericProps, // refs: elmRef: mergedBackdropRef, // semantics: semanticTag: props.semanticTag ?? '', semanticRole: props.semanticRole ?? 'dialog', "aria-modal": props['aria-modal'] ?? (isModal || undefined), // classes: mainClass: props.mainClass ?? styleSheet.main, variantClasses: variantClasses, stateClasses: stateClasses, // handlers: onKeyDown: handleKeyDown, onMouseDown: handleMouseDown, onTouchStart: handleTouchStart, onContextMenu: handleContextMenu, onAnimationStart: handleAnimationStart, onAnimationEnd: handleAnimationEnd }, (!lazy || isVisible) && React.cloneElement(modalUiComponent, // props: { // refs: [isReusableUiModalComponent ? 'elmRef' : 'ref']: mergedModalUiRef, // classes: ...(isReusableUiModalComponent ? { classes: modalUiClasses, } : { className: modalUiClasses.filter((c) => !!c).join(' '), }), // accessibilities: tabIndex: modalUiComponent.props.tabIndex ?? tabIndex, // [open]: ...collapsibleState.props, // handlers: onAnimationStart: handleModalUiAnimationStart, onAnimationEnd: handleModalUiAnimationEnd, })), portalElm); }; export { Modal, Modal as default, };