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(
+
+
+ ,
+ );
+
+ expect(container.querySelector(`.${Classes.TOOLTIP}.foo`)).to.exist;
+ expect(container.querySelector(`.${Classes.POPOVER_TARGET}.bar`)).to.exist;
});
it("targetTagName renders the right elements", () => {
- const tooltip = renderTooltip({
- isOpen: true,
- targetTagName: "address",
- });
- assert.isTrue(tooltip.find("address").hasClass(Classes.POPOVER_TARGET));
+ const { container } = render(
+
+
+ ,
+ );
+
+ expect(container.querySelector(`address.${Classes.POPOVER_TARGET}`)).to.exist;
});
- it("applies minimal class & hides arrow when minimal is true", () => {
- const tooltip = renderTooltip({ isOpen: true, minimal: true });
- assert.isTrue(tooltip.find(TOOLTIP_SELECTOR).hasClass(Classes.MINIMAL));
- assert.isFalse(tooltip.find(Popover).props().modifiers!.arrow!.enabled);
+ it("applies minimal class when minimal is true", () => {
+ const { container } = render(
+
+
+ ,
+ );
+
+ expect(container.querySelector(`.${Classes.TOOLTIP}.${Classes.MINIMAL}`)).to.exist;
});
- it("does not apply minimal class & shows arrow when minimal is false", () => {
- const tooltip = renderTooltip({ isOpen: true });
- // Minimal should be false by default.
- assert.isFalse(tooltip.props().minimal);
- assert.isFalse(tooltip.find(TOOLTIP_SELECTOR).hasClass(Classes.MINIMAL));
- assert.isTrue(tooltip.find(Popover).props().modifiers!.arrow!.enabled);
+ it("does not apply minimal class when minimal is false", () => {
+ const { container } = render(
+
+
+ ,
+ );
+
+ expect(container.querySelector(`.${Classes.TOOLTIP}.${Classes.MINIMAL}`)).not.to.exist;
});
});
describe("basic functionality", () => {
it("supports overlay lifecycle props", () => {
const onOpening = spy();
- renderTooltip({ isOpen: true, onOpening });
- assert.isTrue(onOpening.calledOnce);
+ render(
+
+
+ ,
+ );
+
+ expect(onOpening.calledOnce).to.be.true;
});
});
describe("in uncontrolled mode", () => {
- it("defaultIsOpen determines initial open state", () => {
- assert.lengthOf(renderTooltip({ defaultIsOpen: true }).find(TOOLTIP_SELECTOR), 1);
+ it("defaultIsOpen determines initial open state", async () => {
+ render(
+
+
+ ,
+ );
+
+ await waitFor(() => expect(screen.getByText("content")).to.exist);
});
- it("triggers on hover", () => {
- const tooltip = renderTooltip();
- assert.lengthOf(tooltip.find(TOOLTIP_SELECTOR), 0);
+ it("triggers on hover", async () => {
+ render(
+
+
+ ,
+ );
- tooltip.find(TARGET_SELECTOR).simulate("mouseenter");
- assert.lengthOf(tooltip.find(TOOLTIP_SELECTOR), 1);
- });
+ expect(screen.queryByText("content")).not.to.exist;
- it("triggers on focus", () => {
- const tooltip = renderTooltip();
- assert.lengthOf(tooltip.find(TOOLTIP_SELECTOR), 0);
+ await userEvent.hover(screen.getByText("target"));
- tooltip.find(TARGET_SELECTOR).simulate("focus");
- assert.lengthOf(tooltip.find(TOOLTIP_SELECTOR), 1);
+ await waitFor(() => expect(screen.getByText("content")).to.exist);
});
- it("does not trigger on focus if openOnTargetFocus={false}", () => {
- const tooltip = renderTooltip({ openOnTargetFocus: false });
- assert.lengthOf(tooltip.find(TOOLTIP_SELECTOR), 0);
+ it("triggers on focus", async () => {
+ render(
+
+
+ ,
+ );
+ const button = screen.getByText("target");
- tooltip.find(Popover).simulate("focus");
- assert.lengthOf(tooltip.find(TOOLTIP_SELECTOR), 0);
- });
+ expect(screen.queryByText("content")).not.to.exist;
- it("empty content disables Popover and warns", () => {
- const warnSpy = stub(console, "warn");
- const tooltip = renderTooltip({ content: "", isOpen: true });
-
- function assertDisabledPopover(content: string) {
- tooltip.setProps({ content });
- assert.isFalse(tooltip.find(Overlay2).exists(), `"${content}"`);
- assert.isTrue(warnSpy.called, "spy not called");
- warnSpy.resetHistory();
- }
-
- assertDisabledPopover("");
- assertDisabledPopover(" ");
- // @ts-expect-error
- assertDisabledPopover(null);
- warnSpy.restore();
- });
+ fireEvent.focus(button);
- it("setting disabled=true prevents opening tooltip", () => {
- const tooltip = renderTooltip({ disabled: true });
- tooltip.find(Popover).simulate("mouseenter");
- assert.lengthOf(tooltip.find(TOOLTIP_SELECTOR), 0);
+ await waitFor(() => expect(screen.getByText("content")).to.exist);
});
- });
- describe("in controlled mode", () => {
- it("renders when open", () => {
- const tooltip = renderTooltip({ isOpen: true });
- assert.lengthOf(tooltip.find(TOOLTIP_SELECTOR), 1);
- });
+ it("does not trigger on focus if openOnTargetFocus={false}", async () => {
+ render(
+
+
+ ,
+ );
+ const button = screen.getByText("target");
- it("doesn't render when not open", () => {
- const tooltip = renderTooltip({ isOpen: false });
- assert.lengthOf(tooltip.find(TOOLTIP_SELECTOR), 0);
- });
+ expect(screen.queryByText("content")).not.to.exist;
- it("empty content disables Popover and warns", () => {
- const warnSpy = stub(console, "warn");
- const tooltip = renderTooltip({ content: "", isOpen: true });
- assert.isFalse(tooltip.find(Overlay2).exists());
- assert.isTrue(warnSpy.called);
- warnSpy.restore();
- });
+ fireEvent.focus(button);
- describe("onInteraction()", () => {
- it("is invoked with `true` when closed tooltip target is hovered", () => {
- const handleInteraction = spy();
- renderTooltip({ isOpen: false, onInteraction: handleInteraction })
- .find(TARGET_SELECTOR)
- .simulate("mouseenter");
- assert.isTrue(handleInteraction.calledOnce, "called once");
- assert.isTrue(handleInteraction.calledWith(true), "call args");
- });
- });
- });
+ // Wait a bit to ensure tooltip doesn't appear
+ await new Promise(resolve => setTimeout(resolve, 50));
- describe("keyboard interactions", () => {
- it("Escape key closes tooltip", () => {
- const onClose = spy();
- const tooltip = renderTooltip({ isOpen: true, onClose });
+ expect(screen.queryByText("content")).not.to.exist;
+ });
- // Verify tooltip is rendered
- assert.lengthOf(tooltip.find(TOOLTIP_SELECTOR), 1, "tooltip should be rendered");
+ it("empty content disables Popover and warns with empty string", () => {
+ const warnSpy = stub(console, "warn");
+ render(
+
+
+ ,
+ );
- // 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);
+ expect(screen.queryByText("content")).not.to.exist;
+ expect(warnSpy.called).to.be.true;
- assert.isTrue(onClose.calledOnce, "onClose callback should be called");
+ warnSpy.restore();
});
- it("Escape key works without tooltip receiving focus", () => {
- const onClose = spy();
- const tooltip = renderTooltip({ isOpen: true, onClose });
+ it("empty content disables Popover and warns with whitespace", () => {
+ const warnSpy = stub(console, "warn");
+ render(
+
+
+ ,
+ );
- // Verify that autoFocus is false (tooltip should not steal focus)
- const overlay2 = tooltip.find(Overlay2);
- assert.isFalse(overlay2.prop("autoFocus"), "autoFocus should be false");
+ expect(screen.queryByText("content")).not.to.exist;
+ expect(warnSpy.called).to.be.true;
- // Verify tooltip is rendered
- assert.lengthOf(tooltip.find(TOOLTIP_SELECTOR), 1, "tooltip should be rendered");
+ warnSpy.restore();
+ });
- // 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",
+ it("setting disabled=true prevents opening tooltip", async () => {
+ render(
+
+
+ ,
);
- // 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);
+ await userEvent.hover(screen.getByText("target"));
- assert.isTrue(onClose.calledOnce, "onClose should be called even without focus");
+ expect(screen.queryByText("content")).not.to.exist;
});
+ });
- it("tooltip does not steal focus on hover", () => {
- const tooltip = renderTooltip();
+ describe("in controlled mode", () => {
+ it("renders when open", () => {
+ render(
+
+
+ ,
+ );
- // Open tooltip via hover
- tooltip.find(TARGET_SELECTOR).simulate("mouseenter");
- tooltip.update();
+ expect(screen.getByText("content")).to.exist;
+ });
- // Verify tooltip is open
- assert.lengthOf(tooltip.find(TOOLTIP_SELECTOR), 1, "tooltip should be open");
+ it("doesn't render when not open", () => {
+ render(
+
+
+ ,
+ );
- // 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");
+ expect(screen.queryByText("content")).not.to.exist;
});
- it("enforceFocus is false to allow focus to remain outside tooltip", () => {
- const tooltip = renderTooltip({ isOpen: true });
- const overlay2 = tooltip.find(Overlay2);
+ it("empty content disables Popover and warns", () => {
+ const warnSpy = stub(console, "warn");
+ render(
+
+
+ ,
+ );
- assert.isFalse(overlay2.prop("enforceFocus"), "enforceFocus should be false");
- assert.isFalse(overlay2.prop("autoFocus"), "autoFocus should be false");
+ expect(screen.queryByText("content")).not.to.exist;
+ expect(warnSpy.called).to.be.true;
+
+ warnSpy.restore();
+ });
+
+ describe("onInteraction()", () => {
+ it("is invoked with `true` when closed tooltip target is hovered", async () => {
+ const onInteraction = spy();
+ render(
+
+
+ ,
+ );
+
+ await userEvent.hover(screen.getByText("target"));
+
+ expect(onInteraction.calledOnce).to.be.true;
+ expect(onInteraction.calledWith(true)).to.be.true;
+ });
});
});
- function renderTooltip(props?: Partial) {
- return mount(
- Text} hoverOpenDelay={0} {...props} usePortal={false}>
-
+ it("Escape key closes tooltip", async () => {
+ const onClose = spy();
+ render(
+
+
,
);
- }
+
+ expect(screen.getByText("content")).to.exist;
+
+ await userEvent.keyboard("{Escape}");
+
+ expect(onClose.calledOnce).to.be.true;
+ });
});
From 306ac4eed577b32325913bdb55323bf760a40256 Mon Sep 17 00:00:00 2001
From: Gregory Douglas
Date: Mon, 1 Dec 2025 12:14:00 -0500
Subject: [PATCH 3/3] Write test to verify closing multiple Tooltips with Esc
---
packages/core/test/tooltip/tooltipTests.tsx | 35 +++++++++++++++++++++
1 file changed, 35 insertions(+)
diff --git a/packages/core/test/tooltip/tooltipTests.tsx b/packages/core/test/tooltip/tooltipTests.tsx
index 929b6752d0b..6f6d077e568 100644
--- a/packages/core/test/tooltip/tooltipTests.tsx
+++ b/packages/core/test/tooltip/tooltipTests.tsx
@@ -252,4 +252,39 @@ describe("", () => {
expect(onClose.calledOnce).to.be.true;
});
+
+ it("Escape key closes only the most recently opened tooltip when multiple are open", async () => {
+ render(
+
+
+
+
+
+
+
+
,
+ );
+
+ // Wait for first tooltip to be open
+ await waitFor(() => expect(screen.getByText("first tooltip")).to.exist);
+
+ // Hover second tooltip to open it
+ await userEvent.hover(screen.getByText("second target"));
+ await waitFor(() => expect(screen.getByText("second tooltip")).to.exist);
+
+ // Both tooltips should be visible
+ expect(screen.getByText("first tooltip")).to.exist;
+ expect(screen.getByText("second tooltip")).to.exist;
+
+ // Press Escape to close second (most recent) tooltip
+ await userEvent.keyboard("{Escape}");
+
+ await waitFor(() => expect(screen.queryByText("second tooltip")).not.to.exist);
+ expect(screen.getByText("first tooltip")).to.exist;
+
+ // Press Escape again to close the first tooltip
+ await userEvent.keyboard("{Escape}");
+
+ await waitFor(() => expect(screen.queryByText("first tooltip")).not.to.exist);
+ });
});