Repository: melt-ui/melt-ui
Pull request #1016 (2024-02-26 12:04:24)

Revert "fix: Interact outside issues (#977)" and "fix: portal issues (#995)"

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

Latest commit: bc3ad1868e98e1a6fa976b34946d5aae61cf42ef

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/melt-ui/melt-ui/new/revert-regressions?filename=.changeset/afraid-tips-itch.md&value=---%0A%22%40melt-ui%2Fsvelte%22%3A%20patch%0A---%0A%0ARevert%20%22fix%3A%20Interact%20outside%20issues%20(%23977)%22%20and%20%22fix%3A%20portal%20issues%20(%23995)%22%0A)


github-actions[bot]: <!-- refined-cf-pages-action:deployment-summary:melt-ui -->

###### built with [Refined Cloudflare Pages Action](https://github.com/AdrianGonz97/refined-cf-pages-action)
## ⚡ Cloudflare Pages Deployment
| Name | Status | Preview | Last Commit |
| :--- | :----- | :------ | :---------- |
| **melt-ui** | 🔨 Building ([View Log](https://github.com/melt-ui/melt-ui/actions/runs/8048535458)) |  | 15797a93d42e38f907c2daba5d11352df38795a2 |


Patch:
diff --git a/.changeset/four-dolls-march.md b/.changeset/four-dolls-march.md
new file mode 100644
index 00000000..8960b606
--- /dev/null
+++ b/.changeset/four-dolls-march.md
@@ -0,0 +1,5 @@
+---
+'@melt-ui/svelte': patch
+---
+
+Revert regressions caused by PRs #977 and #995
diff --git a/src/lib/builders/context-menu/create.ts b/src/lib/builders/context-menu/create.ts
index f7a0867e..dc50ea0e 100644
--- a/src/lib/builders/context-menu/create.ts
+++ b/src/lib/builders/context-menu/create.ts
@@ -1,4 +1,4 @@
-import { usePopper, type InteractOutsideEvent } from '$lib/internal/actions/index.js';
+import { usePopper } from '$lib/internal/actions/index.js';
 import {
 	FIRST_LAST_KEYS,
 	addMeltEventListener,
@@ -11,6 +11,7 @@ import {
 	getPortalDestination,
 	getPreviousFocusable,
 	isHTMLElement,
+	isLeftClick,
 	kbd,
 	noop,
 	omit,
@@ -19,7 +20,6 @@ import {
 	toWritableStores,
 	withGet,
 	type WithGet,
-	portalAttr,
 } from '$lib/internal/helpers/index.js';
 import type { MeltActionReturn } from '$lib/internal/types.js';
 import type { VirtualElement } from '@floating-ui/core';
@@ -113,20 +113,19 @@ export function createContextMenu(props?: CreateContextMenuProps) {
 	);
 	const longPressTimer = withGet.writable(0);
 
-	function handleClickOutside(e: InteractOutsideEvent) {
+	function handleClickOutside(e: PointerEvent) {
 		rootOptions.onOutsideClick.get()?.(e);
-		if (e.defaultPrevented) return false;
+		if (e.defaultPrevented) return;
 
 		const target = e.target;
-		if (!(target instanceof Element)) return false;
+		if (!(target instanceof Element)) return;
 
 		const isClickInsideTrigger = target.closest(`[data-id="${ids.trigger.get()}"]`) !== null;
 
 		if (!isClickInsideTrigger || isLeftClick(e)) {
-			return true;
+			rootOpen.set(false);
+			return;
 		}
-
-		return false;
 	}
 
 	const isVisible = derivedVisible({
@@ -148,7 +147,7 @@ export function createContextMenu(props?: CreateContextMenuProps) {
 				id: $menuId,
 				'aria-labelledby': $triggerId,
 				'data-state': $isVisible ? 'open' : 'closed',
-				'data-portal': portalAttr($portal),
+				'data-portal': $portal ? '' : undefined,
 				tabindex: -1,
 			} as const;
 		},
@@ -175,14 +174,11 @@ export function createContextMenu(props?: CreateContextMenuProps) {
 							open: rootOpen,
 							options: {
 								floating: $positioning,
-								modal: {
-									closeOnInteractOutside: $closeOnOutsideClick,
-									onClose: () => {
-										rootOpen.set(false);
-									},
-									shouldCloseOnInteractOutside: handleClickOutside,
-									open: $isVisible,
-								},
+								clickOutside: $closeOnOutsideClick
+									? {
+											handler: handleClickOutside,
+									  }
+									: null,
 								portal: getPortalDestination(node, $portal),
 								escapeKeydown: $closeOnEscape ? undefined : null,
 							},
@@ -344,10 +340,3 @@ export function createContextMenu(props?: CreateContextMenuProps) {
 function isTouchOrPen(e: PointerEvent) {
 	return e.pointerType !== 'mouse';
 }
-
-export function isLeftClick(event: InteractOutsideEvent): boolean {
-	if ('button' in event) {
-		return event.button === 0 && event.ctrlKey === false && event.metaKey === false;
-	}
-	return true;
-}
diff --git a/src/lib/builders/dialog/create.ts b/src/lib/builders/dialog/create.ts
index 9f028bb2..662467e4 100644
--- a/src/lib/builders/dialog/create.ts
+++ b/src/lib/builders/dialog/create.ts
@@ -17,7 +17,6 @@ import {
 	removeScroll,
 	styleToString,
 	toWritableStores,
-	portalAttr,
 } from '$lib/internal/helpers/index.js';
 import { withGet } from '$lib/internal/helpers/withGet.js';
 import type { Defaults, MeltActionReturn } from '$lib/internal/types.js';
@@ -43,7 +42,7 @@ const defaults = {
 	closeOnOutsideClick: true,
 	role: 'dialog',
 	defaultOpen: false,
-	portal: undefined,
+	portal: 'body',
 	forceVisible: false,
 	openFocus: undefined,
 	closeFocus: undefined,
@@ -257,11 +256,11 @@ export function createDialog(props?: CreateDialogProps) {
 	const portalled = makeElement(name('portalled'), {
 		stores: portal,
 		returned: ($portal) => ({
-			'data-portal': portalAttr($portal),
+			'data-portal': $portal ? '' : undefined,
 		}),
 		action: (node: HTMLElement) => {
 			const unsubPortal = effect([portal], ([$portal]) => {
-				if ($portal === null) return noop;
+				if (!$portal) return noop;
 				const portalDestination = getPortalDestination(node, $portal);
 				if (portalDestination === null) return noop;
 				const portalAction = usePortal(node, portalDestination);
diff --git a/src/lib/builders/link-preview/create.ts b/src/lib/builders/link-preview/create.ts
index bb013909..5ab7d08b 100644
--- a/src/lib/builders/link-preview/create.ts
+++ b/src/lib/builders/link-preview/create.ts
@@ -18,7 +18,6 @@ import {
 	sleep,
 	styleToString,
 	toWritableStores,
-	portalAttr,
 } from '$lib/internal/helpers/index.js';
 import { safeOnMount } from '$lib/internal/helpers/lifecycle.js';
 import { withGet, type WithGet } from '$lib/internal/helpers/withGet.js';
@@ -42,7 +41,7 @@ const defaults = {
 	arrowSize: 8,
 	closeOnOutsideClick: true,
 	forceVisible: false,
-	portal: undefined,
+	portal: 'body',
 	closeOnEscape: true,
 	onOutsideClick: undefined,
 } satisfies CreateLinkPreviewProps;
@@ -162,7 +161,7 @@ export function createLinkPreview(props: CreateLinkPreviewProps = {}) {
 				}),
 				id: $contentId,
 				'data-state': $isVisible ? 'open' : 'closed',
-				'data-portal': portalAttr($portal),
+				'data-portal': $portal ? '' : undefined,
 			};
 		},
 		action: (node: HTMLElement): MeltActionReturn<LinkPreviewEvents['content']> => {
@@ -194,21 +193,22 @@ export function createLinkPreview(props: CreateLinkPreviewProps = {}) {
 						open: open,
 						options: {
 							floating: $positioning,
-							modal: {
-								closeOnInteractOutside: $closeOnOutsideClick,
-								onClose: () => {
-									open.set(false);
-									$activeTrigger.focus();
-								},
-								shouldCloseOnInteractOutside: (e) => {
-									onOutsideClick.get()?.(e);
-									if (e.defaultPrevented) return false;
-									if (isHTMLElement($activeTrigger) && $activeTrigger.contains(e.target as Element))
-										return false;
-									return true;
-								},
-								open: $isVisible,
-							},
+							clickOutside: $closeOnOutsideClick
+								? {
+										handler: (e) => {
+											onOutsideClick.get()?.(e);
+											if (e.defaultPrevented) return;
+
+											if (
+												isHTMLElement($activeTrigger) &&
+												!$activeTrigger.contains(e.target as Element)
+											) {
+												open.set(false);
+												$activeTrigger.focus();
+											}
+										},
+								  }
+								: null,
 							portal: getPortalDestination(node, $portal),
 							focusTrap: null,
 							escapeKeydown: $closeOnEscape ? undefined : null,
diff --git a/src/lib/builders/link-preview/types.ts b/src/lib/builders/link-preview/types.ts
index 665b5600..21094a75 100644
--- a/src/lib/builders/link-preview/types.ts
+++ b/src/lib/builders/link-preview/types.ts
@@ -1,4 +1,4 @@
-import type { FloatingConfig, InteractOutsideEvent } from '$lib/internal/actions/index.js';
+import type { FloatingConfig } from '$lib/internal/actions/index.js';
 import type { ChangeFn, IdObj } from '$lib/internal/helpers/index.js';
 import type { BuilderReturn } from '$lib/internal/types.js';
 import type { Writable } from 'svelte/store';
@@ -63,7 +63,7 @@ export type CreateLinkPreviewProps = {
 	 * If `event.preventDefault()` is called within the function,
 	 * the dialog will not close when the user clicks outside of it.
 	 */
-	onOutsideClick?: (event: InteractOutsideEvent) => void;
+	onOutsideClick?: (event: PointerEvent) => void;
 
 	/**
 	 * Whether or not to close the linkpreview when the escape key is pressed
diff --git a/src/lib/builders/listbox/create.ts b/src/lib/builders/listbox/create.ts
index 24518e51..705466a9 100644
--- a/src/lib/builders/listbox/create.ts
+++ b/src/lib/builders/listbox/create.ts
@@ -487,22 +487,22 @@ export function createListbox<
 							options: {
 								floating: $positioning,
 								focusTrap: null,
-								modal: {
-									closeOnInteractOutside: $closeOnOutsideClick,
-									onClose: closeMenu,
-									open: $isVisible,
-									shouldCloseOnInteractOutside: (e) => {
-										onOutsideClick.get()?.(e);
-										if (e.defaultPrevented) return false;
-										const target = e.target;
-										if (!isElement(target)) return false;
-										if (target === $activeTrigger || $activeTrigger.contains(target)) {
-											return false;
-										}
-										// return opposite of the result of the ignoreHandler
-										return !ignoreHandler(e);
-									},
-								},
+								clickOutside: $closeOnOutsideClick
+									? {
+											handler: (e) => {
+												onOutsideClick.get()?.(e);
+												if (e.defaultPrevented) return;
+
+												const target = e.target;
+												if (!isElement(target)) return;
+												if (target === $activeTrigger || $activeTrigger.contains(target)) {
+													return;
+												}
+												closeMenu();
+											},
+											ignore: ignoreHandler,
+									  }
+									: null,
 								escapeKeydown: null,
 								portal: getPortalDestination(node, $portal),
 							},
diff --git a/src/lib/builders/listbox/types.ts b/src/lib/builders/listbox/types.ts
index a35c9a01..ba1bc0a7 100644
--- a/src/lib/builders/listbox/types.ts
+++ b/src/lib/builders/listbox/types.ts
@@ -1,4 +1,4 @@
-import type { FloatingConfig, InteractOutsideEvent } from '$lib/internal/actions/index.js';
+import type { FloatingConfig } from '$lib/internal/actions/index.js';
 import type { ChangeFn, IdObj } from '$lib/internal/helpers/index.js';
 import type { BuilderReturn, WhenTrue } from '$lib/internal/types.js';
 import type { Writable } from 'svelte/store';
@@ -117,7 +117,7 @@ export type CreateListboxProps<
 	 * If `event.preventDefault()` is called within the function,
 	 * the dialog will not close when the user clicks outside of it.
 	 */
-	onOutsideClick?: (event: InteractOutsideEvent) => void;
+	onOutsideClick?: (event: PointerEvent) => void;
 
 	/**
 	 * Whether or not to prevent scrolling the page when the
diff --git a/src/lib/builders/menu/create.ts b/src/lib/builders/menu/create.ts
index c42e929f..79b6481a 100644
--- a/src/lib/builders/menu/create.ts
+++ b/src/lib/builders/menu/create.ts
@@ -31,7 +31,6 @@ import {
 	sleep,
 	styleToString,
 	toWritableStores,
-	portalAttr,
 } from '$lib/internal/helpers/index.js';
 import type { Defaults, MeltActionReturn, TextDirection } from '$lib/internal/types.js';
 import { tick } from 'svelte';
@@ -71,7 +70,7 @@ const defaults = {
 	preventScroll: true,
 	closeOnEscape: true,
 	closeOnOutsideClick: true,
-	portal: undefined,
+	portal: 'body',
 	loop: false,
 	dir: 'ltr',
 	defaultOpen: false,
@@ -165,7 +164,7 @@ export function createMenuBuilder(opts: _MenuBuilderOptions) {
 				id: $rootMenuId,
 				'aria-labelledby': $rootTriggerId,
 				'data-state': $isVisible ? 'open' : 'closed',
-				'data-portal': portalAttr($portal),
+				'data-portal': $portal ? '' : undefined,
 				tabindex: -1,
 			} as const;
 		},
@@ -191,26 +190,22 @@ export function createMenuBuilder(opts: _MenuBuilderOptions) {
 							open: rootOpen,
 							options: {
 								floating: $positioning,
-								modal: {
-									closeOnInteractOutside: $closeOnOutsideClick,
-									shouldCloseOnInteractOutside: (e) => {
-										onOutsideClick.get()?.(e);
-										if (e.defaultPrevented) return false;
-
-										if (
-											isHTMLElement($rootActiveTrigger) &&
-											$rootActiveTrigger.contains(e.target as Element)
-										) {
-											return false;
-										}
-										return true;
-									},
-									onClose: () => {
-										rootOpen.set(false);
-										$rootActiveTrigger.focus();
-									},
-									open: $isVisible,
-								},
+								clickOutside: $closeOnOutsideClick
+									? {
+											handler: (e) => {
+												onOutsideClick.get()?.(e);
+												if (e.defaultPrevented) return;
+
+												if (
+													isHTMLElement($rootActiveTrigger) &&
+													!$rootActiveTrigger.contains(e.target as Element)
+												) {
+													rootOpen.set(false);
+													$rootActiveTrigger.focus();
+												}
+											},
+									  }
+									: null,
 								portal: getPortalDestination(node, $portal),
 								escapeKeydown: $closeOnEscape ? undefined : null,
 							},
@@ -750,7 +745,7 @@ export function createMenuBuilder(opts: _MenuBuilderOptions) {
 								options: {
 									floating: $positioning,
 									portal: isHTMLElement(parentMenuEl) ? parentMenuEl : undefined,
-									modal: null,
+									clickOutside: null,
 									focusTrap: null,
 									escapeKeydown: null,
 								},
diff --git a/src/lib/builders/menu/types.ts b/src/lib/builders/menu/types.ts
index 16a0fbde..614551bf 100644
--- a/src/lib/builders/menu/types.ts
+++ b/src/lib/builders/menu/types.ts
@@ -1,4 +1,4 @@
-import type { FloatingConfig, InteractOutsideEvent } from '$lib/internal/actions/index.js';
+import type { FloatingConfig } from '$lib/internal/actions/index.js';
 import type { TextDirection } from '$lib/internal/types.js';
 import type { ChangeFn, FocusProp, IdObj } from '$lib/internal/helpers/index.js';
 import type { Writable } from 'svelte/store';
@@ -67,7 +67,7 @@ export type _CreateMenuProps = {
 	 * If `event.preventDefault()` is called within the function,
 	 * the dialog will not close when the user clicks outside of it.
 	 */
-	onOutsideClick?: (event: InteractOutsideEvent) => void;
+	onOutsideClick?: (event: PointerEvent) => void;
 
 	/**
 	 * Whether or not to loop the menu navigation.
@@ -190,7 +190,7 @@ export type _MenuBuilderOptions = {
 		closeFocus: WithGet<Writable<FocusProp | undefined>>;
 		disableFocusFirstItem: WithGet<Writable<boolean>>;
 		closeOnItemClick: WithGet<Writable<boolean>>;
-		onOutsideClick: WithGet<Writable<((event: InteractOutsideEvent) => void) | undefined>>;
+		onOutsideClick: WithGet<Writable<((event: PointerEvent) => void) | undefined>>;
 	};
 
 	nextFocusable: WithGet<Writable<HTMLElement | null>>;
diff --git a/src/lib/builders/menubar/create.ts b/src/lib/builders/menubar/create.ts
index 27904212..e3eafda3 100644
--- a/src/lib/builders/menubar/create.ts
+++ b/src/lib/builders/menubar/create.ts
@@ -25,7 +25,6 @@ import {
 	removeScroll,
 	styleToString,
 	toWritableStores,
-	portalAttr,
 } from '$lib/internal/helpers/index.js';
 import { safeOnDestroy, safeOnMount } from '$lib/internal/helpers/lifecycle.js';
 import type { MeltActionReturn } from '$lib/internal/types.js';
@@ -149,7 +148,7 @@ export function createMenubar(props?: CreateMenubarProps) {
 					'aria-labelledby': $triggerId,
 					'data-state': $isVisible ? 'open' : 'closed',
 					'data-melt-scope': $menubarId,
-					'data-portal': portalAttr($portal),
+					'data-portal': $portal ? '' : undefined,
 					tabindex: -1,
 				} as const;
 			},
@@ -169,21 +168,21 @@ export function createMenubar(props?: CreateMenubarProps) {
 								options: {
 									floating: $positioning,
 									portal: getPortalDestination(node, $portal),
-									modal: {
-										closeOnInteractOutside: $closeOnOutsideClick,
-										shouldCloseOnInteractOutside: (e) => {
-											onOutsideClick.get()?.(e);
-											if (e.defaultPrevented) return false;
-											const target = e.target;
-											const menubarEl = document.getElementById(ids.menubar.get());
-											if (!menubarEl || !isElement(target)) return true;
-											return !menubarEl.contains(target);
-										},
-										onClose: () => {
-											activeMenu.set('');
-										},
-										open: $rootOpen,
-									},
+									clickOutside: $closeOnOutsideClick
+										? {
+												ignore: (e) => {
+													const target = e.target;
+													const menubarEl = document.getElementById(ids.menubar.get());
+													if (!menubarEl || !isElement(target)) return false;
+													return menubarEl.contains(target);
+												},
+												handler: (e) => {
+													onOutsideClick.get()?.(e);
+													if (e.defaultPrevented) return;
+													activeMenu.set('');
+												},
+										  }
+										: null,
 								},
 							});
 
diff --git a/src/lib/builders/popover/create.ts b/src/lib/builders/popover/create.ts
index 0a19d274..8fa0b2f5 100644
--- a/src/lib/builders/popover/create.ts
+++ b/src/lib/builders/popover/create.ts
@@ -17,10 +17,9 @@ import {
 	removeScroll,
 	styleToString,
 	toWritableStores,
-	portalAttr,
 } from '$lib/internal/helpers/index.js';
 
-import { usePopper, type InteractOutsideEvent } from '$lib/internal/actions/index.js';
+import { usePopper } from '$lib/internal/actions/index.js';
 import { safeOnMount } from '$lib/internal/helpers/lifecycle.js';
 import type { Defaults, MeltActionReturn } from '$lib/internal/types.js';
 import { tick } from 'svelte';
@@ -101,7 +100,7 @@ export function createPopover(args?: CreatePopoverProps) {
 				}),
 				id: $contentId,
 				'data-state': $isVisible ? 'open' : 'closed',
-				'data-portal': portalAttr($portal),
+				'data-portal': $portal ? '' : undefined,
 			};
 		},
 		action: (node: HTMLElement) => {
@@ -141,12 +140,11 @@ export function createPopover(args?: CreatePopoverProps) {
 										clickOutsideDeactivates: true,
 										escapeDeactivates: true,
 								  },
-							modal: {
-								shouldCloseOnInteractOutside: shouldCloseOnInteractOutside,
-								onClose: handleClose,
-								open: $isVisible,
-								closeOnInteractOutside: $closeOnOutsideClick,
-							},
+							clickOutside: $closeOnOutsideClick
+								? {
+										handler: handleClickOutside,
+								  }
+								: null,
 							escapeKeydown: $closeOnEscape
 								? {
 										handler: () => {
@@ -182,16 +180,16 @@ export function createPopover(args?: CreatePopoverProps) {
 		}
 	}
 
-	function shouldCloseOnInteractOutside(e: InteractOutsideEvent) {
+	function handleClickOutside(e: PointerEvent) {
 		onOutsideClick.get()?.(e);
-		if (e.defaultPrevented) return false;
+		if (e.defaultPrevented) return;
 		const target = e.target;
 		const triggerEl = document.getElementById(ids.trigger.get());
 
 		if (triggerEl && isElement(target)) {
-			if (target === triggerEl || triggerEl.contains(target)) return false;
+			if (target === triggerEl || triggerEl.contains(target)) return;
 		}
-		return true;
+		handleClose();
 	}
 
 	const trigger = makeElement(name('trigger'), {
diff --git a/src/lib/builders/popover/types.ts b/src/lib/builders/popover/types.ts
index db498c4d..48731e8e 100644
--- a/src/lib/builders/popover/types.ts
+++ b/src/lib/builders/popover/types.ts
@@ -1,4 +1,4 @@
-import type { FloatingConfig, InteractOutsideEvent } from '$lib/internal/actions/index.js';
+import type { FloatingConfig } from '$lib/internal/actions/index.js';
 import type { ChangeFn, FocusProp, IdObj } from '$lib/internal/helpers/index.js';
 import type { BuilderReturn } from '$lib/internal/types.js';
 import type { Writable } from 'svelte/store';
@@ -66,7 +66,7 @@ export type CreatePopoverProps = {
 	 * If `event.preventDefault()` is called within the function,
 	 * the dialog will not close when the user clicks outside of it.
 	 */
-	onOutsideClick?: (event: InteractOutsideEvent) => void;
+	onOutsideClick?: (event: PointerEvent) => void;
 
 	/**
 	 * Whether or not to prevent scrolling when the popover is open.
diff --git a/src/lib/builders/tooltip/create.ts b/src/lib/builders/tooltip/create.ts
index 28a465c7..a4383ddb 100644
--- a/src/lib/builders/tooltip/create.ts
+++ b/src/lib/builders/tooltip/create.ts
@@ -18,7 +18,6 @@ import {
 	pointInPolygon,
 	styleToString,
 	toWritableStores,
-	portalAttr,
 } from '$lib/internal/helpers/index.js';
 
 import { useFloating, usePortal } from '$lib/internal/actions/index.js';
@@ -38,7 +37,7 @@ const defaults = {
 	openDelay: 1000,
 	closeDelay: 0,
 	forceVisible: false,
-	portal: undefined,
+	portal: 'body',
 	closeOnEscape: true,
 	disableHoverableContent: false,
 	group: undefined,
@@ -202,7 +201,7 @@ export function createTooltip(props?: CreateTooltipProps) {
 					display: $isVisible ? undefined : 'none',
 				}),
 				id: $contentId,
-				'data-portal': portalAttr($portal),
+				'data-portal': $portal ? '' : undefined,
 				'data-state': $open ? 'open' : 'closed',
 			};
 		},
@@ -222,7 +221,7 @@ export function createTooltip(props?: CreateTooltipProps) {
 
 					const floatingReturn = useFloating(triggerEl, node, $positioning);
 					unsubFloating = floatingReturn.destroy;
-					if ($portal === null) {
+					if (!$portal) {
 						unsubPortal();
 						return;
 					}
diff --git a/src/lib/internal/actions/modal/types.ts b/src/lib/internal/actions/modal/types.ts
index 625aa2b6..b28bbc95 100644
--- a/src/lib/internal/actions/modal/types.ts
+++ b/src/lib/internal/actions/modal/types.ts
@@ -4,7 +4,7 @@ export type ModalConfig = {
 	/**
 	 * Whether the modal is currently open.
 	 */
-	open?: boolean;
+	open: boolean;
 
 	/**
 	 * Handler called when the overlay closes.
diff --git a/src/lib/internal/actions/popper/action.ts b/src/lib/internal/actions/popper/action.ts
index f17366a3..a62b23e3 100644
--- a/src/lib/internal/actions/popper/action.ts
+++ b/src/lib/internal/actions/popper/action.ts
@@ -1,5 +1,6 @@
 import {
 	createFocusTrap,
+	useClickOutside,
 	useEscapeKeydown,
 	useFloating,
 	usePortal,
@@ -12,12 +13,11 @@ import {
 } from '$lib/internal/helpers/index.js';
 import type { Action } from 'svelte/action';
 import type { PopperArgs, PopperConfig } from './types.js';
-import { useModal } from '../modal/action.js';
 
 const defaultConfig = {
 	floating: {},
 	focusTrap: {},
-	modal: {},
+	clickOutside: {},
 	escapeKeydown: {},
 	portal: 'body',
 } satisfies PopperConfig;
@@ -60,25 +60,19 @@ export const usePopper: Action<HTMLElement, PopperArgs> = (popperElement, args)
 		}
 	}
 
-	if (opts.modal !== null) {
+	if (opts.clickOutside !== null) {
 		callbacks.push(
-			useModal(popperElement, {
-				onClose: () => {
-					if (isHTMLElement(anchorElement)) {
+			useClickOutside(popperElement, {
+				enabled: open,
+				handler: (e: PointerEvent) => {
+					if (e.defaultPrevented) return;
+
+					if (isHTMLElement(anchorElement) && !anchorElement.contains(e.target as Element)) {
 						open.set(false);
 						anchorElement.focus();
 					}
 				},
-				shouldCloseOnInteractOutside: (e) => {
-					if (e.defaultPrevented) return false;
-
-					if (isHTMLElement(anchorElement) && anchorElement.contains(e.target as Element)) {
-						return false;
-					}
-
-					return true;
-				},
-				...opts.modal,
+				...opts.clickOutside,
 			}).destroy
 		);
 	}
diff --git a/src/lib/internal/actions/popper/types.ts b/src/lib/internal/actions/popper/types.ts
index 6e25cc30..71ed3e62 100644
--- a/src/lib/internal/actions/popper/types.ts
+++ b/src/lib/internal/actions/popper/types.ts
@@ -1,4 +1,5 @@
 import type {
+	ClickOutsideConfig,
 	FloatingConfig,
 	FocusTrapConfig,
 	PortalConfig,
@@ -6,12 +7,11 @@ import type {
 } from '$lib/internal/actions/index.js';
 import type { VirtualElement } from '@floating-ui/core';
 import type { Writable } from 'svelte/store';
-import type { ModalConfig } from '../modal/types.js';
 
 export type PopperConfig = {
 	floating?: FloatingConfig;
 	focusTrap?: FocusTrapConfig | null;
-	modal?: ModalConfig | null;
+	clickOutside?: ClickOutsideConfig | null;
 	portal?: PortalConfig | null;
 	escapeKeydown?: EscapeKeydownConfig | null;
 };
diff --git a/src/lib/internal/helpers/attr.ts b/src/lib/internal/helpers/attr.ts
index 621863a9..fc71525e 100644
--- a/src/lib/internal/helpers/attr.ts
+++ b/src/lib/internal/helpers/attr.ts
@@ -17,14 +17,3 @@ export const hiddenInputAttrs = {
 		transform: 'translateX(-100%)',
 	}),
 };
-
-/**
- * @param portal The value of the `portal` option store.
- * @returns the value of the `data-portal` attribute.
- */
-export function portalAttr(portal: string | HTMLElement | null | undefined) {
-	if (portal !== null) {
-		return portal;
-	}
-	return undefined;
-}
diff --git a/src/lib/internal/helpers/elements.ts b/src/lib/internal/helpers/elements.ts
index af6d254a..507fae26 100644
--- a/src/lib/internal/helpers/elements.ts
+++ b/src/lib/internal/helpers/elements.ts
@@ -16,7 +16,8 @@ export function getPortalDestination(
 	node: HTMLElement,
 	portalProp: string | HTMLElement | undefined | null
 ) {
+	const portalParent = getPortalParent(node);
 	if (portalProp !== undefined) return portalProp;
-	if (portalProp === null) return null;
-	return getPortalParent(node);
+	if (portalParent === 'body') return document.body;
+	return null;
 }
diff --git a/src/lib/internal/helpers/ignore.ts b/src/lib/internal/helpers/ignore.ts
index 6477beba..d59241c7 100644
--- a/src/lib/internal/helpers/ignore.ts
+++ b/src/lib/internal/helpers/ignore.ts
@@ -1,8 +1,7 @@
 import { getElementByMeltId, isElement, isHTMLLabelElement } from '$lib/internal/helpers/index.js';
-import type { InteractOutsideEvent } from '../actions/index.js';
 
 export function createClickOutsideIgnore(meltId: string) {
-	return (e: InteractOutsideEvent) => {
+	return (e: PointerEvent) => {
 		const target = e.target;
 		const triggerEl = getElementByMeltId(meltId);
 		if (!triggerEl || !isElement(target)) return false;
diff --git a/src/lib/shared/index.ts b/src/lib/shared/index.ts
index 2847a128..f9b788d1 100644
--- a/src/lib/shared/index.ts
+++ b/src/lib/shared/index.ts
@@ -7,7 +7,6 @@
 import type { SegmentPart, EditableSegmentPart } from '$lib/builders/date-field/_internal/types.js';
 import type { Granularity, Matcher, DateRange, Month } from '$lib/internal/helpers/date/index.js';
 import type { FocusProp, FocusTarget } from '$lib/internal/helpers/index.js';
-import type { InteractOutsideEvent } from '$lib/internal/actions/index.js';
 
 export type {
 	Granularity,
@@ -18,5 +17,4 @@ export type {
 	Month,
 	SegmentPart,
 	EditableSegmentPart,
-	InteractOutsideEvent,
 };
diff --git a/src/routes/(landing-ui)/search.svelte b/src/routes/(landing-ui)/search.svelte
index f3849490..0614fac9 100644
--- a/src/routes/(landing-ui)/search.svelte
+++ b/src/routes/(landing-ui)/search.svelte
@@ -37,11 +37,6 @@
 		},
 		preventScroll: false,
 		highlightOnHover: false,
-		portal: null,
-		onOutsideClick() {
-			open.set(false);
-			cbOpen.set(false);
-		},
 	});
 
 	let comboboxInput: HTMLInputElement | null = null;
diff --git a/src/tests/portal/index.ts b/src/tests/portal/index.ts
deleted file mode 100644
index e69de29b..00000000
diff --git a/vite.config.ts b/vite.config.ts
index 5f4b7ad3..7df879ab 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -24,6 +24,6 @@ export default defineConfig({
 		deps: {
 			inline: ['clsx'],
 		},
-		retry: 3,
+		retry: 5,
 	},
 });


Test patch:
diff --git a/src/tests/context-menu/ContextMenu.spec.ts b/src/tests/context-menu/ContextMenu.spec.ts
index 7e00a02d..0de7ce19 100644
--- a/src/tests/context-menu/ContextMenu.spec.ts
+++ b/src/tests/context-menu/ContextMenu.spec.ts
@@ -5,7 +5,6 @@ import { userEvent } from '@testing-library/user-event';
 import { testKbd as kbd } from '../utils.js';
 import ContextMenuTest from './ContextMenuTest.svelte';
 import type { CreateContextMenuProps } from '$lib/index.js';
-import { sleep } from '$lib/internal/helpers/sleep.js';
 
 function setup(props: CreateContextMenuProps = {}) {
 	const user = userEvent.setup();
@@ -148,10 +147,7 @@ describe('Context Menu', () => {
 	test('Should close on outside click by default', async () => {
 		const { user, getByTestId, queryByTestId } = await open();
 		const outsideClick = getByTestId('outside-click');
-		await sleep(100);
-		expect(outsideClick).toBeVisible();
 		await user.click(outsideClick);
-		await sleep(100);
 		await waitFor(() => expect(queryByTestId('menu')).toBeNull());
 	});
 
diff --git a/src/tests/popover/Popover.spec.ts b/src/tests/popover/Popover.spec.ts
index 6c29d3a8..9ed142a2 100644
--- a/src/tests/popover/Popover.spec.ts
+++ b/src/tests/popover/Popover.spec.ts
@@ -53,10 +53,7 @@ describe('Popover (Default)', () => {
 		await user.click(content);
 		await waitFor(() => expect(content).toBeVisible());
 		const outside = getByTestId('outside');
-		await sleep(100);
-		expect(outside).toBeVisible();
 		await user.click(outside);
-		await sleep(100);
 		await waitFor(() => expect(content).not.toBeVisible());
 	});
 
@@ -73,10 +70,7 @@ describe('Popover (Default)', () => {
 		await user.click(content);
 		await waitFor(() => expect(content).toBeVisible());
 		const outside = getByTestId('outside');
-		await sleep(100);
-		expect(outside).toBeVisible();
 		await user.click(outside);
-		await sleep(100);
 		await waitFor(() => expect(content).toBeVisible());
 	});
 
diff --git a/src/tests/portal/PortalNested.spec.ts b/src/tests/portal/PortalNested.spec.ts
index 6e961296..ae35084f 100644
--- a/src/tests/portal/PortalNested.spec.ts
+++ b/src/tests/portal/PortalNested.spec.ts
@@ -63,7 +63,7 @@ const testComponent = async (component: Structure, level = 0) => {
 };
 
 // Execute the test
-test.skip('recursive component test', async () => {
+test('recursive component test', async () => {
 	await render(PortalNestedTest);
 	await testComponent(structure);
 });
diff --git a/src/tests/portal/PortalNested.svelte b/src/tests/portal/PortalNested.svelte
index 099c45ca..fdda1a15 100644
--- a/src/tests/portal/PortalNested.svelte
+++ b/src/tests/portal/PortalNested.svelte
@@ -16,14 +16,14 @@
 	export const structure: Structure = {
 		name: 'dialog',
 		children: [
-			{
-				name: 'dialog',
-				children: [
-					{
-						name: 'select',
-					},
-				],
-			},
+			// {
+			// 	name: 'dialog',
+			// 	children: [
+			// 		{
+			// 			name: 'select',
+			// 		},
+			// 	],
+			// },
 			{
 				name: 'popover',
 				children: [{ name: 'dialog' }, { name: 'popover' }],