@reusable-ui/modal
Version:
Overlays a dialog to the entire site's page.
506 lines (505 loc) • 23 kB
JavaScript
// 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, };