/*
* Copyright 2015 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.
*/
/**
* @fileoverview This component is DEPRECATED, and the code is frozen.
* All changes & bugfixes should be made to Overlay2 instead.
*/
/* eslint-disable @typescript-eslint/no-deprecated */
import { waitFor } from "@testing-library/dom";
import { mount, ReactWrapper, shallow } from "enzyme";
import { createRef } from "react";
import { afterAll, afterEach, assert, beforeEach, describe, expect, it, vi } from "@blueprintjs/test-commons/vitest";
import { dispatchMouseEvent } from "@blueprintjs/test-commons/vitest-utils";
import { Classes, Utils } from "../../common";
import { sleep } from "../../common/test-utils";
import { Portal, type PortalProps } from "../portal/portal";
import { Overlay } from "./overlay";
import { type OverlayProps } from "./overlayProps";
function findInPortal
(overlay: ReactWrapper
, selector: string) {
// React 16: createPortal preserves React tree so simple find works.
const element = overlay.find(Portal).find(selector);
if (element.exists()) {
return element;
}
// React 15: unstable_renderSubtree does not preserve tree so we must create new wrapper.
const portal = overlay.find(Portal).instance() as React.Component;
const portalChildren = new ReactWrapper(<>{portal.props.children}>);
if (portalChildren.is(selector)) {
return portalChildren;
}
return portalChildren.find(selector);
}
const BACKDROP_SELECTOR = `.${Classes.OVERLAY_BACKDROP}`;
/*
IMPORTANT NOTE: It is critical that every wrapper be unmounted after the test, to avoid
polluting the DOM with leftover overlay elements. This was the cause of the Overlay test flakes of
late 2017/early 2018 and was resolved by ensuring that every wrapper is unmounted.
The `wrapper` variable below and the `mountWrapper` method should be used for full enzyme mounts.
For shallow mounts, be sure to call `shallowWrapper.unmount()` after the assertions.
*/
describe("", () => {
let wrapper: ReactWrapper;
let isMounted = false;
const containerElement = document.createElement("div");
document.documentElement.appendChild(containerElement);
/**
* Mount the `content` into `containerElement` 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: containerElement });
isMounted = true;
return wrapper;
}
afterEach(() => {
if (isMounted) {
// clean up wrapper after each test, if it was used
wrapper?.unmount();
wrapper?.detach();
isMounted = false;
}
});
afterAll(() => {
document.documentElement.removeChild(containerElement);
});
it("renders its content correctly", () => {
const overlay = shallow(
{createOverlayContents()}
,
);
assert.lengthOf(overlay.find("strong"), 1);
assert.lengthOf(overlay.find(BACKDROP_SELECTOR), 1);
overlay.unmount();
});
it("renders contents to specified container correctly", () => {
const CLASS_TO_TEST = "bp-test-content";
const container = document.createElement("div");
document.body.appendChild(container);
mountWrapper(
test
,
);
assert.lengthOf(container.getElementsByClassName(CLASS_TO_TEST), 1);
document.body.removeChild(container);
});
it("sets aria-live", () => {
// Using an open Overlay because an initially closed Overlay will not render anything to the
// DOM
mountWrapper();
const overlayElement = document.querySelector(".aria-test");
assert.exists(overlayElement);
// Element#ariaLive not supported in Firefox or IE
assert.equal(overlayElement?.getAttribute("aria-live"), "polite");
});
it("portalClassName appears on Portal", () => {
const CLASS_TO_TEST = "bp-test-content";
mountWrapper(
test
,
);
// search document for portal container element.
assert.isDefined(document.querySelector(`.${Classes.PORTAL}.${CLASS_TO_TEST}`));
});
it("renders Portal after first opened", () => {
mountWrapper({createOverlayContents()});
assert.lengthOf(wrapper.find(Portal), 0, "unexpected Portal");
wrapper.setProps({ isOpen: true });
assert.lengthOf(wrapper.find(Portal), 1, "expected Portal");
});
it("supports non-element children", () => {
assert.doesNotThrow(() =>
shallow(
{null} {undefined}
,
).unmount(),
);
});
it("hasBackdrop=false does not render backdrop", () => {
const overlay = shallow(
{createOverlayContents()}
,
);
assert.lengthOf(overlay.find("strong"), 1);
assert.lengthOf(overlay.find(BACKDROP_SELECTOR), 0);
overlay.unmount();
});
it("renders portal attached to body when not inline after first opened", () => {
mountWrapper({createOverlayContents()});
assert.lengthOf(wrapper.find(Portal), 0, "unexpected Portal");
wrapper.setProps({ isOpen: true });
assert.lengthOf(wrapper.find(Portal), 1, "expected Portal");
});
describe("onClose", () => {
it("invoked on backdrop mousedown when canOutsideClickClose=true", () => {
const onClose = vi.fn();
const overlay = shallow(
{createOverlayContents()}
,
);
overlay.find(BACKDROP_SELECTOR).simulate("mousedown");
expect(onClose).toHaveBeenCalledOnce();
overlay.unmount();
});
it("not invoked on backdrop mousedown when canOutsideClickClose=false", () => {
const onClose = vi.fn();
const overlay = shallow(
{createOverlayContents()}
,
);
overlay.find(BACKDROP_SELECTOR).simulate("mousedown");
expect(onClose).not.toHaveBeenCalled();
overlay.unmount();
});
it("invoked on document mousedown when hasBackdrop=false", () => {
const onClose = vi.fn();
// mounting cuz we need document events + lifecycle
mountWrapper(
{createOverlayContents()}
,
);
dispatchMouseEvent(document.documentElement, "mousedown");
expect(onClose).toHaveBeenCalledOnce();
});
it("not invoked on document mousedown when hasBackdrop=false and canOutsideClickClose=false", () => {
const onClose = vi.fn();
mountWrapper(
{createOverlayContents()}
,
);
dispatchMouseEvent(document.documentElement, "mousedown");
expect(onClose).not.toHaveBeenCalled();
});
it("not invoked on click of a nested overlay", () => {
const onClose = vi.fn();
mountWrapper(
{createOverlayContents()}
{createOverlayContents()}
,
);
// this hackery is necessary for React 15 support, where Portals break trees.
findInPortal(findInPortal(wrapper, "#outer-element"), "#inner-element").simulate("mousedown");
expect(onClose).not.toHaveBeenCalled();
});
it("invoked on escape key", () => {
const onClose = vi.fn();
mountWrapper(
{createOverlayContents()}
,
);
wrapper.simulate("keydown", { key: "Escape" });
expect(onClose).toHaveBeenCalledOnce();
});
it("not invoked on escape key when canEscapeKeyClose=false", () => {
const onClose = vi.fn();
const overlay = shallow(
{createOverlayContents()}
,
);
overlay.simulate("keydown", { key: "Escape" });
expect(onClose).not.toHaveBeenCalled();
overlay.unmount();
});
it("renders portal attached to body when not inline", () => {
const overlay = shallow(
{createOverlayContents()}
,
);
const portal = overlay.find(Portal);
assert.isTrue(portal.exists(), "missing Portal");
assert.lengthOf(portal.find("strong"), 1, "missing h1");
overlay.unmount();
});
});
describe("Focus management", () => {
const overlayClassName = "test-overlay";
it("brings focus to overlay if autoFocus=true", async () => {
mountWrapper(
,
);
await assertFocusIsInOverlay();
});
it("does not bring focus to overlay if autoFocus=false and enforceFocus=false", async () => {
mountWrapper(
,
);
await assertFocus("body");
});
// 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", async () => {
mountWrapper(
,
);
await assertFocus("input");
});
it("returns focus to overlay if enforceFocus=true", async () => {
const buttonRef = createRef();
const inputRef = createRef();
mountWrapper(
,
);
assert.strictEqual(document.activeElement, inputRef.current);
buttonRef.current?.focus();
await assertFocusIsInOverlay();
});
it("returns focus to overlay after clicking the backdrop if enforceFocus=true", async () => {
mountWrapper(
{createOverlayContents()}
,
);
wrapper.find(BACKDROP_SELECTOR).simulate("mousedown");
await assertFocusIsInOverlay();
});
it("returns focus to overlay after clicking an outside element if enforceFocus=true", async () => {
mountWrapper(
{createOverlayContents()}
,
);
wrapper.find("#buttonId").simulate("click");
await assertFocusIsInOverlay();
});
it("does not result in maximum call stack if two overlays open with enforceFocus=true", () => {
const anotherContainer = document.createElement("div");
document.documentElement.appendChild(anotherContainer);
const temporaryWrapper = mount(
,
{ attachTo: anotherContainer },
);
mountWrapper(
,
);
// ES6 class property vs prototype, see: https://github.com/airbnb/enzyme/issues/365
const bringFocusSpy = vi.spyOn(wrapper.instance() as Overlay, "bringFocusInsideOverlay");
wrapper.setProps({ isOpen: true });
// triggers the infinite recursion
wrapper.find("#inputId").simulate("click");
expect(bringFocusSpy).toHaveBeenCalledOnce();
// don't need spy.restore() since the wrapper will be destroyed after test anyways
temporaryWrapper.unmount();
document.documentElement.removeChild(anotherContainer);
});
it("does not return focus to overlay if enforceFocus=false", () => {
let buttonRef: HTMLElement | null;
const focusBtnAndAssert = async () => {
buttonRef?.focus();
await waitFor(() => assert.strictEqual(buttonRef, document.activeElement));
};
mountWrapper(
,
);
});
it("doesn't focus overlay if focus is already inside overlay", async () => {
let textarea: HTMLTextAreaElement | null;
mountWrapper(
,
);
textarea!.focus();
await assertFocus("textarea");
});
it("does not focus overlay when closed", async () => {
mountWrapper(
,
);
await assertFocus("button");
});
// SKIP: @testing-library/user-event v14 installs a global focus listener that
// crashes when event.target is not a DOM element. This test dispatches a focus
// event with window as the target to simulate clicking browser chrome.
// The underlying Blueprint behavior is still valid.
it.skip("does not crash while trying to return focus to overlay if user clicks outside the document", () => {
mountWrapper(
{createOverlayContents()}
,
);
// 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");
}
});
async function assertFocus(selector: string | (() => void)) {
// the behavior being tested relies on requestAnimationFrame.
// waitFor to reduce flakes.
await waitFor(() => {
wrapper.update();
if (Utils.isFunction(selector)) {
selector();
} else {
assert.strictEqual(document.querySelector(selector), document.activeElement);
}
});
}
async function assertFocusIsInOverlay() {
await assertFocus(() => {
const overlayElement = document.querySelector(`.${overlayClassName}`);
assert.isTrue(overlayElement?.contains(document.activeElement));
});
}
});
describe("Background scrolling", () => {
beforeEach(() => {
// force-reset Overlay stack state between tests
(Overlay as any).openStack = [];
document.body.classList.remove(Classes.OVERLAY_OPEN);
});
it("disables document scrolling by default", async () => {
wrapper = mountWrapper(renderBackdropOverlay());
await assertBodyScrollingDisabled(true);
});
it("disables document scrolling if hasBackdrop=true and usePortal=true", async () => {
wrapper = mountWrapper(renderBackdropOverlay(true, true));
await assertBodyScrollingDisabled(true);
});
it("does not disable document scrolling if hasBackdrop=true and usePortal=false", async () => {
wrapper = mountWrapper(renderBackdropOverlay(true, false));
await assertBodyScrollingDisabled(false);
});
it("does not disable document scrolling if hasBackdrop=false and usePortal=true", async () => {
wrapper = mountWrapper(renderBackdropOverlay(false, true));
await assertBodyScrollingDisabled(false);
});
it("does not disable document scrolling if hasBackdrop=false and usePortal=false", async () => {
wrapper = mountWrapper(renderBackdropOverlay(false, false));
await assertBodyScrollingDisabled(false);
});
it("keeps scrolling disabled if hasBackdrop=true overlay exists following unmount", async () => {
const backdropOverlay = mount(renderBackdropOverlay(true));
wrapper = mountWrapper(renderBackdropOverlay(true));
backdropOverlay.unmount();
await assertBodyScrollingDisabled(true);
});
it("doesn't keep scrolling disabled if no hasBackdrop=true overlay exists following unmount", async () => {
const backdropOverlay = mount(renderBackdropOverlay(true));
wrapper = mountWrapper(renderBackdropOverlay(false));
backdropOverlay.unmount();
await assertBodyScrollingDisabled(false);
});
function renderBackdropOverlay(hasBackdrop?: boolean, usePortal?: boolean) {
return (
Some overlay content
);
}
async function assertBodyScrollingDisabled(disabled: boolean) {
// wait for the DOM to settle before checking body classes
await waitFor(() => {
const hasClass = document.body.classList.contains(Classes.OVERLAY_OPEN);
assert.equal(hasClass, disabled);
});
}
});
it.skip("lifecycle methods called as expected", async () => {
// 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 = vi.fn();
const onClosing = vi.fn();
const onOpened = vi.fn();
const onOpening = vi.fn();
wrapper = mountWrapper(
{createOverlayContents()}
,
);
expect(onOpening).toHaveBeenCalledOnce();
expect(onOpened).not.toHaveBeenCalled();
await sleep(10);
// on*ed called after transition completes
expect(onOpened).toHaveBeenCalledOnce();
wrapper.setProps({ isOpen: false });
// on*ing called immediately when prop changes
expect(onClosing).toHaveBeenCalledOnce();
expect(onClosed).not.toHaveBeenCalled();
await sleep(10);
expect(onClosed).toHaveBeenCalledOnce();
});
let index = 0;
function createOverlayContents() {
return (
Overlay content!
);
}
});