From dadd0570d61d34a069b189c3b675d01f8f36c557 Mon Sep 17 00:00:00 2001 From: Adi Dahiya Date: Tue, 16 Jan 2024 16:40:06 -0500 Subject: [PATCH 01/23] [core] feat: Overlay2 component --- packages/core/src/components/index.ts | 4 +- .../core/src/components/overlay/overlay.tsx | 177 +----- .../src/components/overlay/overlayProps.ts | 192 ++++++ .../core/src/components/overlay2/overlay2.tsx | 550 +++++++++++++++++ packages/core/test/index.ts | 1 + packages/core/test/overlay2/overlay2Tests.tsx | 583 ++++++++++++++++++ 6 files changed, 1331 insertions(+), 176 deletions(-) create mode 100644 packages/core/src/components/overlay/overlayProps.ts create mode 100644 packages/core/src/components/overlay2/overlay2.tsx create mode 100644 packages/core/test/overlay2/overlay2Tests.tsx diff --git a/packages/core/src/components/index.ts b/packages/core/src/components/index.ts index 176e0e8301..56fc2931eb 100644 --- a/packages/core/src/components/index.ts +++ b/packages/core/src/components/index.ts @@ -70,7 +70,9 @@ export { NavbarGroup, type NavbarGroupProps } from "./navbar/navbarGroup"; export { NavbarHeading, type NavbarHeadingProps } from "./navbar/navbarHeading"; export { NonIdealState, type NonIdealStateProps, NonIdealStateIconSize } from "./non-ideal-state/nonIdealState"; export { OverflowList, type OverflowListProps } from "./overflow-list/overflowList"; -export { Overlay, type OverlayLifecycleProps, type OverlayProps, type OverlayableProps } from "./overlay/overlay"; +export { Overlay } from "./overlay/overlay"; +export type { OverlayLifecycleProps, OverlayProps, OverlayableProps } from "./overlay/overlayProps"; +export { Overlay2, type Overlay2Props, type OverlayInstance } from "./overlay2/overlay2"; export { Text, type TextProps } from "./text/text"; // eslint-disable-next-line deprecation/deprecation export { PanelStack, type PanelStackProps } from "./panel-stack/panelStack"; diff --git a/packages/core/src/components/overlay/overlay.tsx b/packages/core/src/components/overlay/overlay.tsx index c28a65697a..7efc5f7265 100644 --- a/packages/core/src/components/overlay/overlay.tsx +++ b/packages/core/src/components/overlay/overlay.tsx @@ -19,184 +19,11 @@ import * as React from "react"; import { CSSTransition, TransitionGroup } from "react-transition-group"; import { AbstractPureComponent, Classes } from "../../common"; -import { DISPLAYNAME_PREFIX, type HTMLDivProps, type Props } from "../../common/props"; +import { DISPLAYNAME_PREFIX, type HTMLDivProps } from "../../common/props"; import { getActiveElement, isFunction } from "../../common/utils"; import { Portal } from "../portal/portal"; -export interface OverlayableProps extends OverlayLifecycleProps { - /** - * Whether the overlay should acquire application focus when it first opens. - * - * @default true - */ - autoFocus?: boolean; - - /** - * Whether pressing the `esc` key should invoke `onClose`. - * - * @default true - */ - canEscapeKeyClose?: boolean; - - /** - * Whether the overlay should prevent focus from leaving itself. That is, if the user attempts - * to focus an element outside the overlay and this prop is enabled, then the overlay will - * immediately bring focus back to itself. If you are nesting overlay components, either disable - * this prop on the "outermost" overlays or mark the nested ones `usePortal={false}`. - * - * @default true - */ - enforceFocus?: boolean; - - /** - * If `true` and `usePortal={true}`, the `Portal` containing the children is created and attached - * to the DOM when the overlay is opened for the first time; otherwise this happens when the - * component mounts. Lazy mounting provides noticeable performance improvements if you have lots - * of overlays at once, such as on each row of a table. - * - * @default true - */ - lazy?: boolean; - - /** - * Whether the application should return focus to the last active element in the - * document after this overlay closes. - * - * @default true - */ - shouldReturnFocusOnClose?: boolean; - - /** - * Indicates how long (in milliseconds) the overlay's enter/leave transition takes. - * This is used by React `CSSTransition` to know when a transition completes and must match - * the duration of the animation in CSS. Only set this prop if you override Blueprint's default - * transitions with new transitions of a different length. - * - * @default 300 - */ - transitionDuration?: number; - - /** - * Whether the overlay should be wrapped in a `Portal`, which renders its contents in a new - * element attached to `portalContainer` prop. - * - * This prop essentially determines which element is covered by the backdrop: if `false`, - * then only its parent is covered; otherwise, the entire page is covered (because the parent - * of the `Portal` is the `` itself). - * - * Set this prop to `false` on nested overlays (such as `Dialog` or `Popover`) to ensure that they - * are rendered above their parents. - * - * @default true - */ - usePortal?: boolean; - - /** - * Space-delimited string of class names applied to the `Portal` element if - * `usePortal={true}`. - */ - portalClassName?: string; - - /** - * The container element into which the overlay renders its contents, when `usePortal` is `true`. - * This prop is ignored if `usePortal` is `false`. - * - * @default document.body - */ - portalContainer?: HTMLElement; - - /** - * A list of DOM events which should be stopped from propagating through the Portal. - * This prop is ignored if `usePortal` is `false`. - * - * @deprecated this prop's implementation no longer works in React v17+ - * @see https://legacy.reactjs.org/docs/portals.html#event-bubbling-through-portals - * @see https://github.com/palantir/blueprint/issues/6124 - * @see https://github.com/palantir/blueprint/issues/6580 - */ - portalStopPropagationEvents?: Array; - - /** - * A callback that is invoked when user interaction causes the overlay to close, such as - * clicking on the overlay or pressing the `esc` key (if enabled). - * - * Receives the event from the user's interaction, if there was an event (generally either a - * mouse or key event). Note that, since this component is controlled by the `isOpen` prop, it - * will not actually close itself until that prop becomes `false`. - */ - onClose?: (event: React.SyntheticEvent) => void; -} - -export interface OverlayLifecycleProps { - /** - * Lifecycle method invoked just before the CSS _close_ transition begins on - * a child. Receives the DOM element of the child being closed. - */ - onClosing?: (node: HTMLElement) => void; - - /** - * Lifecycle method invoked just after the CSS _close_ transition ends but - * before the child has been removed from the DOM. Receives the DOM element - * of the child being closed. - */ - onClosed?: (node: HTMLElement) => void; - - /** - * Lifecycle method invoked just after mounting the child in the DOM but - * just before the CSS _open_ transition begins. Receives the DOM element of - * the child being opened. - */ - onOpening?: (node: HTMLElement) => void; - - /** - * Lifecycle method invoked just after the CSS _open_ transition ends. - * Receives the DOM element of the child being opened. - */ - onOpened?: (node: HTMLElement) => void; -} - -export interface BackdropProps { - /** CSS class names to apply to backdrop element. */ - backdropClassName?: string; - - /** HTML props for the backdrop element. */ - backdropProps?: React.HTMLProps; - - /** - * Whether clicking outside the overlay element (either on backdrop when present or on document) - * should invoke `onClose`. - * - * @default true - */ - canOutsideClickClose?: boolean; - - /** - * Whether a container-spanning backdrop element should be rendered behind the contents. - * When `false`, users will be able to scroll through and interact with overlaid content. - * - * @default true - */ - hasBackdrop?: boolean; -} - -export interface OverlayProps extends OverlayableProps, BackdropProps, Props { - /** Element to overlay. */ - children?: React.ReactNode; - - /** - * Toggles the visibility of the overlay and its children. - * This prop is required because the component is controlled. - */ - isOpen: boolean; - - /** - * Name of the transition for internal `CSSTransition`. - * Providing your own name here will require defining new CSS transition properties. - * - * @default Classes.OVERLAY - */ - transitionName?: string; -} +import type { OverlayProps } from "./overlayProps"; export interface OverlayState { hasEverOpened?: boolean; diff --git a/packages/core/src/components/overlay/overlayProps.ts b/packages/core/src/components/overlay/overlayProps.ts new file mode 100644 index 0000000000..4e9d021ffa --- /dev/null +++ b/packages/core/src/components/overlay/overlayProps.ts @@ -0,0 +1,192 @@ +/* + * 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 type { Props } from "../../common/props"; + +export interface OverlayableProps extends OverlayLifecycleProps { + /** + * Whether the overlay should acquire application focus when it first opens. + * + * @default true + */ + autoFocus?: boolean; + + /** + * Whether pressing the `esc` key should invoke `onClose`. + * + * @default true + */ + canEscapeKeyClose?: boolean; + + /** + * Whether the overlay should prevent focus from leaving itself. That is, if the user attempts + * to focus an element outside the overlay and this prop is enabled, then the overlay will + * immediately bring focus back to itself. If you are nesting overlay components, either disable + * this prop on the "outermost" overlays or mark the nested ones `usePortal={false}`. + * + * @default true + */ + enforceFocus?: boolean; + + /** + * If `true` and `usePortal={true}`, the `Portal` containing the children is created and attached + * to the DOM when the overlay is opened for the first time; otherwise this happens when the + * component mounts. Lazy mounting provides noticeable performance improvements if you have lots + * of overlays at once, such as on each row of a table. + * + * @default true + */ + lazy?: boolean; + + /** + * Whether the application should return focus to the last active element in the + * document after this overlay closes. + * + * @default true + */ + shouldReturnFocusOnClose?: boolean; + + /** + * Indicates how long (in milliseconds) the overlay's enter/leave transition takes. + * This is used by React `CSSTransition` to know when a transition completes and must match + * the duration of the animation in CSS. Only set this prop if you override Blueprint's default + * transitions with new transitions of a different length. + * + * @default 300 + */ + transitionDuration?: number; + + /** + * Whether the overlay should be wrapped in a `Portal`, which renders its contents in a new + * element attached to `portalContainer` prop. + * + * This prop essentially determines which element is covered by the backdrop: if `false`, + * then only its parent is covered; otherwise, the entire page is covered (because the parent + * of the `Portal` is the `` itself). + * + * Set this prop to `false` on nested overlays (such as `Dialog` or `Popover`) to ensure that they + * are rendered above their parents. + * + * @default true + */ + usePortal?: boolean; + + /** + * Space-delimited string of class names applied to the `Portal` element if + * `usePortal={true}`. + */ + portalClassName?: string; + + /** + * The container element into which the overlay renders its contents, when `usePortal` is `true`. + * This prop is ignored if `usePortal` is `false`. + * + * @default document.body + */ + portalContainer?: HTMLElement; + + /** + * A list of DOM events which should be stopped from propagating through the Portal. + * This prop is ignored if `usePortal` is `false`. + * + * @deprecated this prop's implementation no longer works in React v17+ + * @see https://legacy.reactjs.org/docs/portals.html#event-bubbling-through-portals + * @see https://github.com/palantir/blueprint/issues/6124 + * @see https://github.com/palantir/blueprint/issues/6580 + */ + portalStopPropagationEvents?: Array; + + /** + * A callback that is invoked when user interaction causes the overlay to close, such as + * clicking on the overlay or pressing the `esc` key (if enabled). + * + * Receives the event from the user's interaction, if there was an event (generally either a + * mouse or key event). Note that, since this component is controlled by the `isOpen` prop, it + * will not actually close itself until that prop becomes `false`. + */ + onClose?: (event: React.SyntheticEvent) => void; +} + +export interface OverlayLifecycleProps { + /** + * Lifecycle method invoked just before the CSS _close_ transition begins on + * a child. Receives the DOM element of the child being closed. + */ + onClosing?: (node: HTMLElement) => void; + + /** + * Lifecycle method invoked just after the CSS _close_ transition ends but + * before the child has been removed from the DOM. Receives the DOM element + * of the child being closed. + */ + onClosed?: (node: HTMLElement) => void; + + /** + * Lifecycle method invoked just after mounting the child in the DOM but + * just before the CSS _open_ transition begins. Receives the DOM element of + * the child being opened. + */ + onOpening?: (node: HTMLElement) => void; + + /** + * Lifecycle method invoked just after the CSS _open_ transition ends. + * Receives the DOM element of the child being opened. + */ + onOpened?: (node: HTMLElement) => void; +} + +export interface BackdropProps { + /** CSS class names to apply to backdrop element. */ + backdropClassName?: string; + + /** HTML props for the backdrop element. */ + backdropProps?: React.HTMLProps; + + /** + * Whether clicking outside the overlay element (either on backdrop when present or on document) + * should invoke `onClose`. + * + * @default true + */ + canOutsideClickClose?: boolean; + + /** + * Whether a container-spanning backdrop element should be rendered behind the contents. + * When `false`, users will be able to scroll through and interact with overlaid content. + * + * @default true + */ + hasBackdrop?: boolean; +} + +export interface OverlayProps extends OverlayableProps, BackdropProps, Props { + /** Element to overlay. */ + children?: React.ReactNode; + + /** + * Toggles the visibility of the overlay and its children. + * This prop is required because the component is controlled. + */ + isOpen: boolean; + + /** + * Name of the transition for internal `CSSTransition`. + * Providing your own name here will require defining new CSS transition properties. + * + * @default Classes.OVERLAY + */ + transitionName?: string; +} diff --git a/packages/core/src/components/overlay2/overlay2.tsx b/packages/core/src/components/overlay2/overlay2.tsx new file mode 100644 index 0000000000..4953413b8a --- /dev/null +++ b/packages/core/src/components/overlay2/overlay2.tsx @@ -0,0 +1,550 @@ +/* + * 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 classNames from "classnames"; +import * as React from "react"; +import { CSSTransition, TransitionGroup } from "react-transition-group"; + +import { Classes } from "../../common"; +import { DISPLAYNAME_PREFIX, type HTMLDivProps } from "../../common/props"; +import { getActiveElement, isFunction } from "../../common/utils"; +import type { OverlayProps } from "../overlay/overlayProps"; +import { Portal } from "../portal/portal"; + +// HACKHACK: move global state to context +const openStack: OverlayInstance[] = []; +const getLastOpened = () => openStack[openStack.length - 1]; + +export interface OverlayInstance { + bringFocusInsideOverlay: () => void; + containerElement: React.RefObject; + handleDocumentFocus: (e: FocusEvent) => void; + isAutoFocusing: boolean; + lastActiveElementBeforeOpened: Element | null | undefined; + props: Pick; +} + +export interface Overlay2Props extends OverlayProps { + ref?: React.RefObject; +} + +export const Overlay2: React.FC = ({ + autoFocus, + backdropClassName, + backdropProps, + canEscapeKeyClose, + canOutsideClickClose, + children, + className, + enforceFocus, + hasBackdrop, + isOpen, + lazy, + onClose, + onClosed, + onClosing, + onOpened, + onOpening, + portalClassName, + portalContainer, + ref, + shouldReturnFocusOnClose, + transitionDuration, + transitionName, + usePortal, +}) => { + const [hasEverOpened, setHasEverOpened] = React.useState(false); + + /** Ref for container element, containing all children and the backdrop */ + const containerElement = React.useRef(null); + + // An empty, keyboard-focusable div at the beginning of the Overlay content + const startFocusTrapElement = React.useRef(null); + + // An empty, keyboard-focusable div at the end of the Overlay content + const endFocusTrapElement = React.useRef(null); + + const bringFocusInsideOverlay = React.useCallback(() => { + // TODO + }, []); + + /** + * When multiple Overlays are open, this event handler is only active for the most recently + * opened one to avoid Overlays competing with each other for focus. + */ + const handleDocumentFocus = React.useCallback( + (e: FocusEvent) => { + // get the actual target even in the Shadow DOM + // see https://github.com/palantir/blueprint/issues/4220 + const eventTarget = e.composed ? e.composedPath()[0] : e.target; + if ( + enforceFocus && + containerElement.current != null && + eventTarget instanceof Node && + !containerElement.current.contains(eventTarget as HTMLElement) + ) { + // prevent default focus behavior (sometimes auto-scrolls the page) + e.preventDefault(); + e.stopImmediatePropagation(); + bringFocusInsideOverlay(); + } + }, + [bringFocusInsideOverlay, enforceFocus], + ); + + const instance: OverlayInstance = React.useMemo( + () => ({ + bringFocusInsideOverlay, + containerElement, + handleDocumentFocus, + isAutoFocusing: false, + lastActiveElementBeforeOpened: undefined, + props: { + autoFocus, + enforceFocus, + hasBackdrop, + usePortal, + }, + }), + [autoFocus, bringFocusInsideOverlay, enforceFocus, handleDocumentFocus, hasBackdrop, usePortal], + ); + + React.useEffect(() => { + if (ref) { + ref.current = instance; + } + }, [instance, ref]); + + const handleDocumentClick = React.useCallback( + (e: MouseEvent) => { + // get the actual target even in the Shadow DOM + // see https://github.com/palantir/blueprint/issues/4220 + const eventTarget = (e.composed ? e.composedPath()[0] : e.target) as HTMLElement; + + const stackIndex = openStack.indexOf(instance); + const isClickInThisOverlayOrDescendant = openStack.slice(stackIndex).some(({ containerElement: elem }) => { + // `elem` is the container of backdrop & content, so clicking directly on that container + // should not count as being "inside" the overlay. + return elem.current?.contains(eventTarget) && !elem.current.isSameNode(eventTarget); + }); + + if (isOpen && !isClickInThisOverlayOrDescendant && canOutsideClickClose) { + // casting to any because this is a native event + onClose?.(e as any); + } + }, + [canOutsideClickClose, instance, isOpen, onClose], + ); + + const handleContainerKeyDown = React.useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Escape" && canEscapeKeyClose) { + onClose?.(e); + // prevent other overlays from closing + e.stopPropagation(); + // prevent browser-specific escape key behavior (Safari exits fullscreen) + e.preventDefault(); + } + }, + [canEscapeKeyClose, onClose], + ); + + const overlayWillOpen = React.useCallback(() => { + if (openStack.length > 0) { + document.removeEventListener("focus", getLastOpened().handleDocumentFocus, /* useCapture */ true); + } + openStack.push(instance); + + if (autoFocus) { + instance.isAutoFocusing = true; + instance.bringFocusInsideOverlay(); + } + + if (enforceFocus) { + // Focus events do not bubble, but setting useCapture allows us to listen in and execute + // our handler before all others + document.addEventListener("focus", instance.handleDocumentFocus, /* useCapture */ true); + } + + if (canOutsideClickClose && !hasBackdrop) { + document.addEventListener("mousedown", handleDocumentClick); + } + + if (hasBackdrop && usePortal) { + // add a class to the body to prevent scrolling of content below the overlay + document.body.classList.add(Classes.OVERLAY_OPEN); + } + + instance.lastActiveElementBeforeOpened = getActiveElement(containerElement.current); + }, [autoFocus, canOutsideClickClose, enforceFocus, handleDocumentClick, hasBackdrop, instance, usePortal]); + + const overlayWillClose = React.useCallback(() => { + document.removeEventListener("focus", handleDocumentFocus, /* useCapture */ true); + document.removeEventListener("mousedown", handleDocumentClick); + + const stackIndex = openStack.indexOf(instance); + if (stackIndex !== -1) { + openStack.splice(stackIndex, 1); + if (openStack.length > 0) { + const lastOpenedOverlay = getLastOpened(); + // 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) { + lastOpenedOverlay.bringFocusInsideOverlay(); + document.addEventListener("focus", lastOpenedOverlay.handleDocumentFocus, /* useCapture */ true); + } + } + + if (openStack.filter(o => o.props.usePortal && o.props.hasBackdrop).length === 0) { + document.body.classList.remove(Classes.OVERLAY_OPEN); + } + } + }, [handleDocumentClick, handleDocumentFocus, instance]); + + React.useEffect(() => { + if (isOpen) { + setHasEverOpened(true); + overlayWillOpen(); + } else { + // TODO: call overlayWillClose when isOpen toggles to false + } + return () => { + overlayWillClose(); + }; + }, [isOpen, overlayWillOpen, overlayWillClose]); + + const handleTransitionExited = React.useCallback( + (node: HTMLElement) => { + if (shouldReturnFocusOnClose && instance.lastActiveElementBeforeOpened instanceof HTMLElement) { + instance.lastActiveElementBeforeOpened.focus(); + } + onClosed?.(node); + }, + [instance.lastActiveElementBeforeOpened, onClosed, shouldReturnFocusOnClose], + ); + + const handleTransitionAddEnd = React.useCallback(() => { + // no-op + }, []); + + const maybeRenderChild = React.useCallback( + (child?: React.ReactNode) => { + if (isFunction(child)) { + child = child(); + } + + if (child == null) { + return null; + } + + // decorate the child with a few injected props + const tabIndex = enforceFocus || autoFocus ? 0 : undefined; + const decoratedChild = + typeof child === "object" ? ( + React.cloneElement(child as React.ReactElement, { + className: classNames((child as React.ReactElement).props.className, Classes.OVERLAY_CONTENT), + tabIndex, + }) + ) : ( + + {child} + + ); + + return ( + + {decoratedChild} + + ); + }, + [ + autoFocus, + enforceFocus, + handleTransitionAddEnd, + handleTransitionExited, + onClosing, + onOpened, + onOpening, + transitionDuration, + transitionName, + ], + ); + + const handleBackdropMouseDown = React.useCallback( + (e: React.MouseEvent) => { + if (canOutsideClickClose) { + onClose?.(e); + } + if (enforceFocus) { + bringFocusInsideOverlay(); + } + backdropProps?.onMouseDown?.(e); + }, + [backdropProps, bringFocusInsideOverlay, canOutsideClickClose, enforceFocus, onClose], + ); + + const maybeRenderBackdrop = React.useCallback(() => { + if (hasBackdrop && isOpen) { + return ( + +
+ + ); + } else { + return null; + } + }, [ + backdropClassName, + backdropProps, + handleBackdropMouseDown, + handleTransitionAddEnd, + hasBackdrop, + isOpen, + transitionDuration, + transitionName, + ]); + + const renderDummyElement = React.useCallback( + (key: string, dummyElementProps: HTMLDivProps & { ref?: React.Ref }) => { + return ( + +
+ + ); + }, + [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. + */ + const handleStartFocusTrapElementFocus = React.useCallback( + (e: React.FocusEvent) => { + if (!enforceFocus || instance.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. + if ( + e.relatedTarget != null && + containerElement.current?.contains(e.relatedTarget as Element) && + e.relatedTarget !== endFocusTrapElement.current + ) { + endFocusTrapElement.current?.focus({ preventScroll: true }); + } + }, + [enforceFocus, instance.isAutoFocusing], + ); + + /** + * Wrap around to the end of the dialog if `enforceFocus` is enabled. + */ + const handleStartFocusTrapElementKeyDown = React.useCallback( + (e: React.KeyboardEvent) => { + if (!enforceFocus) { + return; + } + if (e.shiftKey && e.key === "Tab") { + const lastFocusableElement = getKeyboardFocusableElements(containerElement).pop(); + if (lastFocusableElement != null) { + lastFocusableElement.focus(); + } else { + endFocusTrapElement.current?.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. + */ + const handleEndFocusTrapElementFocus = React.useCallback( + (e: React.FocusEvent) => { + // 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. + if ( + e.relatedTarget != null && + containerElement.current?.contains(e.relatedTarget as Element) && + e.relatedTarget !== startFocusTrapElement.current + ) { + const firstFocusableElement = getKeyboardFocusableElements(containerElement).shift(); + // ensure we don't re-focus an already active element by comparing against e.relatedTarget + if ( + !instance.isAutoFocusing && + firstFocusableElement != null && + firstFocusableElement !== e.relatedTarget + ) { + firstFocusableElement.focus(); + } else { + startFocusTrapElement.current?.focus({ preventScroll: true }); + } + } else { + const lastFocusableElement = getKeyboardFocusableElements(containerElement).pop(); + if (lastFocusableElement != null) { + lastFocusableElement.focus(); + } else { + // Keeps focus within Overlay even if there are no keyboard-focusable children + startFocusTrapElement.current?.focus({ preventScroll: true }); + } + } + }, + [instance.isAutoFocusing], + ); + + // 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). + const childrenWithTransitions = isOpen ? React.Children.map(children, maybeRenderChild) ?? [] : []; + + 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, + }), + ); + } + } + + const transitionGroup = ( +
+ + {childrenWithTransitions} + +
+ ); + + if (usePortal) { + return ( + + {transitionGroup} + + ); + } else { + return transitionGroup; + } +}; +Overlay2.defaultProps = { + autoFocus: true, + backdropProps: {}, + canEscapeKeyClose: true, + canOutsideClickClose: true, + enforceFocus: true, + hasBackdrop: true, + isOpen: false, + lazy: true, + shouldReturnFocusOnClose: true, + transitionDuration: 300, + transitionName: Classes.OVERLAY, + usePortal: true, +}; +Overlay2.displayName = `${DISPLAYNAME_PREFIX}.Overlay2`; + +function getKeyboardFocusableElements(containerElement: React.RefObject) { + const focusableElements: HTMLElement[] = + containerElement.current !== null + ? Array.from( + // Order may not be correct if children elements use tabindex values > 0. + // Selectors derived from this SO question: + // https://stackoverflow.com/questions/1599660/which-html-elements-can-receive-focus + containerElement.current.querySelectorAll( + [ + 'a[href]:not([tabindex="-1"])', + 'button:not([disabled]):not([tabindex="-1"])', + 'details:not([tabindex="-1"])', + 'input:not([disabled]):not([tabindex="-1"])', + 'select:not([disabled]):not([tabindex="-1"])', + 'textarea:not([disabled]):not([tabindex="-1"])', + '[tabindex]:not([tabindex="-1"])', + ].join(","), + ), + ) + : []; + + return focusableElements.filter( + el => + !el.classList.contains(Classes.OVERLAY_START_FOCUS_TRAP) && + !el.classList.contains(Classes.OVERLAY_END_FOCUS_TRAP), + ); +} diff --git a/packages/core/test/index.ts b/packages/core/test/index.ts index 9cbec22898..74dd1e0373 100644 --- a/packages/core/test/index.ts +++ b/packages/core/test/index.ts @@ -58,6 +58,7 @@ import "./multistep-dialog/multistepDialogTests"; import "./non-ideal-state/nonIdealStateTests"; import "./overflow-list/overflowListTests"; import "./overlay/overlayTests"; +import "./overlay2/overlay2Tests"; import "./panel-stack/panelStackTests"; import "./panel-stack2/panelStack2Tests"; import "./popover/popoverTests"; diff --git a/packages/core/test/overlay2/overlay2Tests.tsx b/packages/core/test/overlay2/overlay2Tests.tsx new file mode 100644 index 0000000000..26da03cf87 --- /dev/null +++ b/packages/core/test/overlay2/overlay2Tests.tsx @@ -0,0 +1,583 @@ +/* + * 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 { assert } from "chai"; +import { mount, type ReactWrapper, shallow } from "enzyme"; +import * as React from "react"; +import { spy } from "sinon"; + +import { dispatchMouseEvent } from "@blueprintjs/test-commons"; + +import { Classes, Overlay2, type OverlayInstance, type OverlayProps, Portal, Utils } from "../../src"; +import { findInPortal } from "../utils"; + +const BACKDROP_SELECTOR = `.${Classes.OVERLAY_BACKDROP}`; + +/* + * IMPORTANT NOTE: It is critical that every wrapper be unmounted after the test, to avoid + * polluting the DOM with leftover overlay elements. This was the cause of the Overlay test flakes of + * late 2017/early 2018 and was resolved by ensuring that every wrapper is unmounted. + * + * The `wrapper` variable below and the `mountWrapper` method should be used for full enzyme mounts. + * For shallow mounts, be sure to call `shallowWrapper.unmount()` after the assertions. + */ +describe("", () => { + let wrapper: ReactWrapper; + let isMounted = false; + const testsContainerElement = document.createElement("div"); + document.documentElement.appendChild(testsContainerElement); + + /** + * Mount the `content` into `testsContainerElement` and assign to local `wrapper` variable. + * Use this method in this suite instead of Enzyme's `mount` method. + */ + function mountWrapper(content: React.JSX.Element) { + wrapper = mount(content, { attachTo: testsContainerElement }); + isMounted = true; + return wrapper; + } + + afterEach(() => { + if (isMounted) { + // clean up wrapper after each test, if it was used + wrapper?.unmount(); + wrapper?.detach(); + isMounted = false; + } + }); + + after(() => { + document.documentElement.removeChild(testsContainerElement); + }); + + it("renders its content correctly", () => { + const overlay = shallow( + + {createOverlayContents()} + , + ); + assert.lengthOf(overlay.find("strong"), 1); + assert.lengthOf(overlay.find(BACKDROP_SELECTOR), 1); + overlay.unmount(); + }); + + it("renders contents to specified container correctly", () => { + const CLASS_TO_TEST = "bp-test-content"; + const container = document.createElement("div"); + document.body.appendChild(container); + mountWrapper( + +

test

+
, + ); + assert.lengthOf(container.getElementsByClassName(CLASS_TO_TEST), 1); + document.body.removeChild(container); + }); + + it("sets aria-live", () => { + // Using an open Overlay2 because an initially closed Overlay2 will not render anything to the + // DOM + mountWrapper(); + const overlayElement = document.querySelector(".aria-test"); + assert.exists(overlayElement); + // Element#ariaLive not supported in Firefox or IE + assert.equal(overlayElement?.getAttribute("aria-live"), "polite"); + }); + + it("portalClassName appears on Portal", () => { + const CLASS_TO_TEST = "bp-test-content"; + mountWrapper( + +

test

+
, + ); + // search document for portal container element. + assert.isDefined(document.querySelector(`.${Classes.PORTAL}.${CLASS_TO_TEST}`)); + }); + + it("renders Portal after first opened", () => { + mountWrapper({createOverlayContents()}); + assert.lengthOf(wrapper.find(Portal), 0, "unexpected Portal"); + wrapper.setProps({ isOpen: true }); + assert.lengthOf(wrapper.find(Portal), 1, "expected Portal"); + }); + + it("supports non-element children", () => { + assert.doesNotThrow(() => + shallow( + + {null} {undefined} + , + ).unmount(), + ); + }); + + it("hasBackdrop=false does not render backdrop", () => { + const overlay = shallow( + + {createOverlayContents()} + , + ); + assert.lengthOf(overlay.find("strong"), 1); + assert.lengthOf(overlay.find(BACKDROP_SELECTOR), 0); + overlay.unmount(); + }); + + it("renders portal attached to body when not inline after first opened", () => { + mountWrapper({createOverlayContents()}); + assert.lengthOf(wrapper.find(Portal), 0, "unexpected Portal"); + wrapper.setProps({ isOpen: true }); + assert.lengthOf(wrapper.find(Portal), 1, "expected Portal"); + }); + + describe("onClose", () => { + it("invoked on backdrop mousedown when canOutsideClickClose=true", () => { + const onClose = spy(); + const overlay = shallow( + + {createOverlayContents()} + , + ); + overlay.find(BACKDROP_SELECTOR).simulate("mousedown"); + assert.isTrue(onClose.calledOnce); + overlay.unmount(); + }); + + it("not invoked on backdrop mousedown when canOutsideClickClose=false", () => { + const onClose = spy(); + const overlay = shallow( + + {createOverlayContents()} + , + ); + overlay.find(BACKDROP_SELECTOR).simulate("mousedown"); + assert.isTrue(onClose.notCalled); + overlay.unmount(); + }); + + it("invoked on document mousedown when hasBackdrop=false", () => { + const onClose = spy(); + // mounting cuz we need document events + lifecycle + mountWrapper( + + {createOverlayContents()} + , + ); + + dispatchMouseEvent(document.documentElement, "mousedown"); + assert.isTrue(onClose.calledOnce); + }); + + it("not invoked on document mousedown when hasBackdrop=false and canOutsideClickClose=false", () => { + const onClose = spy(); + mountWrapper( + + {createOverlayContents()} + , + ); + + dispatchMouseEvent(document.documentElement, "mousedown"); + assert.isTrue(onClose.notCalled); + }); + + it("not invoked on click of a nested overlay", () => { + const onClose = spy(); + mountWrapper( + +
+ {createOverlayContents()} + +
{createOverlayContents()}
+
+
+
, + ); + // this hackery is necessary for React 15 support, where Portals break trees. + findInPortal(findInPortal(wrapper, "#outer-element"), "#inner-element").simulate("mousedown"); + assert.isTrue(onClose.notCalled); + }); + + it("invoked on escape key", () => { + const onClose = spy(); + mountWrapper( + + {createOverlayContents()} + , + ); + wrapper.simulate("keydown", { key: "Escape" }); + assert.isTrue(onClose.calledOnce); + }); + + it("not invoked on escape key when canEscapeKeyClose=false", () => { + const onClose = spy(); + const overlay = shallow( + + {createOverlayContents()} + , + ); + overlay.simulate("keydown", { key: "Escape" }); + assert.isTrue(onClose.notCalled); + overlay.unmount(); + }); + + it("renders portal attached to body when not inline", () => { + const overlay = shallow( + + {createOverlayContents()} + , + ); + const portal = overlay.find(Portal); + assert.isTrue(portal.exists(), "missing Portal"); + assert.lengthOf(portal.find("strong"), 1, "missing h1"); + overlay.unmount(); + }); + }); + + describe("Focus management", () => { + const overlayClassName = "test-overlay"; + + it("brings focus to overlay if autoFocus=true", done => { + mountWrapper( + + + , + ); + assertFocusIsInOverlayWithTimeout(done); + }); + + it("does not bring focus to overlay if autoFocus=false and enforceFocus=false", done => { + mountWrapper( +
+ + + + +
, + ); + assertFocusWithTimeout("body", done); + }); + + // React implements autoFocus itself so our `[autofocus]` logic never fires. + // Still, worth testing we can control where the focus goes. + it("autoFocus element inside overlay gets the focus", done => { + mountWrapper( + + + , + ); + assertFocusWithTimeout("input", done); + }); + + it("returns focus to overlay if enforceFocus=true", done => { + const buttonRef = React.createRef(); + const inputRef = React.createRef(); + mountWrapper( +
+
, + ); + assert.strictEqual(document.activeElement, inputRef.current); + buttonRef.current?.focus(); + assertFocusIsInOverlayWithTimeout(done); + }); + + it("returns focus to overlay after clicking the backdrop if enforceFocus=true", done => { + mountWrapper( + + {createOverlayContents()} + , + ); + wrapper.find(BACKDROP_SELECTOR).simulate("mousedown"); + assertFocusIsInOverlayWithTimeout(done); + }); + + it("returns focus to overlay after clicking an outside element if enforceFocus=true", done => { + mountWrapper( +
+ + {createOverlayContents()} + +
, + ); + wrapper.find("#buttonId").simulate("click"); + assertFocusIsInOverlayWithTimeout(done); + }); + + it("does not result in maximum call stack if two overlays open with enforceFocus=true", () => { + const instanceRef = React.createRef(); + const anotherContainer = document.createElement("div"); + document.documentElement.appendChild(anotherContainer); + const temporaryWrapper = mount( + + + , + { attachTo: anotherContainer }, + ); + + mountWrapper( + + + , + ); + + assert.isNotNull(instanceRef.current, "ref should be set"); + + const bringFocusSpy = spy(instanceRef.current!, "bringFocusInsideOverlay"); + wrapper.setProps({ isOpen: true }); + + // triggers the infinite recursion + wrapper.find("#inputId").simulate("click"); + assert.isTrue(bringFocusSpy.calledOnce); + + // don't need spy.restore() since the wrapper will be destroyed after test anyways + temporaryWrapper.unmount(); + document.documentElement.removeChild(anotherContainer); + }); + + it("does not return focus to overlay if enforceFocus=false", done => { + let buttonRef: HTMLElement | null; + const focusBtnAndAssert = () => { + buttonRef?.focus(); + assert.strictEqual(buttonRef, document.activeElement); + done(); + }; + + mountWrapper( +
+
, + ); + }); + + it("doesn't focus overlay if focus is already inside overlay", done => { + let textarea: HTMLTextAreaElement | null; + mountWrapper( + +