UNPKG

@blueprintjs/core

Version:

Core styles & components

491 lines 26.3 kB
/* * Copyright 2024 Palantir Technologies, Inc. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { __assign } from "tslib"; import classNames from "classnames"; import * as React from "react"; import { CSSTransition, TransitionGroup } from "react-transition-group"; import { useUID } from "react-uid"; import { Classes, mergeRefs } from "../../common"; import { OVERLAY_CHILD_REF_AND_REFS_MUTEX, OVERLAY_CHILD_REQUIRES_KEY, OVERLAY_WITH_MULTIPLE_CHILDREN_REQUIRES_CHILD_REFS, } from "../../common/errors"; import { DISPLAYNAME_PREFIX } from "../../common/props"; import { ensureElement, getActiveElement, getRef, isEmptyString, isNodeEnv, isReactElement, setRef, } from "../../common/utils"; import { hasDOMEnvironment } from "../../common/utils/domUtils"; import { useOverlayStack } from "../../hooks/overlays/useOverlayStack"; import { usePrevious } from "../../hooks/usePrevious"; import { getKeyboardFocusableElements } from "../overlay/overlayUtils"; import { Portal } from "../portal/portal"; export var OVERLAY2_DEFAULT_PROPS = { autoFocus: true, backdropProps: {}, canEscapeKeyClose: true, canOutsideClickClose: true, enforceFocus: true, hasBackdrop: true, isOpen: false, lazy: hasDOMEnvironment(), shouldReturnFocusOnClose: true, transitionDuration: 300, transitionName: Classes.OVERLAY, usePortal: true, }; /** * Overlay2 component. * * @see https://blueprintjs.com/docs/#core/components/overlay2 */ export var Overlay2 = React.forwardRef(function (props, forwardedRef) { var _a; var _b, _c; var autoFocus = props.autoFocus, backdropClassName = props.backdropClassName, backdropProps = props.backdropProps, canEscapeKeyClose = props.canEscapeKeyClose, canOutsideClickClose = props.canOutsideClickClose, childRef = props.childRef, childRefs = props.childRefs, children = props.children, className = props.className, enforceFocus = props.enforceFocus, hasBackdrop = props.hasBackdrop, isOpen = props.isOpen, lazy = props.lazy, onClose = props.onClose, onClosed = props.onClosed, onClosing = props.onClosing, onOpened = props.onOpened, onOpening = props.onOpening, portalClassName = props.portalClassName, portalContainer = props.portalContainer, shouldReturnFocusOnClose = props.shouldReturnFocusOnClose, transitionDuration = props.transitionDuration, transitionName = props.transitionName, usePortal = props.usePortal; useOverlay2Validation(props); var _d = useOverlayStack(), closeOverlay = _d.closeOverlay, getLastOpened = _d.getLastOpened, getThisOverlayAndDescendants = _d.getThisOverlayAndDescendants, openOverlay = _d.openOverlay; var _e = React.useState(false), isAutoFocusing = _e[0], setIsAutoFocusing = _e[1]; var _f = React.useState(false), hasEverOpened = _f[0], setHasEverOpened = _f[1]; var lastActiveElementBeforeOpened = React.useRef(null); /** Ref for container element, containing all children and the backdrop */ var containerElement = React.useRef(null); /** Ref for backdrop element */ var backdropElement = React.useRef(null); /* An empty, keyboard-focusable div at the beginning of the Overlay content */ var startFocusTrapElement = React.useRef(null); /* An empty, keyboard-focusable div at the end of the Overlay content */ var endFocusTrapElement = React.useRef(null); /** * Locally-generated DOM ref for a singleton child element. * This is only used iff the user does not specify the `childRef` or `childRefs` props. */ var localChildRef = React.useRef(null); var bringFocusInsideOverlay = React.useCallback(function () { // always delay focus manipulation to just before repaint to prevent scroll jumping return requestAnimationFrame(function () { var _a; // container element may be undefined between component mounting and Portal rendering // activeElement may be undefined in some rare cases in IE var container = getRef(containerElement); var activeElement = getActiveElement(container); if (container == null || activeElement == null) { return; } // Overlay2 is guaranteed to be mounted here var isFocusOutsideModal = !container.contains(activeElement); if (isFocusOutsideModal) { (_a = getRef(startFocusTrapElement)) === null || _a === void 0 ? void 0 : _a.focus({ preventScroll: true }); setIsAutoFocusing(false); } }); }, []); /** Unique ID for this overlay in the global stack */ var id = useOverlay2ID(); // N.B. use `null` here and not simply `undefined` because `useImperativeHandle` will set `null` on unmount, // and we need the following code to be resilient to that value. var instance = React.useRef(null); /** * When multiple `enforceFocus` Overlays are open, this event handler is only active for the most * recently opened one to avoid Overlays competing with each other for focus. */ var handleDocumentFocus = React.useCallback(function (e) { // get the actual target even in the Shadow DOM // see https://github.com/palantir/blueprint/issues/4220 var eventTarget = e.composed ? e.composedPath()[0] : e.target; var container = getRef(containerElement); if (container != null && eventTarget instanceof Node && !container.contains(eventTarget)) { // prevent default focus behavior (sometimes auto-scrolls the page) e.preventDefault(); e.stopImmediatePropagation(); bringFocusInsideOverlay(); } }, [bringFocusInsideOverlay]); // N.B. this listener is only kept attached when `isOpen={true}` and `canOutsideClickClose={true}` var handleDocumentMousedown = React.useCallback(function (e) { // get the actual target even in the Shadow DOM // see https://github.com/palantir/blueprint/issues/4220 var eventTarget = (e.composed ? e.composedPath()[0] : e.target); var thisOverlayAndDescendants = getThisOverlayAndDescendants(id); var isClickInThisOverlayOrDescendant = thisOverlayAndDescendants.some(function (_a) { var containerRef = _a.containerElement; // `elem` is the container of backdrop & content, so clicking directly on that container // should not count as being "inside" the overlay. var elem = getRef(containerRef); return (elem === null || elem === void 0 ? void 0 : elem.contains(eventTarget)) && !elem.isSameNode(eventTarget); }); if (!isClickInThisOverlayOrDescendant) { // casting to any because this is a native event onClose === null || onClose === void 0 ? void 0 : onClose(e); } }, [getThisOverlayAndDescendants, id, onClose]); // send this instance's imperative handle to the the forwarded ref as well as our local ref var ref = React.useMemo(function () { return mergeRefs(forwardedRef, instance); }, [forwardedRef]); React.useImperativeHandle(ref, function () { return ({ bringFocusInsideOverlay: bringFocusInsideOverlay, containerElement: containerElement, handleDocumentFocus: handleDocumentFocus, handleDocumentMousedown: handleDocumentMousedown, id: id, props: { autoFocus: autoFocus, enforceFocus: enforceFocus, hasBackdrop: hasBackdrop, usePortal: usePortal, }, }); }, [ autoFocus, bringFocusInsideOverlay, enforceFocus, handleDocumentFocus, handleDocumentMousedown, hasBackdrop, id, usePortal, ]); var handleContainerKeyDown = React.useCallback(function (e) { if (e.key === "Escape" && canEscapeKeyClose) { onClose === null || onClose === void 0 ? void 0 : onClose(e); // prevent other overlays from closing e.stopPropagation(); // prevent browser-specific escape key behavior (Safari exits fullscreen) e.preventDefault(); } }, [canEscapeKeyClose, onClose]); var overlayWillOpen = React.useCallback(function () { if (instance.current == null) { return; } var lastOpenedOverlay = getLastOpened(); if ((lastOpenedOverlay === null || lastOpenedOverlay === void 0 ? void 0 : lastOpenedOverlay.handleDocumentFocus) !== undefined) { document.removeEventListener("focus", lastOpenedOverlay.handleDocumentFocus, /* useCapture */ true); } openOverlay(instance.current); if (autoFocus) { setIsAutoFocusing(true); bringFocusInsideOverlay(); } setRef(lastActiveElementBeforeOpened, getActiveElement(getRef(containerElement))); }, [autoFocus, bringFocusInsideOverlay, getLastOpened, openOverlay]); var overlayWillClose = React.useCallback(function () { var _a; document.removeEventListener("focus", handleDocumentFocus, /* useCapture */ true); document.removeEventListener("mousedown", handleDocumentMousedown); // N.B. `instance.current` may be null at this point if we are cleaning up an open overlay during the unmount phase // (this is common, for example, with context menu's singleton `showContextMenu` / `hideContextMenu` imperative APIs). closeOverlay(id); var lastOpenedOverlay = getLastOpened(); if (lastOpenedOverlay !== undefined) { // Only bring focus back to last overlay if it had autoFocus _and_ enforceFocus enabled. // If `autoFocus={false}`, it's likely that the overlay never received focus in the first place, // so it would be surprising for us to send it there. See https://github.com/palantir/blueprint/issues/4921 if (lastOpenedOverlay.props.autoFocus && lastOpenedOverlay.props.enforceFocus) { (_a = lastOpenedOverlay.bringFocusInsideOverlay) === null || _a === void 0 ? void 0 : _a.call(lastOpenedOverlay); if (lastOpenedOverlay.handleDocumentFocus !== undefined) { document.addEventListener("focus", lastOpenedOverlay.handleDocumentFocus, /* useCapture */ true); } } } }, [closeOverlay, getLastOpened, handleDocumentFocus, handleDocumentMousedown, id]); var prevIsOpen = (_b = usePrevious(isOpen)) !== null && _b !== void 0 ? _b : false; React.useEffect(function () { if (isOpen) { setHasEverOpened(true); } if (!prevIsOpen && isOpen) { // just opened overlayWillOpen(); } if (prevIsOpen && !isOpen) { // just closed overlayWillClose(); } }, [isOpen, overlayWillOpen, overlayWillClose, prevIsOpen]); // Important: clean up old document-level event listeners if their memoized values change (this is rare, but // may happen, for example, if a user forgets to use `React.useCallback` in the `props.onClose` value). // Otherwise, we will lose the reference to those values and create a memory leak since we won't be able // to successfully detach them inside overlayWillClose. React.useEffect(function () { if (!isOpen || !(canOutsideClickClose && !hasBackdrop)) { return; } document.addEventListener("mousedown", handleDocumentMousedown); return function () { document.removeEventListener("mousedown", handleDocumentMousedown); }; }, [handleDocumentMousedown, isOpen, canOutsideClickClose, hasBackdrop]); React.useEffect(function () { if (!isOpen || !enforceFocus) { return; } // Focus events do not bubble, but setting useCapture allows us to listen in and execute // our handler before all others document.addEventListener("focus", handleDocumentFocus, /* useCapture */ true); return function () { document.removeEventListener("focus", handleDocumentFocus, /* useCapture */ true); }; }, [handleDocumentFocus, enforceFocus, isOpen]); var overlayWillCloseRef = React.useRef(overlayWillClose); overlayWillCloseRef.current = overlayWillClose; React.useEffect(function () { // run cleanup code once on unmount, ensuring we call the most recent overlayWillClose callback // by storing in a ref and keeping up to date return function () { overlayWillCloseRef.current(); }; }, []); var handleTransitionExited = React.useCallback(function (node) { var lastActiveElement = getRef(lastActiveElementBeforeOpened); if (shouldReturnFocusOnClose && lastActiveElement instanceof HTMLElement) { lastActiveElement.focus(); } onClosed === null || onClosed === void 0 ? void 0 : onClosed(node); }, [onClosed, shouldReturnFocusOnClose]); // N.B. CSSTransition requires this callback to be defined, even if it's unused. var handleTransitionAddEnd = React.useCallback(function () { // no-op }, []); /** * Gets the relevant DOM ref for a child element using the `childRef` or `childRefs` props (if possible). * This ref is necessary for `CSSTransition` to work in React 18 without relying on `ReactDOM.findDOMNode`. * * Returns `undefined` if the user did not specify either of those props. In those cases, we use the ref we * have locally generated and expect that the user _did not_ specify their own `ref` on the child element * (it will get clobbered / overriden). * * @see https://reactcommunity.org/react-transition-group/css-transition */ var getUserChildRef = React.useCallback(function (child) { if (childRef != null) { return childRef; } else if (childRefs != null) { var key = child.key; if (key == null) { if (!isNodeEnv("production")) { console.error(OVERLAY_CHILD_REQUIRES_KEY); } return undefined; } return childRefs[key]; } return undefined; }, [childRef, childRefs]); var maybeRenderChild = React.useCallback(function (child) { if (child == null || isEmptyString(child)) { return null; } // decorate the child with a few injected props var userChildRef = getUserChildRef(child); var childProps = isReactElement(child) ? child.props : {}; // if the child is a string, number, or fragment, it will be wrapped in a <span> element var decoratedChild = ensureElement(child, "span", { className: classNames(childProps.className, Classes.OVERLAY_CONTENT), // IMPORTANT: only inject our ref if the user didn't specify childRef or childRefs already. Otherwise, // we risk clobbering the user's ref (which we cannot inspect here while cloning/decorating the child). ref: userChildRef === undefined ? localChildRef : undefined, tabIndex: enforceFocus || autoFocus ? 0 : undefined, }); var resolvedChildRef = userChildRef !== null && userChildRef !== void 0 ? userChildRef : localChildRef; return (React.createElement(CSSTransition, { addEndListener: handleTransitionAddEnd, classNames: transitionName, // HACKHACK: CSSTransition types are slightly incompatible with React types here. // React prefers `| null` but not `| undefined` for the ref value, while // CSSTransition _demands_ that `| undefined` be part of the element type. nodeRef: resolvedChildRef, onEntered: getLifecycleCallbackWithChildRef(onOpened, resolvedChildRef), onEntering: getLifecycleCallbackWithChildRef(onOpening, resolvedChildRef), onExited: getLifecycleCallbackWithChildRef(handleTransitionExited, resolvedChildRef), onExiting: getLifecycleCallbackWithChildRef(onClosing, resolvedChildRef), timeout: transitionDuration }, decoratedChild)); }, [ autoFocus, enforceFocus, getUserChildRef, handleTransitionAddEnd, handleTransitionExited, onClosing, onOpened, onOpening, transitionDuration, transitionName, ]); var handleBackdropMouseDown = React.useCallback(function (e) { var _a; if (canOutsideClickClose) { onClose === null || onClose === void 0 ? void 0 : onClose(e); } if (enforceFocus) { bringFocusInsideOverlay(); } (_a = backdropProps === null || backdropProps === void 0 ? void 0 : backdropProps.onMouseDown) === null || _a === void 0 ? void 0 : _a.call(backdropProps, e); }, [backdropProps, bringFocusInsideOverlay, canOutsideClickClose, enforceFocus, onClose]); var renderDummyElement = React.useCallback(function (key, dummyElementProps) { return (React.createElement(CSSTransition, { addEndListener: handleTransitionAddEnd, classNames: transitionName, key: key, nodeRef: dummyElementProps.ref, timeout: transitionDuration, unmountOnExit: true }, React.createElement("div", __assign({ tabIndex: 0 }, dummyElementProps)))); }, [handleTransitionAddEnd, transitionDuration, transitionName]); /** * Ensures repeatedly pressing shift+tab keeps focus inside the Overlay. Moves focus to * the `endFocusTrapElement` or the first keyboard-focusable element in the Overlay (excluding * the `startFocusTrapElement`), depending on whether the element losing focus is inside the * Overlay. */ var handleStartFocusTrapElementFocus = React.useCallback(function (e) { if (!enforceFocus || isAutoFocusing) { return; } // e.relatedTarget will not be defined if this was a programmatic focus event, as is the // case when we call this.bringFocusInsideOverlay() after a user clicked on the backdrop. // Otherwise, we're handling a user interaction, and we should wrap around to the last // element in this transition group. var container = getRef(containerElement); var endFocusTrap = getRef(endFocusTrapElement); if (e.relatedTarget != null && (container === null || container === void 0 ? void 0 : container.contains(e.relatedTarget)) && e.relatedTarget !== endFocusTrap) { endFocusTrap === null || endFocusTrap === void 0 ? void 0 : endFocusTrap.focus({ preventScroll: true }); } }, [enforceFocus, isAutoFocusing]); /** * Wrap around to the end of the dialog if `enforceFocus` is enabled. */ var handleStartFocusTrapElementKeyDown = React.useCallback(function (e) { var _a; if (!enforceFocus) { return; } if (e.shiftKey && e.key === "Tab") { var lastFocusableElement = getKeyboardFocusableElements(containerElement).pop(); if (lastFocusableElement != null) { lastFocusableElement.focus(); } else { (_a = getRef(endFocusTrapElement)) === null || _a === void 0 ? void 0 : _a.focus({ preventScroll: true }); } } }, [enforceFocus]); /** * Ensures repeatedly pressing tab keeps focus inside the Overlay. Moves focus to the * `startFocusTrapElement` or the last keyboard-focusable element in the Overlay (excluding the * `startFocusTrapElement`), depending on whether the element losing focus is inside the * Overlay. */ var handleEndFocusTrapElementFocus = React.useCallback(function (e) { var _a; // No need for this.props.enforceFocus check here because this element is only rendered // when that prop is true. // During user interactions, e.relatedTarget will be defined, and we should wrap around to the // "start focus trap" element. // Otherwise, we're handling a programmatic focus event, which can only happen after a user // presses shift+tab from the first focusable element in the overlay. var startFocusTrap = getRef(startFocusTrapElement); if (e.relatedTarget != null && ((_a = getRef(containerElement)) === null || _a === void 0 ? void 0 : _a.contains(e.relatedTarget)) && e.relatedTarget !== startFocusTrap) { var firstFocusableElement = getKeyboardFocusableElements(containerElement).shift(); // ensure we don't re-focus an already active element by comparing against e.relatedTarget if (!isAutoFocusing && firstFocusableElement != null && firstFocusableElement !== e.relatedTarget) { firstFocusableElement.focus(); } else { startFocusTrap === null || startFocusTrap === void 0 ? void 0 : startFocusTrap.focus({ preventScroll: true }); } } else { var lastFocusableElement = getKeyboardFocusableElements(containerElement).pop(); if (lastFocusableElement != null) { lastFocusableElement.focus(); } else { // Keeps focus within Overlay even if there are no keyboard-focusable children startFocusTrap === null || startFocusTrap === void 0 ? void 0 : startFocusTrap.focus({ preventScroll: true }); } } }, [isAutoFocusing]); var maybeBackdrop = React.useMemo(function () { return hasBackdrop && isOpen ? (React.createElement(CSSTransition, { classNames: transitionName, key: "__backdrop", nodeRef: backdropElement, timeout: transitionDuration, addEndListener: handleTransitionAddEnd }, React.createElement("div", __assign({}, backdropProps, { className: classNames(Classes.OVERLAY_BACKDROP, backdropClassName, backdropProps === null || backdropProps === void 0 ? void 0 : backdropProps.className), onMouseDown: handleBackdropMouseDown, ref: backdropElement })))) : null; }, [ backdropClassName, backdropProps, handleBackdropMouseDown, handleTransitionAddEnd, hasBackdrop, isOpen, transitionDuration, transitionName, ]); // no reason to render anything at all if we're being truly lazy if (lazy && !hasEverOpened) { return null; } // TransitionGroup types require single array of children; does not support nested arrays. // So we must collapse backdrop and children into one array, and every item must be wrapped in a // Transition element (no ReactText allowed). var childrenWithTransitions = isOpen ? (_c = React.Children.map(children, maybeRenderChild)) !== null && _c !== void 0 ? _c : [] : []; // const maybeBackdrop = maybeRenderBackdrop(); if (maybeBackdrop !== null) { childrenWithTransitions.unshift(maybeBackdrop); } if (isOpen && (autoFocus || enforceFocus) && childrenWithTransitions.length > 0) { childrenWithTransitions.unshift(renderDummyElement("__start", { className: Classes.OVERLAY_START_FOCUS_TRAP, onFocus: handleStartFocusTrapElementFocus, onKeyDown: handleStartFocusTrapElementKeyDown, ref: startFocusTrapElement, })); if (enforceFocus) { childrenWithTransitions.push(renderDummyElement("__end", { className: Classes.OVERLAY_END_FOCUS_TRAP, onFocus: handleEndFocusTrapElementFocus, ref: endFocusTrapElement, })); } } var transitionGroup = (React.createElement("div", { "aria-live": "polite", className: classNames(Classes.OVERLAY, (_a = {}, _a[Classes.OVERLAY_OPEN] = isOpen, _a[Classes.OVERLAY_INLINE] = !usePortal, _a), className), onKeyDown: handleContainerKeyDown, ref: containerElement }, React.createElement(TransitionGroup, { appear: true, component: null }, childrenWithTransitions))); if (usePortal) { return (React.createElement(Portal, { className: portalClassName, container: portalContainer }, transitionGroup)); } else { return transitionGroup; } }); // eslint-disable-next-line @typescript-eslint/no-deprecated Overlay2.defaultProps = OVERLAY2_DEFAULT_PROPS; Overlay2.displayName = "".concat(DISPLAYNAME_PREFIX, ".Overlay2"); function useOverlay2Validation(_a) { var childRef = _a.childRef, childRefs = _a.childRefs, children = _a.children; var numChildren = React.Children.count(children); React.useEffect(function () { if (isNodeEnv("production")) { return; } if (childRef != null && childRefs != null) { console.error(OVERLAY_CHILD_REF_AND_REFS_MUTEX); } if (numChildren > 1 && childRefs == null) { console.error(OVERLAY_WITH_MULTIPLE_CHILDREN_REQUIRES_CHILD_REFS); } }, [childRef, childRefs, numChildren]); } /** * Generates a unique ID for a given Overlay which persists across the component's lifecycle. */ function useOverlay2ID() { // TODO: migrate to React.useId() in React 18 var id = useUID(); return "".concat(Overlay2.displayName, "-").concat(id); } // N.B. the `onExiting` callback is not provided with the `node` argument as suggested in CSSTransition types since // we are using the `nodeRef` prop, so we must inject it dynamically. function getLifecycleCallbackWithChildRef(callback, childRef) { return function () { if ((childRef === null || childRef === void 0 ? void 0 : childRef.current) != null) { callback === null || callback === void 0 ? void 0 : callback(childRef.current); } }; } //# sourceMappingURL=overlay2.js.map