Skip to content

Commit

Permalink
fix: account for clientX and clientY in outsidePress (#1089)
Browse files Browse the repository at this point in the history
  • Loading branch information
huntabyte authored Feb 1, 2025
1 parent 978a8e0 commit 7c02a90
Show file tree
Hide file tree
Showing 7 changed files with 68 additions and 12 deletions.
5 changes: 5 additions & 0 deletions .changeset/tough-carrots-grin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"bits-ui": patch
---

fix: Dialog/Popover clientX/Y detection to prevent password managers and other injected elements from closing the dialog when pressed
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { debounce } from "$lib/internal/debounce.js";
import { noop } from "$lib/internal/noop.js";
import { getOwnerDocument, isOrContainsTarget } from "$lib/internal/elements.js";
import { isElement } from "$lib/internal/is.js";
import { isClickTrulyOutside } from "$lib/internal/dom.js";

globalThis.bitsDismissableLayers ??= new Map<
DismissibleLayerState,
Expand Down Expand Up @@ -269,7 +270,9 @@ function isValidEvent(e: PointerEvent, node: HTMLElement): boolean {
if (!isElement(target)) return false;
const ownerDocument = getOwnerDocument(target);
const isValid =
ownerDocument.documentElement.contains(target) && !isOrContainsTarget(node, target);
ownerDocument.documentElement.contains(target) &&
!isOrContainsTarget(node, target) &&
isClickTrulyOutside(e, node);
return isValid;
}

Expand Down
14 changes: 14 additions & 0 deletions packages/bits-ui/src/lib/internal/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,17 @@ export function getFirstNonCommentChild(element: HTMLElement | null) {
}
return null;
}

/**
* Determines if the click event truly occurred outside the content node.
* This was added to handle password managers and other elements that may be injected
* into the DOM but visually appear inside the content.
*/
export function isClickTrulyOutside(event: PointerEvent, contentNode: HTMLElement): boolean {
const { clientX, clientY } = event;
const rect = contentNode.getBoundingClientRect();

return (
clientX < rect.left || clientX > rect.right || clientY < rect.top || clientY > rect.bottom
);
}
8 changes: 6 additions & 2 deletions packages/tests/src/tests/combobox/combobox.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { render, waitFor } from "@testing-library/svelte";
import { axe } from "jest-axe";
import { describe, it } from "vitest";
import { describe, it, vi } from "vitest";
import { type Component, tick } from "svelte";
import { type AnyFn, getTestKbd, setupUserEvents, sleep } from "../utils.js";
import ComboboxTest from "./combobox-test.svelte";
Expand Down Expand Up @@ -32,6 +32,8 @@ const testItems: Item[] = [
},
];

const contentRect = { left: 100, right: 200, top: 100, bottom: 200 };

function setupSingle(
props: Partial<ComboboxSingleTestProps | ComboboxForceMountTestProps> = {},
items: Item[] = testItems,
Expand Down Expand Up @@ -216,8 +218,10 @@ describe("combobox - single", () => {
it("should close on outside click", async () => {
const { user, getContent, outside } = await openSingle();
await sleep(100);
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue(
contentRect as DOMRect
);
await user.click(outside);
await sleep(100);
expect(getContent()).toBeNull();
});

Expand Down
7 changes: 6 additions & 1 deletion packages/tests/src/tests/dialog/dialog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
waitFor,
} from "@testing-library/svelte/svelte5";
import { axe } from "jest-axe";
import { describe, it } from "vitest";
import { describe, it, vi } from "vitest";
import { tick } from "svelte";
import { getTestKbd, setupUserEvents, sleep } from "../utils.js";
import DialogTest, { type DialogTestProps } from "./dialog-test.svelte";
Expand Down Expand Up @@ -50,6 +50,8 @@ async function open(props: DialogTestProps = {}) {
return { getByTestId, queryByTestId, user };
}

const contentRect = { left: 100, right: 200, top: 100, bottom: 200 };

describe("dialog", () => {
it("should have no accessibility violations", async () => {
const { container } = render(DialogTest);
Expand Down Expand Up @@ -99,6 +101,9 @@ describe("dialog", () => {
await sleep(100);

const overlay = getByTestId("overlay");
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue(
contentRect as DOMRect
);
await user.click(overlay);
await sleep(25);

Expand Down
30 changes: 23 additions & 7 deletions packages/tests/src/tests/popover/popover.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { render, waitFor } from "@testing-library/svelte/svelte5";
import { axe } from "jest-axe";
import { describe, it, vi } from "vitest";
import type { Component } from "svelte";
import { type Component } from "svelte";
import { getTestKbd, setupUserEvents } from "../utils.js";
import PopoverTest, { type PopoverTestProps } from "./popover-test.svelte";
import PopoverForceMountTest, {
Expand Down Expand Up @@ -73,16 +73,32 @@ describe("popover", () => {
expect(getContent()).toBeNull();
});

it("should close on outside click by default", async () => {
it("should close on outside click", async () => {
const mockFn = vi.fn();
const { user, getByTestId } = await open({
contentProps: {
onInteractOutside: mockFn,
},
const { getByTestId, user } = await open({
contentProps: { onInteractOutside: mockFn },
});

const outside = getByTestId("outside");

const contentRect = { left: 100, right: 200, top: 100, bottom: 200 };
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue(
contentRect as DOMRect
);
await user.click(outside);
expect(mockFn).toHaveBeenCalledTimes(1);

expect(mockFn).toHaveBeenCalledOnce();
});

it("should not close when clicking within bounds", async () => {
const mockFn = vi.fn();
const { user, content } = await open({
contentProps: { onInteractOutside: mockFn },
});

await user.click(content);

expect(mockFn).not.toHaveBeenCalled();
});

it("should close when the close button is clicked", async () => {
Expand Down
11 changes: 10 additions & 1 deletion packages/tests/src/tests/select/select.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ const testItems: Item[] = [
},
];

const contentRect = { left: 100, right: 200, top: 100, bottom: 200 };

function setupSingle(
props: Partial<SelectSingleTestProps | SelectForceMountTestProps> = {},
items: Item[] = testItems,
Expand Down Expand Up @@ -223,6 +225,11 @@ describe("select - single", () => {
it("should close on outside click", async () => {
const { user, getContent, outside } = await openSingle();
await sleep(100);

vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue(
contentRect as DOMRect
);

await user.click(outside);
await sleep(100);
expect(getContent()).toBeNull();
Expand Down Expand Up @@ -623,8 +630,10 @@ describe("select - multiple", () => {
it("should close on outside click", async () => {
const { user, getContent, outside } = await openMultiple();
await sleep(100);
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue(
contentRect as DOMRect
);
await user.click(outside);
await sleep(100);
expect(getContent()).toBeNull();
});

Expand Down

0 comments on commit 7c02a90

Please sign in to comment.