UNPKG

reblend-ui

Version:

Utilities for creating robust overlay components

310 lines (299 loc) 12.2 kB
"use strict"; exports.__esModule = true; exports.default = void 0; var _activeElement = require("dom-helpers/activeElement"); var _contains = require("dom-helpers/contains"); var _canUseDOM = require("dom-helpers/canUseDOM"); var _listen = require("dom-helpers/listen"); var _reblendjs = require("reblendjs"); var Reblend = _reblendjs; var _reactDom = require("react-dom"); var _reblendHooks = require("reblend-hooks"); var _ModalManager = require("./ModalManager"); var _useWaitForDOMRef = require("./useWaitForDOMRef"); var _useWindow = require("./useWindow"); var _ImperativeTransition = require("./ImperativeTransition"); var _utils = require("./utils"); /* eslint-disable react/prop-types */ let manager; /* Modal props are split into a version with and without index signature so that you can fully use them in another projects This is due to Typescript not playing well with index signatures e.g. when using Omit */ function getManager(window) { if (!manager) manager = new _ModalManager.default({ ownerDocument: window?.document }); return manager; } function useModalManager(provided) { const window = _useWindow.default.bind(this)(); this.state.window = window; const modalManager = provided || getManager(this.state.window); this.state.modalManager = modalManager; const modal = _reblendjs.useRef.bind(this)({ dialog: null, backdrop: null }); this.state.modal = modal; return Object.assign(this.state.modal.current, { add: () => this.state.modalManager.add(this.state.modal.current), remove: () => this.state.modalManager.remove(this.state.modal.current), isTopModal: () => this.state.modalManager.isTopModal(this.state.modal.current), setDialogRef: _reblendjs.useCallback.bind(this)(ref => { this.state.modal.current.dialog = ref; }, []), setBackdropRef: _reblendjs.useCallback.bind(this)(ref => { this.state.modal.current.backdrop = ref; }, []) }); } /* @Reblend: Transformed from function to class */ const Modal = class /* @Reblend: Transformed from function to class */ extends Reblend.Reblend { static ELEMENT_NAME = "Modal"; constructor() { super(); } async initState() { const ownerWindow = _useWindow.default.bind(this)(); this.state.ownerWindow = ownerWindow; const container = _useWaitForDOMRef.default.bind(this)(this.props.containerRef); this.state.container = container; const modal = useModalManager.bind(this)(this.props.providedManager); this.state.modal = modal; const isMounted = _reblendHooks.useMounted.bind(this)(); this.state.isMounted = isMounted; const prevShow = Reblend.useMemo.bind(this)(({ previous }) => previous, "prevShow", (() => this.props.show).bind(this)); this.state.prevShow = prevShow; const [exited, setExited] = _reblendjs.useState.bind(this)(!this.props.show, "exited"); this.state.exited = exited; this.state.setExited = setExited; const lastFocusRef = _reblendjs.useRef.bind(this)(null); this.state.lastFocusRef = lastFocusRef; useImperativeHandle.bind(this)(ref, () => this.state.modal, [this.state.modal]); if (_canUseDOM.default && !this.state.prevShow && this.props.show) { this.state.lastFocusRef.current = (0, _activeElement.default)(this.state.ownerWindow?.document); } // TODO: I think this needs to be in an effect if (this.props.show && this.state.exited) { this.state.setExited(false); } const handleShow = _reblendHooks.useEventCallback.bind(this)(() => { this.state.modal.add(); this.state.removeKeydownListenerRef.current = (0, _listen.default)(document, 'keydown', this.state.handleDocumentKeyDown); this.state.removeFocusListenerRef.current = (0, _listen.default)(document, 'focus', // the timeout is necessary b/c this will run before the new modal is mounted // and so steals focus from it () => setTimeout(this.state.handleEnforceFocus), true); if (this.props.onShow) { this.props.onShow(); } // autofocus after onShow to not trigger a focus event for previous // modals before this one is shown. if (this.props.autoFocus) { const currentActiveElement = (0, _activeElement.default)(this.state.modal.dialog?.ownerDocument ?? this.state.ownerWindow?.document); if (this.state.modal.dialog && currentActiveElement && !(0, _contains.default)(this.state.modal.dialog, currentActiveElement)) { this.state.lastFocusRef.current = currentActiveElement; this.state.modal.dialog.focus(); } } }); /* should never change: */this.state.handleShow = handleShow; const handleHide = _reblendHooks.useEventCallback.bind(this)(() => { this.state.modal.remove(); this.state.removeKeydownListenerRef.current?.(); this.state.removeFocusListenerRef.current?.(); if (this.props.restoreFocus) { // Support: <=IE11 doesn't support `focus()` on svg elements (RB: #917) this.state.lastFocusRef.current?.focus?.(this.props.restoreFocusOptions); this.state.lastFocusRef.current = null; } }); // TODO: try and combine these effects: https://github.com/react-bootstrap/react-overlays/pull/794#discussion_r409954120 // Show logic when: // - show is `true` _and_ `container` has resolved this.state.handleHide = handleHide; _reblendjs.useEffect.bind(this)(() => { if (!this.props.show || !this.state.container && this.props.portal) return; this.state.handleShow(); }, (() => [this.props.show, this.state.container, this.props.portal, this.state.handleShow]).bind(this)); // Hide cleanup logic when: // - `exited` switches to true // - component unmounts; _reblendjs.useEffect.bind(this)(() => { if (!this.state.exited) return; this.state.handleHide(); }, (() => [this.state.exited, this.state.handleHide]).bind(this)); useWillUnmount.bind(this)(() => { this.state.handleHide(); }); // -------------------------------- const handleEnforceFocus = _reblendHooks.useEventCallback.bind(this)(() => { if (!this.props.enforceFocus || !this.state.isMounted() || !this.state.modal.isTopModal()) { return; } const currentActiveElement = (0, _activeElement.default)(this.state.ownerWindow?.document); if (this.state.modal.dialog && currentActiveElement && !(0, _contains.default)(this.state.modal.dialog, currentActiveElement)) { this.state.modal.dialog.focus(); } }); this.state.handleEnforceFocus = handleEnforceFocus; const handleBackdropClick = _reblendHooks.useEventCallback.bind(this)(e => { if (e.target !== e.currentTarget) { return; } this.props.onBackdropClick?.(e); if (this.props.backdrop === true) { this.props.onHide(); } }); this.state.handleBackdropClick = handleBackdropClick; const handleDocumentKeyDown = _reblendHooks.useEventCallback.bind(this)(e => { if (this.props.keyboard && (0, _utils.isEscKey)(e) && this.state.modal.isTopModal()) { this.props.onEscapeKeyDown?.(e); if (!e.defaultPrevented) { this.props.onHide(); } } }); this.state.handleDocumentKeyDown = handleDocumentKeyDown; const removeFocusListenerRef = _reblendjs.useRef.bind(this)(null); this.state.removeFocusListenerRef = removeFocusListenerRef; const removeKeydownListenerRef = _reblendjs.useRef.bind(this)(null); this.state.removeKeydownListenerRef = removeKeydownListenerRef; const handleHidden = (...args) => { this.state.setExited(true); this.props.onExited?.(...args); }; this.state.handleHidden = handleHidden; if (!this.state.container && this.props.portal) { return null; } const dialogProps = { role: this.props.show ? this.props.role : undefined, ref: this.state.modal.setDialogRef, // apparently only works on the dialog role element 'aria-modal': this.props.show && this.props.role === 'dialog' ? true : undefined, ...this.props, style: this.props.style, className: this.props.className, tabIndex: -1 }; this.state.dialogProps = dialogProps; let dialog = this.props.renderDialog ? this.props.renderDialog(this.state.dialogProps) : Reblend.Reblend.construct.bind(this)("div", this.state.dialogProps, Reblend.cloneElement(this.props.children, { role: 'document' })); this.state.dialog = dialog; this.state.dialog = (0, _ImperativeTransition.renderTransition)(transition, this.props.runTransition, { unmountOnExit: this.props.unmountDialogOnExit, mountOnEnter: this.props.mountDialogOnEnter, appear: true, in: !!this.props.show, onExit: this.props.onExit, onExiting: this.props.onExiting, onExited: this.state.handleHidden, onEnter: this.props.onEnter, onEntering: this.props.onEntering, onEntered: this.props.onEntered, children: dialog }); let backdropElement = null; this.state.backdropElement = backdropElement; if (this.props.backdrop) { this.state.backdropElement = this.props.renderBackdrop({ ref: this.state.modal.setBackdropRef, onClick: this.state.handleBackdropClick }); this.state.backdropElement = (0, _ImperativeTransition.renderTransition)(backdropTransition, this.props.runBackdropTransition, { in: !!this.props.show, appear: true, mountOnEnter: true, unmountOnExit: true, children: backdropElement }); } } async initProps({ show = false, role = 'dialog', className, style, children, backdrop = true, keyboard = true, onBackdropClick, onEscapeKeyDown, transition, runTransition, backdropTransition, runBackdropTransition, autoFocus = true, enforceFocus = true, restoreFocus = true, restoreFocusOptions, mountDialogOnEnter = true, unmountDialogOnExit = true, portal = true, renderDialog, renderBackdrop = props => Reblend.Reblend.construct.bind(this)("div", props), manager: providedManager, container: containerRef, onShow, onHide = () => {}, onExit, onExited, onExiting, onEnter, onEntering, onEntered, ...rest }) { this.props = {}; this.props.show = show; this.props.role = role; this.props.className = className; this.props.style = style; this.props.children = children; this.props.backdrop = backdrop; this.props.keyboard = keyboard; this.props.onBackdropClick = onBackdropClick; this.props.onEscapeKeyDown = onEscapeKeyDown; this.props.transition = transition; this.props.runTransition = runTransition; this.props.backdropTransition = backdropTransition; this.props.runBackdropTransition = runBackdropTransition; this.props.autoFocus = autoFocus; this.props.enforceFocus = enforceFocus; this.props.restoreFocus = restoreFocus; this.props.restoreFocusOptions = restoreFocusOptions; this.props.mountDialogOnEnter = mountDialogOnEnter; this.props.unmountDialogOnExit = unmountDialogOnExit; this.props.portal = portal; this.props.renderDialog = renderDialog; this.props.renderBackdrop = renderBackdrop; this.props.providedManager = providedManager; this.props.containerRef = containerRef; this.props.onShow = onShow; this.props.onHide = onHide; this.props.onExit = onExit; this.props.onExited = onExited; this.props.onExiting = onExiting; this.props.onEnter = onEnter; this.props.onEntering = onEntering; this.props.onEntered = onEntered; this.props = { ...this.props, ...rest }; } async html() { return this.props.portal && this.state.container ? _reactDom.default.createPortal(Reblend.Reblend.construct.bind(this)(Reblend.Reblend, null, this.state.backdropElement, this.state.dialog), this.state.container) : Reblend.Reblend.construct.bind(this)(Reblend.Reblend, null, this.state.backdropElement, this.state.dialog); } }; var _default = exports.default = Object.assign(Modal, { Manager: _ModalManager.default });