Repository: melt-ui/melt-ui
Pull request #977 (2024-02-11 22:43:30)

fix: Interact outside issues
This PR updates existing builders to use the new `useModal` action, which has improved the handling of outside interaction events across various platforms. 

Closes: #992 

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

Latest commit: 51204211eadfc1f4b28c4614d71ab082f9716f69

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/huntabyte/melt-ui/new/fix/939?filename=.changeset/purple-maps-crash.md&value=---%0A%22%40melt-ui%2Fsvelte%22%3A%20patch%0A---%0A%0Afix%3A%20Interact%20outside%20issues%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/7865175044)) |  | 51204211eadfc1f4b28c4614d71ab082f9716f69 |


Patch:
diff --git a/.changeset/empty-peas-smoke.md b/.changeset/empty-peas-smoke.md
new file mode 100644
index 00000000..fccc1092
--- /dev/null
+++ b/.changeset/empty-peas-smoke.md
@@ -0,0 +1,5 @@
+---
+'@melt-ui/svelte': patch
+---
+
+Fix custom portal issues
diff --git a/.changeset/forty-beers-reply.md b/.changeset/forty-beers-reply.md
new file mode 100644
index 00000000..0aa3e0de
--- /dev/null
+++ b/.changeset/forty-beers-reply.md
@@ -0,0 +1,5 @@
+---
+"@melt-ui/svelte": patch
+---
+
+Dialog: fixed interact outside pointerup/mouseup issue where the dialog would close if you clicked and dragged outside of the dialog before releasing the press (Closes: #750)
diff --git a/.changeset/popular-dolls-attend.md b/.changeset/popular-dolls-attend.md
new file mode 100644
index 00000000..bfc013a7
--- /dev/null
+++ b/.changeset/popular-dolls-attend.md
@@ -0,0 +1,5 @@
+---
+'@melt-ui/svelte': patch
+---
+
+Toggle Group: fixed bug where `single` groups wouldn't focus (Closes #810)
diff --git a/.github/reproduire/needs-reproduction.md b/.github/reproduire/needs-reproduction.md
new file mode 100644
index 00000000..6914844b
--- /dev/null
+++ b/.github/reproduire/needs-reproduction.md
@@ -0,0 +1,27 @@
+Please provide a [reproduction](https://melt-ui.com/repro).
+
+<details>
+<summary>More info</summary>
+
+### Why do I need to provide a reproduction?
+
+This project is maintained by a very small team, and we simply don't have the bandwidth to investigate issues that we can't easily replicate. Reproductions enable us to fix issues faster and more efficiently. If you care about getting your issue resolved, providing a reproduction is the best way to do that.
+
+### I've provided a reproduction - what happens now?
+
+Once a reproduction is provided, we'll remove the `needs reproduction` label and review the issue to determine how to resolve it. If we can confirm it's a bug, we'll label it as such and prioritize it based on its severity.
+
+If `needs reproduction` labeled issues don't receive any activity (e.g., a comment with a reproduction link), they'll be closed. Feel free to comment with a reproduction at any time and we'll reopen the issue to take a look.
+
+### How can I create a reproduction?
+
+You can use [this template](https://melt-ui.com/repro) to create a minimal reproduction. You can also link to a GitHub repository with the reproduction.
+
+Please ensure that the reproduction is as **minimal** as possible. If there is a ton of custom logic in your reproduction, it is difficult to determine if the issue is with your code or with the library. The more minimal the reproduction, the more likely it is that we'll be able to assist.
+
+You might also find these other articles interesting and/or helpful:
+
+- [The Importance of Reproductions](https://antfu.me/posts/why-reproductions-are-required)
+- [How to Generate a Minimal, Complete, and Verifiable Example](https://stackoverflow.com/help/mcve)
+
+</details>
diff --git a/.github/workflows/reproduire-close.yml b/.github/workflows/reproduire-close.yml
new file mode 100644
index 00000000..932bc83b
--- /dev/null
+++ b/.github/workflows/reproduire-close.yml
@@ -0,0 +1,23 @@
+name: Close incomplete issues
+on:
+  workflow_dispatch:
+  schedule:
+    - cron: "30 1 * * *" # run every day
+
+permissions:
+  issues: write
+
+jobs:
+  stale:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/stale@28ca1036281a5e5922ead5184a1bbf96e5fc984e # v9.0.0
+        with:
+          days-before-stale: -1 # Issues and PR will never be flagged stale automatically.
+          stale-issue-label: "needs reproduction" # Label that flags an issue as stale.
+          only-labels: "needs reproduction" # Only process these issues
+          days-before-issue-close: 7
+          ignore-updates: true
+          remove-stale-when-updated: false
+          close-issue-message: This issue was closed because it was open for 7 days without a reproduction.
+          close-issue-label: closed-by-bot
diff --git a/.github/workflows/reproduire.yml b/.github/workflows/reproduire.yml
new file mode 100644
index 00000000..fd7895b4
--- /dev/null
+++ b/.github/workflows/reproduire.yml
@@ -0,0 +1,16 @@
+name: Reproduire
+on:
+  issues:
+    types: [labeled]
+
+permissions:
+  issues: write
+
+jobs:
+  reproduire:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
+      - uses: Hebilicious/reproduire@4b686ae9cbb72dad60f001d278b6e3b2ce40a9ac # v0.0.9-mp
+        with:
+          label: needs reproduction
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 525bf182..0472ac1b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,25 @@
 # @melt-ui/svelte
 
+## 0.74.1
+
+### Patch Changes
+
+- Date Picker/Date Range Picker: disable trigger when picker is disabled (Closes #815) ([#989](https://github.com/melt-ui/melt-ui/pull/989))
+
+- Toast: return close element `type` attribute type as `"button"` instead of `string` ([#993](https://github.com/melt-ui/melt-ui/pull/993))
+
+- ToggleGroup: Fixes curr & next always bbeing the same value ([#983](https://github.com/melt-ui/melt-ui/pull/983))
+
+## 0.74.0
+
+### Minor Changes
+
+- Dialog: change data-state conditions ([`0c7bf6df`](https://github.com/melt-ui/melt-ui/commit/0c7bf6dfd338103f7288b84c1fc5ae1c7f8b8770))
+
+### Patch Changes
+
+- Combobox: autocomplete off ([`db53236b`](https://github.com/melt-ui/melt-ui/commit/db53236b6bac88f3a3008269e32841467885b8f3))
+
 ## 0.73.0
 
 ### Minor Changes
diff --git a/package.json b/package.json
index 64625475..798f88d5 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
 	"name": "@melt-ui/svelte",
-	"version": "0.73.0",
+	"version": "0.74.1",
 	"license": "MIT",
 	"repository": "github:melt-ui/melt-ui",
 	"author": "Thomas G. Lopes",
diff --git a/src/docs/components/markdown/pre.svelte b/src/docs/components/markdown/pre.svelte
index 7e8d4043..3e10f08f 100644
--- a/src/docs/components/markdown/pre.svelte
+++ b/src/docs/components/markdown/pre.svelte
@@ -65,4 +65,8 @@
 	pre {
 		font-weight: initial;
 	}
+
+	pre :global(span) {
+		display: inline;
+	}
 </style>
diff --git a/src/lib/builders/combobox/create.ts b/src/lib/builders/combobox/create.ts
index 4633b17f..115e65e2 100644
--- a/src/lib/builders/combobox/create.ts
+++ b/src/lib/builders/combobox/create.ts
@@ -51,6 +51,7 @@ export function createCombobox<
 				...omit($trigger, 'action'),
 				role: 'combobox',
 				value: $inputValue,
+				autocomplete: 'off',
 			} as const;
 		},
 		action: (node: HTMLElement): MeltActionReturn<ComboboxEvents['input']> => {
diff --git a/src/lib/builders/context-menu/create.ts b/src/lib/builders/context-menu/create.ts
index dc50ea0e..f7a0867e 100644
--- a/src/lib/builders/context-menu/create.ts
+++ b/src/lib/builders/context-menu/create.ts
@@ -1,4 +1,4 @@
-import { usePopper } from '$lib/internal/actions/index.js';
+import { usePopper, type InteractOutsideEvent } from '$lib/internal/actions/index.js';
 import {
 	FIRST_LAST_KEYS,
 	addMeltEventListener,
@@ -11,7 +11,6 @@ import {
 	getPortalDestination,
 	getPreviousFocusable,
 	isHTMLElement,
-	isLeftClick,
 	kbd,
 	noop,
 	omit,
@@ -20,6 +19,7 @@ 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,19 +113,20 @@ export function createContextMenu(props?: CreateContextMenuProps) {
 	);
 	const longPressTimer = withGet.writable(0);
 
-	function handleClickOutside(e: PointerEvent) {
+	function handleClickOutside(e: InteractOutsideEvent) {
 		rootOptions.onOutsideClick.get()?.(e);
-		if (e.defaultPrevented) return;
+		if (e.defaultPrevented) return false;
 
 		const target = e.target;
-		if (!(target instanceof Element)) return;
+		if (!(target instanceof Element)) return false;
 
 		const isClickInsideTrigger = target.closest(`[data-id="${ids.trigger.get()}"]`) !== null;
 
 		if (!isClickInsideTrigger || isLeftClick(e)) {
-			rootOpen.set(false);
-			return;
+			return true;
 		}
+
+		return false;
 	}
 
 	const isVisible = derivedVisible({
@@ -147,7 +148,7 @@ export function createContextMenu(props?: CreateContextMenuProps) {
 				id: $menuId,
 				'aria-labelledby': $triggerId,
 				'data-state': $isVisible ? 'open' : 'closed',
-				'data-portal': $portal ? '' : undefined,
+				'data-portal': portalAttr($portal),
 				tabindex: -1,
 			} as const;
 		},
@@ -174,11 +175,14 @@ export function createContextMenu(props?: CreateContextMenuProps) {
 							open: rootOpen,
 							options: {
 								floating: $positioning,
-								clickOutside: $closeOnOutsideClick
-									? {
-											handler: handleClickOutside,
-									  }
-									: null,
+								modal: {
+									closeOnInteractOutside: $closeOnOutsideClick,
+									onClose: () => {
+										rootOpen.set(false);
+									},
+									shouldCloseOnInteractOutside: handleClickOutside,
+									open: $isVisible,
+								},
 								portal: getPortalDestination(node, $portal),
 								escapeKeydown: $closeOnEscape ? undefined : null,
 							},
@@ -340,3 +344,10 @@ 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/date-picker/create.ts b/src/lib/builders/date-picker/create.ts
index 9e52e512..3adbfce7 100644
--- a/src/lib/builders/date-picker/create.ts
+++ b/src/lib/builders/date-picker/create.ts
@@ -76,12 +76,13 @@ export function createDatePicker(props?: CreateDatePickerProps) {
 	});
 
 	const trigger = makeElement('popover-trigger', {
-		stores: [popover.elements.trigger],
-		returned: ([$trigger]) => {
+		stores: [popover.elements.trigger, options.disabled],
+		returned: ([$trigger, $disabled]) => {
 			return {
 				...omit($trigger, 'action'),
 				'aria-label': 'Open date picker',
 				'data-segment': 'trigger',
+				disabled: $disabled ? true : undefined,
 			};
 		},
 		action: (node: HTMLElement): MeltActionReturn<DatePickerEvents['trigger']> => {
diff --git a/src/lib/builders/date-range-picker/create.ts b/src/lib/builders/date-range-picker/create.ts
index 6c89c640..5f046587 100644
--- a/src/lib/builders/date-range-picker/create.ts
+++ b/src/lib/builders/date-range-picker/create.ts
@@ -88,12 +88,13 @@ export function createDateRangePicker(props?: CreateDateRangePickerProps) {
 	const placeholder = dateStore(rfPlaceholder, withDefaults.defaultPlaceholder ?? defaultDate);
 
 	const trigger = makeElement('popover-trigger', {
-		stores: [popover.elements.trigger],
-		returned: ([$trigger]) => {
+		stores: [popover.elements.trigger, options.disabled],
+		returned: ([$trigger, $disabled]) => {
 			return {
 				...omit($trigger, 'action'),
 				'aria-label': 'Open date picker',
 				'data-segment': 'trigger',
+				disabled: $disabled ? true : undefined,
 			};
 		},
 		action: (node: HTMLElement): MeltActionReturn<DateRangePickerEvents['trigger']> => {
diff --git a/src/lib/builders/dialog/create.ts b/src/lib/builders/dialog/create.ts
index 3b003499..9f028bb2 100644
--- a/src/lib/builders/dialog/create.ts
+++ b/src/lib/builders/dialog/create.ts
@@ -17,6 +17,7 @@ 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';
@@ -42,7 +43,7 @@ const defaults = {
 	closeOnOutsideClick: true,
 	role: 'dialog',
 	defaultOpen: false,
-	portal: 'body',
+	portal: undefined,
 	forceVisible: false,
 	openFocus: undefined,
 	closeFocus: undefined,
@@ -128,8 +129,8 @@ export function createDialog(props?: CreateDialogProps) {
 	});
 
 	const overlay = makeElement(name('overlay'), {
-		stores: [isVisible],
-		returned: ([$isVisible]) => {
+		stores: [isVisible, open],
+		returned: ([$isVisible, $open]) => {
 			return {
 				hidden: $isVisible ? undefined : true,
 				tabindex: -1,
@@ -137,7 +138,7 @@ export function createDialog(props?: CreateDialogProps) {
 					display: $isVisible ? undefined : 'none',
 				}),
 				'aria-hidden': true,
-				'data-state': $isVisible ? 'open' : 'closed',
+				'data-state': $open ? 'open' : 'closed',
 			} as const;
 		},
 		action: (node: HTMLElement) => {
@@ -163,15 +164,15 @@ export function createDialog(props?: CreateDialogProps) {
 	});
 
 	const content = makeElement(name('content'), {
-		stores: [isVisible, ids.content, ids.description, ids.title],
-		returned: ([$isVisible, $contentId, $descriptionId, $titleId]) => {
+		stores: [isVisible, ids.content, ids.description, ids.title, open],
+		returned: ([$isVisible, $contentId, $descriptionId, $titleId, $open]) => {
 			return {
 				id: $contentId,
 				role: role.get(),
 				'aria-describedby': $descriptionId,
 				'aria-labelledby': $titleId,
 				'aria-modal': $isVisible ? ('true' as const) : undefined,
-				'data-state': $isVisible ? 'open' : 'closed',
+				'data-state': $open ? 'open' : 'closed',
 				tabindex: -1,
 				hidden: $isVisible ? undefined : true,
 				style: styleToString({
@@ -256,11 +257,11 @@ export function createDialog(props?: CreateDialogProps) {
 	const portalled = makeElement(name('portalled'), {
 		stores: portal,
 		returned: ($portal) => ({
-			'data-portal': $portal ? '' : undefined,
+			'data-portal': portalAttr($portal),
 		}),
 		action: (node: HTMLElement) => {
 			const unsubPortal = effect([portal], ([$portal]) => {
-				if (!$portal) return noop;
+				if ($portal === null) 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 5ab7d08b..bb013909 100644
--- a/src/lib/builders/link-preview/create.ts
+++ b/src/lib/builders/link-preview/create.ts
@@ -18,6 +18,7 @@ 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';
@@ -41,7 +42,7 @@ const defaults = {
 	arrowSize: 8,
 	closeOnOutsideClick: true,
 	forceVisible: false,
-	portal: 'body',
+	portal: undefined,
 	closeOnEscape: true,
 	onOutsideClick: undefined,
 } satisfies CreateLinkPreviewProps;
@@ -161,7 +162,7 @@ export function createLinkPreview(props: CreateLinkPreviewProps = {}) {
 				}),
 				id: $contentId,
 				'data-state': $isVisible ? 'open' : 'closed',
-				'data-portal': $portal ? '' : undefined,
+				'data-portal': portalAttr($portal),
 			};
 		},
 		action: (node: HTMLElement): MeltActionReturn<LinkPreviewEvents['content']> => {
@@ -193,22 +194,21 @@ export function createLinkPreview(props: CreateLinkPreviewProps = {}) {
 						open: open,
 						options: {
 							floating: $positioning,
-							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,
+							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,
+							},
 							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 21094a75..665b5600 100644
--- a/src/lib/builders/link-preview/types.ts
+++ b/src/lib/builders/link-preview/types.ts
@@ -1,4 +1,4 @@
-import type { FloatingConfig } from '$lib/internal/actions/index.js';
+import type { FloatingConfig, InteractOutsideEvent } 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: PointerEvent) => void;
+	onOutsideClick?: (event: InteractOutsideEvent) => 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 705466a9..24518e51 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,
-								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,
+								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);
+									},
+								},
 								escapeKeydown: null,
 								portal: getPortalDestination(node, $portal),
 							},
diff --git a/src/lib/builders/listbox/types.ts b/src/lib/builders/listbox/types.ts
index ba1bc0a7..a35c9a01 100644
--- a/src/lib/builders/listbox/types.ts
+++ b/src/lib/builders/listbox/types.ts
@@ -1,4 +1,4 @@
-import type { FloatingConfig } from '$lib/internal/actions/index.js';
+import type { FloatingConfig, InteractOutsideEvent } 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: PointerEvent) => void;
+	onOutsideClick?: (event: InteractOutsideEvent) => 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 79b6481a..c42e929f 100644
--- a/src/lib/builders/menu/create.ts
+++ b/src/lib/builders/menu/create.ts
@@ -31,6 +31,7 @@ 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';
@@ -70,7 +71,7 @@ const defaults = {
 	preventScroll: true,
 	closeOnEscape: true,
 	closeOnOutsideClick: true,
-	portal: 'body',
+	portal: undefined,
 	loop: false,
 	dir: 'ltr',
 	defaultOpen: false,
@@ -164,7 +165,7 @@ export function createMenuBuilder(opts: _MenuBuilderOptions) {
 				id: $rootMenuId,
 				'aria-labelledby': $rootTriggerId,
 				'data-state': $isVisible ? 'open' : 'closed',
-				'data-portal': $portal ? '' : undefined,
+				'data-portal': portalAttr($portal),
 				tabindex: -1,
 			} as const;
 		},
@@ -190,22 +191,26 @@ export function createMenuBuilder(opts: _MenuBuilderOptions) {
 							open: rootOpen,
 							options: {
 								floating: $positioning,
-								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,
+								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,
+								},
 								portal: getPortalDestination(node, $portal),
 								escapeKeydown: $closeOnEscape ? undefined : null,
 							},
@@ -745,7 +750,7 @@ export function createMenuBuilder(opts: _MenuBuilderOptions) {
 								options: {
 									floating: $positioning,
 									portal: isHTMLElement(parentMenuEl) ? parentMenuEl : undefined,
-									clickOutside: null,
+									modal: null,
 									focusTrap: null,
 									escapeKeydown: null,
 								},
diff --git a/src/lib/builders/menu/types.ts b/src/lib/builders/menu/types.ts
index 614551bf..16a0fbde 100644
--- a/src/lib/builders/menu/types.ts
+++ b/src/lib/builders/menu/types.ts
@@ -1,4 +1,4 @@
-import type { FloatingConfig } from '$lib/internal/actions/index.js';
+import type { FloatingConfig, InteractOutsideEvent } 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: PointerEvent) => void;
+	onOutsideClick?: (event: InteractOutsideEvent) => 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: PointerEvent) => void) | undefined>>;
+		onOutsideClick: WithGet<Writable<((event: InteractOutsideEvent) => void) | undefined>>;
 	};
 
 	nextFocusable: WithGet<Writable<HTMLElement | null>>;
diff --git a/src/lib/builders/menubar/create.ts b/src/lib/builders/menubar/create.ts
index e3eafda3..27904212 100644
--- a/src/lib/builders/menubar/create.ts
+++ b/src/lib/builders/menubar/create.ts
@@ -25,6 +25,7 @@ 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';
@@ -148,7 +149,7 @@ export function createMenubar(props?: CreateMenubarProps) {
 					'aria-labelledby': $triggerId,
 					'data-state': $isVisible ? 'open' : 'closed',
 					'data-melt-scope': $menubarId,
-					'data-portal': $portal ? '' : undefined,
+					'data-portal': portalAttr($portal),
 					tabindex: -1,
 				} as const;
 			},
@@ -168,21 +169,21 @@ export function createMenubar(props?: CreateMenubarProps) {
 								options: {
 									floating: $positioning,
 									portal: getPortalDestination(node, $portal),
-									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,
+									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,
+									},
 								},
 							});
 
diff --git a/src/lib/builders/popover/create.ts b/src/lib/builders/popover/create.ts
index 8fa0b2f5..0a19d274 100644
--- a/src/lib/builders/popover/create.ts
+++ b/src/lib/builders/popover/create.ts
@@ -17,9 +17,10 @@ import {
 	removeScroll,
 	styleToString,
 	toWritableStores,
+	portalAttr,
 } from '$lib/internal/helpers/index.js';
 
-import { usePopper } from '$lib/internal/actions/index.js';
+import { usePopper, type InteractOutsideEvent } 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';
@@ -100,7 +101,7 @@ export function createPopover(args?: CreatePopoverProps) {
 				}),
 				id: $contentId,
 				'data-state': $isVisible ? 'open' : 'closed',
-				'data-portal': $portal ? '' : undefined,
+				'data-portal': portalAttr($portal),
 			};
 		},
 		action: (node: HTMLElement) => {
@@ -140,11 +141,12 @@ export function createPopover(args?: CreatePopoverProps) {
 										clickOutsideDeactivates: true,
 										escapeDeactivates: true,
 								  },
-							clickOutside: $closeOnOutsideClick
-								? {
-										handler: handleClickOutside,
-								  }
-								: null,
+							modal: {
+								shouldCloseOnInteractOutside: shouldCloseOnInteractOutside,
+								onClose: handleClose,
+								open: $isVisible,
+								closeOnInteractOutside: $closeOnOutsideClick,
+							},
 							escapeKeydown: $closeOnEscape
 								? {
 										handler: () => {
@@ -180,16 +182,16 @@ export function createPopover(args?: CreatePopoverProps) {
 		}
 	}
 
-	function handleClickOutside(e: PointerEvent) {
+	function shouldCloseOnInteractOutside(e: InteractOutsideEvent) {
 		onOutsideClick.get()?.(e);
-		if (e.defaultPrevented) return;
+		if (e.defaultPrevented) return false;
 		const target = e.target;
 		const triggerEl = document.getElementById(ids.trigger.get());
 
 		if (triggerEl && isElement(target)) {
-			if (target === triggerEl || triggerEl.contains(target)) return;
+			if (target === triggerEl || triggerEl.contains(target)) return false;
 		}
-		handleClose();
+		return true;
 	}
 
 	const trigger = makeElement(name('trigger'), {
diff --git a/src/lib/builders/popover/types.ts b/src/lib/builders/popover/types.ts
index 48731e8e..db498c4d 100644
--- a/src/lib/builders/popover/types.ts
+++ b/src/lib/builders/popover/types.ts
@@ -1,4 +1,4 @@
-import type { FloatingConfig } from '$lib/internal/actions/index.js';
+import type { FloatingConfig, InteractOutsideEvent } 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: PointerEvent) => void;
+	onOutsideClick?: (event: InteractOutsideEvent) => void;
 
 	/**
 	 * Whether or not to prevent scrolling when the popover is open.
diff --git a/src/lib/builders/toast/create.ts b/src/lib/builders/toast/create.ts
index cf3b30a0..fd7d104a 100644
--- a/src/lib/builders/toast/create.ts
+++ b/src/lib/builders/toast/create.ts
@@ -192,7 +192,7 @@ export function createToaster<T = object>(props?: CreateToasterProps) {
 	const close = makeElement(name('close'), {
 		returned: () => {
 			return (id: string) => ({
-				type: 'button',
+				type: 'button' as const,
 				'data-id': id,
 			});
 		},
diff --git a/src/lib/builders/toggle-group/create.ts b/src/lib/builders/toggle-group/create.ts
index f79af420..59017dc8 100644
--- a/src/lib/builders/toggle-group/create.ts
+++ b/src/lib/builders/toggle-group/create.ts
@@ -41,7 +41,7 @@ export const createToggleGroup = <T extends ToggleGroupType = 'single'>(
 	const defaultValue = withDefaults.defaultValue
 		? withDefaults.defaultValue
 		: withDefaults.type === 'single'
-		? 'undefined'
+		? undefined
 		: [];
 
 	const valueWritable = withDefaults.value ?? writable(defaultValue);
@@ -90,7 +90,7 @@ export const createToggleGroup = <T extends ToggleGroupType = 'single'>(
 
 			const items = Array.from(parentGroup.querySelectorAll(selector('item')));
 			const $value = value.get();
-			const anyPressed = Array.isArray($value) ? $value.length > 0 : $value !== null;
+			const anyPressed = Array.isArray($value) ? $value.length > 0 : $value ? true : false;
 
 			if (!anyPressed && items[0] === node) {
 				node.tabIndex = 0;
@@ -112,8 +112,7 @@ export const createToggleGroup = <T extends ToggleGroupType = 'single'>(
 						if ($value.includes(itemValue)) {
 							return $value.filter((i) => i !== itemValue);
 						}
-						$value.push(itemValue);
-						return $value;
+						return [...$value, itemValue];
 					}
 					return $value === itemValue ? undefined : itemValue;
 				});
diff --git a/src/lib/builders/tooltip/create.ts b/src/lib/builders/tooltip/create.ts
index a4383ddb..28a465c7 100644
--- a/src/lib/builders/tooltip/create.ts
+++ b/src/lib/builders/tooltip/create.ts
@@ -18,6 +18,7 @@ import {
 	pointInPolygon,
 	styleToString,
 	toWritableStores,
+	portalAttr,
 } from '$lib/internal/helpers/index.js';
 
 import { useFloating, usePortal } from '$lib/internal/actions/index.js';
@@ -37,7 +38,7 @@ const defaults = {
 	openDelay: 1000,
 	closeDelay: 0,
 	forceVisible: false,
-	portal: 'body',
+	portal: undefined,
 	closeOnEscape: true,
 	disableHoverableContent: false,
 	group: undefined,
@@ -201,7 +202,7 @@ export function createTooltip(props?: CreateTooltipProps) {
 					display: $isVisible ? undefined : 'none',
 				}),
 				id: $contentId,
-				'data-portal': $portal ? '' : undefined,
+				'data-portal': portalAttr($portal),
 				'data-state': $open ? 'open' : 'closed',
 			};
 		},
@@ -221,7 +222,7 @@ export function createTooltip(props?: CreateTooltipProps) {
 
 					const floatingReturn = useFloating(triggerEl, node, $positioning);
 					unsubFloating = floatingReturn.destroy;
-					if (!$portal) {
+					if ($portal === null) {
 						unsubPortal();
 						return;
 					}
diff --git a/src/lib/internal/actions/interact-outside/action.ts b/src/lib/internal/actions/interact-outside/action.ts
index 9f6f6712..4fcdbb2d 100644
--- a/src/lib/internal/actions/interact-outside/action.ts
+++ b/src/lib/internal/actions/interact-outside/action.ts
@@ -9,19 +9,26 @@ import type { InteractOutsideConfig, InteractOutsideEvent } from './types.js';
 export function useInteractOutside(node: HTMLElement, config: InteractOutsideConfig) {
 	let unsub = noop;
 
+	let isPointerDown = false;
+	let isPointerDownInside = false;
+	let ignoreEmulatedMouseEvents = false;
+
 	function update(config: InteractOutsideConfig) {
 		unsub();
 		const { onInteractOutside, onInteractOutsideStart, enabled } = config;
 
 		if (!enabled) return;
 
-		let isPointerDown = false;
-		let ignoreEmulatedMouseEvents = false;
-
 		function onPointerDown(e: PointerEvent | MouseEvent | TouchEvent) {
 			if (onInteractOutside && isValidEvent(e, node)) {
 				onInteractOutsideStart?.(e);
 			}
+			const target = e.target;
+
+			if (isElement(target) && isOrContainsTarget(node, target)) {
+				isPointerDownInside = true;
+			}
+
 			isPointerDown = true;
 		}
 
@@ -34,10 +41,10 @@ export function useInteractOutside(node: HTMLElement, config: InteractOutsideCon
 		// Use pointer events if available, otherwise use mouse/touch events
 		if (typeof PointerEvent !== 'undefined') {
 			const onPointerUp = (e: PointerEvent) => {
-				if (isPointerDown && isValidEvent(e, node)) {
+				if (shouldTriggerInteractOutside(e)) {
 					triggerInteractOutside(e);
 				}
-				isPointerDown = false;
+				resetPointerState();
 			};
 
 			unsub = executeCallbacks(
@@ -48,18 +55,18 @@ export function useInteractOutside(node: HTMLElement, config: InteractOutsideCon
 			const onMouseUp = (e: MouseEvent) => {
 				if (ignoreEmulatedMouseEvents) {
 					ignoreEmulatedMouseEvents = false;
-				} else if (isPointerDown && isValidEvent(e, node)) {
+				} else if (shouldTriggerInteractOutside(e)) {
 					triggerInteractOutside(e);
 				}
-				isPointerDown = false;
+				resetPointerState();
 			};
 
 			const onTouchEnd = (e: TouchEvent) => {
 				ignoreEmulatedMouseEvents = true;
-				if (isPointerDown && isValidEvent(e, node)) {
+				if (shouldTriggerInteractOutside(e)) {
 					triggerInteractOutside(e);
 				}
-				isPointerDown = false;
+				resetPointerState();
 			};
 
 			unsub = executeCallbacks(
@@ -71,6 +78,18 @@ export function useInteractOutside(node: HTMLElement, config: InteractOutsideCon
 		}
 	}
 
+	function shouldTriggerInteractOutside(e: InteractOutsideEvent) {
+		if (isPointerDown && !isPointerDownInside && isValidEvent(e, node)) {
+			return true;
+		}
+		return false;
+	}
+
+	function resetPointerState() {
+		isPointerDown = false;
+		isPointerDownInside = false;
+	}
+
 	update(config);
 
 	return {
@@ -90,7 +109,11 @@ function isValidEvent(e: InteractOutsideEvent, node: HTMLElement): boolean {
 		return false;
 	}
 
-	return node && !node.contains(target);
+	return node && !isOrContainsTarget(node, target);
+}
+
+function isOrContainsTarget(node: HTMLElement, target: Element) {
+	return node === target || node.contains(target);
 }
 
 function getOwnerDocument(el: Element | null | undefined) {
diff --git a/src/lib/internal/actions/modal/types.ts b/src/lib/internal/actions/modal/types.ts
index b28bbc95..625aa2b6 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 a62b23e3..f17366a3 100644
--- a/src/lib/internal/actions/popper/action.ts
+++ b/src/lib/internal/actions/popper/action.ts
@@ -1,6 +1,5 @@
 import {
 	createFocusTrap,
-	useClickOutside,
 	useEscapeKeydown,
 	useFloating,
 	usePortal,
@@ -13,11 +12,12 @@ 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: {},
-	clickOutside: {},
+	modal: {},
 	escapeKeydown: {},
 	portal: 'body',
 } satisfies PopperConfig;
@@ -60,19 +60,25 @@ export const usePopper: Action<HTMLElement, PopperArgs> = (popperElement, args)
 		}
 	}
 
-	if (opts.clickOutside !== null) {
+	if (opts.modal !== null) {
 		callbacks.push(
-			useClickOutside(popperElement, {
-				enabled: open,
-				handler: (e: PointerEvent) => {
-					if (e.defaultPrevented) return;
-
-					if (isHTMLElement(anchorElement) && !anchorElement.contains(e.target as Element)) {
+			useModal(popperElement, {
+				onClose: () => {
+					if (isHTMLElement(anchorElement)) {
 						open.set(false);
 						anchorElement.focus();
 					}
 				},
-				...opts.clickOutside,
+				shouldCloseOnInteractOutside: (e) => {
+					if (e.defaultPrevented) return false;
+
+					if (isHTMLElement(anchorElement) && anchorElement.contains(e.target as Element)) {
+						return false;
+					}
+
+					return true;
+				},
+				...opts.modal,
 			}).destroy
 		);
 	}
diff --git a/src/lib/internal/actions/popper/types.ts b/src/lib/internal/actions/popper/types.ts
index 71ed3e62..6e25cc30 100644
--- a/src/lib/internal/actions/popper/types.ts
+++ b/src/lib/internal/actions/popper/types.ts
@@ -1,5 +1,4 @@
 import type {
-	ClickOutsideConfig,
 	FloatingConfig,
 	FocusTrapConfig,
 	PortalConfig,
@@ -7,11 +6,12 @@ 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;
-	clickOutside?: ClickOutsideConfig | null;
+	modal?: ModalConfig | null;
 	portal?: PortalConfig | null;
 	escapeKeydown?: EscapeKeydownConfig | null;
 };
diff --git a/src/lib/internal/helpers/attr.ts b/src/lib/internal/helpers/attr.ts
index fc71525e..621863a9 100644
--- a/src/lib/internal/helpers/attr.ts
+++ b/src/lib/internal/helpers/attr.ts
@@ -17,3 +17,14 @@ 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 507fae26..af6d254a 100644
--- a/src/lib/internal/helpers/elements.ts
+++ b/src/lib/internal/helpers/elements.ts
@@ -16,8 +16,7 @@ export function getPortalDestination(
 	node: HTMLElement,
 	portalProp: string | HTMLElement | undefined | null
 ) {
-	const portalParent = getPortalParent(node);
 	if (portalProp !== undefined) return portalProp;
-	if (portalParent === 'body') return document.body;
-	return null;
+	if (portalProp === null) return null;
+	return getPortalParent(node);
 }
diff --git a/src/lib/internal/helpers/ignore.ts b/src/lib/internal/helpers/ignore.ts
index d59241c7..6477beba 100644
--- a/src/lib/internal/helpers/ignore.ts
+++ b/src/lib/internal/helpers/ignore.ts
@@ -1,7 +1,8 @@
 import { getElementByMeltId, isElement, isHTMLLabelElement } from '$lib/internal/helpers/index.js';
+import type { InteractOutsideEvent } from '../actions/index.js';
 
 export function createClickOutsideIgnore(meltId: string) {
-	return (e: PointerEvent) => {
+	return (e: InteractOutsideEvent) => {
 		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 f9b788d1..2847a128 100644
--- a/src/lib/shared/index.ts
+++ b/src/lib/shared/index.ts
@@ -7,6 +7,7 @@
 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,
@@ -17,4 +18,5 @@ export type {
 	Month,
 	SegmentPart,
 	EditableSegmentPart,
+	InteractOutsideEvent,
 };
diff --git a/src/markdown.postcss b/src/markdown.postcss
index 2729ee12..4f167617 100644
--- a/src/markdown.postcss
+++ b/src/markdown.postcss
@@ -38,7 +38,7 @@
 	}
 
 	& [data-line] {
-		@apply flex min-h-[1.5rem] w-full items-center border-l-[3px] border-l-transparent px-4;
+		@apply min-h-[1.375rem] w-full border-l-[3px] border-l-transparent px-4;
 	}
 
 	& [data-highlighted-line] {


Test patch:
diff --git a/src/tests/context-menu/ContextMenu.spec.ts b/src/tests/context-menu/ContextMenu.spec.ts
index 0de7ce19..7e00a02d 100644
--- a/src/tests/context-menu/ContextMenu.spec.ts
+++ b/src/tests/context-menu/ContextMenu.spec.ts
@@ -5,6 +5,7 @@ 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();
@@ -147,7 +148,10 @@ 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/date-picker/DatePicker.spec.ts b/src/tests/date-picker/DatePicker.spec.ts
index 821fea42..2ff9089e 100644
--- a/src/tests/date-picker/DatePicker.spec.ts
+++ b/src/tests/date-picker/DatePicker.spec.ts
@@ -552,4 +552,12 @@ describe('DatePicker', () => {
 		await user.click(outside);
 		await waitFor(() => expect(content).toBeVisible());
 	});
+
+	test('trigger is disabled when `disabled` prop is true', async () => {
+		const { trigger } = setup({
+			disabled: true,
+		});
+
+		expect(trigger).toBeDisabled();
+	});
 });
diff --git a/src/tests/dialog/Dialog.spec.ts b/src/tests/dialog/Dialog.spec.ts
index 2fd27dc6..c9dedae1 100644
--- a/src/tests/dialog/Dialog.spec.ts
+++ b/src/tests/dialog/Dialog.spec.ts
@@ -247,4 +247,34 @@ describe('Dialog', () => {
 		expect(title.id).toBe(ids.title);
 		expect(description.id).toBe(ids.description);
 	});
+
+	it("Doesn't close on pointerup if the previous pointerdown didn't occur inside the dialog", async () => {
+		const { getByTestId, user, trigger } = setup();
+		const overlay = getByTestId('overlay');
+		const content = getByTestId('content');
+
+		expect(content).not.toBeVisible();
+		await user.click(trigger);
+		expect(content).toBeVisible();
+		await sleep(100);
+		expect(overlay).toBeVisible();
+		await user.pointer({ target: content, offset: 2, keys: '[MouseLeft>]' });
+		await user.pointer({ target: overlay, offset: 2, keys: '[/MouseLeft]' });
+		expect(content).toBeVisible();
+	});
+
+	it('Closes on pointerup if the previous pointerdown occurred outside the dialog', async () => {
+		const { getByTestId, user, trigger } = setup();
+		const overlay = getByTestId('overlay');
+		const content = getByTestId('content');
+
+		expect(content).not.toBeVisible();
+		await user.click(trigger);
+		expect(content).toBeVisible();
+		await sleep(100);
+		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();
+	});
 });
diff --git a/src/tests/popover/Popover.spec.ts b/src/tests/popover/Popover.spec.ts
index 9ed142a2..6c29d3a8 100644
--- a/src/tests/popover/Popover.spec.ts
+++ b/src/tests/popover/Popover.spec.ts
@@ -53,7 +53,10 @@ 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());
 	});
 
@@ -70,7 +73,10 @@ 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/toggle-group/ToggleGroup.spec.ts b/src/tests/toggle-group/ToggleGroup.spec.ts
index 1016d6e1..42bbeac3 100644
--- a/src/tests/toggle-group/ToggleGroup.spec.ts
+++ b/src/tests/toggle-group/ToggleGroup.spec.ts
@@ -1,4 +1,3 @@
-import { kbd } from '$lib/internal/helpers/keyboard.js';
 import { render } from '@testing-library/svelte';
 import { userEvent } from '@testing-library/user-event';
 import { axe } from 'jest-axe';
@@ -6,8 +5,25 @@ import { describe } from 'vitest';
 import ToggleGroupTest from './ToggleGroupTest.svelte';
 import { tick } from 'svelte';
 import { writable } from 'svelte/store';
+import type { CreateToggleGroupProps } from '$lib/index.js';
+import { testKbd as kbd } from '../utils.js';
 
 const items = ['item-1', 'item-2', 'item-3'];
+const defaults: { items: string[]; type: 'single' | 'multiple' } = {
+	items,
+	type: 'single',
+};
+
+function setup<T extends 'single' | 'multiple'>(
+	props?: CreateToggleGroupProps<T> & { items?: string[]; type: 'single' | 'multiple' }
+) {
+	const withDefaults = { ...defaults, ...props };
+	const user = userEvent.setup();
+	const result = render(ToggleGroupTest, withDefaults);
+	const tabButton = result.getByTestId('tab-btn');
+	const root = result.getByTestId('root');
+	return { user, ...result, tabButton, root };
+}
 
 describe('Toggle Group', () => {
 	test('has no accessibility violations', async () => {
@@ -17,11 +33,7 @@ describe('Toggle Group', () => {
 	});
 
 	test('tab does not navigate between items', async () => {
-		const user = userEvent.setup();
-		const { getByTestId } = render(ToggleGroupTest, {
-			type: 'single',
-			items,
-		});
+		const { user, getByTestId } = setup();
 
 		getByTestId(items[0]).focus();
 		expect(getByTestId(items[0])).toHaveFocus();
@@ -34,30 +46,27 @@ describe('Toggle Group', () => {
 	});
 
 	test('keyboard navigation between items', async () => {
-		const user = userEvent.setup();
-		const { getByTestId } = render(ToggleGroupTest, {
+		const { getByTestId, user } = setup({
 			type: 'single',
-			items,
+			loop: false,
 		});
 
 		getByTestId(items[0]).focus();
 
 		for (const item of items) {
 			expect(getByTestId(item)).toHaveFocus();
-			await user.keyboard(`{${kbd.ARROW_RIGHT}}`);
+			await user.keyboard(kbd.ARROW_RIGHT);
 		}
 
 		for (const item of [...items].reverse()) {
 			expect(getByTestId(item)).toHaveFocus();
-			await user.keyboard(`{${kbd.ARROW_LEFT}}`);
+			await user.keyboard(kbd.ARROW_LEFT);
 		}
 	});
 
 	test('keyboard navigation between items - loop', async () => {
-		const user = userEvent.setup();
-		const { getByTestId } = render(ToggleGroupTest, {
+		const { getByTestId, user } = setup({
 			type: 'single',
-			items,
 			loop: true,
 		});
 
@@ -65,30 +74,29 @@ describe('Toggle Group', () => {
 
 		for (const item of items) {
 			expect(getByTestId(item)).toHaveFocus();
-			await user.keyboard(`{${kbd.ARROW_RIGHT}}`);
+			await user.keyboard(kbd.ARROW_RIGHT);
 		}
 
 		for (const item of items) {
 			expect(getByTestId(item)).toHaveFocus();
-			await user.keyboard(`{${kbd.ARROW_RIGHT}}`);
+			await user.keyboard(kbd.ARROW_RIGHT);
 		}
 	});
 
 	test('only one item can be selected in single mode', async () => {
-		const user = userEvent.setup();
-		const { getByTestId } = render(ToggleGroupTest, {
+		const { getByTestId, user } = setup({
 			type: 'single',
 			items,
 		});
 
 		getByTestId(items[0]).focus();
 		expect(getByTestId(items[0])).toHaveFocus();
-		await user.keyboard(`{${kbd.SPACE}}`);
+		await user.keyboard(kbd.SPACE);
 		expect(getByTestId(items[0])).toHaveAttribute('aria-checked', 'true');
 
-		await user.keyboard(`{${kbd.ARROW_RIGHT}}`);
+		await user.keyboard(kbd.ARROW_RIGHT);
 		expect(getByTestId(items[1])).toHaveFocus();
-		await user.keyboard(`{${kbd.SPACE}}`);
+		await user.keyboard(kbd.SPACE);
 
 		const root = getByTestId('root');
 		const checkedItems = root.querySelectorAll<HTMLElement>('[aria-checked="true"]');
@@ -96,20 +104,18 @@ describe('Toggle Group', () => {
 	});
 
 	test('multiple items can be selected in multiple mode', async () => {
-		const user = userEvent.setup();
-		const { getByTestId } = render(ToggleGroupTest, {
+		const { getByTestId, user } = setup({
 			type: 'multiple',
-			items,
 		});
 
 		getByTestId(items[0]).focus();
 		expect(getByTestId(items[0])).toHaveFocus();
-		await user.keyboard(`{${kbd.SPACE}}`);
+		await user.keyboard(kbd.SPACE);
 		expect(getByTestId(items[0])).toHaveAttribute('aria-pressed', 'true');
 
-		await user.keyboard(`{${kbd.ARROW_RIGHT}}`);
+		await user.keyboard(kbd.ARROW_RIGHT);
 		expect(getByTestId(items[1])).toHaveFocus();
-		await user.keyboard(`{${kbd.SPACE}}`);
+		await user.keyboard(kbd.SPACE);
 		expect(getByTestId(items[1])).toHaveAttribute('aria-pressed', 'true');
 
 		const root = getByTestId('root');
@@ -118,39 +124,35 @@ describe('Toggle Group', () => {
 	});
 
 	test('can uncheck items in single mode', async () => {
-		const user = userEvent.setup();
-		const { getByTestId } = render(ToggleGroupTest, {
+		const { getByTestId, user } = setup({
 			type: 'single',
-			items,
 		});
 
 		getByTestId(items[0]).focus();
 		expect(getByTestId(items[0])).toHaveFocus();
-		await user.keyboard(`{${kbd.SPACE}}`);
+		await user.keyboard(kbd.SPACE);
 		expect(getByTestId(items[0])).toHaveAttribute('aria-checked', 'true');
-		await user.keyboard(`{${kbd.SPACE}}`);
+		await user.keyboard(kbd.SPACE);
 		expect(getByTestId(items[0])).toHaveAttribute('aria-checked', 'false');
 	});
 
 	test('can uncheck items in multiple mode', async () => {
-		const user = userEvent.setup();
-		const { getByTestId } = render(ToggleGroupTest, {
+		const { getByTestId, user } = setup({
 			type: 'multiple',
 			items,
 		});
 
 		getByTestId(items[0]).focus();
 		expect(getByTestId(items[0])).toHaveFocus();
-		await user.keyboard(`{${kbd.SPACE}}`);
+		await user.keyboard(kbd.SPACE);
 		expect(getByTestId(items[0])).toHaveAttribute('aria-pressed', 'true');
-		await user.keyboard(`{${kbd.SPACE}}`);
+		await user.keyboard(kbd.SPACE);
 		expect(getByTestId(items[0])).toHaveAttribute('aria-pressed', 'false');
 	});
 
 	test('defaultValue is respected in single mode', async () => {
-		const { getByTestId } = render(ToggleGroupTest, {
+		const { getByTestId } = setup({
 			type: 'single',
-			items,
 			defaultValue: items[1],
 		});
 
@@ -160,9 +162,8 @@ describe('Toggle Group', () => {
 	});
 
 	test('defaultValue is respected in multiple mode', async () => {
-		const { getByTestId } = render(ToggleGroupTest, {
+		const { getByTestId } = setup({
 			type: 'multiple',
-			items,
 			defaultValue: [items[1], items[2]],
 		});
 
@@ -173,9 +174,8 @@ describe('Toggle Group', () => {
 
 	test('value prop overrides defaultValue - single', async () => {
 		const valueStore = writable<string>(items[1]);
-		const { getByTestId } = render(ToggleGroupTest, {
+		const { getByTestId } = setup({
 			type: 'single',
-			items,
 			defaultValue: items[0],
 			value: valueStore,
 		});
@@ -187,9 +187,8 @@ describe('Toggle Group', () => {
 
 	test('value prop overrides defaultValue - multiple', async () => {
 		const valueStore = writable<string[]>([items[1], items[2]]);
-		const { getByTestId } = render(ToggleGroupTest, {
+		const { getByTestId } = setup({
 			type: 'multiple',
-			items,
 			defaultValue: [items[0]],
 			value: valueStore,
 		});
@@ -201,9 +200,8 @@ describe('Toggle Group', () => {
 
 	test('value prop programmatically updates - single', async () => {
 		const valueStore = writable<string>(items[1]);
-		const { getByTestId } = render(ToggleGroupTest, {
+		const { getByTestId } = setup({
 			type: 'single',
-			items,
 			value: valueStore,
 		});
 
@@ -221,9 +219,8 @@ describe('Toggle Group', () => {
 
 	test('value prop programmatically updates - multiple', async () => {
 		const valueStore = writable<string[]>([items[1], items[2]]);
-		const { getByTestId } = render(ToggleGroupTest, {
+		const { getByTestId } = setup({
 			type: 'multiple',
-			items,
 			value: valueStore,
 		});
 
@@ -240,10 +237,8 @@ describe('Toggle Group', () => {
 	});
 
 	test('change function is respected - single', async () => {
-		const user = userEvent.setup();
-		const { getByTestId } = render(ToggleGroupTest, {
+		const { getByTestId, user } = setup({
 			type: 'single',
-			items,
 			onValueChange: () => items[0],
 		});
 
@@ -258,10 +253,8 @@ describe('Toggle Group', () => {
 	});
 
 	test('change function is respected - multiple', async () => {
-		const user = userEvent.setup();
-		const { getByTestId } = render(ToggleGroupTest, {
+		const { user, getByTestId } = setup({
 			type: 'multiple',
-			items,
 			onValueChange: () => [items[0], items[1]],
 		});
 
@@ -275,4 +268,22 @@ describe('Toggle Group', () => {
 		expect(getByTestId(items[0])).toHaveAttribute('aria-pressed', 'true');
 		expect(getByTestId(items[1])).toHaveAttribute('aria-pressed', 'true');
 	});
+
+	test('toggle group focusable when in single mode', async () => {
+		const { user, tabButton, getByTestId } = setup();
+
+		tabButton.focus();
+		expect(tabButton).toHaveFocus();
+		await user.keyboard(kbd.TAB);
+		expect(getByTestId(items[0])).toHaveFocus();
+	});
+
+	test('toggle group focusable when in multiple mode', async () => {
+		const { user, tabButton, getByTestId } = setup({ type: 'multiple', items });
+
+		tabButton.focus();
+		expect(tabButton).toHaveFocus();
+		await user.keyboard(kbd.TAB);
+		expect(getByTestId(items[0])).toHaveFocus();
+	});
 });
diff --git a/src/tests/toggle-group/ToggleGroupTest.svelte b/src/tests/toggle-group/ToggleGroupTest.svelte
index 801dee21..c70df696 100644
--- a/src/tests/toggle-group/ToggleGroupTest.svelte
+++ b/src/tests/toggle-group/ToggleGroupTest.svelte
@@ -1,7 +1,11 @@
-<script lang="ts">
-	import { createToggleGroup, melt, type CreateToggleGroupProps } from '$lib/index.js';
+<script lang="ts" context="module">
+	type T = unknown;
+</script>
 
-	type T = $$Generic<'single' | 'multiple'>;
+<script lang="ts" generics="T extends 'single' | 'multiple'">
+	import { removeUndefined } from '../utils.js';
+
+	import { createToggleGroup, melt, type CreateToggleGroupProps } from '$lib/index.js';
 
 	export let type: T;
 	export let defaultValue: CreateToggleGroupProps<T>['defaultValue'] = undefined;
@@ -12,20 +16,29 @@
 	export let items: string[] = ['item-1', 'item-2', 'item-3'];
 	export let value: CreateToggleGroupProps<T>['value'] = undefined;
 
+	type $$Props = CreateToggleGroupProps<T> & {
+		items: string[];
+		type: T;
+	};
+
 	const {
 		elements: { root, item },
 	} = createToggleGroup<T>({
-		type,
-		defaultValue,
-		disabled,
-		loop,
-		onValueChange,
-		orientation,
-		value,
+		...removeUndefined({
+			type,
+			defaultValue,
+			disabled,
+			loop,
+			onValueChange,
+			orientation,
+			value,
+			...$$restProps,
+		}),
 	});
 </script>
 
 <main>
+	<button data-testid="tab-btn">Tab Focus Step button</button>
 	<div use:melt={$root} aria-label="root-1" data-testid="root">
 		{#each items as tItem}
 			<button use:melt={$item(tItem)} data-testid={tItem}>