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(
+
+
+ ,
+ );
+
+ 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;
+
+ await userEvent.hover(screen.getByText("target"));
+
+ await waitFor(() => expect(screen.getByText("content")).to.exist);
});
- it("triggers on focus", () => {
- const tooltip = renderTooltip();
- assert.lengthOf(tooltip.find(TOOLTIP_SELECTOR), 0);
+ it("triggers on focus", async () => {
+ render(
+
+
+ ,
+ );
+ const button = screen.getByText("target");
+
+ expect(screen.queryByText("content")).not.to.exist;
- tooltip.find(TARGET_SELECTOR).simulate("focus");
- assert.lengthOf(tooltip.find(TOOLTIP_SELECTOR), 1);
+ fireEvent.focus(button);
+
+ 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("does not trigger on focus if openOnTargetFocus={false}", async () => {
+ render(
+
+
+ ,
+ );
+ const button = screen.getByText("target");
+
+ expect(screen.queryByText("content")).not.to.exist;
+
+ fireEvent.focus(button);
- tooltip.find(Popover).simulate("focus");
- assert.lengthOf(tooltip.find(TOOLTIP_SELECTOR), 0);
+ // Wait a bit to ensure tooltip doesn't appear
+ await new Promise(resolve => setTimeout(resolve, 50));
+
+ expect(screen.queryByText("content")).not.to.exist;
});
- it("empty content disables Popover and warns", () => {
+ it("empty content disables Popover and warns with empty string", () => {
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);
+ render(
+
+
+ ,
+ );
+
+ expect(screen.queryByText("content")).not.to.exist;
+ expect(warnSpy.called).to.be.true;
+
warnSpy.restore();
});
- it("setting disabled=true prevents opening tooltip", () => {
- const tooltip = renderTooltip({ disabled: true });
- tooltip.find(Popover).simulate("mouseenter");
- assert.lengthOf(tooltip.find(TOOLTIP_SELECTOR), 0);
+ it("empty content disables Popover and warns with whitespace", () => {
+ const warnSpy = stub(console, "warn");
+ render(
+
+
+ ,
+ );
+
+ expect(screen.queryByText("content")).not.to.exist;
+ expect(warnSpy.called).to.be.true;
+
+ warnSpy.restore();
+ });
+
+ it("setting disabled=true prevents opening tooltip", async () => {
+ render(
+
+
+ ,
+ );
+
+ await userEvent.hover(screen.getByText("target"));
+
+ expect(screen.queryByText("content")).not.to.exist;
});
});
describe("in controlled mode", () => {
it("renders when open", () => {
- const tooltip = renderTooltip({ isOpen: true });
- assert.lengthOf(tooltip.find(TOOLTIP_SELECTOR), 1);
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText("content")).to.exist;
});
it("doesn't render when not open", () => {
- const tooltip = renderTooltip({ isOpen: false });
- assert.lengthOf(tooltip.find(TOOLTIP_SELECTOR), 0);
+ render(
+
+
+ ,
+ );
+
+ 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);
+ render(
+
+
+ ,
+ );
+
+ 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", () => {
- 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");
+ 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;
+ });
+
+ 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);
+ });
});