/* * 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 { fireEvent, render, type RenderOptions, type RenderResult, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { createRef, useState } from "react"; import { describe, expect, it, vi } from "@blueprintjs/test-commons/vitest"; import { Classes } from "../../common"; import { OverlaysProvider } from "../../context/overlays/overlaysProvider"; import { Overlay2, type Overlay2Props } from "./overlay2"; import { type OverlayInstance } from "./overlayInstance"; import "../../../test/overlay2/overlay2-test-debugging.scss"; const BACKDROP_SELECTOR = `.${Classes.OVERLAY_BACKDROP}`; /** * Custom render function for overlay tests * * @see https://testing-library.com/docs/example-react-context/ */ function renderWithOverlaysProvider(ui: React.ReactElement, renderOptions: RenderOptions = {}): RenderResult { return render(ui, { wrapper: OverlaysProvider, ...renderOptions, }); } describe("", () => { it("should render its contents", () => { const { container } = renderWithOverlaysProvider( test content , ); const backdropElement = container.querySelector(BACKDROP_SELECTOR); expect(screen.getByText("test content")).to.exist; expect(backdropElement).to.exist; }); it("should render contents to a specified container", () => { const portalContainer = document.createElement("div"); document.body.appendChild(portalContainer); renderWithOverlaysProvider( test content , ); expect(screen.getByText("test content")).to.exist; document.body.removeChild(portalContainer); }); it("should set aria-live attribute", () => { // Using an open Overlay2 because an initially closed Overlay2 will not render anything to the DOM const { container } = renderWithOverlaysProvider( , ); const overlayElement = container.querySelector(".aria-test"); expect(overlayElement).to.exist; // Element#ariaLive not supported in Firefox or IE expect(overlayElement?.getAttribute("aria-live")).to.equal("polite"); }); it("should apply portalClassName to Portal", () => { const portalClassName = "test-portal-class"; renderWithOverlaysProvider(); const portalElement = document.querySelector(`.${Classes.PORTAL}.${portalClassName}`); expect(portalElement).to.exist; }); it("should not render Portal when closed", () => { const portalClassName = "test-portal-closed"; renderWithOverlaysProvider( , ); const portalElement = document.querySelector(`.${Classes.PORTAL}.${portalClassName}`); expect(portalElement).not.toBeInTheDocument(); }); it("should render Portal when opened", () => { const portalClassName = "test-portal-opened"; renderWithOverlaysProvider(); const portalElement = document.querySelector(`.${Classes.PORTAL}.${portalClassName}`); expect(portalElement).to.exist; }); it("should support non-element children", () => { // If this doesn't throw, the test passes expect(() => { renderWithOverlaysProvider( {null} {undefined} , ); }).to.not.throw(); }); it("should not render backdrop when hasBackdrop is false", () => { const { container } = renderWithOverlaysProvider( test content , ); expect(screen.getByText("test content")).to.exist; expect(container.querySelector(BACKDROP_SELECTOR)).not.toBeInTheDocument(); }); describe("onClose", () => { it("should invoke on backdrop mousedown when canOutsideClickClose=true", async () => { const user = userEvent.setup(); const onClose = vi.fn(); const { container } = renderWithOverlaysProvider( , ); const backdropElement = container.querySelector(BACKDROP_SELECTOR); expect(backdropElement).to.exist; await user.click(backdropElement!); expect(onClose).toHaveBeenCalledOnce(); }); it("should not invoke on backdrop mousedown when canOutsideClickClose=false", async () => { const user = userEvent.setup(); const onClose = vi.fn(); const { container } = renderWithOverlaysProvider( , ); const backdropElement = container.querySelector(BACKDROP_SELECTOR); expect(backdropElement).to.exist; await user.click(backdropElement!); expect(onClose).not.toHaveBeenCalled(); }); it("should invoke on document mousedown when hasBackdrop=false", async () => { const user = userEvent.setup(); const onClose = vi.fn(); renderWithOverlaysProvider( , ); await user.click(document.documentElement); expect(onClose).toHaveBeenCalledOnce(); }); it("should not invoke on document mousedown when hasBackdrop=false and canOutsideClickClose=false", async () => { const user = userEvent.setup(); const onClose = vi.fn(); renderWithOverlaysProvider( , ); await user.click(document.documentElement); expect(onClose).not.toHaveBeenCalled(); }); it("should not invoke on click of a nested overlay", async () => { const user = userEvent.setup(); const onClose = vi.fn(); renderWithOverlaysProvider( <> outer content inner content , ); const innerElement = screen.getByText("inner content"); await user.click(innerElement); expect(onClose).not.toHaveBeenCalled(); }); it("should invoke on escape key", async () => { const onClose = vi.fn(); function TestOverlay() { const [isOpen, setIsOpen] = useState(true); return ( { onClose(e); setIsOpen(false); }} usePortal={false} > test content ); } const { container } = renderWithOverlaysProvider(); const overlayElement = container.querySelector(`.${Classes.OVERLAY}`); expect(overlayElement).to.exist; expect(screen.queryByText("test content")).to.exist; fireEvent.keyDown(overlayElement!, { key: "Escape" }); expect(onClose).toHaveBeenCalledOnce(); await waitFor(() => expect(screen.queryByText("test content")).not.toBeInTheDocument()); }); it("should not invoke on escape key when canEscapeKeyClose is false", () => { const onClose = vi.fn(); function TestOverlay() { const [isOpen, setIsOpen] = useState(true); return ( { onClose(e); setIsOpen(false); }} usePortal={false} > test content ); } const { container } = renderWithOverlaysProvider(); const overlayElement = container.querySelector(`.${Classes.OVERLAY}`); expect(overlayElement).to.exist; expect(screen.queryByText("test content")).to.exist; fireEvent.keyDown(overlayElement!, { key: "Escape" }); expect(onClose).not.toHaveBeenCalled(); expect(screen.queryByText("test content")).to.exist; }); it("should close second overlay with escape key and return focus to first overlay", async () => { const firstOnClose = vi.fn(); const secondOnClose = vi.fn(); function TestOverlays() { const [isFirstOpen, setIsFirstOpen] = useState(true); const [isSecondOpen, setIsSecondOpen] = useState(true); return ( <> { firstOnClose(e); setIsFirstOpen(false); }} usePortal={false} > { secondOnClose(e); setIsSecondOpen(false); }} usePortal={false} > ); } renderWithOverlaysProvider(); const firstInput = screen.getByTestId("first-overlay-input"); const secondInput = screen.getByTestId("second-overlay-input"); // Verify both overlays are open expect(firstInput).to.exist; expect(secondInput).to.exist; // Find the second overlay container element const secondOverlayContainer = secondInput.closest(`.${Classes.OVERLAY}`); expect(secondOverlayContainer).to.exist; // Press Escape on the second overlay fireEvent.keyDown(secondOverlayContainer!, { key: "Escape" }); // Verify only the second overlay's onClose was called expect(firstOnClose).not.toHaveBeenCalled(); expect(secondOnClose).toHaveBeenCalledOnce(); // Wait for the second overlay to close await waitFor(() => expect(screen.queryByTestId("second-overlay-input")).not.toBeInTheDocument()); // Verify the first overlay is still open expect(screen.queryByTestId("first-overlay-input")).to.exist; // Verify focus returns to the first overlay await waitFor(() => { const firstOverlayContainer = firstInput.closest(`.${Classes.OVERLAY}`); expect(firstOverlayContainer?.contains(document.activeElement)).to.be.true; }); }); }); describe("Focus management", () => { const overlayClassName = "test-overlay"; it("should bring focus to overlay if autoFocus is true", async () => { renderWithOverlaysProvider( , ); await waitFor( () => expect(document.querySelector(`.${overlayClassName}`)?.contains(document.activeElement)).to.be.true, ); }); it("should not bring focus to overlay if autoFocus=false and enforceFocus=false", async () => { renderWithOverlaysProvider(
, ); await waitFor(() => expect(document.activeElement).to.equal(document.body)); }); // React implements autoFocus itself so our `[autofocus]` logic never fires. // Still, worth testing we can control where the focus goes. it("should focus autoFocus element inside overlay", async () => { renderWithOverlaysProvider( , ); await waitFor(() => expect(document.activeElement).to.equal(document.querySelector("input"))); }); it("should return focus to overlay if enforceFocus=true", async () => { const buttonRef = createRef(); const inputRef = createRef(); renderWithOverlaysProvider(
, ); expect(document.activeElement).to.equal(inputRef.current); buttonRef.current?.focus(); await waitFor( () => expect(document.querySelector(`.${overlayClassName}`)?.contains(document.activeElement)).to.be.true, ); }); it("should return focus to overlay after clicking the backdrop if enforceFocus=true", async () => { const user = userEvent.setup(); const { container } = renderWithOverlaysProvider( , ); const backdropElement = container.querySelector(BACKDROP_SELECTOR); expect(backdropElement).to.exist; await user.click(backdropElement!); await waitFor( () => expect(document.querySelector(`.${overlayClassName}`)?.contains(document.activeElement)).to.be.true, ); }); // SKIP: jsdom + requestAnimationFrame timing issue. The enforceFocus mechanism uses // requestAnimationFrame to delay focus manipulation (Overlay2), and RAF // timing in jsdom is inconsistent with userEvent clicks. it.skip("should return focus to overlay after clicking an outside element if enforceFocus=true", async () => { const user = userEvent.setup(); renderWithOverlaysProvider(
Overlay2 content!
, ); const buttonElement = screen.getByRole("button", { name: /button outside overlay/i }); await user.click(buttonElement); await waitFor( () => expect(document.querySelector(`.${overlayClassName}`)?.contains(document.activeElement)).to.be.true, ); }); it("should not result in maximum call stack if two overlays open with enforceFocus=true", async () => { const user = userEvent.setup(); const firstOverlayInstance = createRef(); const secondOverlayInputID = "inputId"; const firstOverlay: Overlay2Props = { children: , className: overlayClassName, enforceFocus: true, isOpen: true, ref: firstOverlayInstance, usePortal: false, }; const secondOverlay: Overlay2Props = { children: , className: overlayClassName, enforceFocus: true, isOpen: false, usePortal: false, }; const { rerender } = renderWithOverlaysProvider( <> , ); expect(firstOverlayInstance.current).to.exist; // open the second overlay rerender( <> , ); const secondOverlayInputElement = screen.getByTestId(secondOverlayInputID); // this click potentially triggers infinite recursion if both overlays try to bring focus back to themselves await user.click(secondOverlayInputElement!); // 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. }); it("should not return focus to overlay if enforceFocus=false", async () => { const buttonRef = createRef(); renderWithOverlaysProvider(
, ); expect(buttonRef.current).to.exist; buttonRef.current!.focus(); await waitFor(() => expect(document.activeElement).to.equal(buttonRef.current)); }); it("should not focus overlay if focus is already inside overlay", async () => { const textareaRef = createRef(); renderWithOverlaysProvider(