From 9dfba77ec5094c7752e5b73eb49a7e72dced9dcb Mon Sep 17 00:00:00 2001 From: Adi Dahiya <adahiya@palantir.com> Date: Wed, 24 Jan 2024 08:27:34 -0500 Subject: [PATCH] [core] feat: Overlay2 component (#6656) --- packages/core/karma.conf.js | 3 + packages/core/src/common/errors.ts | 7 + packages/core/src/common/refs.ts | 6 +- packages/core/src/common/utils/domUtils.ts | 5 + packages/core/src/common/utils/jsUtils.ts | 7 + packages/core/src/common/utils/reactUtils.ts | 53 +- packages/core/src/components/alert/alert.tsx | 2 +- packages/core/src/components/components.md | 1 + .../context-menu/contextMenuShared.ts | 2 +- .../core/src/components/dialog/dialog.tsx | 40 +- .../core/src/components/drawer/drawer.tsx | 7 +- packages/core/src/components/index.ts | 10 +- .../core/src/components/overlay/overlay.md | 25 +- .../core/src/components/overlay/overlay.tsx | 220 +----- .../src/components/overlay/overlayProps.ts | 192 +++++ .../src/components/overlay/overlayUtils.ts | 49 ++ .../core/src/components/overlay2/overlay2.md | 174 +++++ .../core/src/components/overlay2/overlay2.tsx | 710 ++++++++++++++++++ .../core/src/components/popover/popover.tsx | 31 +- .../components/popover/popoverSharedProps.ts | 4 +- .../src/components/toast/overlayToaster.tsx | 79 +- packages/core/src/components/toast/toast.md | 143 ++-- packages/core/src/components/toast/toast.tsx | 58 +- packages/core/src/components/toast/toast2.tsx | 109 +++ .../core/src/components/toast/toastProps.ts | 55 ++ packages/core/src/components/toast/toaster.ts | 2 +- .../src/hooks/useIsomorphicLayoutEffect.ts | 21 + packages/core/src/hooks/useTimeout.ts | 48 ++ .../core/src/legacy/contextMenuLegacy.tsx | 2 +- packages/core/test/dialog/dialogTests.tsx | 2 +- packages/core/test/index.ts | 2 + packages/core/test/isotest.mjs | 14 +- packages/core/test/overlay/overlayTests.tsx | 7 + .../overlay2/overlay2-test-debugging.scss | 10 + packages/core/test/overlay2/overlay2Tests.tsx | 611 +++++++++++++++ packages/core/test/popover/popoverTests.tsx | 8 +- .../core/test/toast/overlayToasterTests.tsx | 12 +- packages/core/test/toast/toast2Tests.tsx | 102 +++ packages/core/test/toast/toastTests.tsx | 7 + packages/core/test/tooltip/tooltipTests.tsx | 6 +- .../demo-app/src/examples/ToastExample.tsx | 4 +- .../docs-app/src/components/blueprintDocs.tsx | 28 +- .../src/examples/core-examples/index.ts | 3 +- .../core-examples/overlay2Example.tsx | 133 ++++ .../examples/core-examples/overlayExample.tsx | 7 + packages/docs-app/src/styles/_examples.scss | 12 + .../select/src/components/omnibar/omnibar.tsx | 6 +- packages/table/test/columnHeaderCellTests.tsx | 39 +- packages/table/test/harness.ts | 11 +- packages/table/test/index.ts | 60 +- 50 files changed, 2683 insertions(+), 466 deletions(-) create mode 100644 packages/core/src/components/overlay/overlayProps.ts create mode 100644 packages/core/src/components/overlay/overlayUtils.ts create mode 100644 packages/core/src/components/overlay2/overlay2.md create mode 100644 packages/core/src/components/overlay2/overlay2.tsx create mode 100644 packages/core/src/components/toast/toast2.tsx create mode 100644 packages/core/src/components/toast/toastProps.ts create mode 100644 packages/core/src/hooks/useIsomorphicLayoutEffect.ts create mode 100644 packages/core/src/hooks/useTimeout.ts create mode 100644 packages/core/test/overlay2/overlay2-test-debugging.scss create mode 100644 packages/core/test/overlay2/overlay2Tests.tsx create mode 100644 packages/core/test/toast/toast2Tests.tsx create mode 100644 packages/docs-app/src/examples/core-examples/overlay2Example.tsx diff --git a/packages/core/karma.conf.js b/packages/core/karma.conf.js index 7321e4a3ac..118f9e05a2 100644 --- a/packages/core/karma.conf.js +++ b/packages/core/karma.conf.js @@ -21,6 +21,9 @@ module.exports = async function (config) { "src/common/abstractComponent*", "src/common/abstractPureComponent*", "src/components/html/html.tsx", + // focus mangement is difficult to test, and this function may no longer be required + // if we use the react-focus-lock library in Overlay2. + "src/components/overlay/overlayUtils.ts", // HACKHACK: for karma upgrade only "src/common/refs.ts", diff --git a/packages/core/src/common/errors.ts b/packages/core/src/common/errors.ts index bcb88f16c7..0abb9c84d7 100644 --- a/packages/core/src/common/errors.ts +++ b/packages/core/src/common/errors.ts @@ -109,3 +109,10 @@ export const DIALOG_WARN_NO_HEADER_CLOSE_BUTTON = export const DRAWER_ANGLE_POSITIONS_ARE_CASTED = ns + ` <Drawer> all angle positions are casted into pure position (TOP, BOTTOM, LEFT or RIGHT)`; + +export const OVERLAY_CHILD_REF_AND_REFS_MUTEX = + ns + ` <Overlay2> cannot use childRef and childRefs props simultaneously`; +export const OVERLAY_WITH_MULTIPLE_CHILDREN_REQUIRES_CHILD_REFS = + ns + ` <Overlay2> requires childRefs prop when rendering multiple child elements`; +export const OVERLAY_CHILD_REQUIRES_KEY = + ns + ` <Overlay2> requires each child element to have a unique key prop when childRefs is used`; diff --git a/packages/core/src/common/refs.ts b/packages/core/src/common/refs.ts index 2b14b6028b..ad4f238621 100644 --- a/packages/core/src/common/refs.ts +++ b/packages/core/src/common/refs.ts @@ -53,7 +53,11 @@ export function getRef<T>(ref: T | React.RefObject<T> | null): T | null { return null; } - return (ref as React.RefObject<T>).current ?? (ref as T); + if (typeof (ref as React.RefObject<T>).current === "undefined") { + return ref as T; + } + + return (ref as React.RefObject<T>).current; } /** diff --git a/packages/core/src/common/utils/domUtils.ts b/packages/core/src/common/utils/domUtils.ts index bb78d568f3..dea1ad826f 100644 --- a/packages/core/src/common/utils/domUtils.ts +++ b/packages/core/src/common/utils/domUtils.ts @@ -14,6 +14,11 @@ * limitations under the License. */ +/** @returns true if React is running in a client environment, and false if it's in a server */ +export function hasDOMEnvironment(): boolean { + return typeof window !== "undefined" && window.document != null; +} + export function elementIsOrContains(element: HTMLElement, testElement: HTMLElement) { return element === testElement || element.contains(testElement); } diff --git a/packages/core/src/common/utils/jsUtils.ts b/packages/core/src/common/utils/jsUtils.ts index 5126962aeb..b8cba3e827 100644 --- a/packages/core/src/common/utils/jsUtils.ts +++ b/packages/core/src/common/utils/jsUtils.ts @@ -79,3 +79,10 @@ export function uniqueId(namespace: string) { uniqueCountForNamespace.set(namespace, curCount + 1); return `${namespace}-${curCount}`; } + +/** + * @returns `true` if the value is an empty string after trimming whitespace + */ +export function isEmptyString(val: any) { + return typeof val === "string" && val.trim().length === 0; +} diff --git a/packages/core/src/common/utils/reactUtils.ts b/packages/core/src/common/utils/reactUtils.ts index 86cd7561d9..60b42665b0 100644 --- a/packages/core/src/common/utils/reactUtils.ts +++ b/packages/core/src/common/utils/reactUtils.ts @@ -16,6 +16,8 @@ import * as React from "react"; +import { isEmptyString } from "./jsUtils"; + /** * Returns true if `node` is null/undefined, false, empty string, or an array * composed of those. If `node` is an array, only one level of the array is @@ -45,28 +47,44 @@ export function isReactChildrenElementOrElements( } /** - * Converts a React node to an element: non-empty string or number or - * `React.Fragment` (React 16.3+) is wrapped in given tag name; empty strings - * and booleans are discarded. + * Converts a React node to an element. Non-empty strings, numbers, and Fragments will be wrapped in given tag name; + * empty strings and booleans will be discarded. + * + * @param child the React node to convert + * @param tagName the HTML tag name to use when a wrapper element is needed + * @param props additional props to spread onto the element, if any. If the child is a React element and this argument + * is defined, the child will be cloned and these props will be merged in. */ -export function ensureElement(child: React.ReactNode | undefined, tagName: keyof React.JSX.IntrinsicElements = "span") { - if (child == null || typeof child === "boolean") { +export function ensureElement( + child: React.ReactNode | undefined, + tagName: keyof React.JSX.IntrinsicElements = "span", + props: React.HTMLProps<HTMLElement> = {}, +) { + if (child == null || typeof child === "boolean" || isEmptyString(child)) { return undefined; - } else if (typeof child === "string") { - // cull whitespace strings - return child.trim().length > 0 ? React.createElement(tagName, {}, child) : undefined; - } else if (typeof child === "number" || typeof (child as any).type === "symbol" || Array.isArray(child)) { - // React.Fragment has a symbol type, ReactNodeArray extends from Array - return React.createElement(tagName, {}, child); + } else if ( + typeof child === "string" || + typeof child === "number" || + isReactFragment(child) || + isReactNodeArray(child) + ) { + // wrap the child element + return React.createElement(tagName, props, child); } else if (isReactElement(child)) { - return child; + if (Object.keys(props).length > 0) { + // clone the element and merge props + return React.cloneElement(child, props); + } else { + // nothing to do, it's a valid ReactElement + return child; + } } else { // child is inferred as {} return undefined; } } -function isReactElement<T = any>(child: React.ReactNode): child is React.ReactElement<T> { +export function isReactElement<T = any>(child: React.ReactNode): child is React.ReactElement<T> { return ( typeof child === "object" && typeof (child as any).type !== "undefined" && @@ -74,6 +92,15 @@ function isReactElement<T = any>(child: React.ReactNode): child is React.ReactEl ); } +function isReactFragment(child: React.ReactNode): child is React.ReactFragment { + // bit hacky, but generally works + return typeof (child as any).type === "symbol"; +} + +function isReactNodeArray(child: React.ReactNode): child is React.ReactNodeArray { + return Array.isArray(child); +} + /** * Returns true if the given JSX element matches the given component type. * diff --git a/packages/core/src/components/alert/alert.tsx b/packages/core/src/components/alert/alert.tsx index a78956a794..aca45e912b 100644 --- a/packages/core/src/components/alert/alert.tsx +++ b/packages/core/src/components/alert/alert.tsx @@ -33,7 +33,7 @@ import { import { Button } from "../button/buttons"; import { Dialog } from "../dialog/dialog"; import { Icon, type IconName } from "../icon/icon"; -import type { OverlayLifecycleProps } from "../overlay/overlay"; +import type { OverlayLifecycleProps } from "../overlay/overlayProps"; export interface AlertProps extends OverlayLifecycleProps, Props { /** diff --git a/packages/core/src/components/components.md b/packages/core/src/components/components.md index 48e5cf9ebc..8f3b84e737 100644 --- a/packages/core/src/components/components.md +++ b/packages/core/src/components/components.md @@ -56,6 +56,7 @@ @## Overlays @page overlay +@page overlay2 @page portal @page alert @page context-menu diff --git a/packages/core/src/components/context-menu/contextMenuShared.ts b/packages/core/src/components/context-menu/contextMenuShared.ts index e5ec0bca87..4fb5a0b43c 100644 --- a/packages/core/src/components/context-menu/contextMenuShared.ts +++ b/packages/core/src/components/context-menu/contextMenuShared.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import type { OverlayLifecycleProps } from "../overlay/overlay"; +import type { OverlayLifecycleProps } from "../overlay/overlayProps"; import type { PopoverProps } from "../popover/popover"; export type Offset = { diff --git a/packages/core/src/components/dialog/dialog.tsx b/packages/core/src/components/dialog/dialog.tsx index e38979a0c5..1c7347aacb 100644 --- a/packages/core/src/components/dialog/dialog.tsx +++ b/packages/core/src/components/dialog/dialog.tsx @@ -19,13 +19,21 @@ import * as React from "react"; import { type IconName, IconSize, SmallCross } from "@blueprintjs/icons"; -import { AbstractPureComponent, Classes, DISPLAYNAME_PREFIX, type MaybeElement, type Props } from "../../common"; +import { + AbstractPureComponent, + Classes, + DISPLAYNAME_PREFIX, + type MaybeElement, + mergeRefs, + type Props, +} from "../../common"; import * as Errors from "../../common/errors"; import { uniqueId } from "../../common/utils"; import { Button } from "../button/buttons"; import { H6 } from "../html/html"; import { Icon } from "../icon/icon"; -import { type BackdropProps, Overlay, type OverlayableProps } from "../overlay/overlay"; +import type { BackdropProps, OverlayableProps } from "../overlay/overlayProps"; +import { Overlay2 } from "../overlay2/overlay2"; export interface DialogProps extends OverlayableProps, BackdropProps, Props { /** Dialog contents. */ @@ -77,7 +85,7 @@ export interface DialogProps extends OverlayableProps, BackdropProps, Props { transitionName?: string; /** - * Ref supplied to the `Classes.DIALOG_CONTAINER` element. + * Ref attached to the `Classes.DIALOG_CONTAINER` element. */ containerRef?: React.Ref<HTMLDivElement>; @@ -106,6 +114,8 @@ export class Dialog extends AbstractPureComponent<DialogProps> { isOpen: false, }; + private childRef = React.createRef<HTMLDivElement>(); + private titleId: string; public static displayName = `${DISPLAYNAME_PREFIX}.Dialog`; @@ -118,21 +128,31 @@ export class Dialog extends AbstractPureComponent<DialogProps> { } public render() { + const { className, children, containerRef, style, title, ...overlayProps } = this.props; + return ( - <Overlay {...this.props} className={Classes.OVERLAY_SCROLL_CONTAINER} hasBackdrop={true}> - <div className={Classes.DIALOG_CONTAINER} ref={this.props.containerRef}> + <Overlay2 + {...overlayProps} + className={Classes.OVERLAY_SCROLL_CONTAINER} + childRef={this.childRef} + hasBackdrop={true} + > + <div + className={Classes.DIALOG_CONTAINER} + ref={containerRef === undefined ? this.childRef : mergeRefs(containerRef, this.childRef)} + > <div - className={classNames(Classes.DIALOG, this.props.className)} + className={classNames(Classes.DIALOG, className)} role="dialog" - aria-labelledby={this.props["aria-labelledby"] || (this.props.title ? this.titleId : undefined)} + aria-labelledby={this.props["aria-labelledby"] || (title ? this.titleId : undefined)} aria-describedby={this.props["aria-describedby"]} - style={this.props.style} + style={style} > {this.maybeRenderHeader()} - {this.props.children} + {children} </div> </div> - </Overlay> + </Overlay2> ); } diff --git a/packages/core/src/components/drawer/drawer.tsx b/packages/core/src/components/drawer/drawer.tsx index 47c3257bcc..d2b061df55 100644 --- a/packages/core/src/components/drawer/drawer.tsx +++ b/packages/core/src/components/drawer/drawer.tsx @@ -26,7 +26,8 @@ import { DISPLAYNAME_PREFIX, type MaybeElement } from "../../common/props"; import { Button } from "../button/buttons"; import { H4 } from "../html/html"; import { Icon } from "../icon/icon"; -import { type BackdropProps, Overlay, type OverlayableProps } from "../overlay/overlay"; +import type { BackdropProps, OverlayableProps } from "../overlay/overlayProps"; +import { Overlay2 } from "../overlay2/overlay2"; export enum DrawerSize { SMALL = "360px", @@ -134,12 +135,12 @@ export class Drawer extends AbstractPureComponent<DrawerProps> { return ( // N.B. the `OVERLAY_CONTAINER` class is a bit of a misnomer since it is only being used by the Drawer // component, but we keep it for backwards compatibility. - <Overlay {...overlayProps} className={classNames({ [Classes.OVERLAY_CONTAINER]: hasBackdrop })}> + <Overlay2 {...overlayProps} className={classNames({ [Classes.OVERLAY_CONTAINER]: hasBackdrop })}> <div className={classes} style={styleProp}> {this.maybeRenderHeader()} {children} </div> - </Overlay> + </Overlay2> ); } diff --git a/packages/core/src/components/index.ts b/packages/core/src/components/index.ts index 176e0e8301..7b14377c41 100644 --- a/packages/core/src/components/index.ts +++ b/packages/core/src/components/index.ts @@ -70,7 +70,10 @@ 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"; +// eslint-disable-next-line deprecation/deprecation +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"; @@ -120,7 +123,10 @@ export { Tag, type TagProps } from "./tag/tag"; export { TagInput, type TagInputProps, type TagInputAddMethod } from "./tag-input/tagInput"; export { OverlayToaster, type OverlayToasterCreateOptions } from "./toast/overlayToaster"; export type { OverlayToasterProps, ToasterPosition } from "./toast/overlayToasterProps"; -export { Toast, type ToastProps } from "./toast/toast"; +// eslint-disable-next-line deprecation/deprecation +export { Toast } from "./toast/toast"; +export { Toast2 } from "./toast/toast2"; +export type { ToastProps } from "./toast/toastProps"; export { Toaster, type ToastOptions } from "./toast/toaster"; export { type TooltipProps, Tooltip } from "./tooltip/tooltip"; export { Tree, type TreeProps } from "./tree/tree"; diff --git a/packages/core/src/components/overlay/overlay.md b/packages/core/src/components/overlay/overlay.md index f4249c6812..14df046f11 100644 --- a/packages/core/src/components/overlay/overlay.md +++ b/packages/core/src/components/overlay/overlay.md @@ -1,23 +1,36 @@ @# Overlay -__Overlay__ is a generic low-level component for rendering content _on top of_ its siblings or above the entire +<div class="@ns-callout @ns-intent-danger @ns-icon-error @ns-callout-has-body-content"> + <h5 class="@ns-heading"> + +Deprecated: use [**Overlay2**](#core/components/overlay2) + +</h5> + +This component is **deprecated since @blueprintjs/core v5.9.0** in favor of the new +**Overlay2** component which is compatible with React 18 strict mode. You should migrate to the +new API which will become the standard in a future major version of Blueprint. + +</div> + +**Overlay** is a generic low-level component for rendering content _on top of_ its siblings or above the entire application. -It combines the functionality of the [__Portal__](#core/components/portal) component (which allows React elements to -escape their current DOM hierarchy) with a [__CSSTransitionGroup__](https://reactcommunity.org/react-transition-group/) +It combines the functionality of the [**Portal**](#core/components/portal) component (which allows React elements to +escape their current DOM hierarchy) with a [**CSSTransitionGroup**](https://reactcommunity.org/react-transition-group/) (to show elegant enter and leave transitions). An optional "backdrop" element can be rendered behind the overlaid children to provide modal behavior, whereby the overlay prevents interaction with anything behind it. -__Overlay__ is the backbone of all the components listed in the **Overlays** group in the sidebar. Using __Overlay__ +**Overlay** is the backbone of all the components listed in the **Overlays** group in the sidebar. Using **Overlay** directly should be rare in your application; it should only be necessary if no existing component meets your needs. @reactExample OverlayExample @## Usage -__Overlay__ is a controlled component that renders its children only when `isOpen={true}`. The optional backdrop element +**Overlay** is a controlled component that renders its children only when `isOpen={true}`. The optional backdrop element will be inserted before the children if `hasBackdrop={true}`. The `onClose` callback prop is invoked when user interaction causes the overlay to close, but your application is @@ -50,7 +63,7 @@ scrollable, so any overflowing content will be hidden. Fortunately, making an ov <Overlay className={Classes.OVERLAY_SCROLL_CONTAINER} /> ``` -Note that the [__Dialog__](https://blueprintjs.com/docs/#core/components/dialog) component applies this CSS class +Note that the [**Dialog**](https://blueprintjs.com/docs/#core/components/dialog) component applies this CSS class automatically. @## Props interface diff --git a/packages/core/src/components/overlay/overlay.tsx b/packages/core/src/components/overlay/overlay.tsx index c28a65697a..4efe4df06a 100644 --- a/packages/core/src/components/overlay/overlay.tsx +++ b/packages/core/src/components/overlay/overlay.tsx @@ -14,189 +14,24 @@ * limitations under the License. */ +/** + * @fileoverview This component is DEPRECATED, and the code is frozen. + * All changes & bugfixes should be made to Overlay2 instead. + */ + +/* eslint-disable deprecation/deprecation, @blueprintjs/no-deprecated-components */ + import classNames from "classnames"; 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 `<body>` 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<keyof HTMLElementEventMap>; - - /** - * 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<HTMLElement>) => 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<HTMLDivElement>; - - /** - * 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"; +import { getKeyboardFocusableElements } from "./overlayUtils"; export interface OverlayState { hasEverOpened?: boolean; @@ -205,6 +40,7 @@ export interface OverlayState { /** * Overlay component. * + * @deprecated use `Overlay2` instead * @see https://blueprintjs.com/docs/#core/components/overlay */ export class Overlay extends AbstractPureComponent<OverlayProps, OverlayState> { @@ -479,7 +315,7 @@ export class Overlay extends AbstractPureComponent<OverlayProps, OverlayState> { return; } if (e.shiftKey && e.key === "Tab") { - const lastFocusableElement = this.getKeyboardFocusableElements().pop(); + const lastFocusableElement = getKeyboardFocusableElements(this.containerElement).pop(); if (lastFocusableElement != null) { lastFocusableElement.focus(); } else { @@ -506,7 +342,7 @@ export class Overlay extends AbstractPureComponent<OverlayProps, OverlayState> { this.containerElement.current?.contains(e.relatedTarget as Element) && e.relatedTarget !== this.startFocusTrapElement.current ) { - const firstFocusableElement = this.getKeyboardFocusableElements().shift(); + const firstFocusableElement = getKeyboardFocusableElements(this.containerElement).shift(); // ensure we don't re-focus an already active element by comparing against e.relatedTarget if (!this.isAutoFocusing && firstFocusableElement != null && firstFocusableElement !== e.relatedTarget) { firstFocusableElement.focus(); @@ -514,7 +350,7 @@ export class Overlay extends AbstractPureComponent<OverlayProps, OverlayState> { this.startFocusTrapElement.current?.focus({ preventScroll: true }); } } else { - const lastFocusableElement = this.getKeyboardFocusableElements().pop(); + const lastFocusableElement = getKeyboardFocusableElements(this.containerElement).pop(); if (lastFocusableElement != null) { lastFocusableElement.focus(); } else { @@ -524,34 +360,6 @@ export class Overlay extends AbstractPureComponent<OverlayProps, OverlayState> { } }; - private getKeyboardFocusableElements() { - const focusableElements: HTMLElement[] = - this.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 - this.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), - ); - } - private overlayWillClose() { document.removeEventListener("focus", this.handleDocumentFocus, /* useCapture */ true); document.removeEventListener("mousedown", this.handleDocumentClick); 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 `<body>` 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<keyof HTMLElementEventMap>; + + /** + * 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<HTMLElement>) => 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<HTMLDivElement>; + + /** + * 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/overlay/overlayUtils.ts b/packages/core/src/components/overlay/overlayUtils.ts new file mode 100644 index 0000000000..aab21bca9f --- /dev/null +++ b/packages/core/src/components/overlay/overlayUtils.ts @@ -0,0 +1,49 @@ +/* + * 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 { OVERLAY_END_FOCUS_TRAP, OVERLAY_START_FOCUS_TRAP } from "../../common/classes"; +import { getRef } from "../../common/refs"; + +/** + * Returns the keyboard-focusable elements inside a given container element, ignoring focus traps + * rendered by Overlay/Overlay2. + */ +export function getKeyboardFocusableElements(container: HTMLElement | React.RefObject<HTMLElement>): HTMLElement[] { + const containerElement = getRef(container); + const focusableElements: HTMLElement[] = + containerElement != 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.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(OVERLAY_START_FOCUS_TRAP) && !el.classList.contains(OVERLAY_END_FOCUS_TRAP), + ); +} diff --git a/packages/core/src/components/overlay2/overlay2.md b/packages/core/src/components/overlay2/overlay2.md new file mode 100644 index 0000000000..58dd6d568d --- /dev/null +++ b/packages/core/src/components/overlay2/overlay2.md @@ -0,0 +1,174 @@ +--- +tag: new +--- + +@# Overlay2 + +<div class="@ns-callout @ns-intent-primary @ns-icon-info-sign @ns-callout-has-body-content"> + <h5 class="@ns-heading"> + +Migrating from [Overlay](#core/components/overlay)? + +</h5> + +**Overlay2** is a replacement for **Overlay**. It will become the standard API in a future major version of +Blueprint. You are encouraged to use this new API now for forwards-compatibility. See the full +[migration guide](https://github.com/palantir/blueprint/wiki/Overlay2-migration) on the wiki. + +</div> + +**Overlay2** is a generic low-level component for rendering content _on top of_ its siblings or +above the entire application. + +It combines the functionality of the [**Portal**](#core/components/portal) component (which allows +React elements to escape their current DOM hierarchy) with a +[**CSSTransitionGroup**](https://reactcommunity.org/react-transition-group/) +(to show elegant enter and leave transitions). + +An optional "backdrop" element can be rendered behind the overlaid children to provide modal +behavior, whereby the overlay prevents interaction with anything behind it. + +**Overlay2** is the backbone of all the components listed in the "Overlays" group in the sidebar. +Using **Overlay2** directly should be rare in your application; it should only be necessary if no +existing component meets your needs. + +@reactExample Overlay2Example + +@## Usage + +**Overlay2** is a controlled component that renders its children only when `isOpen={true}`. +The optional backdrop element will be inserted before the children if `hasBackdrop={true}`. + +The `onClose` callback prop is invoked when user interaction causes the overlay to close, but your +application is responsible for updating the state that actually closes the overlay. + +```tsx +import { useCallback, useState } from "react"; + +function Example() { + const [isOpen, setIsOpen] = useState(false); + const toggleOverlay = useCallback(() => setIsOpen(open => !open), [setIsOpen]); + + return ( + <div> + <Button text="Show overlay" onClick={toggleOverlay} /> + <Overlay2 isOpen={isOpen} onClose={toggleOverlay}> + Overlaid contents... + </Overlay2> + </div> + ); +} +``` + +@## DOM layout + +<div class="@ns-callout @ns-intent-primary @ns-icon-info-sign @ns-callout-has-body-content"> + <h5 class="@ns-heading">A note about overlay content positioning</h5> + +When rendered inline, content will automatically be set to `position: absolute` to respect +document flow. Otherwise, content will be set to `position: fixed` to cover the entire viewport. + +</div> + +Overlays rely on fixed and absolute CSS positioning. By default, an overlay larger than the viewport +will not be scrollable, so any overflowing content will be hidden. Fortunately, making an overlay +scrollable is very easy: pass `Classes.OVERLAY_SCROLL_CONTAINER` in the Overlay2 `className` prop, +and the component will take care of the rest. + +```tsx +<Overlay2 className={Classes.OVERLAY_SCROLL_CONTAINER} /> +``` + +Note that the [**Dialog**](https://blueprintjs.com/docs/#core/components/dialog) component applies +this modifier class automatically. + +@## Child refs + +<div class="@ns-callout @ns-intent-warning @ns-icon-warning-sign @ns-callout-has-body-content"> + <h5 class="@ns-heading">DOM ref(s) required</h5> + +Overlay2 needs to be able to attach DOM refs to its child elements, so the children of this +component _must be a native DOM element_ or utilize +[`React.forwardRef()`](https://reactjs.org/docs/forwarding-refs.html) to forward any +injected ref to the underlying DOM element. + +</div> + +**Overlay2** utilizes the react-transition-group library to declaratively configure "enter" and +"exit" transitions for its contents; it does so by individually wrapping its child nodes with +[**CSSTransition**](https://reactcommunity.org/react-transition-group/css-transition). This +third-party component requires a DOM ref to its child node in order to work correctly in React 18 +strict mode (where `ReactDOM.findDOMNode()` is not available). **Overlay2** can manage this ref for +you automatically in some cases, but it requires some user help to handle more advanced use cases: + +### Single child with automatic ref + +If you provide a _single_ child element to `<Overlay2>` and _do not_ set its `ref` prop, you +don't need to do anything. The component will generate a child ref and happily pass it along +to the underlying `<CSSTransition>`. + +```tsx +function Example() { + const [isOpen, setIsOpen] = React.useState<boolean>(true); + return ( + <Overlay2 isOpen={isOpen}> + <div>Contents</div> + </Overlay2> + ); +} +``` + +### Single child with manual ref + +If you provide a _single_ child element to `<Overlay2>` and _do_ set its `ref` prop, you must +pass the same ref to `<Overlay2 childRef={..}>`. + +```tsx +function Example() { + const [isOpen, setIsOpen] = React.useState<boolean>(true); + const myRef = React.useRef<HTMLElement>(); + + return ( + <Overlay2 isOpen={isOpen} childRef={myRef}> + <div ref={myRef}>Contents</div> + </Overlay2> + ); +} +``` + +### Multiple children + +If you provide _multiple_ child elements to `<Overlay2>`, you must enumerate a collection of +refs for each of those elements and pass those along as a record (keyed by the elements' +corresponding React `key` values) to `<Overlay2 childRefs={...}>`. + +```tsx +import { uniqueId } from "../utils"; + +function Example() { + const [isOpen, setIsOpen] = React.useState<boolean>(true); + const [childRefs, setChildRefs] = React.useState<Record<string, React.RefObject<HTMLDivElement>>>({}); + const [children, setChildren] = React.useState<Array<{ key: string }>>([]); + const addChild = React.useCallback(() => { + const newRef = React.createRef<HTMLDivElement>(); + const newKey = uniqueId(); + setChildren(oldChildren => [...oldChildren, { key: newKey }]); + setChildRefs(oldRefs => ({ ...oldRefs, [newKey]: newRef })); + }, []); + + return ( + <div> + <Button onClick={addChild}>Add child</Button> + <Overlay2 isOpen={isOpen} childRefs={childRefs}> + {children.map(child => ( + <div key={child.key} ref={childRefs[child.key]} /> + ))} + </Overlay2> + </div> + ); +} +``` + +@## Props interface + +@interface Overlay2Props diff --git a/packages/core/src/components/overlay2/overlay2.tsx b/packages/core/src/components/overlay2/overlay2.tsx new file mode 100644 index 0000000000..817b205157 --- /dev/null +++ b/packages/core/src/components/overlay2/overlay2.tsx @@ -0,0 +1,710 @@ +/* + * 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 { + OVERLAY_CHILD_REF_AND_REFS_MUTEX, + OVERLAY_CHILD_REQUIRES_KEY, + OVERLAY_WITH_MULTIPLE_CHILDREN_REQUIRES_CHILD_REFS, +} from "../../common/errors"; +import { DISPLAYNAME_PREFIX, type HTMLDivProps } from "../../common/props"; +import { + ensureElement, + getActiveElement, + getRef, + isEmptyString, + isNodeEnv, + isReactElement, + setRef, + uniqueId, +} from "../../common/utils"; +import { hasDOMEnvironment } from "../../common/utils/domUtils"; +import { usePrevious } from "../../hooks/usePrevious"; +import type { OverlayProps } from "../overlay/overlayProps"; +import { getKeyboardFocusableElements } from "../overlay/overlayUtils"; +import { Portal } from "../portal/portal"; + +// HACKHACK: ugly global state manipulation should move to a proper React context +const openStack: OverlayInstance[] = []; +const getLastOpened = () => openStack[openStack.length - 1]; + +/** + * HACKHACK: ugly global state manipulation should move to a proper React context + * + * @public for testing + * @internal + */ +export function resetOpenStack() { + openStack.splice(0, openStack.length); +} + +/** + * Public instance properties & methods for an overlay in the current overlay stack. + */ +export interface OverlayInstance { + /** Bring document focus inside this eoverlay element. */ + bringFocusInsideOverlay: () => void; + + /** Reference to the overlay container element which may or may not be in a Portal. */ + containerElement: React.RefObject<HTMLDivElement>; + + /** Document "focus" event handler which needs to be attached & detached appropriately. */ + handleDocumentFocus: (e: FocusEvent) => void; + + /** Unique ID for this overlay which helps to identify it across prop changes. */ + id: string | null; + + /** Subset of props necessary for some overlay stack focus management logic. */ + props: Pick<OverlayProps, "autoFocus" | "enforceFocus" | "usePortal" | "hasBackdrop">; +} + +export interface Overlay2Props extends OverlayProps, React.RefAttributes<OverlayInstance> { + /** + * If you provide a single child element to Overlay2 and attach your own `ref` to the node, you must pass the + * same value here (otherwise, Overlay2 won't be able to render CSSTransition correctly). + * + * Mutually exclusive with the `childRefs` prop. This prop is a shorthand for `childRefs={{ [key: string]: ref }}`. + */ + childRef?: React.RefObject<HTMLElement>; + + /** + * If you provide a _multiple child elements_ to Overlay2, you must enumerate and generate a + * collection of DOM refs to those elements and provide it here. The object's keys must correspond to the child + * React element `key` values. + * + * Mutually exclusive with the `childRef` prop. If you only provide a single child element, consider using + * `childRef` instead. + */ + childRefs?: Record<string, React.RefObject<HTMLElement>>; +} + +/** + * Overlay2 component. + * + * @see https://blueprintjs.com/docs/#core/components/overlay2 + */ +export const Overlay2: React.FC<Overlay2Props> = React.forwardRef<OverlayInstance, Overlay2Props>((props, ref) => { + const { + autoFocus, + backdropClassName, + backdropProps, + canEscapeKeyClose, + canOutsideClickClose, + childRef, + childRefs, + children, + className, + enforceFocus, + hasBackdrop, + isOpen, + lazy, + onClose, + onClosed, + onClosing, + onOpened, + onOpening, + portalClassName, + portalContainer, + shouldReturnFocusOnClose, + transitionDuration, + transitionName, + usePortal, + } = props; + + useOverlay2Validation(props); + + const [isAutoFocusing, setIsAutoFocusing] = React.useState(false); + const [hasEverOpened, setHasEverOpened] = React.useState(false); + const lastActiveElementBeforeOpened = React.useRef<Element>(null); + + /** Ref for container element, containing all children and the backdrop */ + const containerElement = React.useRef<HTMLDivElement>(null); + + /** Ref for backdrop element */ + const backdropElement = React.useRef<HTMLDivElement>(null); + + /* An empty, keyboard-focusable div at the beginning of the Overlay content */ + const startFocusTrapElement = React.useRef<HTMLDivElement>(null); + + /* An empty, keyboard-focusable div at the end of the Overlay content */ + const endFocusTrapElement = React.useRef<HTMLDivElement>(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. + */ + const localChildRef = React.useRef<HTMLElement>(null); + + const bringFocusInsideOverlay = React.useCallback(() => { + // always delay focus manipulation to just before repaint to prevent scroll jumping + return requestAnimationFrame(() => { + // container element may be undefined between component mounting and Portal rendering + // activeElement may be undefined in some rare cases in IE + const container = getRef(containerElement); + const activeElement = getActiveElement(container); + + if (container == null || activeElement == null || !isOpen) { + return; + } + + // Overlay2 is guaranteed to be mounted here + const isFocusOutsideModal = !container.contains(activeElement); + if (isFocusOutsideModal) { + getRef(startFocusTrapElement)?.focus({ preventScroll: true }); + setIsAutoFocusing(false); + } + }); + }, [isOpen]); + + /** + * 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; + const container = getRef(containerElement); + if ( + enforceFocus && + container != null && + eventTarget instanceof Node && + !container.contains(eventTarget as HTMLElement) + ) { + // prevent default focus behavior (sometimes auto-scrolls the page) + e.preventDefault(); + e.stopImmediatePropagation(); + bringFocusInsideOverlay(); + } + }, + [bringFocusInsideOverlay, enforceFocus], + ); + + const id = useOverlay2ID(); + const instance = React.useMemo<OverlayInstance>( + () => ({ + bringFocusInsideOverlay, + containerElement, + handleDocumentFocus, + id, + props: { + autoFocus, + enforceFocus, + hasBackdrop, + usePortal, + }, + }), + [autoFocus, bringFocusInsideOverlay, enforceFocus, handleDocumentFocus, hasBackdrop, id, usePortal], + ); + + React.useEffect(() => { + setRef(ref, 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: containerRef }) => { + // `elem` is the container of backdrop & content, so clicking directly on that container + // should not count as being "inside" the overlay. + const elem = getRef(containerRef); + return elem?.contains(eventTarget) && !elem.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<HTMLElement>) => { + 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) { + setIsAutoFocusing(true); + 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", 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); + } + + setRef(lastActiveElementBeforeOpened, getActiveElement(getRef(containerElement))); + }, [ + instance, + autoFocus, + enforceFocus, + canOutsideClickClose, + handleDocumentFocus, + hasBackdrop, + usePortal, + bringFocusInsideOverlay, + handleDocumentClick, + ]); + + const overlayWillClose = React.useCallback(() => { + document.removeEventListener("focus", handleDocumentFocus, /* useCapture */ true); + document.removeEventListener("mousedown", handleDocumentClick); + + const stackIndex = openStack.findIndex(o => + // fall back to container element ref + o.id != null ? o.id === id : o.containerElement.current === containerElement.current, + ); + 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, id]); + + const prevIsOpen = usePrevious(isOpen); + React.useEffect(() => { + if (isOpen) { + setHasEverOpened(true); + } + + if (!prevIsOpen && isOpen) { + // just opened + overlayWillOpen(); + } + + if (prevIsOpen && !isOpen) { + // just closed + overlayWillClose(); + } + }, [isOpen, overlayWillOpen, overlayWillClose, prevIsOpen]); + + // run once on unmount + // eslint-disable-next-line react-hooks/exhaustive-deps + React.useEffect(() => overlayWillClose, []); + + const handleTransitionExited = React.useCallback( + (node: HTMLElement) => { + const lastActiveElement = getRef(lastActiveElementBeforeOpened); + if (shouldReturnFocusOnClose && lastActiveElement instanceof HTMLElement) { + lastActiveElement.focus(); + } + onClosed?.(node); + }, + [onClosed, shouldReturnFocusOnClose], + ); + + // N.B. CSSTransition requires this callback to be defined, even if it's unused. + const handleTransitionAddEnd = React.useCallback(() => { + // 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 + */ + const getUserChildRef = React.useCallback( + (child: React.ReactNode) => { + if (childRef != null) { + return childRef; + } else if (childRefs != null) { + const key = (child as React.ReactElement).key; + if (key == null) { + if (!isNodeEnv("production")) { + console.error(OVERLAY_CHILD_REQUIRES_KEY); + } + return undefined; + } + return childRefs[key]; + } + return undefined; + }, + [childRef, childRefs], + ); + + const maybeRenderChild = React.useCallback( + (child: React.ReactNode | undefined) => { + if (child == null || isEmptyString(child)) { + return null; + } + + // decorate the child with a few injected props + const userChildRef = getUserChildRef(child); + const childProps = isReactElement(child) ? child.props : {}; + // if the child is a string, number, or fragment, it will be wrapped in a <span> element + const 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, + }); + const resolvedChildRef = userChildRef ?? localChildRef; + + return ( + <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 as React.RefObject<HTMLElement | undefined>} + onEntered={getLifecycleCallbackWithChildRef(onOpened, resolvedChildRef)} + onEntering={getLifecycleCallbackWithChildRef(onOpening, resolvedChildRef)} + onExited={getLifecycleCallbackWithChildRef(handleTransitionExited, resolvedChildRef)} + onExiting={getLifecycleCallbackWithChildRef(onClosing, resolvedChildRef)} + timeout={transitionDuration} + > + {decoratedChild} + </CSSTransition> + ); + }, + [ + autoFocus, + enforceFocus, + getUserChildRef, + handleTransitionAddEnd, + handleTransitionExited, + onClosing, + onOpened, + onOpening, + transitionDuration, + transitionName, + ], + ); + + const handleBackdropMouseDown = React.useCallback( + (e: React.MouseEvent<HTMLDivElement>) => { + if (canOutsideClickClose) { + onClose?.(e); + } + if (enforceFocus) { + bringFocusInsideOverlay(); + } + backdropProps?.onMouseDown?.(e); + }, + [backdropProps, bringFocusInsideOverlay, canOutsideClickClose, enforceFocus, onClose], + ); + + const renderDummyElement = React.useCallback( + (key: string, dummyElementProps: HTMLDivProps & { ref?: React.Ref<HTMLDivElement> }) => ( + <CSSTransition + addEndListener={handleTransitionAddEnd} + classNames={transitionName} + key={key} + nodeRef={dummyElementProps.ref} + timeout={transitionDuration} + unmountOnExit={true} + > + <div tabIndex={0} {...dummyElementProps} /> + </CSSTransition> + ), + [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<HTMLDivElement>) => { + 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. + const container = getRef(containerElement); + const endFocusTrap = getRef(endFocusTrapElement); + if ( + e.relatedTarget != null && + container?.contains(e.relatedTarget as Element) && + e.relatedTarget !== endFocusTrap + ) { + endFocusTrap?.focus({ preventScroll: true }); + } + }, + [enforceFocus, isAutoFocusing], + ); + + /** + * Wrap around to the end of the dialog if `enforceFocus` is enabled. + */ + const handleStartFocusTrapElementKeyDown = React.useCallback( + (e: React.KeyboardEvent<HTMLDivElement>) => { + if (!enforceFocus) { + return; + } + if (e.shiftKey && e.key === "Tab") { + const lastFocusableElement = getKeyboardFocusableElements(containerElement).pop(); + if (lastFocusableElement != null) { + lastFocusableElement.focus(); + } else { + getRef(endFocusTrapElement)?.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<HTMLDivElement>) => { + // 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. + const startFocusTrap = getRef(startFocusTrapElement); + if ( + e.relatedTarget != null && + getRef(containerElement)?.contains(e.relatedTarget as Element) && + e.relatedTarget !== startFocusTrap + ) { + const 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?.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 + startFocusTrap?.focus({ preventScroll: true }); + } + } + }, + [isAutoFocusing], + ); + + const maybeBackdrop = React.useMemo( + () => + hasBackdrop && isOpen ? ( + <CSSTransition + classNames={transitionName} + key="__backdrop" + nodeRef={backdropElement} + timeout={transitionDuration} + addEndListener={handleTransitionAddEnd} + > + <div + {...backdropProps} + className={classNames(Classes.OVERLAY_BACKDROP, backdropClassName, backdropProps?.className)} + onMouseDown={handleBackdropMouseDown} + ref={backdropElement} + /> + </CSSTransition> + ) : 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). + 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 = ( + <div + aria-live="polite" + className={classNames( + Classes.OVERLAY, + { + [Classes.OVERLAY_OPEN]: isOpen, + [Classes.OVERLAY_INLINE]: !usePortal, + }, + className, + )} + onKeyDown={handleContainerKeyDown} + ref={containerElement} + > + <TransitionGroup appear={true} component={null}> + {childrenWithTransitions} + </TransitionGroup> + </div> + ); + + if (usePortal) { + return ( + <Portal className={portalClassName} container={portalContainer}> + {transitionGroup} + </Portal> + ); + } else { + return transitionGroup; + } +}); +Overlay2.defaultProps = { + 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.displayName = `${DISPLAYNAME_PREFIX}.Overlay2`; + +function useOverlay2Validation({ childRef, childRefs, children }: Overlay2Props) { + const numChildren = React.Children.count(children); + React.useEffect(() => { + 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. + * + * N.B. unmounted overlays will have a `null` ID. + */ +function useOverlay2ID(): string | null { + const [id, setId] = React.useState<string | null>(null); + React.useEffect(() => { + setId(uniqueId(Overlay2.displayName!)); + }, []); + return 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: ((node: HTMLElement) => void) | undefined, + childRef: React.RefObject<HTMLElement> | undefined, +) { + return () => { + if (childRef?.current != null) { + callback?.(childRef.current); + } + }; +} diff --git a/packages/core/src/components/popover/popover.tsx b/packages/core/src/components/popover/popover.tsx index a5c4f56a3c..7d5831b295 100644 --- a/packages/core/src/components/popover/popover.tsx +++ b/packages/core/src/components/popover/popover.tsx @@ -36,7 +36,7 @@ import { Utils, } from "../../common"; import * as Errors from "../../common/errors"; -import { Overlay } from "../overlay/overlay"; +import { Overlay2 } from "../overlay2/overlay2"; import { ResizeSensor } from "../resize-sensor/resizeSensor"; // eslint-disable-next-line import/no-cycle import { Tooltip } from "../tooltip/tooltip"; @@ -205,6 +205,11 @@ export class Popover< */ public targetRef = React.createRef<HTMLElement>(); + /** + * Overlay transition container element ref. + */ + private transitionContainerElement = React.createRef<HTMLDivElement>(); + private cancelOpenTimeout?: () => void; // a flag that lets us detect mouse movement between the target and popover, @@ -351,7 +356,7 @@ export class Popover< targetTagName = "div"; } - // react-popper has a wide type for this ref, but we can narrow it based on the source + // N.B. react-popper has a wide type for this ref, but we can narrow it based on the source, // see https://github.com/floating-ui/react-popper/blob/beac280d61082852c4efc302be902911ce2d424c/src/Reference.js#L17 const ref = mergeRefs(popperChildRef as React.RefCallback<HTMLElement>, this.targetRef); @@ -498,12 +503,13 @@ export class Popover< : this.props.shouldReturnFocusOnClose; return ( - <Overlay + <Overlay2 autoFocus={autoFocus ?? defaultAutoFocus} backdropClassName={Classes.POPOVER_BACKDROP} backdropProps={backdropProps} canEscapeKeyClose={canEscapeKeyClose} canOutsideClickClose={interactionKind === PopoverInteractionKind.CLICK} + childRef={this.transitionContainerElement} enforceFocus={enforceFocus} hasBackdrop={hasBackdrop} isOpen={isOpen} @@ -522,7 +528,16 @@ export class Popover< portalStopPropagationEvents={this.props.portalStopPropagationEvents} shouldReturnFocusOnClose={shouldReturnFocusOnClose} > - <div className={Classes.POPOVER_TRANSITION_CONTAINER} ref={popperProps.ref} style={popperProps.style}> + <div + className={Classes.POPOVER_TRANSITION_CONTAINER} + // We need to attach a ref that notifies both react-popper and our Popover component about the DOM + // element inside the Overlay2. We cannot re-use `PopperChildrenProps.ref` because Overlay2 only + // accepts a ref object (not a callback) due to a CSSTransition API limitation. + // N.B. react-popper has a wide type for this ref, but we can narrow it based on the source, + // see https://github.com/floating-ui/react-popper/blob/beac280d61082852c4efc302be902911ce2d424c/src/Popper.js#L94 + ref={mergeRefs(popperProps.ref as React.RefCallback<HTMLElement>, this.transitionContainerElement)} + style={popperProps.style} + > <ResizeSensor onResize={this.reposition}> <div className={popoverClasses} @@ -537,7 +552,7 @@ export class Popover< </div> </ResizeSensor> </div> - </Overlay> + </Overlay2> ); }; @@ -661,9 +676,11 @@ export class Popover< private handleMouseLeave = (e: React.MouseEvent<HTMLElement>) => { this.isMouseInTargetOrPopover = false; - // wait until the event queue is flushed, because we want to leave the + // Wait until the event queue is flushed, because we want to leave the // popover open if the mouse entered the popover immediately after - // leaving the target (or vice versa). + // leaving the target (or vice versa). Make sure to persist the event since + // we need to access `nativeEvent` in `this.setOpenState()`. + e.persist(); this.setTimeout(() => { if (this.isMouseInTargetOrPopover) { return; diff --git a/packages/core/src/components/popover/popoverSharedProps.ts b/packages/core/src/components/popover/popoverSharedProps.ts index 24fcac63a2..b161e5bcb4 100644 --- a/packages/core/src/components/popover/popoverSharedProps.ts +++ b/packages/core/src/components/popover/popoverSharedProps.ts @@ -19,7 +19,7 @@ import type * as React from "react"; import type { StrictModifier } from "react-popper"; import type { Props } from "../../common"; -import type { OverlayableProps } from "../overlay/overlay"; +import type { OverlayableProps } from "../overlay/overlayProps"; import type { PopoverPosition } from "./popoverPosition"; @@ -221,7 +221,7 @@ export interface PopoverSharedProps<TProps extends DefaultPopoverTargetHTMLProps openOnTargetFocus?: boolean; /** - * Ref supplied to the `Classes.POPOVER` element. + * DOM ref attached to the `Classes.POPOVER` element. */ popoverRef?: React.Ref<HTMLElement>; diff --git a/packages/core/src/components/toast/overlayToaster.tsx b/packages/core/src/components/toast/overlayToaster.tsx index 33b3baa0d0..d2681f719b 100644 --- a/packages/core/src/components/toast/overlayToaster.tsx +++ b/packages/core/src/components/toast/overlayToaster.tsx @@ -26,15 +26,18 @@ import { TOASTER_WARN_INLINE, } from "../../common/errors"; import { DISPLAYNAME_PREFIX } from "../../common/props"; -import { isNodeEnv } from "../../common/utils"; -import { Overlay } from "../overlay/overlay"; +import { isElementOfType, isNodeEnv } from "../../common/utils"; +import { Overlay2 } from "../overlay2/overlay2"; import type { OverlayToasterProps } from "./overlayToasterProps"; -import { Toast, type ToastProps } from "./toast"; +import { Toast } from "./toast"; +import { Toast2 } from "./toast2"; import type { Toaster, ToastOptions } from "./toaster"; +import type { ToastProps } from "./toastProps"; export interface OverlayToasterState { toasts: ToastOptions[]; + toastRefs: Record<string, React.RefObject<HTMLElement>>; } export interface OverlayToasterCreateOptions { @@ -142,45 +145,57 @@ export class OverlayToaster extends AbstractPureComponent<OverlayToasterProps, O } public state: OverlayToasterState = { + toastRefs: {}, toasts: [], }; // auto-incrementing identifier for un-keyed toasts private toastId = 0; + private toastRefs: Record<string, React.RefObject<HTMLElement>> = {}; + + /** Compute a new collection of toast refs (usually after updating toasts) */ + private getToastRefs = (toasts: ToastOptions[]) => { + return toasts.reduce<typeof this.toastRefs>((refs, toast) => { + refs[toast.key!] = React.createRef<HTMLElement>(); + return refs; + }, {}); + }; + public show(props: ToastProps, key?: string) { if (this.props.maxToasts) { // check if active number of toasts are at the maxToasts limit this.dismissIfAtLimit(); } const options = this.createToastOptions(props, key); - if (key === undefined || this.isNewToastKey(key)) { - this.setState(prevState => ({ - toasts: [options, ...prevState.toasts], - })); - } else { - this.setState(prevState => ({ - toasts: prevState.toasts.map(t => (t.key === key ? options : t)), - })); - } + this.setState(prevState => { + const toasts = + key === undefined || this.isNewToastKey(key) + ? // prepend a new toast + [options, ...prevState.toasts] + : // update a specific toast + prevState.toasts.map(t => (t.key === key ? options : t)); + return { toasts, toastRefs: this.getToastRefs(toasts) }; + }); return options.key; } public dismiss(key: string, timeoutExpired = false) { - this.setState(({ toasts }) => ({ - toasts: toasts.filter(t => { + this.setState(prevState => { + const toasts = prevState.toasts.filter(t => { const matchesKey = t.key === key; if (matchesKey) { t.onDismiss?.(timeoutExpired); } return !matchesKey; - }), - })); + }); + return { toasts, toastRefs: this.getToastRefs(toasts) }; + }); } public clear() { this.state.toasts.forEach(t => t.onDismiss?.(false)); - this.setState({ toasts: [] }); + this.setState({ toasts: [], toastRefs: {} }); } public getToasts() { @@ -190,11 +205,12 @@ export class OverlayToaster extends AbstractPureComponent<OverlayToasterProps, O public render() { const classes = classNames(Classes.TOAST_CONTAINER, this.getPositionClasses(), this.props.className); return ( - <Overlay + <Overlay2 autoFocus={this.props.autoFocus} canEscapeKeyClose={this.props.canEscapeKeyClear} canOutsideClickClose={false} className={classes} + childRefs={this.toastRefs} enforceFocus={false} hasBackdrop={false} isOpen={this.state.toasts.length > 0 || this.props.children != null} @@ -206,8 +222,8 @@ export class OverlayToaster extends AbstractPureComponent<OverlayToasterProps, O usePortal={this.props.usePortal} > {this.state.toasts.map(this.renderToast, this)} - {this.props.children} - </Overlay> + {this.renderChildren()} + </Overlay2> ); } @@ -218,6 +234,27 @@ export class OverlayToaster extends AbstractPureComponent<OverlayToasterProps, O } } + /** + * If provided `Toast` children, automaticaly upgrade them to `Toast2` elements so that `Overlay` can inject + * refs into them for use by `CSSTransition`. This is a bit hacky but ensures backwards compatibility for + * `OverlayToaster`. It should be an uncommon code path in most applications, since we expect most usage to + * occur via the imperative toaster APIs. + * + * We can remove this indirection once `Toast2` fully replaces `Toast` in a future major version. + * + * TODO(@adidahiya): Blueprint v6.0 + */ + private renderChildren() { + return React.Children.map(this.props.children, child => { + // eslint-disable-next-line deprecation/deprecation + if (isElementOfType(child, Toast)) { + return <Toast2 {...child.props} />; + } else { + return child; + } + }); + } + private isNewToastKey(key: string) { return this.state.toasts.every(toast => toast.key !== key); } @@ -230,7 +267,7 @@ export class OverlayToaster extends AbstractPureComponent<OverlayToasterProps, O } private renderToast = (toast: ToastOptions) => { - return <Toast {...toast} onDismiss={this.getDismissHandler(toast)} />; + return <Toast2 {...toast} onDismiss={this.getDismissHandler(toast)} />; }; private createToastOptions(props: ToastProps, key = `toast-${this.toastId++}`) { diff --git a/packages/core/src/components/toast/toast.md b/packages/core/src/components/toast/toast.md index 60b96d9d28..edc30ff763 100644 --- a/packages/core/src/components/toast/toast.md +++ b/packages/core/src/components/toast/toast.md @@ -1,9 +1,9 @@ @# Toast -A __Toast__ is a lightweight, ephemeral notice from an application in direct response to a user's action. +A toast is a lightweight, ephemeral notice from an application in direct response to a user's action. -__Toasts__ can be configured to appear at either the top or the bottom of an application window, and it is possible to -have more than one toast onscreen at a time. +**Toasts** can be configured to appear at either the top or the bottom of an application window. +It is possible to show more than one toast on-screen at a time. @reactExample ToastExample @@ -11,44 +11,52 @@ have more than one toast onscreen at a time. @### Toast -__Toasts__ have a built-in timeout of five seconds. Users can also dismiss them manually by clicking the × button. -Hovering the cursor over a toast prevents it from disappearing. When the cursor leaves the toast, the toast's timeout restarts. -Similarly, focusing the toast (for example, by hitting the `tab` key) halts the timeout, and blurring restarts the timeout. +**Toasts** have a built-in timeout of five seconds. Users can also dismiss them manually by clicking +the × button. overing the cursor over a toast prevents it from disappearing. When the cursor +leaves the toast, the toast's timeout restarts. Similarly, focussing the toast DOM element (for +example, by hitting the `tab` key) halts the timeout, and blurring restarts the timeout. -You can add one additional action button to a toast. You might use this to provide an undo button, for example. +You may add one additional action button to a toast. You might use this to provide an undo button, +for example. -You can also apply the same visual intent styles to `Toast`s that you can to [`Button`s](#core/components/button.css). +You may also apply the same visual intents to **Toasts** as other core components like +[**Buttons**](#core/components/button.css). @interface ToastProps @### OverlayToaster -The __OverlayToaster__ component (previously named __Toaster__) is a stateful container for a single list of toasts. -Internally, it uses [__Overlay__](#core/components/overlay) to manage children and transitions. It can be vertically -aligned along the top or bottom edge of its container (new toasts will slide in from that edge) and -horizontally aligned along the left edge, center, or right edge of its container. +The **OverlayToaster** component (previously named **Toaster**) is a stateful container for a single +list of toasts. Internally, it uses [**Overlay2**](#core/components/overlay2) to manage children and +transitions. It can be vertically aligned along the top or bottom edge of its container (new toasts +will slide in from that edge) and horizontally aligned along the left edge, center, or right edge +of its container. -There are three ways to use __OverlayToaster__: +There are three ways to use **OverlayToaster**: + +1. **Recommended**: use the `OverlayToaster.createAsync()` static method to create a new `Toaster` instance: -1. __Recommended__: use the `OverlayToaster.createAsync()` static method to create a new `Toaster` instance: ```ts const myToaster: Toaster = await OverlayToaster.createAsync({ position: "bottom" }); myToaster.show({ ...toastOptions }); ``` - We recommend calling `OverlayToaster.createAsync` once in your application and [sharing the created instance](#core/components/toast.example) throughout your application. + We recommend calling `OverlayToaster.createAsync` once in your application and + [sharing the generated instance](#core/components/toast.example) throughout your application. - A synchronous `OverlayToaster.create()` static method is also available, but will be phased out since React 18+ no longer synchronously renders components to the DOM. + A synchronous `OverlayToaster.create()` static method is also available, but will be phased out + since React 18+ no longer synchronously renders components to the DOM. ```ts const myToaster: Toaster = OverlayToaster.create({ position: "bottom" }); myToaster.show({ ...toastOptions }); ``` -2. Render an `<OverlayToaster>` with `<Toast>` children: + +2. Render an `<OverlayToaster>` with `<Toast2>` children: ```ts render( <OverlayToaster> - <Toast {...toastOptions} /> + <Toast2 {...toastOptions} /> </OverlayToaster>, targetElement, ); @@ -68,28 +76,28 @@ There are three ways to use __OverlayToaster__: <div class="@ns-callout @ns-intent-primary @ns-icon-info-sign @ns-callout-has-body-content"> <h5 class="@ns-heading">Working with multiple toasters</h5> -You can have multiple toasters in a single application, but you must ensure that each has a unique `position` to -prevent overlap. +You can have multiple toasters in a single application, but you must ensure that each has a unique +`position` to prevent overlap. </div> <div class="@ns-callout @ns-intent-primary @ns-icon-info-sign @ns-callout-has-body-content"> <h5 class="@ns-heading">Toaster focus</h5> -__OverlayToaster__ always disables Overlay's `enforceFocus` behavior (meaning that you're not blocked +**OverlayToaster** always disables Overlay2's `enforceFocus` behavior (meaning that you're not blocked from accessing other parts of the application while a toast is active), and by default also disables `autoFocus` (meaning that focus will not switch to a toast when it appears). You can enable `autoFocus` for an individual `OverlayToaster` via a prop, if desired. </div> - @interface OverlayToasterProps @## Static usage -__OverlayToaster__ provides the static `createAsync` method that returns a new `Toaster`, rendered into an -element attached to `<body>`. A toaster instance has a collection of methods to show and hide toasts in its given container. +**OverlayToaster** provides the static `createAsync` method that returns a new `Toaster`, rendered +into an element attached to `<body>`. A toaster instance has a collection of methods to show and +hide toasts in its given container. ```ts OverlayToaster.createAsync(props?: OverlayToasterProps, options?: OverlayToasterCreateOptions): Promise<Toaster>; @@ -98,15 +106,16 @@ OverlayToaster.createAsync(props?: OverlayToasterProps, options?: OverlayToaster @interface OverlayToasterCreateOptions The toaster will be rendered into a new element appended to the given `container`. -The `container` determines which element toasts are positioned relative to; the default value of `<body>` allows them to use the entire viewport. +The `container` determines which element toasts are positioned relative to; the default value of +`<body>` allows them to use the entire viewport. -The return type is `Promise<Toaster>`, which is a minimal interface that exposes only the instance methods detailed -below. It can be thought of as `OverlayToaster` minus the `React.Component` methods, because the `OverlayToaster` should -not be treated as a normal React component. +The return type is `Promise<Toaster>`, which is a minimal interface that exposes only the instance +methods detailed below. It can be thought of as `OverlayToaster` minus the `React.Component` methods, +because the `OverlayToaster` should not be treated as a normal React component. -A promise is returned as React components cannot be rendered synchronously after React version 18. If this makes -`Toaster` usage difficult outside of a function that's not `async`, it's still possible to attach `.then()` handlers to -the returned toaster. +A promise is returned as React components cannot be rendered synchronously after React version 18. +If this makes `Toaster` usage difficult outside of a function that's not `async`, it's still +possible to attach `.then()` handlers to the returned toaster. ```ts function synchronousFn() { @@ -115,8 +124,8 @@ function synchronousFn() { } ``` -Note that `OverlayToaster.createAsync()` will throw an error if invoked inside a component lifecycle method, as -`ReactDOM.render()` will return `null` resulting in an inaccessible toaster instance. +Note that `OverlayToaster.createAsync()` will throw an error if invoked inside a component lifecycle +method, as `ReactDOM.render()` will return `null` resulting in an inaccessible toaster instance. <div class="@ns-callout @ns-intent-primary @ns-icon-info-sign @ns-callout-has-body-content"> <h5 class="@ns-heading">Beware of memory leaks</h5> @@ -167,7 +176,7 @@ export class App extends React.PureComponent { // create toasts in response to interactions. // in most cases, it's enough to simply create and forget (thanks to timeout). (await AppToaster).show({ message: "Toasted." }); - } + }; } ``` @@ -177,7 +186,9 @@ The example below uses the `OverlayToaster.createAsync()` static method. Clickin #### React 18 -To maintain backwards compatibility with React 16 and 17, `OverlayToaster.createAsync` uses `ReactDOM.render` out of the box. This triggers a [console warning on React 18](https://react.dev/blog/2022/03/08/react-18-upgrade-guide#updates-to-client-rendering-apis). A future major version of Blueprint will drop support for React versions before 18 and switch the default rendering function from `ReactDOM.render` to `createRoot`. +To maintain backwards compatibility with React 16 and 17, `OverlayToaster.createAsync` uses `ReactDOM.render` out of the box. This triggers a [console warning on React 18](https://react.dev/blog/2022/03/08/react-18-upgrade-guide#updates-to-client-rendering-apis). +A future major version of Blueprint will drop support for React versions before 18 and switch the +default rendering function from `ReactDOM.render` to `createRoot`. If you're using React 18, we recommend passing in a custom `domRenderer` function. @@ -191,44 +202,44 @@ const toaster = await OverlayToaster.createAsync(toasterProps, { domRenderer: (toaster, containerElement) => createRoot(containerElement).render(toaster), }); -toaster.show({ message: "Hello React 18!" }) +toaster.show({ message: "Hello React 18!" }); ``` - @## React component usage -Render the `<OverlayToaster>` component like any other element and supply `<Toast>` elements as `children`. You can -optionally attach a `ref` handler to access the instance methods, but we strongly recommend using the -[`OverlayToaster.create` static method](#core/components/toast.static-usage) documented above instead. Note that -`children` and `ref` can be used together, but `children` will always appear _after_ toasts created with -`ref.show()`. +Render the `<OverlayToaster>` component like any other element and supply `<Toast2>` elements as +`children`. You can optionally attach a `ref` handler to access the instance methods, but we +strongly recommend using the [`OverlayToaster.create` static method](#core/components/toast.static-usage) +documented above instead. Note that `children` and `ref` can be used together, but `children` will +always appear _after_ toasts created with `ref.show()`. ```tsx -import { Button, OverlayToaster, Position, Toast, Toaster } from "@blueprintjs/core"; +import { Button, OverlayToaster, Position, Toast2, ToastOptions } from "@blueprintjs/core"; import * as React from "react"; -class MyComponent extends React.PureComponent { - public state = { toasts: [ /* ToastProps[] */ ] } - - private toaster: Toaster; - private refHandlers = { - toaster: (ref: Toaster) => this.toaster = ref, - }; - - public render() { - return ( - <div> - <Button onClick={this.addToast} text="Procure toast" /> - <OverlayToaster position={Position.TOP_RIGHT} ref={this.refHandlers.toaster}> - {/* "Toasted!" will appear here after clicking button. */} - {this.state.toasts.map(toast => <Toast {...toast} />)} - </OverlayToaster> - </div> - ) - } - - private addToast = () => { - this.toaster.show({ message: "Toasted!" }); - } +function MyComponent() { + const [toasts, setToasts] = React.useState<ToastOptions[]>([]); + const toaster = React.useRef<OverlayToaster>(null); + + const addToastViaRef = React.useCallback(() => { + toaster.current?.show({ message: "Toasted!" }); + }, []); + + const addToastLocally = React.useCallback(() => { + setToasts(t => [...t, { key: "toasted", message: "Toasted!" }]); + }, []); + + return ( + <div> + <Button onClick={addToastViaRef} text="Procure toast remotely" /> + <Button onClick={addToastLocally} text="Procure toast locally" /> + <OverlayToaster position={Position.TOP_RIGHT} ref={toaster}> + {/* "Toasted!" will appear here after clicking button. */} + {toasts.map(toast => ( + <Toast2 key={toast.key} {...toast} /> + ))} + </OverlayToaster> + </div> + ); } ``` diff --git a/packages/core/src/components/toast/toast.tsx b/packages/core/src/components/toast/toast.tsx index 1a95768408..c517936b44 100644 --- a/packages/core/src/components/toast/toast.tsx +++ b/packages/core/src/components/toast/toast.tsx @@ -14,64 +14,30 @@ * limitations under the License. */ +/** + * @fileoverview This component is DEPRECATED, and the code is frozen. + * All changes & bugfixes should be made to Toast2 instead. + */ + +/* eslint-disable deprecation/deprecation, @blueprintjs/no-deprecated-components */ + import classNames from "classnames"; import * as React from "react"; import { Cross } from "@blueprintjs/icons"; import { AbstractPureComponent, Classes } from "../../common"; -import { - type ActionProps, - DISPLAYNAME_PREFIX, - type IntentProps, - type LinkProps, - type MaybeElement, - type Props, -} from "../../common/props"; +import { DISPLAYNAME_PREFIX } from "../../common/props"; import { ButtonGroup } from "../button/buttonGroup"; import { AnchorButton, Button } from "../button/buttons"; -import { Icon, type IconName } from "../icon/icon"; - -export interface ToastProps extends Props, IntentProps { - /** - * Action rendered as a minimal `AnchorButton`. The toast is dismissed automatically when the - * user clicks the action button. Note that the `intent` prop is ignored (the action button - * cannot have its own intent color that might conflict with the toast's intent). Omit this - * prop to omit the action button. - */ - action?: ActionProps & LinkProps; - - /** Name of a Blueprint UI icon (or an icon element) to render before the message. */ - icon?: IconName | MaybeElement; - - /** - * Whether to show the close button in the toast. - * - * @default true - */ - isCloseButtonShown?: boolean; - - /** Message to display in the body of the toast. */ - message: React.ReactNode; - - /** - * Callback invoked when the toast is dismissed, either by the user or by the timeout. - * The value of the argument indicates whether the toast was closed because the timeout expired. - */ - onDismiss?: (didTimeoutExpire: boolean) => void; - - /** - * Milliseconds to wait before automatically dismissing toast. - * Providing a value less than or equal to 0 will disable the timeout (this is discouraged). - * - * @default 5000 - */ - timeout?: number; -} +import { Icon } from "../icon/icon"; + +import type { ToastProps } from "./toastProps"; /** * Toast component. * + * @deprecated use `Toast2` instead, which forwards DOM refs and is thus compatible with `Overlay2`. * @see https://blueprintjs.com/docs/#core/components/toast */ export class Toast extends AbstractPureComponent<ToastProps> { diff --git a/packages/core/src/components/toast/toast2.tsx b/packages/core/src/components/toast/toast2.tsx new file mode 100644 index 0000000000..d1c08212ca --- /dev/null +++ b/packages/core/src/components/toast/toast2.tsx @@ -0,0 +1,109 @@ +/* + * 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 { Cross } from "@blueprintjs/icons"; + +import { Classes } from "../../common"; +import { DISPLAYNAME_PREFIX } from "../../common/props"; +import { useTimeout } from "../../hooks/useTimeout"; +import { ButtonGroup } from "../button/buttonGroup"; +import { AnchorButton, Button } from "../button/buttons"; +import { Icon } from "../icon/icon"; + +import type { ToastProps } from "./toastProps"; + +/** + * Toast2 component. + * + * Compared to the deprecated `Toast` component, this is a function component which forwards DOM + * refs and is thus compatible with `Overlay2`. + * + * @see https://blueprintjs.com/docs/#core/components/toast2 + */ +export const Toast2 = React.forwardRef<HTMLDivElement, ToastProps>((props, ref) => { + const { action, className, icon, intent, isCloseButtonShown, message, onDismiss, timeout } = props; + + const [isTimeoutStarted, setIsTimeoutStarted] = React.useState(false); + const startTimeout = React.useCallback(() => setIsTimeoutStarted(true), []); + const clearTimeout = React.useCallback(() => setIsTimeoutStarted(false), []); + + // timeout is triggered & cancelled by updating `isTimeoutStarted` state + useTimeout( + () => { + triggerDismiss(true); + }, + isTimeoutStarted && timeout !== undefined ? timeout : null, + ); + + // start timeout on mount or change, cancel on unmount + React.useEffect(() => { + if (timeout != null && timeout > 0) { + startTimeout(); + } else { + clearTimeout(); + } + return clearTimeout; + }, [clearTimeout, startTimeout, timeout]); + + const triggerDismiss = React.useCallback( + (didTimeoutExpire: boolean) => { + clearTimeout(); + onDismiss?.(didTimeoutExpire); + }, + [clearTimeout, onDismiss], + ); + + const handleCloseClick = React.useCallback(() => triggerDismiss(false), [triggerDismiss]); + + const handleActionClick = React.useCallback( + (e: React.MouseEvent<HTMLElement>) => { + action?.onClick?.(e); + triggerDismiss(false); + }, + [action, triggerDismiss], + ); + + return ( + <div + className={classNames(Classes.TOAST, Classes.intentClass(intent), className)} + onBlur={startTimeout} + onFocus={clearTimeout} + onMouseEnter={clearTimeout} + onMouseLeave={startTimeout} + ref={ref} + tabIndex={0} + > + <Icon icon={icon} /> + <span className={Classes.TOAST_MESSAGE} role="alert"> + {message} + </span> + <ButtonGroup minimal={true}> + {action && <AnchorButton {...action} intent={undefined} onClick={handleActionClick} />} + {isCloseButtonShown && <Button aria-label="Close" icon={<Cross />} onClick={handleCloseClick} />} + </ButtonGroup> + </div> + ); +}); +Toast2.defaultProps = { + className: "", + isCloseButtonShown: true, + message: "", + timeout: 5000, +}; +Toast2.displayName = `${DISPLAYNAME_PREFIX}.Toast2`; diff --git a/packages/core/src/components/toast/toastProps.ts b/packages/core/src/components/toast/toastProps.ts new file mode 100644 index 0000000000..63cc02577d --- /dev/null +++ b/packages/core/src/components/toast/toastProps.ts @@ -0,0 +1,55 @@ +/* + * 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 { ActionProps, IntentProps, LinkProps, MaybeElement, Props } from "../../common/props"; +import type { IconName } from "../icon/icon"; + +export interface ToastProps extends Props, IntentProps { + /** + * Action rendered as a minimal `AnchorButton`. The toast is dismissed automatically when the + * user clicks the action button. Note that the `intent` prop is ignored (the action button + * cannot have its own intent color that might conflict with the toast's intent). Omit this + * prop to omit the action button. + */ + action?: ActionProps & LinkProps; + + /** Name of a Blueprint UI icon (or an icon element) to render before the message. */ + icon?: IconName | MaybeElement; + + /** + * Whether to show the close button in the toast. + * + * @default true + */ + isCloseButtonShown?: boolean; + + /** Message to display in the body of the toast. */ + message: React.ReactNode; + + /** + * Callback invoked when the toast is dismissed, either by the user or by the timeout. + * The value of the argument indicates whether the toast was closed because the timeout expired. + */ + onDismiss?: (didTimeoutExpire: boolean) => void; + + /** + * Milliseconds to wait before automatically dismissing toast. + * Providing a value less than or equal to 0 will disable the timeout (this is discouraged). + * + * @default 5000 + */ + timeout?: number; +} diff --git a/packages/core/src/components/toast/toaster.ts b/packages/core/src/components/toast/toaster.ts index 45c609d355..e0d46d9a0d 100644 --- a/packages/core/src/components/toast/toaster.ts +++ b/packages/core/src/components/toast/toaster.ts @@ -16,7 +16,7 @@ import { OverlayToaster } from "./overlayToaster"; import type { OverlayToasterProps } from "./overlayToasterProps"; -import type { ToastProps } from "./toast"; +import type { ToastProps } from "./toastProps"; export type ToastOptions = ToastProps & { key: string }; /** Instance methods available on a toaster component instance. */ diff --git a/packages/core/src/hooks/useIsomorphicLayoutEffect.ts b/packages/core/src/hooks/useIsomorphicLayoutEffect.ts new file mode 100644 index 0000000000..a52a793e64 --- /dev/null +++ b/packages/core/src/hooks/useIsomorphicLayoutEffect.ts @@ -0,0 +1,21 @@ +/* + * 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 * as React from "react"; + +import { hasDOMEnvironment } from "../common/utils/domUtils"; + +export const useIsomorphicLayoutEffect = hasDOMEnvironment() ? React.useLayoutEffect : React.useEffect; diff --git a/packages/core/src/hooks/useTimeout.ts b/packages/core/src/hooks/useTimeout.ts new file mode 100644 index 0000000000..7bb7e6487c --- /dev/null +++ b/packages/core/src/hooks/useTimeout.ts @@ -0,0 +1,48 @@ +/* + * 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 * as React from "react"; + +import { useIsomorphicLayoutEffect } from "./useIsomorphicLayoutEffect"; + +/** + * React hook wrapper for setTimeout(), adapted from usehooks-ts. + * The provided callback is invoked after the specified delay in milliseconds. + * If the delay is null or the component is unmounted, any pending timeout is cleared. + * + * @see https://usehooks-ts.com/react-hook/use-timeout + */ +export function useTimeout(callback: () => void, delay: number | null) { + const savedCallback = React.useRef(callback); + + // remember the latest callback if it changes + useIsomorphicLayoutEffect(() => { + savedCallback.current = callback; + }, [callback]); + + // set up the timeout + React.useEffect(() => { + // Don't schedule if no delay is specified. + // Note: 0 is a valid value for delay. + if (!delay && delay !== 0) { + return; + } + + const id = setTimeout(() => savedCallback.current(), delay); + + return () => clearTimeout(id); + }, [delay]); +} diff --git a/packages/core/src/legacy/contextMenuLegacy.tsx b/packages/core/src/legacy/contextMenuLegacy.tsx index f943937c34..6e6c83b787 100644 --- a/packages/core/src/legacy/contextMenuLegacy.tsx +++ b/packages/core/src/legacy/contextMenuLegacy.tsx @@ -26,7 +26,7 @@ import * as React from "react"; import * as ReactDOM from "react-dom"; import { AbstractPureComponent, Classes } from "../common"; -import type { OverlayLifecycleProps } from "../components/overlay/overlay"; +import type { OverlayLifecycleProps } from "../components/overlay/overlayProps"; import { Popover } from "../components/popover/popover"; export interface Offset { diff --git a/packages/core/test/dialog/dialogTests.tsx b/packages/core/test/dialog/dialogTests.tsx index 2ec8ef5bee..b18de8168b 100644 --- a/packages/core/test/dialog/dialogTests.tsx +++ b/packages/core/test/dialog/dialogTests.tsx @@ -193,7 +193,7 @@ describe("<Dialog>", () => { setTimeout(() => { assert.isTrue(containerRef.current?.classList.contains(Classes.DIALOG_CONTAINER)); done(); - }, 0); + }); }); }); diff --git a/packages/core/test/index.ts b/packages/core/test/index.ts index 9cbec22898..cd904db70d 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"; @@ -78,6 +79,7 @@ import "./tag/tagTests"; import "./text/textTests"; import "./toast/overlayToasterTests"; import "./toast/toastTests"; +import "./toast/toast2Tests"; import "./toast/toasterTests"; import "./tooltip/tooltipTests"; import "./tree/treeTests"; diff --git a/packages/core/test/isotest.mjs b/packages/core/test/isotest.mjs index e5916fc94c..e9309c7c8b 100644 --- a/packages/core/test/isotest.mjs +++ b/packages/core/test/isotest.mjs @@ -30,7 +30,7 @@ describe("@blueprintjs/core isomorphic rendering", () => { Core, { Alert: { - props: { isOpen: true, usePortal: false }, + props: { isOpen: true, lazy: false, usePortal: false }, }, Breadcrumbs: { props: { items: [] }, @@ -39,10 +39,10 @@ describe("@blueprintjs/core isomorphic rendering", () => { props: { children: React.createElement("div"), content: React.createElement("div") }, }, Dialog: { - props: { isOpen: true, usePortal: false }, + props: { isOpen: true, lazy: false, usePortal: false }, }, Drawer: { - props: { isOpen: true, usePortal: false }, + props: { isOpen: true, lazy: false, usePortal: false }, }, Hotkey: { props: EXAMPLE_HOTKEY_CONFIG, @@ -54,6 +54,7 @@ describe("@blueprintjs/core isomorphic rendering", () => { props: { hotkeys: [EXAMPLE_HOTKEY_CONFIG], isOpen: true, + lazy: false, usePortal: false, }, }, @@ -71,7 +72,7 @@ describe("@blueprintjs/core isomorphic rendering", () => { props: { icon: "build" }, }, MultistepDialog: { - props: { isOpen: true, usePortal: false }, + props: { isOpen: true, lazy: false, usePortal: false }, children: React.createElement(Core.DialogStep, { key: 1, id: 1, @@ -88,9 +89,12 @@ describe("@blueprintjs/core isomorphic rendering", () => { Overlay: { props: { lazy: false, usePortal: false }, }, + Overlay2: { + props: { lazy: false, usePortal: false }, + }, OverlayToaster: { props: { usePortal: false }, - children: React.createElement(Core.Toast, { message: "Toast" }), + children: React.createElement(Core.Toast2, { message: "Toast" }), }, PanelStack: { props: { diff --git a/packages/core/test/overlay/overlayTests.tsx b/packages/core/test/overlay/overlayTests.tsx index 446de2fd92..d80ce37c4f 100644 --- a/packages/core/test/overlay/overlayTests.tsx +++ b/packages/core/test/overlay/overlayTests.tsx @@ -14,6 +14,13 @@ * limitations under the License. */ +/** + * @fileoverview This component is DEPRECATED, and the code is frozen. + * All changes & bugfixes should be made to Overlay2 instead. + */ + +/* eslint-disable deprecation/deprecation, @blueprintjs/no-deprecated-components */ + import { assert } from "chai"; import { mount, type ReactWrapper, shallow } from "enzyme"; import * as React from "react"; diff --git a/packages/core/test/overlay2/overlay2-test-debugging.scss b/packages/core/test/overlay2/overlay2-test-debugging.scss new file mode 100644 index 0000000000..ec47250b9c --- /dev/null +++ b/packages/core/test/overlay2/overlay2-test-debugging.scss @@ -0,0 +1,10 @@ +// Copyright 2024 Palantir Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. + +@import "@blueprintjs/colors/lib/scss/colors"; +@import "@blueprintjs/core/src/common/variables"; + +body { + min-width: 500px; + min-height: 500px; +} diff --git a/packages/core/test/overlay2/overlay2Tests.tsx b/packages/core/test/overlay2/overlay2Tests.tsx new file mode 100644 index 0000000000..323e76a87d --- /dev/null +++ b/packages/core/test/overlay2/overlay2Tests.tsx @@ -0,0 +1,611 @@ +/* + * 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 } 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 { resetOpenStack } from "../../src/components/overlay2/overlay2"; +import { findInPortal } from "../utils"; + +import "./overlay2-test-debugging.scss"; + +const BACKDROP_SELECTOR = `.${Classes.OVERLAY_BACKDROP}`; + +/* + * IMPORTANT NOTE: It is critical that every <Overlay2> 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("<Overlay2>", () => { + let wrapper: ReactWrapper<OverlayProps, any>; + 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 = mountWrapper( + <Overlay2 isOpen={true} usePortal={false}> + {createOverlayContents()} + </Overlay2>, + ); + assert.lengthOf(overlay.find("strong"), 1); + assert.lengthOf(overlay.find(BACKDROP_SELECTOR), 1); + }); + + it("renders contents to specified container correctly", () => { + const CLASS_TO_TEST = "bp-test-content"; + const container = document.createElement("div"); + document.body.appendChild(container); + mountWrapper( + <Overlay2 isOpen={true} portalContainer={container}> + <p className={CLASS_TO_TEST}>test</p> + </Overlay2>, + ); + 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(<Overlay2 className="aria-test" isOpen={true} usePortal={false} />); + 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( + <Overlay2 isOpen={true} portalClassName={CLASS_TO_TEST}> + <p>test</p> + </Overlay2>, + ); + // search document for portal container element. + assert.isDefined(document.querySelector(`.${Classes.PORTAL}.${CLASS_TO_TEST}`)); + }); + + it("renders Portal after first opened", () => { + mountWrapper(<Overlay2 isOpen={false}>{createOverlayContents()}</Overlay2>); + assert.lengthOf(wrapper.find(Portal), 0, "unexpected Portal"); + wrapper.setProps({ isOpen: true }).update(); + assert.lengthOf(wrapper.find(Portal), 1, "expected Portal"); + }); + + it("supports non-element children", () => { + assert.doesNotThrow(() => { + mountWrapper( + <Overlay2 isOpen={true} usePortal={false}> + {null} {undefined} + </Overlay2>, + ); + }); + }); + + it("hasBackdrop=false does not render backdrop", () => { + const overlay = mountWrapper( + <Overlay2 hasBackdrop={false} isOpen={true} usePortal={false}> + {createOverlayContents()} + </Overlay2>, + ); + assert.lengthOf(overlay.find("strong"), 1); + assert.lengthOf(overlay.find(BACKDROP_SELECTOR), 0); + }); + + it("renders portal attached to body when not inline after first opened", () => { + mountWrapper(<Overlay2 isOpen={false}>{createOverlayContents()}</Overlay2>); + assert.lengthOf(wrapper.find(Portal), 0, "unexpected Portal"); + wrapper.setProps({ isOpen: true }).update(); + assert.lengthOf(wrapper.find(Portal), 1, "expected Portal"); + }); + + describe("onClose", () => { + it("invoked on backdrop mousedown when canOutsideClickClose=true", () => { + const onClose = spy(); + const overlay = mountWrapper( + <Overlay2 canOutsideClickClose={true} isOpen={true} onClose={onClose} usePortal={false}> + {createOverlayContents()} + </Overlay2>, + ); + overlay.find(BACKDROP_SELECTOR).simulate("mousedown"); + assert.isTrue(onClose.calledOnce); + }); + + it("not invoked on backdrop mousedown when canOutsideClickClose=false", () => { + const onClose = spy(); + const overlay = mountWrapper( + <Overlay2 canOutsideClickClose={false} isOpen={true} onClose={onClose} usePortal={false}> + {createOverlayContents()} + </Overlay2>, + ); + overlay.find(BACKDROP_SELECTOR).simulate("mousedown"); + assert.isTrue(onClose.notCalled); + }); + + it("invoked on document mousedown when hasBackdrop=false", () => { + const onClose = spy(); + // mounting cuz we need document events + lifecycle + mountWrapper( + <Overlay2 hasBackdrop={false} isOpen={true} onClose={onClose} usePortal={false}> + {createOverlayContents()} + </Overlay2>, + ); + + dispatchMouseEvent(document.documentElement, "mousedown"); + assert.isTrue(onClose.calledOnce); + }); + + it("not invoked on document mousedown when hasBackdrop=false and canOutsideClickClose=false", () => { + const onClose = spy(); + mountWrapper( + <Overlay2 + canOutsideClickClose={false} + hasBackdrop={false} + isOpen={true} + onClose={onClose} + usePortal={false} + > + {createOverlayContents()} + </Overlay2>, + ); + + dispatchMouseEvent(document.documentElement, "mousedown"); + assert.isTrue(onClose.notCalled); + }); + + it("not invoked on click of a nested overlay", () => { + const onClose = spy(); + mountWrapper( + <Overlay2 isOpen={true} onClose={onClose}> + <div id="outer-element"> + {createOverlayContents()} + <Overlay2 isOpen={true}> + <div id="inner-element">{createOverlayContents()}</div> + </Overlay2> + </div> + </Overlay2>, + ); + // 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( + <Overlay2 isOpen={true} onClose={onClose} usePortal={false}> + {createOverlayContents()} + </Overlay2>, + ); + wrapper.simulate("keydown", { key: "Escape" }); + assert.isTrue(onClose.calledOnce); + }); + + it("not invoked on escape key when canEscapeKeyClose=false", () => { + const onClose = spy(); + const overlay = mountWrapper( + <Overlay2 canEscapeKeyClose={false} isOpen={true} onClose={onClose} usePortal={false}> + {createOverlayContents()} + </Overlay2>, + ); + overlay.simulate("keydown", { key: "Escape" }); + assert.isTrue(onClose.notCalled); + }); + + it("renders portal attached to body when not inline", () => { + const overlay = mountWrapper( + <Overlay2 isOpen={true} usePortal={true}> + {createOverlayContents()} + </Overlay2>, + ); + const portal = overlay.find(Portal); + assert.isTrue(portal.exists(), "missing Portal"); + assert.lengthOf(portal.find("strong"), 1, "missing h1"); + }); + }); + + describe("Focus management", () => { + const overlayClassName = "test-overlay"; + + it("brings focus to overlay if autoFocus=true", done => { + mountWrapper( + <Overlay2 className={overlayClassName} autoFocus={true} isOpen={true} usePortal={true}> + <input type="text" /> + </Overlay2>, + ); + assertFocusIsInOverlayWithTimeout(done); + }); + + it("does not bring focus to overlay if autoFocus=false and enforceFocus=false", done => { + mountWrapper( + <div> + <button>something outside overlay for browser to focus on</button> + <Overlay2 + className={overlayClassName} + autoFocus={false} + enforceFocus={false} + isOpen={true} + usePortal={true} + > + <input type="text" /> + </Overlay2> + </div>, + ); + 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( + <Overlay2 className={overlayClassName} isOpen={true} usePortal={true}> + <input autoFocus={true} type="text" /> + </Overlay2>, + ); + assertFocusWithTimeout("input", done); + }); + + it("returns focus to overlay if enforceFocus=true", done => { + const buttonRef = React.createRef<HTMLButtonElement>(); + const inputRef = React.createRef<HTMLInputElement>(); + mountWrapper( + <div> + <button ref={buttonRef} /> + <Overlay2 className={overlayClassName} enforceFocus={true} isOpen={true} usePortal={true}> + <div> + <input autoFocus={true} ref={inputRef} /> + </div> + </Overlay2> + </div>, + ); + 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( + <Overlay2 + className={overlayClassName} + enforceFocus={true} + canOutsideClickClose={false} + isOpen={true} + usePortal={false} + > + {createOverlayContents()} + </Overlay2>, + ); + wrapper.find(BACKDROP_SELECTOR).simulate("mousedown"); + assertFocusIsInOverlayWithTimeout(done); + }); + + it("returns focus to overlay after clicking an outside element if enforceFocus=true", done => { + mountWrapper( + <div> + <Overlay2 + enforceFocus={true} + canOutsideClickClose={false} + className={overlayClassName} + isOpen={true} + usePortal={false} + hasBackdrop={false} + > + {createOverlayContents()} + </Overlay2> + <button id="buttonId" /> + </div>, + ); + 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<OverlayInstance>(); + const anotherContainer = document.createElement("div"); + document.documentElement.appendChild(anotherContainer); + const temporaryWrapper = mount( + <Overlay2 + className={overlayClassName} + enforceFocus={true} + isOpen={true} + usePortal={false} + ref={instanceRef} + > + <input type="text" /> + </Overlay2>, + { attachTo: anotherContainer }, + ); + + mountWrapper( + <Overlay2 className={overlayClassName} enforceFocus={true} isOpen={false} usePortal={false}> + <input id="inputId" type="text" /> + </Overlay2>, + ); + + assert.isNotNull(instanceRef.current, "ref should be set"); + + wrapper.setProps({ isOpen: true }).update(); + // potentially triggers infinite recursion if both overlays try to bring focus back to themselves + wrapper.find("#inputId").simulate("click").update(); + // previous test suites for Overlay spied on bringFocusInsideOverlay and asserted it was called once here, + // but that is more difficult to test with function components and breaches the abstraction of Overlay2. + + 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( + <div> + <button ref={ref => (buttonRef = ref)} /> + <Overlay2 className={overlayClassName} enforceFocus={false} isOpen={true} usePortal={true}> + <div> + <input ref={ref => ref && setTimeout(focusBtnAndAssert)} /> + </div> + </Overlay2> + </div>, + ); + }); + + it("doesn't focus overlay if focus is already inside overlay", done => { + let textarea: HTMLTextAreaElement | null; + mountWrapper( + <Overlay2 className={overlayClassName} isOpen={true} usePortal={true}> + <div> + <textarea ref={ref => (textarea = ref)} /> + </div> + </Overlay2>, + ); + textarea!.focus(); + assertFocusWithTimeout("textarea", done); + }); + + it("does not focus overlay when closed", done => { + mountWrapper( + <div> + <button ref={ref => ref && ref.focus()} /> + <Overlay2 className={overlayClassName} isOpen={false} usePortal={true} /> + </div>, + ); + assertFocusWithTimeout("button", done); + }); + + it("does not crash while trying to return focus to overlay if user clicks outside the document", () => { + mountWrapper( + <Overlay2 + className={overlayClassName} + enforceFocus={true} + canOutsideClickClose={false} + isOpen={true} + usePortal={false} + > + {createOverlayContents()} + </Overlay2>, + ); + + // this is a fairly custom / nonstandard event dispatch, trying to simulate what happens in some browsers when a user clicks + // on the browser toolbar (outside the document), but a focus event is still dispatched to document + // see https://github.com/palantir/blueprint/issues/3928 + const event = new FocusEvent("focus"); + Object.defineProperty(event, "target", { value: window }); + + try { + document.dispatchEvent(event); + } catch (e) { + assert.fail("threw uncaught error"); + } + }); + + function assertFocusWithTimeout(selector: string | (() => void), done: Mocha.Done) { + // the behavior being tested relies on requestAnimationFrame. + // setTimeout for a few frames later to let things settle (to reduce flakes). + setTimeout(() => { + wrapper.update(); + if (Utils.isFunction(selector)) { + selector(); + } else { + assert.strictEqual(document.querySelector(selector), document.activeElement); + } + done(); + }, 40); + } + + function assertFocusIsInOverlayWithTimeout(done: Mocha.Done) { + assertFocusWithTimeout(() => { + const overlayElement = document.querySelector(`.${overlayClassName}`); + assert.isTrue(overlayElement?.contains(document.activeElement)); + }, done); + } + }); + + describe("Background scrolling", () => { + beforeEach(() => { + // force-reset Overlay2 stack state between tests + resetOpenStack(); + document.body.classList.remove(Classes.OVERLAY_OPEN); + }); + + describe("upon mount", () => { + it("disables document scrolling by default", done => { + wrapper = mountWrapper(renderBackdropOverlay()); + assertBodyScrollingDisabled(true, done); + }); + + it("disables document scrolling if hasBackdrop=true and usePortal=true", done => { + wrapper = mountWrapper(renderBackdropOverlay(true, true)); + assertBodyScrollingDisabled(true, done); + }); + + it("does not disable document scrolling if hasBackdrop=true and usePortal=false", done => { + wrapper = mountWrapper(renderBackdropOverlay(true, false)); + assertBodyScrollingDisabled(false, done); + }); + + it("does not disable document scrolling if hasBackdrop=false and usePortal=true", done => { + wrapper = mountWrapper(renderBackdropOverlay(false, true)); + assertBodyScrollingDisabled(false, done); + }); + + it("does not disable document scrolling if hasBackdrop=false and usePortal=false", done => { + wrapper = mountWrapper(renderBackdropOverlay(false, false)); + assertBodyScrollingDisabled(false, done); + }); + }); + + describe("after closing", () => { + it("restores body scrolling", done => { + const handleClosed = () => { + assert.isFalse( + wrapper.getDOMNode().classList.contains(Classes.OVERLAY_OPEN), + `expected overlay element to not have ${Classes.OVERLAY_OPEN} class`, + ); + assertBodyScrollingDisabled(false, done); + }; + + wrapper = mountWrapper( + <Overlay2 isOpen={true} usePortal={true} onClosed={handleClosed} transitionDuration={0}> + {createOverlayContents()} + </Overlay2>, + ); + wrapper.setProps({ isOpen: false }); + }); + }); + + describe("after unmount", () => { + it("keeps scrolling disabled if some overlay with hasBackdrop=true exists", done => { + const otherOverlayWithBackdrop = mount(renderBackdropOverlay(true)); + wrapper = mountWrapper(renderBackdropOverlay(true)); + otherOverlayWithBackdrop.unmount(); + + assertBodyScrollingDisabled(true, done); + }); + + it("doesn't keep scrolling disabled if no overlay exists with hasBackdrop=true", done => { + const otherOverlayWithBackdrop = mount(renderBackdropOverlay(true)); + wrapper = mountWrapper(renderBackdropOverlay(false)); + otherOverlayWithBackdrop.unmount(); + + assertBodyScrollingDisabled(false, done); + }); + }); + + function renderBackdropOverlay(hasBackdrop?: boolean, usePortal?: boolean) { + return ( + <Overlay2 hasBackdrop={hasBackdrop} isOpen={true} usePortal={usePortal}> + <div>Some overlay content</div> + </Overlay2> + ); + } + + function assertBodyScrollingDisabled(disabled: boolean, done: Mocha.Done) { + // wait for the DOM to settle before checking body classes + setTimeout(() => { + const hasClass = document.body.classList.contains(Classes.OVERLAY_OPEN); + assert.equal( + hasClass, + disabled, + `expected <body> element to ${disabled ? "have" : "not have"} ${Classes.OVERLAY_OPEN} class`, + ); + done(); + }); + } + }); + + it("lifecycle methods called as expected", done => { + // these lifecycles are passed directly to CSSTransition from react-transition-group + // so we do not need to test these extensively. one integration test should do. + const onClosed = spy(); + const onClosing = spy(); + const onOpened = spy(); + const onOpening = spy(); + wrapper = mountWrapper( + <Overlay2 + {...{ onClosed, onClosing, onOpened, onOpening }} + isOpen={true} + usePortal={false} + // transition duration shorter than timeout below to ensure it's done + transitionDuration={8} + > + {createOverlayContents()} + </Overlay2>, + ); + assert.isTrue(onOpening.calledOnce, "onOpening"); + assert.isFalse(onOpened.calledOnce, "onOpened not called yet"); + + setTimeout(() => { + // on*ed called after transition completes + assert.isTrue(onOpened.calledOnce, "onOpened"); + + wrapper.setProps({ isOpen: false }); + // on*ing called immediately when prop changes + assert.isTrue(onClosing.calledOnce, "onClosing"); + assert.isFalse(onClosed.calledOnce, "onClosed not called yet"); + + setTimeout(() => { + assert.isTrue(onClosed.calledOnce, "onOpened"); + done(); + }, 10); + }, 10); + }); + + let index = 0; + function createOverlayContents() { + return ( + <strong id={`overlay-${index++}`} tabIndex={0}> + Overlay2 content! + </strong> + ); + } +}); diff --git a/packages/core/test/popover/popoverTests.tsx b/packages/core/test/popover/popoverTests.tsx index 7917cfcb3d..9d78021015 100644 --- a/packages/core/test/popover/popoverTests.tsx +++ b/packages/core/test/popover/popoverTests.tsx @@ -23,7 +23,7 @@ import { dispatchMouseEvent } from "@blueprintjs/test-commons"; import { Classes } from "../../src/common"; import * as Errors from "../../src/common/errors"; -import { Button, Overlay, Portal } from "../../src/components"; +import { Button, Overlay2, Portal } from "../../src/components"; import { Popover, PopoverInteractionKind, @@ -112,11 +112,11 @@ describe("<Popover>", () => { <Button /> </Popover>, ); - assert.isFalse(popover.find(Overlay).exists(), "not open for undefined content"); + assert.isFalse(popover.find(Overlay2).exists(), "not open for undefined content"); assert.equal(warnSpy.callCount, 1); popover.setProps({ content: " " }); - assert.isFalse(popover.find(Overlay).exists(), "not open for white-space string content"); + assert.isFalse(popover.find(Overlay2).exists(), "not open for white-space string content"); assert.equal(warnSpy.callCount, 2); }); @@ -921,7 +921,7 @@ describe("<Popover>", () => { return wrapper!; }; wrapper.assertIsOpen = (isOpen = true, index = 0) => { - const overlay = wrapper!.find(Overlay).at(index); + const overlay = wrapper!.find(Overlay2).at(index); assert.equal(overlay.prop("isOpen"), isOpen, "PopoverWrapper#assertIsOpen()"); return wrapper!; }; diff --git a/packages/core/test/toast/overlayToasterTests.tsx b/packages/core/test/toast/overlayToasterTests.tsx index f8b8f1e7e9..843045f29f 100644 --- a/packages/core/test/toast/overlayToasterTests.tsx +++ b/packages/core/test/toast/overlayToasterTests.tsx @@ -92,12 +92,20 @@ describe("OverlayToaster", () => { ); }); - it("show() renders toast immediately", () => { + it("show() renders toast on next tick", done => { toaster.show({ message: "Hello world", }); assert.lengthOf(toaster.getToasts(), 1, "expected 1 toast"); - assert.isNotNull(document.querySelector(`.${Classes.TOAST_CONTAINER}.${Classes.OVERLAY_OPEN}`)); + + // setState needs a tick to flush DOM updates + setTimeout(() => { + assert.isNotNull( + document.querySelector(`.${Classes.TOAST_CONTAINER}.${Classes.OVERLAY_OPEN}`), + "expected toast container element to have 'overlay open' class name", + ); + done(); + }); }); it("multiple show()s renders them all", () => { diff --git a/packages/core/test/toast/toast2Tests.tsx b/packages/core/test/toast/toast2Tests.tsx new file mode 100644 index 0000000000..adab602202 --- /dev/null +++ b/packages/core/test/toast/toast2Tests.tsx @@ -0,0 +1,102 @@ +/* + * 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, shallow } from "enzyme"; +import * as React from "react"; +import { type SinonSpy, spy } from "sinon"; + +import { AnchorButton, Button, Toast2 } from "../../src"; + +describe("<Toast2>", () => { + it("renders only dismiss button by default", () => { + const { action, dismiss } = wrap(<Toast2 message="Hello World" />); + assert.lengthOf(action, 0); + assert.lengthOf(dismiss, 1); + }); + + it("clicking dismiss button triggers onDismiss callback with `false`", () => { + const handleDismiss = spy(); + wrap(<Toast2 message="Hello" onDismiss={handleDismiss} />).dismiss.simulate("click"); + assert.isTrue(handleDismiss.calledOnce, "onDismiss not called once"); + assert.isTrue(handleDismiss.calledWith(false), "onDismiss not called with false"); + }); + + it("renders action button when action string prop provided", () => { + // pluralize cuz now there are two buttons + const { action } = wrap(<Toast2 action={{ text: "Undo" }} message="hello world" />); + assert.lengthOf(action, 1); + assert.equal(action.prop("text"), "Undo"); + }); + + it("clicking action button triggers onClick callback", () => { + const onClick = spy(); + wrap(<Toast2 action={{ onClick, text: "Undo" }} message="Hello" />).action.simulate("click"); + assert.isTrue(onClick.calledOnce, "action onClick not called once"); + }); + + it("clicking action button also triggers onDismiss callback with `false`", () => { + const handleDismiss = spy(); + wrap(<Toast2 action={{ text: "Undo" }} message="Hello" onDismiss={handleDismiss} />).action.simulate("click"); + assert.isTrue(handleDismiss.calledOnce, "onDismiss not called once"); + assert.isTrue(handleDismiss.calledWith(false), "onDismiss not called with false"); + }); + + function wrap(toast: React.JSX.Element) { + const root = shallow(toast); + return { + action: root.find(AnchorButton), + dismiss: root.find(Button), + root, + }; + } + + describe("timeout", () => { + let handleDismiss: SinonSpy; + beforeEach(() => (handleDismiss = spy())); + + it("calls onDismiss automatically after timeout expires with `true`", done => { + // mounting for lifecycle methods to start timeout + mount(<Toast2 message="Hello" onDismiss={handleDismiss} timeout={20} />); + setTimeout(() => { + assert.isTrue(handleDismiss.calledOnce, "onDismiss not called once"); + assert.isTrue(handleDismiss.firstCall.args[0], "onDismiss not called with `true`"); + done(); + }, 20); + }); + + it("updating with timeout={0} cancels timeout", done => { + mount(<Toast2 message="Hello" onDismiss={handleDismiss} timeout={20} />).setProps({ + timeout: 0, + }); + setTimeout(() => { + assert.isTrue(handleDismiss.notCalled, "onDismiss was called"); + done(); + }, 20); + }); + + it("updating timeout={0} with timeout={X} starts timeout", done => { + mount(<Toast2 message="Hello" onDismiss={handleDismiss} timeout={0} />).setProps({ + timeout: 20, + }); + setTimeout(() => { + assert.isTrue(handleDismiss.calledOnce, "onDismiss not called once"); + assert.isTrue(handleDismiss.firstCall.args[0], "onDismiss not called with `true`"); + done(); + }, 20); + }); + }); +}); diff --git a/packages/core/test/toast/toastTests.tsx b/packages/core/test/toast/toastTests.tsx index 4ac35c157a..4b748f2096 100644 --- a/packages/core/test/toast/toastTests.tsx +++ b/packages/core/test/toast/toastTests.tsx @@ -14,6 +14,13 @@ * limitations under the License. */ +/** + * @fileoverview This component is DEPRECATED, and the code is frozen. + * All changes & bugfixes should be made to Toast2 instead. + */ + +/* eslint-disable deprecation/deprecation, @blueprintjs/no-deprecated-components */ + import { assert } from "chai"; import { mount, shallow } from "enzyme"; import * as React from "react"; diff --git a/packages/core/test/tooltip/tooltipTests.tsx b/packages/core/test/tooltip/tooltipTests.tsx index 1c9384eac1..64ed778c73 100644 --- a/packages/core/test/tooltip/tooltipTests.tsx +++ b/packages/core/test/tooltip/tooltipTests.tsx @@ -20,7 +20,7 @@ import * as React from "react"; import { spy, stub } from "sinon"; import { Classes } from "../../src/common"; -import { Button, Overlay } from "../../src/components"; +import { Button, Overlay2 } from "../../src/components"; import { Popover } from "../../src/components/popover/popover"; import { Tooltip, type TooltipProps } from "../../src/components/tooltip/tooltip"; @@ -106,7 +106,7 @@ describe("<Tooltip>", () => { function assertDisabledPopover(content: string) { tooltip.setProps({ content }); - assert.isFalse(tooltip.find(Overlay).exists(), `"${content}"`); + assert.isFalse(tooltip.find(Overlay2).exists(), `"${content}"`); assert.isTrue(warnSpy.called, "spy not called"); warnSpy.resetHistory(); } @@ -139,7 +139,7 @@ describe("<Tooltip>", () => { it("empty content disables Popover and warns", () => { const warnSpy = stub(console, "warn"); const tooltip = renderTooltip({ content: "", isOpen: true }); - assert.isFalse(tooltip.find(Overlay).exists()); + assert.isFalse(tooltip.find(Overlay2).exists()); assert.isTrue(warnSpy.called); warnSpy.restore(); }); diff --git a/packages/demo-app/src/examples/ToastExample.tsx b/packages/demo-app/src/examples/ToastExample.tsx index 683d3dfa81..1fa71da156 100644 --- a/packages/demo-app/src/examples/ToastExample.tsx +++ b/packages/demo-app/src/examples/ToastExample.tsx @@ -16,7 +16,7 @@ import * as React from "react"; -import { Intent, Toast } from "@blueprintjs/core"; +import { Intent, Toast2 } from "@blueprintjs/core"; import { ExampleCard } from "./ExampleCard"; @@ -30,7 +30,7 @@ export class ToastExample extends React.PureComponent { <div className="example-row"> <ExampleCard label="Toast"> {Object.values(Intent).map(intent => ( - <Toast + <Toast2 key={`${intent}-toast`} intent={intent as Intent} message="This is a toast message" diff --git a/packages/docs-app/src/components/blueprintDocs.tsx b/packages/docs-app/src/components/blueprintDocs.tsx index 46845fa332..03c9cfb456 100644 --- a/packages/docs-app/src/components/blueprintDocs.tsx +++ b/packages/docs-app/src/components/blueprintDocs.tsx @@ -18,7 +18,7 @@ import { type HeadingNode, isPageNode, type PageData, type TsDocBase } from "@do import classNames from "classnames"; import * as React from "react"; -import { AnchorButton, Classes, HotkeysProvider, type Intent, Tag } from "@blueprintjs/core"; +import { AnchorButton, Classes, HotkeysProvider, type Intent, PortalProvider, Tag } from "@blueprintjs/core"; import type { DocsCompleteData } from "@blueprintjs/docs-data"; import { Banner, @@ -100,18 +100,20 @@ export class BlueprintDocs extends React.Component<BlueprintDocsProps, { themeNa ); return ( <HotkeysProvider> - <Documentation - {...this.props} - className={this.state.themeName} - banner={banner} - footer={footer} - header={header} - navigatorExclude={isNavSection} - onComponentUpdate={this.handleComponentUpdate} - renderNavMenuItem={this.renderNavMenuItem} - renderPageActions={this.renderPageActions} - renderViewSourceLinkText={this.renderViewSourceLinkText} - /> + <PortalProvider> + <Documentation + {...this.props} + className={this.state.themeName} + banner={banner} + footer={footer} + header={header} + navigatorExclude={isNavSection} + onComponentUpdate={this.handleComponentUpdate} + renderNavMenuItem={this.renderNavMenuItem} + renderPageActions={this.renderPageActions} + renderViewSourceLinkText={this.renderViewSourceLinkText} + /> + </PortalProvider> </HotkeysProvider> ); } diff --git a/packages/docs-app/src/examples/core-examples/index.ts b/packages/docs-app/src/examples/core-examples/index.ts index 62e8d571fd..bec09829d7 100644 --- a/packages/docs-app/src/examples/core-examples/index.ts +++ b/packages/docs-app/src/examples/core-examples/index.ts @@ -54,7 +54,8 @@ export * from "./numericInputBasicExample"; export * from "./numericInputExtendedExample"; export * from "./nonIdealStateExample"; export * from "./overflowListExample"; -export * from "./overlayExample"; +export { OverlayExample } from "./overlayExample"; +export { Overlay2Example } from "./overlay2Example"; export { PanelStackExample } from "./panelStackExample"; export { PanelStack2Example } from "./panelStack2Example"; export * from "./popoverDismissExample"; diff --git a/packages/docs-app/src/examples/core-examples/overlay2Example.tsx b/packages/docs-app/src/examples/core-examples/overlay2Example.tsx new file mode 100644 index 0000000000..4cef759510 --- /dev/null +++ b/packages/docs-app/src/examples/core-examples/overlay2Example.tsx @@ -0,0 +1,133 @@ +/* + * 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 { Button, Classes, Code, H3, H5, Intent, Overlay2, Switch } from "@blueprintjs/core"; +import { Example, type ExampleProps, handleBooleanChange } from "@blueprintjs/docs-theme"; + +import type { BlueprintExampleData } from "../../tags/types"; + +const OVERLAY_EXAMPLE_CLASS = "docs-overlay-example-transition"; +const OVERLAY_TALL_CLASS = "docs-overlay-example-tall"; + +export const Overlay2Example: React.FC<ExampleProps<BlueprintExampleData>> = props => { + const [autoFocus, setAutoFocus] = React.useState(true); + const [canEscapeKeyClose, setCanEscapeKeyClose] = React.useState(true); + const [canOutsideClickClose, setCanOutsideClickClose] = React.useState(true); + const [enforceFocus, setEnforceFocus] = React.useState(true); + const [hasBackdrop, setHasBackdrop] = React.useState(true); + const [isOpen, setIsOpen] = React.useState(false); + const [usePortal, setUsePortal] = React.useState(true); + const [useTallContent, setUseTallContent] = React.useState(false); + + const buttonRef = React.useRef<HTMLButtonElement>(null); + + const handleOpen = React.useCallback(() => setIsOpen(true), [setIsOpen]); + + const handleClose = React.useCallback(() => { + setIsOpen(false); + setUseTallContent(false); + }, [setIsOpen, setUseTallContent]); + + const focusButton = React.useCallback(() => buttonRef.current?.focus(), [buttonRef]); + + const toggleScrollButton = React.useCallback(() => setUseTallContent(use => !use), [setUseTallContent]); + + const classes = classNames(Classes.CARD, Classes.ELEVATION_4, OVERLAY_EXAMPLE_CLASS, props.data.themeName, { + [OVERLAY_TALL_CLASS]: useTallContent, + }); + + const options = ( + <> + <H5>Props</H5> + <Switch checked={autoFocus} label="Auto focus" onChange={handleBooleanChange(setAutoFocus)} /> + <Switch checked={enforceFocus} label="Enforce focus" onChange={handleBooleanChange(setEnforceFocus)} /> + <Switch checked={usePortal} onChange={handleBooleanChange(setUsePortal)}> + Use <Code>Portal</Code> + </Switch> + <Switch + checked={canOutsideClickClose} + label="Click outside to close" + onChange={handleBooleanChange(setCanOutsideClickClose)} + /> + <Switch + checked={canEscapeKeyClose} + label="Escape key to close" + onChange={handleBooleanChange(setCanEscapeKeyClose)} + /> + <Switch checked={hasBackdrop} label="Has backdrop" onChange={handleBooleanChange(setHasBackdrop)} /> + </> + ); + + return ( + <Example options={options} {...props}> + <React.StrictMode> + <Button ref={buttonRef} onClick={handleOpen} text="Show overlay" /> + <Overlay2 + onClose={handleClose} + className={Classes.OVERLAY_SCROLL_CONTAINER} + {...{ + autoFocus, + canEscapeKeyClose, + canOutsideClickClose, + enforceFocus, + hasBackdrop, + isOpen, + usePortal, + }} + > + <div className={classes}> + <H3>I'm an Overlay!</H3> + <p> + This is a simple container with some inline styles to position it on the screen. Its CSS + transitions are customized for this example only to demonstrate how easily custom + transitions can be implemented. + </p> + <p> + Click the "Focus button" below to transfer focus to the "Show overlay" trigger button + outside of this overlay. If persistent focus is enabled, focus will be constrained to the + overlay. Use the <Code>tab</Code> key to move to the next focusable element to illustrate + this effect. + </p> + <p> + Click the "Make me scroll" button below to make this overlay's content really tall, which + will make the overlay's container (but not the page) scrollable + </p> + <br /> + <div className={Classes.DIALOG_FOOTER_ACTIONS}> + <Button intent={Intent.DANGER} onClick={handleClose} style={{ margin: "" }}> + Close + </Button> + <Button onClick={focusButton} style={{ margin: "" }}> + Focus button + </Button> + <Button + onClick={toggleScrollButton} + icon="double-chevron-down" + rightIcon="double-chevron-down" + active={useTallContent} + style={{ margin: "" }} + > + Make me scroll + </Button> + </div> + </div> + </Overlay2> + </React.StrictMode> + </Example> + ); +}; diff --git a/packages/docs-app/src/examples/core-examples/overlayExample.tsx b/packages/docs-app/src/examples/core-examples/overlayExample.tsx index cb9f271073..9f78df4d5e 100644 --- a/packages/docs-app/src/examples/core-examples/overlayExample.tsx +++ b/packages/docs-app/src/examples/core-examples/overlayExample.tsx @@ -13,6 +13,13 @@ * limitations under the License. */ +/** + * @fileoverview This component is DEPRECATED, and the code is frozen. + * All changes & bugfixes should be made to Overlay2 instead. + */ + +/* eslint-disable deprecation/deprecation, @blueprintjs/no-deprecated-components */ + import classNames from "classnames"; import * as React from "react"; diff --git a/packages/docs-app/src/styles/_examples.scss b/packages/docs-app/src/styles/_examples.scss index ec556620b9..04a9a53c75 100644 --- a/packages/docs-app/src/styles/_examples.scss +++ b/packages/docs-app/src/styles/_examples.scss @@ -320,6 +320,13 @@ $docs-hotkey-piano-height: 510px; } } +#{example("Overlay")}, +#{example("Overlay2")} { + .docs-example { + position: relative; + } +} + // prettier-ignore .docs-overlay-example-transition { $overlay-example-width: $pt-grid-size * 40; @@ -350,6 +357,11 @@ $docs-hotkey-piano-height: 510px; top: 0; width: $overlay-example-width; + + .#{$ns}-overlay-inline & { + left: calc(50% - #{$overlay-example-width * 0.5}); + margin: 20px 0; + } } .docs-overlay-example-tall { diff --git a/packages/select/src/components/omnibar/omnibar.tsx b/packages/select/src/components/omnibar/omnibar.tsx index 3fd0a7ff41..8491a76b8d 100644 --- a/packages/select/src/components/omnibar/omnibar.tsx +++ b/packages/select/src/components/omnibar/omnibar.tsx @@ -17,7 +17,7 @@ import classNames from "classnames"; import * as React from "react"; -import { DISPLAYNAME_PREFIX, InputGroup, type InputGroupProps, Overlay, type OverlayProps } from "@blueprintjs/core"; +import { DISPLAYNAME_PREFIX, InputGroup, type InputGroupProps, Overlay2, type OverlayProps } from "@blueprintjs/core"; import { Search } from "@blueprintjs/icons"; import { Classes, type ListItemsProps } from "../../common"; @@ -89,7 +89,7 @@ export class Omnibar<T> extends React.PureComponent<OmnibarProps<T>> { const handlers = isOpen ? { onKeyDown: handleKeyDown, onKeyUp: handleKeyUp } : {}; return ( - <Overlay + <Overlay2 hasBackdrop={true} {...overlayProps} isOpen={isOpen} @@ -108,7 +108,7 @@ export class Omnibar<T> extends React.PureComponent<OmnibarProps<T>> { /> {listProps.itemList} </div> - </Overlay> + </Overlay2> ); }; diff --git a/packages/table/test/columnHeaderCellTests.tsx b/packages/table/test/columnHeaderCellTests.tsx index ab2cdf6208..66ea3abd78 100644 --- a/packages/table/test/columnHeaderCellTests.tsx +++ b/packages/table/test/columnHeaderCellTests.tsx @@ -89,21 +89,25 @@ describe("<ColumnHeaderCell>", () => { expect(text).to.equal("Header of 2"); }); - it("renders custom menu items with a menuRenderer callback", () => { + it("renders custom menu items with a menuRenderer callback", done => { const columnHeaderCellRenderer = (columnIndex: number) => ( <ColumnHeaderCell name={`COL-${columnIndex}`} menuRenderer={renderMenu} /> ); const table = harness.mount(createTableOfSize(3, 2, { columnHeaderCellRenderer })); expectMenuToOpen(table); - // attempt to click one of the menu items - ElementHarness.document().find('[data-icon="export"]')!.mouse("click"); - expect(menuClickSpy.called).to.be.true; + // popovers need a tick to render contents after they open + setTimeout(() => { + // attempt to click one of the menu items + ElementHarness.document().find('[data-icon="export"]')!.mouse("click"); + expect(menuClickSpy.called, "expected menu item click handler to be called").to.be.true; + done(); + }); }); - it("custom menu supports popover props", () => { + it("custom menu supports popover props", done => { const expectedMenuPopoverProps = { - placement: "top" as const, + placement: "right-start" as const, popoverClassName: "test-popover-class", }; const columnHeaderCellRenderer = (columnIndex: number) => ( @@ -115,10 +119,20 @@ describe("<ColumnHeaderCell>", () => { ); const table = harness.mount(createTableOfSize(3, 2, { columnHeaderCellRenderer })); expectMenuToOpen(table); - const popover = ElementHarness.document().find(`.${CoreClasses.POPOVER}`); - expect(popover.hasClass(expectedMenuPopoverProps.popoverClassName)).to.be.true; - expect(popover.hasClass(`${CoreClasses.POPOVER_CONTENT_PLACEMENT}-${expectedMenuPopoverProps.placement}`)) - .to.be.true; + + // popovers need a tick to render contents after they open + setTimeout(() => { + const popover = ElementHarness.document().find(`.${CoreClasses.POPOVER}`); + expect( + popover.hasClass(expectedMenuPopoverProps.popoverClassName), + `expected popover element to have ${expectedMenuPopoverProps.popoverClassName} class`, + ).to.be.true; + expect( + popover.hasClass(`${CoreClasses.POPOVER_CONTENT_PLACEMENT}-right`), + `expected popover element to have '${expectedMenuPopoverProps.placement}' placement classes applied`, + ).to.be.true; + done(); + }); }); it("renders loading state properly", () => { @@ -146,7 +160,10 @@ describe("<ColumnHeaderCell>", () => { table.find(`.${Classes.TABLE_COLUMN_HEADERS}`)!.mouse("mousemove"); const target = table.find(`.${Classes.TABLE_TH_MENU}.${CoreClasses.POPOVER_TARGET}`)!; target.mouse("click"); - expect(target.hasClass(CoreClasses.POPOVER_OPEN)).to.be.true; + expect( + target.hasClass(CoreClasses.POPOVER_OPEN), + "expected th menu popover target element to have 'popover open' indicator class", + ).to.be.true; } }); diff --git a/packages/table/test/harness.ts b/packages/table/test/harness.ts index 085889163c..62b709ee88 100644 --- a/packages/table/test/harness.ts +++ b/packages/table/test/harness.ts @@ -16,9 +16,11 @@ /* eslint-disable max-classes-per-file */ -import type * as React from "react"; +import * as React from "react"; import * as ReactDOM from "react-dom"; +import { HotkeysProvider } from "@blueprintjs/core"; + export type MouseEventType = "click" | "mousedown" | "mouseup" | "mousemove" | "mouseenter" | "mouseleave"; export type KeyboardEventType = "keypress" | "keydown" | "keyup"; @@ -208,11 +210,12 @@ export class ReactHarness { constructor() { this.container = document.createElement("div"); - document.documentElement.appendChild(this.container); + document.body.appendChild(this.container); } public mount(component: React.ReactElement<any>) { - ReactDOM.render(component, this.container); + // wrap in a <HotkeysProvider> to avoid console warnings + ReactDOM.render(React.createElement(HotkeysProvider, { children: component }), this.container); return new ElementHarness(this.container); } @@ -221,7 +224,7 @@ export class ReactHarness { } public destroy() { - document.documentElement.removeChild(this.container); + document.body.removeChild(this.container); // @ts-ignore delete this.container; } diff --git a/packages/table/test/index.ts b/packages/table/test/index.ts index 4b7c4af81f..9056e24de3 100644 --- a/packages/table/test/index.ts +++ b/packages/table/test/index.ts @@ -16,34 +16,34 @@ import "@blueprintjs/test-commons/bootstrap"; -import "./batcherTests.tsx"; -import "./cellTests.tsx"; -import "./clipboardTests.ts"; -import "./columnHeaderCellTests.tsx"; -import "./columnTests.tsx"; +import "./batcherTests"; +import "./cellTests"; +import "./clipboardTests"; +import "./columnHeaderCellTests"; +import "./columnTests"; import "./common/internal"; -import "./editableCell2Tests.tsx"; -import "./editableCellTests.tsx"; -import "./editableNameTests.tsx"; -import "./formats/jsonFormatTests.tsx"; -import "./formats/truncatedFormatTests.tsx"; -import "./gridTests.ts"; -import "./guidesTests.tsx"; -import "./loadableContentTests.tsx"; -import "./loadingOptionsTests.tsx"; -import "./locatorTests.tsx"; -import "./menusTests.tsx"; -import "./quadrants/tableQuadrantStackTests.tsx"; -import "./quadrants/tableQuadrantTests.tsx"; -import "./rectTests.ts"; -import "./regionsTests.ts"; -import "./reorderableTests.tsx"; -import "./resizableTests.tsx"; -import "./rowHeaderCellTests.tsx"; -import "./selectableTests.tsx"; -import "./selectionTests.tsx"; -import "./table2Tests.tsx"; -import "./tableBody2Tests.tsx"; -import "./tableBodyTests.tsx"; -import "./tableTests.tsx"; -import "./utilsTests.ts"; +import "./editableCell2Tests"; +import "./editableCellTests"; +import "./editableNameTests"; +import "./formats/jsonFormatTests"; +import "./formats/truncatedFormatTests"; +import "./gridTests"; +import "./guidesTests"; +import "./loadableContentTests"; +import "./loadingOptionsTests"; +import "./locatorTests"; +import "./menusTests"; +import "./quadrants/tableQuadrantStackTests"; +import "./quadrants/tableQuadrantTests"; +import "./rectTests"; +import "./regionsTests"; +import "./reorderableTests"; +import "./resizableTests"; +import "./rowHeaderCellTests"; +import "./selectableTests"; +import "./selectionTests"; +import "./table2Tests"; +import "./tableBody2Tests"; +import "./tableBodyTests"; +import "./tableTests"; +import "./utilsTests";