Repository: melt-ui/melt-ui
Pull request #1114 (2024-03-22 15:27:28)

Fix(interact outside): enable interact outside interception
Closes https://github.com/melt-ui/melt-ui/issues/917, https://github.com/melt-ui/melt-ui/issues/1128

This PR adds the ability for a DOM element to intercept an outside interaction by calling `e.stopPropagation()`.

Let's use an example that demonstrates how the DOM behaves and the challenges it imposes for this PR:

On a mouse device, clicking an element will trigger all of the following events:
1. pointerdown
2. pointerup
3. mousedown
4. mouseup
5. click

If the user intercepts the `pointerdown` event, `pointerup` and `click` will still trigger while the mouse events will not.
If the user intercepts `mousedown` or `mouseup`, all the other events will still be triggered.

Let's use another example: on a touch device, clicking an element will trigger all of the following events:
1. pointerdown
2. touchstart
3. pointerup
4. touchend
5. mousedown
6. mouseup
7. click

If the user intercepts `touchstart` or `touchend`, the mouse and click events will NOT be triggered but the pointer events WILL.

Ok, so ideally for our sake, the user should be able to intercept any of the events and prevent an outside interaction.

Currently, we default to only using pointer events and fallback to mouse and touch events when pointer events are not available.

But in the case of issue https://github.com/melt-ui/melt-ui/issues/917, clicking on the 1Password button inside an input that is inside a dialog closes the dialog. Despite 1Password intercepting the mouse events, our `pointerup` handler is still triggered, leading to the unintended closure of the dialog.

So we should listen to all the appropriate events and trigger an outside interaction only if none of the events were intercepted by the user.

Similar to PR https://github.com/melt-ui/melt-ui/pull/845 that enabled intercepting an escape key down by not using `capture` listeners, in order to allow intercepting outside interactions, we should not trigger an outside interaction using capture listeners.

However the challenge is--how do you determine if any of the events were intercepted when the browser will trigger a bunch of other events nonetheless.

To identify intercepted events during an interaction, we implement a two-step approach for the following events: `pointerdown`, `touchstart`, `pointerup`, `touchend`, `mousedown`, `mouseup`, and `click`. First, we attach an event listener in the capture phase (`capture: true`) to flag the event as intercepted. Subsequently, we add another event listener for the bubbling phase, designed to tag the event as NOT intercepted if it passes through. This setup relies on the principle that the bubbling phase listener is called only if the event was not intercepted by the user. Conversely, if an event is intercepted, only the capture phase listener will trigger, which marks the event as intercepted.

So now we can easily check if any events were intercepted and not trigger an outside interaction in that case.

Since we attach event listeners for multiple events, the browser may trigger multiple events at the same time (i.e. `pointerdown` + `touchstart`, `pointerup` + `touchend`, etc.). In order to make sure that we don't run into race conditions and prematurely check whether any events were intercepted before all other bubbling handlers have potentially marked the events as not intercepted, we debounce the event handlers, which also prevents them from being executed more than once.

After an interaction has finished, we also need to correctly reset the `interceptedEvents` object so that future interactions that are not intercepted will behave as expected. We should only reset `interceptedEvents` at the end of an interaction (`touchend`, `pointerup`, `mouseup`, `click`) in case the user intercepted the beginning of an interaction while still expecting the whole interaction to be intercepted. We debounce resetting `interceptedEvents` to avoid race conditions. We should also call `resetInterceptedEventsDebounced` in the capture phase handlers since the bubbling phase handlers may not be called due to user interception.

I modified the dialog example in the docs and added buttons that intercept different events for demonstration purposes. The modified docs should not be included in the final PR.

Also, I added a lot of tests that make sure all this works correctly so don't be intimidated by the number of lines added by this PR :)

Thanks for reading all this :)


### Before

https://github.com/melt-ui/melt-ui/assets/53095479/21002268-8cff-4ed8-a8af-1f21e4f43a95

### After

https://github.com/melt-ui/melt-ui/assets/53095479/51cb8552-c1e5-4041-addc-b33930d6bb69

### Mouse Device

https://github.com/melt-ui/melt-ui/assets/53095479/c95a68dc-772f-43c8-8167-700309dc4bb2

### Touch Device

https://github.com/melt-ui/melt-ui/assets/53095479/c724abf0-e84f-4ecf-aae6-ed6f4111c367



changeset-bot[bot]: ###  ⚠️  No Changeset found

Latest commit: 54d3ea3bcf99317b2655ca6bd963abaa81e94ec0

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. **If these changes should result in a version bump, you need to add a changeset.**

<details><summary>This PR includes no changesets</summary>

  When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

</details>

[Click here to learn what changesets are, and how to add one](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add a changeset to this PR](https://github.com/anatolzak/melt-ui/new/fix/enable-interact-outside-interception?filename=.changeset/sixty-hats-accept.md&value=---%0A%22%40melt-ui%2Fsvelte%22%3A%20patch%0A---%0A%0AFix(interact%20outside)%3A%20enable%20interact%20outside%20interception%0A)


huntabyte: Hey @anatolzak this looks great, incredible investigative work to tackle this problem! Whenever you're ready (after cleaning up the demo) let me know and we'll get this merged!
anatolzak: @huntabyte I am glad all this makes sense lol :)
I cleaned the demo so this PR is ready! 🎉

Patch:
diff --git a/.changeset/young-peaches-retire.md b/.changeset/young-peaches-retire.md
new file mode 100644
index 00000000..f5b95594
--- /dev/null
+++ b/.changeset/young-peaches-retire.md
@@ -0,0 +1,5 @@
+---
+'@melt-ui/svelte': patch
+---
+
+Fixed bug where outside interactions could not be intercepted (closes #917)
diff --git a/src/lib/internal/actions/interact-outside/action.ts b/src/lib/internal/actions/interact-outside/action.ts
index 28638d38..53fc6573 100644
--- a/src/lib/internal/actions/interact-outside/action.ts
+++ b/src/lib/internal/actions/interact-outside/action.ts
@@ -3,101 +3,160 @@ import {
 	isElement,
 	executeCallbacks,
 	noop,
+	debounce,
 } from '$lib/internal/helpers/index.js';
-import type { InteractOutsideConfig, InteractOutsideEvent } from './types.js';
+import type {
+	InteractOutsideConfig,
+	InteractOutsideEvent,
+	InteractOutsideInterceptEventType,
+	InteractOutsideInterceptHandler,
+} from './types.js';
 import type { Action } from 'svelte/action';
 
 export const useInteractOutside = ((node, config) => {
-	let unsub = noop;
-	let unsubClick = noop;
+	let unsubEvents = noop;
+	let unsubPointerDown = noop;
+	let unsubPointerUp = noop;
+	let unsubResetInterceptedEvents = noop;
+
+	const documentObj = getOwnerDocument(node);
 
 	let isPointerDown = false;
 	let isPointerDownInside = false;
-	let ignoreEmulatedMouseEvents = false;
+
+	const interceptedEvents: Record<InteractOutsideInterceptEventType, boolean> = {
+		pointerdown: false,
+		pointerup: false,
+		mousedown: false,
+		mouseup: false,
+		touchstart: false,
+		touchend: false,
+		click: false,
+	};
+
+	const resetInterceptedEvents = () => {
+		for (const eventType in interceptedEvents) {
+			interceptedEvents[eventType as InteractOutsideInterceptEventType] = false;
+		}
+	};
+
+	const isAnyEventIntercepted = () => {
+		for (const isIntercepted of Object.values(interceptedEvents)) {
+			if (isIntercepted) return true;
+		}
+		return false;
+	};
+
+	/**
+	 * To determine event interception, events are marked as intercepted in the capture phase.
+	 * If they are not invoked again in the bubbling phase, it indicates user interception.
+	 */
+	const setupCapturePhaseHandlerAndMarkAsIntercepted = <
+		E extends InteractOutsideInterceptEventType
+	>(
+		eventType: E,
+		handler?: InteractOutsideInterceptHandler<E>
+	) => {
+		return addEventListener(
+			documentObj,
+			eventType,
+			(e: HTMLElementEventMap[E]) => {
+				interceptedEvents[eventType] = true;
+				handler?.(e);
+			},
+			true
+		);
+	};
+
+	/**
+	 * An event listener for the bubbling phase marks events as not intercepted.
+	 */
+	const setupBubblePhaseHandlerAndMarkAsNotIntercepted = <
+		E extends InteractOutsideInterceptEventType
+	>(
+		eventType: E,
+		handler?: InteractOutsideInterceptHandler<E>
+	) => {
+		return addEventListener(documentObj, eventType, (e: HTMLElementEventMap[E]) => {
+			interceptedEvents[eventType] = false;
+			handler?.(e);
+		});
+	};
 
 	function update(config: InteractOutsideConfig) {
-		unsub();
-		unsubClick();
+		unsubEvents();
+		unsubPointerDown();
+		unsubPointerUp();
+		unsubResetInterceptedEvents();
+		resetInterceptedEvents();
 		const { onInteractOutside, onInteractOutsideStart, enabled } = config;
-
 		if (!enabled) return;
 
-		function onPointerDown(e: PointerEvent | MouseEvent | TouchEvent) {
-			if (onInteractOutside && isValidEvent(e, node)) {
-				onInteractOutsideStart?.(e);
-			}
+		/**
+		 * Debouncing `onPointerDown` ensures that other events can be flagged as not intercepted,
+		 * allowing a comprehensive check for intercepted events thereafter.
+		 */
+		const onPointerDownDebounced = debounce((e: InteractOutsideEvent) => {
+			if (isAnyEventIntercepted()) return;
+			if (onInteractOutside && isValidEvent(e, node)) onInteractOutsideStart?.(e);
 			const target = e.target;
-
 			if (isElement(target) && isOrContainsTarget(node, target)) {
 				isPointerDownInside = true;
 			}
-
 			isPointerDown = true;
-		}
-
-		function triggerInteractOutside(e: InteractOutsideEvent) {
-			onInteractOutside?.(e);
-		}
-
-		const documentObj = getOwnerDocument(node);
-
-		// Use pointer events if available, otherwise use mouse/touch events
-		if (typeof PointerEvent !== 'undefined') {
-			const onPointerUp = (e: PointerEvent) => {
-				unsubClick();
-
-				const handler = (e: InteractOutsideEvent) => {
-					if (shouldTriggerInteractOutside(e)) {
-						triggerInteractOutside(e);
-					}
-					resetPointerState();
-				};
-
-				/**
-				 * On touch devices, we need to wait for a click event because browsers implement
-				 * a delay between the time the user stops touching the display and when the
-				 * browser executes the click event. Without waiting for the click event, the
-				 * browser may execute events on other elements that should have been prevented.
-				 */
-				if (e.pointerType === 'touch') {
-					unsubClick = addEventListener(documentObj, 'click', handler, {
-						capture: true,
-						once: true,
-					});
-					return;
-				}
-				handler(e);
-			};
-
-			unsub = executeCallbacks(
-				addEventListener(documentObj, 'pointerdown', onPointerDown, true),
-				addEventListener(documentObj, 'pointerup', onPointerUp, true)
-			);
-		} else {
-			const onMouseUp = (e: MouseEvent) => {
-				if (ignoreEmulatedMouseEvents) {
-					ignoreEmulatedMouseEvents = false;
-				} else if (shouldTriggerInteractOutside(e)) {
-					triggerInteractOutside(e);
-				}
-				resetPointerState();
-			};
-
-			const onTouchEnd = (e: TouchEvent) => {
-				ignoreEmulatedMouseEvents = true;
-				if (shouldTriggerInteractOutside(e)) {
-					triggerInteractOutside(e);
-				}
-				resetPointerState();
-			};
-
-			unsub = executeCallbacks(
-				addEventListener(documentObj, 'mousedown', onPointerDown, true),
-				addEventListener(documentObj, 'mouseup', onMouseUp, true),
-				addEventListener(documentObj, 'touchstart', onPointerDown, true),
-				addEventListener(documentObj, 'touchend', onTouchEnd, true)
-			);
-		}
+		}, 10);
+		unsubPointerDown = onPointerDownDebounced.destroy;
+
+		/**
+		 * Debouncing `onPointerUp` ensures that other events can be flagged as not intercepted,
+		 * allowing a comprehensive check for intercepted events thereafter.
+		 */
+		const onPointerUpDebounced = debounce((e: InteractOutsideEvent) => {
+			if (isAnyEventIntercepted()) return;
+			if (shouldTriggerInteractOutside(e)) onInteractOutside?.(e);
+			resetPointerState();
+		}, 10);
+		unsubPointerUp = onPointerUpDebounced.destroy;
+
+		/**
+		 * Debounces `resetInterceptedEvents` to avoid premature resetting while events are still firing. The debounce
+		 * delay is intentionally set longer than `onPointerUp`'s to ensure `onPointerUp` events are fully processed
+		 * during the bubbling phase before `resetInterceptedEventsDebounced` executes in the capture phase.
+		 */
+		const resetInterceptedEventsDebounced = debounce(resetInterceptedEvents, 20);
+		unsubResetInterceptedEvents = resetInterceptedEventsDebounced.destroy;
+
+		unsubEvents = executeCallbacks(
+			/** Capture Events For Interaction Start */
+			setupCapturePhaseHandlerAndMarkAsIntercepted('pointerdown'),
+			setupCapturePhaseHandlerAndMarkAsIntercepted('mousedown'),
+			setupCapturePhaseHandlerAndMarkAsIntercepted('touchstart'),
+			/**
+			 * Intercepted events are reset only at the end of an interaction, allowing
+			 * interception at the start while still capturing the entire interaction.
+			 * Additionally, intercepted events are reset in the capture phase with `resetInterceptedEventsDebounced`,
+			 * accommodating events not invoked in the bubbling phase due to user interception.
+			 */
+			setupCapturePhaseHandlerAndMarkAsIntercepted('pointerup', resetInterceptedEventsDebounced),
+			setupCapturePhaseHandlerAndMarkAsIntercepted('mouseup', resetInterceptedEventsDebounced),
+			setupCapturePhaseHandlerAndMarkAsIntercepted('touchend', resetInterceptedEventsDebounced),
+			setupCapturePhaseHandlerAndMarkAsIntercepted('click', resetInterceptedEventsDebounced),
+
+			/** Bubbling Events For Interaction Start */
+			setupBubblePhaseHandlerAndMarkAsNotIntercepted('pointerdown', onPointerDownDebounced),
+			setupBubblePhaseHandlerAndMarkAsNotIntercepted('mousedown', onPointerDownDebounced),
+			setupBubblePhaseHandlerAndMarkAsNotIntercepted('touchstart', onPointerDownDebounced),
+			/**
+			 * To effectively detect an end of an interaction, we must monitor all relevant events,
+			 * not just `click` events. This is because on touch devices, actions like pressing,
+			 * moving the finger, and lifting it off the screen may not trigger a `click` event,
+			 * but should still invoke `onPointerUp` to properly handle the interaction.
+			 */
+			setupBubblePhaseHandlerAndMarkAsNotIntercepted('pointerup', onPointerUpDebounced),
+			setupBubblePhaseHandlerAndMarkAsNotIntercepted('mouseup', onPointerUpDebounced),
+			setupBubblePhaseHandlerAndMarkAsNotIntercepted('touchend', onPointerUpDebounced),
+			setupBubblePhaseHandlerAndMarkAsNotIntercepted('click', onPointerUpDebounced)
+		);
 	}
 
 	function shouldTriggerInteractOutside(e: InteractOutsideEvent) {
@@ -117,8 +176,10 @@ export const useInteractOutside = ((node, config) => {
 	return {
 		update,
 		destroy() {
-			unsub();
-			unsubClick();
+			unsubEvents();
+			unsubPointerDown();
+			unsubPointerUp();
+			unsubResetInterceptedEvents();
 		},
 	};
 }) satisfies Action<HTMLElement, InteractOutsideConfig>;
diff --git a/src/lib/internal/actions/interact-outside/types.ts b/src/lib/internal/actions/interact-outside/types.ts
index 164778b2..a840f3af 100644
--- a/src/lib/internal/actions/interact-outside/types.ts
+++ b/src/lib/internal/actions/interact-outside/types.ts
@@ -1,5 +1,18 @@
 export type InteractOutsideEvent = PointerEvent | MouseEvent | TouchEvent;
 
+export type InteractOutsideInterceptEventType =
+	| 'pointerdown'
+	| 'pointerup'
+	| 'mousedown'
+	| 'mouseup'
+	| 'touchstart'
+	| 'touchend'
+	| 'click';
+
+export type InteractOutsideInterceptHandler<E extends InteractOutsideInterceptEventType> = (
+	ev: HTMLElementEventMap[E]
+) => void;
+
 export type InteractOutsideConfig = {
 	/**
 	 * Callback fired when an outside interaction event completes,
diff --git a/src/lib/internal/actions/modal/action.ts b/src/lib/internal/actions/modal/action.ts
index 8cd452f3..27e0155a 100644
--- a/src/lib/internal/actions/modal/action.ts
+++ b/src/lib/internal/actions/modal/action.ts
@@ -40,15 +40,11 @@ export const useModal = ((node, config) => {
 			const target = e.target;
 			if (!isElement(target)) return;
 			if (target && isLastModal()) {
-				e.preventDefault();
-				e.stopPropagation();
 				e.stopImmediatePropagation();
 			}
 		}
 		function onInteractOutside(e: InteractOutsideEvent) {
 			if (shouldCloseOnInteractOutside?.(e) && isLastModal()) {
-				e.preventDefault();
-				e.stopPropagation();
 				e.stopImmediatePropagation();
 				closeModal();
 			}
diff --git a/src/lib/internal/helpers/debounce.ts b/src/lib/internal/helpers/debounce.ts
index 2cb712b8..2d54e29b 100644
--- a/src/lib/internal/helpers/debounce.ts
+++ b/src/lib/internal/helpers/debounce.ts
@@ -1,13 +1,13 @@
-export function debounce<T extends (...args: unknown[]) => unknown>(fn: T, wait = 500) {
-	let timeout: ReturnType<typeof setTimeout> | null = null;
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export function debounce<T extends (...args: any[]) => any>(fn: T, wait = 500) {
+	let timeout: NodeJS.Timeout;
 
-	return function (...args: Parameters<T>) {
-		const later = () => {
-			timeout = null;
-			fn(...args);
-		};
-
-		timeout && clearTimeout(timeout);
+	const debounced = (...args: Parameters<T>) => {
+		clearTimeout(timeout);
+		const later = () => fn(...args);
 		timeout = setTimeout(later, wait);
 	};
+
+	debounced.destroy = () => clearTimeout(timeout);
+	return debounced;
 }


Test patch:
diff --git a/src/tests/dialog/Dialog.spec.ts b/src/tests/dialog/Dialog.spec.ts
index e06be268..3103637e 100644
--- a/src/tests/dialog/Dialog.spec.ts
+++ b/src/tests/dialog/Dialog.spec.ts
@@ -1,10 +1,10 @@
-import { render, waitFor } from '@testing-library/svelte';
+import { fireEvent, render, waitFor } from '@testing-library/svelte';
 import { axe } from 'jest-axe';
 import { describe, it } from 'vitest';
 import DialogTest from './DialogTest.svelte';
 import { userEvent } from '@testing-library/user-event';
 import { sleep } from '$lib/internal/helpers/index.js';
-import { testKbd as kbd } from '../utils.js';
+import { testKbd as kbd, touch } from '../utils.js';
 import type { CreateDialogProps } from '$lib/index.js';
 
 function setup(props: CreateDialogProps = {}) {
@@ -65,7 +65,7 @@ describe('Dialog', () => {
 
 		expect(overlay).toBeVisible();
 		await user.click(overlay);
-		expect(content).not.toBeVisible();
+		await waitFor(() => expect(content).not.toBeVisible());
 	});
 
 	it('Prevents closing on outside click if `defaultPrevented` in `onOutsideClick` callback', async () => {
@@ -224,9 +224,9 @@ describe('Dialog', () => {
 		const { user, overlay, content } = await open();
 
 		expect(overlay).toBeVisible();
-		await user.pointer({ target: overlay, offset: 2, keys: '[MouseLeft>]' });
-		await user.pointer({ target: overlay, offset: 2, keys: '[/MouseLeft]' });
-		expect(content).not.toBeVisible();
+		await user.pointer({ target: overlay, keys: '[MouseLeft>]' });
+		await user.pointer({ target: overlay, keys: '[/MouseLeft]' });
+		await waitFor(() => expect(content).not.toBeVisible());
 	});
 
 	it("Doesn't deactivate focus trap on escape provided `closeOnEscape` false", async () => {
@@ -268,4 +268,148 @@ describe('Dialog', () => {
 		await user.click(getByTestId('toggle-open'));
 		expect(getByTestId('closeFocus')).toHaveFocus();
 	});
+
+	describe('Mouse Device', () => {
+		it("Doesn't close when interacting outside with click interceptor", async () => {
+			const { getByTestId, user, content, overlay } = await open();
+			const clickInterceptor = getByTestId('click-interceptor');
+			await user.click(clickInterceptor);
+			expect(content).toBeVisible();
+			await user.click(overlay);
+			await waitFor(() => expect(content).not.toBeVisible());
+		});
+
+		it("Doesn't close when interacting outside with pointerdown interceptor", async () => {
+			const { getByTestId, user, content, overlay } = await open();
+			await user.click(getByTestId('pointerdown-interceptor'));
+			expect(content).toBeVisible();
+			await user.click(overlay);
+			await waitFor(() => expect(content).not.toBeVisible());
+		});
+
+		it("Doesn't close when interacting outside with pointerup interceptor", async () => {
+			const { getByTestId, user, content, overlay } = await open();
+			await user.click(getByTestId('pointerup-interceptor'));
+			expect(content).toBeVisible();
+			await user.click(overlay);
+			await waitFor(() => expect(content).not.toBeVisible());
+		});
+
+		it("Doesn't close when interacting outside with mousedown interceptor", async () => {
+			const { getByTestId, user, content, overlay } = await open();
+			await user.click(getByTestId('mousedown-interceptor'));
+			expect(content).toBeVisible();
+			await user.click(overlay);
+			await waitFor(() => expect(content).not.toBeVisible());
+		});
+
+		it("Doesn't close when interacting outside with mouseup interceptor", async () => {
+			const { getByTestId, user, content, overlay } = await open();
+			await user.click(getByTestId('mouseup-interceptor'));
+			expect(content).toBeVisible();
+			await user.click(overlay);
+			await waitFor(() => expect(content).not.toBeVisible());
+		});
+	});
+
+	describe('Touch Device', () => {
+		it("Doesn't close when interacting outside with click interceptor", async () => {
+			const { getByTestId, user, content, overlay } = await open();
+			const clickInterceptor = getByTestId('click-interceptor');
+			await touch(clickInterceptor);
+			expect(content).toBeVisible();
+			await sleep(20);
+			await user.click(overlay);
+			await waitFor(() => expect(content).not.toBeVisible());
+		});
+
+		it("Doesn't close when interacting outside with pointerdown interceptor", async () => {
+			const { getByTestId, user, content, overlay } = await open();
+			await touch(getByTestId('pointerdown-interceptor'));
+			expect(content).toBeVisible();
+			await sleep(20);
+			await user.click(overlay);
+			await waitFor(() => expect(content).not.toBeVisible());
+		});
+
+		it("Doesn't close when interacting outside with pointerup interceptor", async () => {
+			const { getByTestId, user, content, overlay } = await open();
+			await touch(getByTestId('pointerup-interceptor'));
+			expect(content).toBeVisible();
+			await sleep(20);
+			await user.click(overlay);
+			await waitFor(() => expect(content).not.toBeVisible());
+		});
+
+		it("Doesn't close when interacting outside with mousedown interceptor", async () => {
+			const { getByTestId, user, content, overlay } = await open();
+			await touch(getByTestId('mousedown-interceptor'));
+			expect(content).toBeVisible();
+			await sleep(20);
+			await user.click(overlay);
+			await waitFor(() => expect(content).not.toBeVisible());
+		});
+
+		it("Doesn't close when interacting outside with mouseup interceptor", async () => {
+			const { getByTestId, user, content, overlay } = await open();
+			await touch(getByTestId('mouseup-interceptor'));
+			expect(content).toBeVisible();
+			await sleep(20);
+			await user.click(overlay);
+			await waitFor(() => expect(content).not.toBeVisible());
+		});
+
+		it("Doesn't close when interacting outside with touchstart interceptor", async () => {
+			const { getByTestId, user, content, overlay } = await open();
+			const touchstartInterceptor = getByTestId('touchstart-interceptor');
+			await touch(touchstartInterceptor);
+			expect(content).toBeVisible();
+			await sleep(20);
+			await user.click(overlay);
+			await waitFor(() => expect(content).not.toBeVisible());
+		});
+
+		it("Doesn't close when interacting outside with touchend interceptor", async () => {
+			const { getByTestId, user, overlay, content } = await open();
+			const touchendInterceptor = getByTestId('touchend-interceptor');
+			await touch(touchendInterceptor);
+			expect(content).toBeVisible();
+			await sleep(20);
+			await user.click(overlay);
+			await waitFor(() => expect(content).not.toBeVisible());
+		});
+
+		it('Closes on touchend if the previous touchstart occurred outside the dialog', async () => {
+			const { content, overlay } = await open();
+			expect(overlay).toBeVisible();
+			await fireEvent(overlay, new TouchEvent('pointerdown', { bubbles: true }));
+			await fireEvent(overlay, new TouchEvent('touchstart', { bubbles: true }));
+			await fireEvent(overlay, new TouchEvent('pointerup', { bubbles: true }));
+			await fireEvent(overlay, new TouchEvent('touchend', { bubbles: true }));
+			await sleep(20);
+			await waitFor(() => expect(content).not.toBeVisible());
+		});
+
+		/**
+		 * This makes sure we reset `interceptedEvents` not only after a click event.
+		 * On touch devices, actions like pressing, moving the finger, and lifting it off
+		 * the screen may not trigger a `click` event.
+		 */
+		it('Resets `interceptedEvents` when calling `preventDefault()` on touch event', async () => {
+			const { getByTestId, user, overlay, content } = await open();
+			const touchendPreventDefault = getByTestId('touchend-prevent-default-interceptor');
+			await fireEvent(touchendPreventDefault, new TouchEvent('touchstart', { bubbles: true }));
+			await fireEvent(touchendPreventDefault, new TouchEvent('touchend', { bubbles: true }));
+			await sleep(20);
+			expect(content).toBeVisible();
+
+			/**
+			 * Clicking the overlay and the dialog getting closed
+			 * will be possible only if we reset `interceptedEvents`
+			 * from the previous interaction.
+			 */
+			await user.click(overlay);
+			await waitFor(() => expect(content).not.toBeVisible());
+		});
+	});
 });
diff --git a/src/tests/dialog/DialogTest.svelte b/src/tests/dialog/DialogTest.svelte
index 16218385..f406ab11 100644
--- a/src/tests/dialog/DialogTest.svelte
+++ b/src/tests/dialog/DialogTest.svelte
@@ -35,6 +35,30 @@
 </main>
 <div id="portal-target" data-testid="portal-target" />
 
+<button on:click|stopPropagation data-testid="click-interceptor">click interceptor</button>
+<button on:pointerdown|stopPropagation data-testid="pointerdown-interceptor">
+	pointerdown interceptor
+</button>
+<button on:pointerup|stopPropagation data-testid="pointerup-interceptor">
+	pointerup interceptor
+</button>
+<button on:mousedown|stopPropagation data-testid="mousedown-interceptor">
+	mousedown interceptor
+</button>
+<button on:mouseup|stopPropagation data-testid="mouseup-interceptor">mouseup interceptor</button>
+<button on:touchstart|stopPropagation data-testid="touchstart-interceptor">
+	touchstart interceptor
+</button>
+<button on:touchend|stopPropagation data-testid="touchend-interceptor">
+	touchend interceptor
+</button>
+<button
+	on:touchend|preventDefault|stopPropagation
+	data-testid="touchend-prevent-default-interceptor"
+>
+	touchend prevent default interceptor
+</button>
+
 {#if $open}
 	<!-- Floating close -->
 	<button use:melt={$close} data-testid="floating-closer">Close</button>
diff --git a/src/tests/utils.ts b/src/tests/utils.ts
index 69fd23c6..c516d5c3 100644
--- a/src/tests/utils.ts
+++ b/src/tests/utils.ts
@@ -1,4 +1,5 @@
 import { kbd, removeUndefined } from '$lib/internal/helpers/index.js';
+import { fireEvent } from '@testing-library/svelte';
 export { removeUndefined };
 type KbdKeys = keyof typeof kbd;
 /**
@@ -18,3 +19,17 @@ export function exists(get: (id: string) => HTMLElement, testId: string) {
 		return false;
 	}
 }
+
+/**
+ * Simulates a touch interaction while triggering the
+ * same event sequence as in the browser.
+ */
+export async function touch(node: HTMLElement) {
+	await fireEvent(node, new Event('pointerdown', { bubbles: true }));
+	await fireEvent(node, new TouchEvent('touchstart', { bubbles: true }));
+	await fireEvent(node, new Event('pointerup', { bubbles: true }));
+	await fireEvent(node, new TouchEvent('touchend', { bubbles: true }));
+	await fireEvent(node, new MouseEvent('mousedown', { bubbles: true }));
+	await fireEvent(node, new MouseEvent('mouseup', { bubbles: true }));
+	await fireEvent(node, new MouseEvent('click', { bubbles: true }));
+}