diff --git a/packages/core/src/components/overlay2/overlay2.tsx b/packages/core/src/components/overlay2/overlay2.tsx index 2295429285a..09d36fae298 100644 --- a/packages/core/src/components/overlay2/overlay2.tsx +++ b/packages/core/src/components/overlay2/overlay2.tsx @@ -226,6 +226,23 @@ export const Overlay2 = forwardRef((props, forwa [getThisOverlayAndDescendants, id, onClose], ); + // N.B. this listener allows Escape key to close overlays that don't have focus (e.g., hover-triggered tooltips) + // It's only attached when `autoFocus={false}` (indicating a hover interaction) and `canEscapeKeyClose={true}` + const handleDocumentKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === "Escape" && canEscapeKeyClose) { + // Only close if this is the topmost overlay to avoid closing multiple overlays at once + const lastOpened = getLastOpened(); + if (lastOpened?.id === id) { + onClose?.(e as any); + e.stopPropagation(); + e.preventDefault(); + } + } + }, + [canEscapeKeyClose, getLastOpened, id, onClose], + ); + // send this instance's imperative handle to the the forwarded ref as well as our local ref const ref = useMemo(() => mergeRefs(forwardedRef, instance), [forwardedRef]); useImperativeHandle( @@ -345,6 +362,21 @@ export const Overlay2 = forwardRef((props, forwa document.removeEventListener("mousedown", handleDocumentMousedown); }; }, [handleDocumentMousedown, isOpen, canOutsideClickClose, hasBackdrop]); + + useEffect(() => { + // Attach document-level keydown listener for overlays that don't receive focus (like hover tooltips) + // This enables Escape key dismissal without stealing focus on hover + if (!isOpen || autoFocus !== false || !canEscapeKeyClose) { + return; + } + + document.addEventListener("keydown", handleDocumentKeyDown); + + return () => { + document.removeEventListener("keydown", handleDocumentKeyDown); + }; + }, [handleDocumentKeyDown, isOpen, autoFocus, canEscapeKeyClose]); + useEffect(() => { if (!isOpen || !enforceFocus) { return; diff --git a/packages/core/src/components/tooltip/tooltip.tsx b/packages/core/src/components/tooltip/tooltip.tsx index db8d39dbc42..5a022e43192 100644 --- a/packages/core/src/components/tooltip/tooltip.tsx +++ b/packages/core/src/components/tooltip/tooltip.tsx @@ -135,7 +135,6 @@ export class Tooltip< }} {...restProps} autoFocus={false} - canEscapeKeyClose={false} disabled={ctxState.forceDisabled ?? disabled} enforceFocus={false} lazy={true} diff --git a/packages/core/test/tooltip/tooltipTests.tsx b/packages/core/test/tooltip/tooltipTests.tsx index 30ec64cfbeb..6f6d077e568 100644 --- a/packages/core/test/tooltip/tooltipTests.tsx +++ b/packages/core/test/tooltip/tooltipTests.tsx @@ -14,152 +14,277 @@ * limitations under the License. */ -import { assert } from "chai"; -import { mount } from "enzyme"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { expect } from "chai"; import { spy, stub } from "sinon"; import { Classes } from "../../src/common"; -import { Button, Overlay2 } from "../../src/components"; -import { Popover } from "../../src/components/popover/popover"; -import { Tooltip, type TooltipProps } from "../../src/components/tooltip/tooltip"; - -const TARGET_SELECTOR = `.${Classes.POPOVER_TARGET}`; -const TOOLTIP_SELECTOR = `.${Classes.TOOLTIP}`; -const TEST_TARGET_ID = "test-target"; +import { Button } from "../../src/components"; +import { Tooltip } from "../../src/components/tooltip/tooltip"; describe("", () => { describe("rendering", () => { it("propogates class names correctly", () => { - const tooltip = renderTooltip({ - className: "bar", - isOpen: true, - popoverClassName: "foo", - }); - assert.isTrue(tooltip.find(TOOLTIP_SELECTOR).hasClass(tooltip.prop("popoverClassName")!), "tooltip"); - assert.isTrue(tooltip.find(`.${Classes.POPOVER_TARGET}`).hasClass(tooltip.prop("className")!), "wrapper"); + const { container } = render( + +