From 7c406fbd7469e3c71abc382cb4d41ec79c6a77b6 Mon Sep 17 00:00:00 2001 From: Gregory Douglas Date: Wed, 26 Nov 2025 14:46:04 -0500 Subject: [PATCH 1/3] Add functionality to allow Tooltips to be esc-key closed --- .../core/src/components/overlay2/overlay2.tsx | 32 ++++++++ .../core/src/components/tooltip/tooltip.tsx | 1 - packages/core/test/tooltip/tooltipTests.tsx | 73 +++++++++++++++++++ 3 files changed, 105 insertions(+), 1 deletion(-) 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..c312067ef70 100644 --- a/packages/core/test/tooltip/tooltipTests.tsx +++ b/packages/core/test/tooltip/tooltipTests.tsx @@ -155,6 +155,79 @@ describe("", () => { }); }); + describe("keyboard interactions", () => { + it("Escape key closes tooltip", () => { + const onClose = spy(); + const tooltip = renderTooltip({ isOpen: true, onClose }); + + // Verify tooltip is rendered + assert.lengthOf(tooltip.find(TOOLTIP_SELECTOR), 1, "tooltip should be rendered"); + + // Simulate Escape key press on document (since tooltip doesn't steal focus) + const escapeEvent = new KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + key: "Escape", + }); + document.dispatchEvent(escapeEvent); + + assert.isTrue(onClose.calledOnce, "onClose callback should be called"); + }); + + it("Escape key works without tooltip receiving focus", () => { + const onClose = spy(); + const tooltip = renderTooltip({ isOpen: true, onClose }); + + // Verify that autoFocus is false (tooltip should not steal focus) + const overlay2 = tooltip.find(Overlay2); + assert.isFalse(overlay2.prop("autoFocus"), "autoFocus should be false"); + + // Verify tooltip is rendered + assert.lengthOf(tooltip.find(TOOLTIP_SELECTOR), 1, "tooltip should be rendered"); + + // The active element should NOT be inside the tooltip + const tooltipContainer = document.querySelector(`.${Classes.OVERLAY}`); + assert.notStrictEqual( + document.activeElement?.parentElement, + tooltipContainer, + "tooltip should not have focus", + ); + + // Escape key should close the tooltip via document-level listener (enabled by default) + const escapeEvent = new KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + key: "Escape", + }); + document.dispatchEvent(escapeEvent); + + assert.isTrue(onClose.calledOnce, "onClose should be called even without focus"); + }); + + it("tooltip does not steal focus on hover", () => { + const tooltip = renderTooltip(); + + // Open tooltip via hover + tooltip.find(TARGET_SELECTOR).simulate("mouseenter"); + tooltip.update(); + + // Verify tooltip is open + assert.lengthOf(tooltip.find(TOOLTIP_SELECTOR), 1, "tooltip should be open"); + + // Verify autoFocus is false (tooltip won't steal focus) + const overlay2 = tooltip.find(Overlay2); + assert.isFalse(overlay2.prop("autoFocus"), "autoFocus should be false to not steal focus"); + }); + + it("enforceFocus is false to allow focus to remain outside tooltip", () => { + const tooltip = renderTooltip({ isOpen: true }); + const overlay2 = tooltip.find(Overlay2); + + assert.isFalse(overlay2.prop("enforceFocus"), "enforceFocus should be false"); + assert.isFalse(overlay2.prop("autoFocus"), "autoFocus should be false"); + }); + }); + function renderTooltip(props?: Partial) { return mount( Text

} hoverOpenDelay={0} {...props} usePortal={false}> From 56a9874525c19104b0dd726042770c7099c5d9ca Mon Sep 17 00:00:00 2001 From: Gregory Douglas Date: Mon, 1 Dec 2025 11:31:21 -0500 Subject: [PATCH 2/3] Rewrite Tooltip tests with RTL --- packages/core/test/tooltip/tooltipTests.tsx | 331 ++++++++++---------- 1 file changed, 174 insertions(+), 157 deletions(-) diff --git a/packages/core/test/tooltip/tooltipTests.tsx b/packages/core/test/tooltip/tooltipTests.tsx index c312067ef70..929b6752d0b 100644 --- a/packages/core/test/tooltip/tooltipTests.tsx +++ b/packages/core/test/tooltip/tooltipTests.tsx @@ -14,225 +14,242 @@ * 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( + +