Repository: melt-ui/melt-ui
Pull request #863 (2023-12-30 20:47:21)

feat: expose `onOutsideClick` handler
This PR exposes an `onOutsideClick` prop for all components where we handle "outside click" events. This is a prop rather than an event dispatch as these events are handled at the `document`, so dispatching them from a random element would be weird.

If you call `event.preventDefault()` inside this handler, it will cancel all default behavior applied by Melt.

I also added some tests for this functionality.

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

Latest commit: 5d134da8e5d9527192c8d5748e6ae7a29b152dee

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/feat/expose-outside-click?filename=.changeset/shy-walls-buy.md&value=---%0A%22%40melt-ui%2Fsvelte%22%3A%20patch%0A---%0A%0Afeat%3A%20expose%20%60onOutsideClick%60%20handler%0A)



Patch:
diff --git a/.changeset/shy-walls-buy.md b/.changeset/shy-walls-buy.md
new file mode 100644
index 00000000..b1b60f88
--- /dev/null
+++ b/.changeset/shy-walls-buy.md
@@ -0,0 +1,5 @@
+---
+"@melt-ui/svelte": minor
+---
+
+feat: expose `onOutsideClick` handler
diff --git a/src/lib/builders/context-menu/create.ts b/src/lib/builders/context-menu/create.ts
index db93db00..47a61232 100644
--- a/src/lib/builders/context-menu/create.ts
+++ b/src/lib/builders/context-menu/create.ts
@@ -55,6 +55,7 @@ const defaults = {
 	disableFocusFirstItem: true,
 	closeFocus: undefined,
 	closeOnItemClick: true,
+	onOutsideClick: undefined,
 } satisfies CreateContextMenuProps;
 
 const { name, selector } = createElHelpers<_MenuParts>('context-menu');
@@ -110,6 +111,7 @@ export function createContextMenu(props?: CreateContextMenuProps) {
 	const longPressTimer = writable(0);
 
 	function handleClickOutside(e: PointerEvent) {
+		get(rootOptions.onOutsideClick)?.(e);
 		if (e.defaultPrevented) return;
 
 		const target = e.target;
diff --git a/src/lib/builders/date-picker/create.ts b/src/lib/builders/date-picker/create.ts
index 7564f008..16f21a86 100644
--- a/src/lib/builders/date-picker/create.ts
+++ b/src/lib/builders/date-picker/create.ts
@@ -27,6 +27,7 @@ const defaults = {
 	},
 	closeOnEscape: true,
 	closeOnOutsideClick: true,
+	onOutsideClick: undefined,
 	preventScroll: false,
 	forceVisible: false,
 	locale: 'en',
@@ -72,6 +73,7 @@ export function createDatePicker(props?: CreateDatePickerProps) {
 		forceVisible: withDefaults.forceVisible,
 		openFocus: pickerOpenFocus,
 		ids: withDefaults.popoverIds,
+		onOutsideClick: withDefaults.onOutsideClick,
 	});
 
 	const trigger = builder('popover-trigger', {
diff --git a/src/lib/builders/date-range-picker/create.ts b/src/lib/builders/date-range-picker/create.ts
index 6caecbdb..d704966b 100644
--- a/src/lib/builders/date-range-picker/create.ts
+++ b/src/lib/builders/date-range-picker/create.ts
@@ -37,6 +37,7 @@ const defaults = {
 	minValue: undefined,
 	maxValue: undefined,
 	weekdayFormat: 'narrow',
+	onOutsideClick: undefined,
 } satisfies CreateDateRangePickerProps;
 
 export function createDateRangePicker(props?: CreateDateRangePickerProps) {
@@ -68,6 +69,7 @@ export function createDateRangePicker(props?: CreateDateRangePickerProps) {
 		portal: withDefaults.portal,
 		forceVisible: withDefaults.forceVisible,
 		openFocus: pickerOpenFocus,
+		onOutsideClick: withDefaults.onOutsideClick,
 	});
 
 	const options = toWritableStores({
@@ -151,6 +153,10 @@ export function createDateRangePicker(props?: CreateDateRangePickerProps) {
 		}
 	});
 
+	effect([options.onOutsideClick], ([$onOutsideClick]) => {
+		popover.options.onOutsideClick.set($onOutsideClick);
+	});
+
 	const rangeFieldOptions = omit(
 		rangeField.options,
 		'locale',
diff --git a/src/lib/builders/dialog/create.ts b/src/lib/builders/dialog/create.ts
index ffa9beab..9fc989b4 100644
--- a/src/lib/builders/dialog/create.ts
+++ b/src/lib/builders/dialog/create.ts
@@ -23,11 +23,11 @@ import {
 	sleep,
 	styleToString,
 	toWritableStores,
+	omit,
 } from '$lib/internal/helpers/index.js';
 import type { Defaults, MeltActionReturn } from '$lib/internal/types.js';
 import { tick } from 'svelte';
 import { derived, get, writable } from 'svelte/store';
-import { omit } from '../../internal/helpers/object';
 import type { DialogEvents } from './events.js';
 import type { CreateDialogProps } from './types.js';
 
@@ -51,6 +51,7 @@ const defaults = {
 	forceVisible: false,
 	openFocus: undefined,
 	closeFocus: undefined,
+	onOutsideClick: undefined,
 } satisfies Defaults<CreateDialogProps>;
 
 const openDialogIds = writable<string[]>([]);
@@ -72,6 +73,7 @@ export function createDialog(props?: CreateDialogProps) {
 		forceVisible,
 		openFocus,
 		closeFocus,
+		onOutsideClick,
 	} = options;
 
 	const activeTrigger = writable<HTMLElement | null>(null);
@@ -229,6 +231,7 @@ export function createDialog(props?: CreateDialogProps) {
 					return useClickOutside(node, {
 						enabled: $open,
 						handler: (e: PointerEvent) => {
+							get(onOutsideClick)?.(e);
 							if (e.defaultPrevented) return;
 
 							const $openDialogIds = get(openDialogIds);
diff --git a/src/lib/builders/dialog/types.ts b/src/lib/builders/dialog/types.ts
index d6087b47..7154acdb 100644
--- a/src/lib/builders/dialog/types.ts
+++ b/src/lib/builders/dialog/types.ts
@@ -4,13 +4,62 @@ import type { Writable } from 'svelte/store';
 import type { DialogIdParts, createDialog } from './create.js';
 export type { DialogComponentEvents } from './events.js';
 export type CreateDialogProps = {
+	/**
+	 * If true, the dialog will prevent scrolling on the body
+	 * when it is open.
+	 *
+	 * @default true
+	 */
 	preventScroll?: boolean;
+
+	/**
+	 * If true, the dialog will close when the user presses the escape key.
+	 *
+	 * @default true
+	 */
 	closeOnEscape?: boolean;
+
+	/**
+	 * If true, the dialog will close when the user clicks outside of it.
+	 *
+	 * @default true
+	 */
 	closeOnOutsideClick?: boolean;
+
+	/**
+	 * A custom event handler for the "outside click" event, which
+	 * is handled by the `document`.
+	 * If `event.preventDefault()` is called within the function,
+	 * the dialog will not close when the user clicks outside of it.
+	 */
+	onOutsideClick?: (event: PointerEvent) => void;
+
+	/**
+	 * The `role` attribute to apply to the dialog.
+	 *
+	 * @default 'dialog'
+	 */
 	role?: 'dialog' | 'alertdialog';
+
+	/**
+	 * If true, the dialog will be open by default.
+	 *
+	 * @default false
+	 */
 	defaultOpen?: boolean;
+
+	/**
+	 * A writable store that controls the open state of the dialog.
+	 * @see [Controlled Usage](https://melt-ui.com/docs/controlled#bring-your-own-store)
+	 */
 	open?: Writable<boolean>;
+
+	/**
+	 * A function that will be called when the open state of the dialog changes.
+	 * @see [Controlled Usage](https://melt-ui.com/docs/controlled#change-functions)
+	 */
 	onOpenChange?: ChangeFn<boolean>;
+
 	/**
 	 * If not undefined, the dialog content will be rendered within the provided element or selector.
 	 *
@@ -18,6 +67,13 @@ export type CreateDialogProps = {
 	 */
 	portal?: HTMLElement | string | null;
 
+	/**
+	 * If true, the dialog will be visible regardless of the open state.
+	 * Use this when you want to conditionally render the content of the dialog
+	 * using an `{#if ...}` block.
+	 *
+	 * @default false
+	 */
 	forceVisible?: boolean;
 
 	/**
diff --git a/src/lib/builders/dropdown-menu/create.ts b/src/lib/builders/dropdown-menu/create.ts
index 0d055ef9..8ba8fddf 100644
--- a/src/lib/builders/dropdown-menu/create.ts
+++ b/src/lib/builders/dropdown-menu/create.ts
@@ -21,6 +21,7 @@ const defaults = {
 	closeFocus: undefined,
 	disableFocusFirstItem: false,
 	closeOnItemClick: true,
+	onOutsideClick: undefined,
 } satisfies CreateDropdownMenuProps;
 
 export function createDropdownMenu(props?: CreateDropdownMenuProps) {
diff --git a/src/lib/builders/link-preview/create.ts b/src/lib/builders/link-preview/create.ts
index 99f92273..7152609b 100644
--- a/src/lib/builders/link-preview/create.ts
+++ b/src/lib/builders/link-preview/create.ts
@@ -42,6 +42,7 @@ const defaults = {
 	forceVisible: false,
 	portal: 'body',
 	closeOnEscape: true,
+	onOutsideClick: undefined,
 } satisfies CreateLinkPreviewProps;
 
 export const linkPreviewIdParts = ['trigger', 'content'] as const;
@@ -71,6 +72,7 @@ export function createLinkPreview(props: CreateLinkPreviewProps = {}) {
 		forceVisible,
 		portal,
 		closeOnEscape,
+		onOutsideClick,
 	} = options;
 
 	const ids = toWritableStores({ ...generateIds(linkPreviewIdParts), ...withDefaults.ids });
@@ -190,7 +192,22 @@ export function createLinkPreview(props: CreateLinkPreviewProps = {}) {
 						open: open,
 						options: {
 							floating: $positioning,
-							clickOutside: $closeOnOutsideClick ? undefined : null,
+							clickOutside: $closeOnOutsideClick
+								? {
+										handler: (e) => {
+											get(onOutsideClick)?.(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 49b772b7..21094a75 100644
--- a/src/lib/builders/link-preview/types.ts
+++ b/src/lib/builders/link-preview/types.ts
@@ -57,6 +57,14 @@ export type CreateLinkPreviewProps = {
 	 */
 	closeOnOutsideClick?: boolean;
 
+	/**
+	 * A custom event handler for the "outside click" event, which
+	 * is handled by the `document`.
+	 * If `event.preventDefault()` is called within the function,
+	 * the dialog will not close when the user clicks outside of it.
+	 */
+	onOutsideClick?: (event: PointerEvent) => void;
+
 	/**
 	 * Whether or not to close the linkpreview when the escape key is pressed
 	 * while it is open.
diff --git a/src/lib/builders/listbox/create.ts b/src/lib/builders/listbox/create.ts
index 168bf2fb..57a03872 100644
--- a/src/lib/builders/listbox/create.ts
+++ b/src/lib/builders/listbox/create.ts
@@ -75,6 +75,7 @@ const defaults = {
 	name: undefined,
 	typeahead: true,
 	highlightOnHover: true,
+	onOutsideClick: undefined,
 } satisfies Defaults<CreateListboxProps<unknown>>;
 
 export const listboxIdParts = ['trigger', 'menu', 'label'] as const;
@@ -133,6 +134,7 @@ export function createListbox<
 		typeahead,
 		name: nameProp,
 		highlightOnHover,
+		onOutsideClick,
 	} = options;
 	const { name, selector } = createElHelpers(withDefaults.builder);
 
@@ -458,15 +460,8 @@ export function createListbox<
 			const unsubscribe = executeCallbacks(
 				// Bind the popper portal to the input element.
 				effect(
-					[isVisible, closeOnEscape, portal, closeOnOutsideClick, positioning, activeTrigger],
-					([
-						$isVisible,
-						$closeOnEscape,
-						$portal,
-						$closeOnOutsideClick,
-						$positioning,
-						$activeTrigger,
-					]) => {
+					[isVisible, portal, closeOnOutsideClick, positioning, activeTrigger],
+					([$isVisible, $portal, $closeOnOutsideClick, $positioning, $activeTrigger]) => {
 						unsubPopper();
 
 						if (!$isVisible || !$activeTrigger) return;
@@ -482,6 +477,9 @@ export function createListbox<
 								clickOutside: $closeOnOutsideClick
 									? {
 											handler: (e) => {
+												get(onOutsideClick)?.(e);
+												if (e.defaultPrevented) return;
+
 												const target = e.target;
 												if (!isElement(target)) return;
 												if (target === $activeTrigger || $activeTrigger.contains(target)) {
@@ -492,13 +490,7 @@ export function createListbox<
 											ignore: ignoreHandler,
 									  }
 									: null,
-								escapeKeydown: $closeOnEscape
-									? {
-											handler: () => {
-												closeMenu();
-											},
-									  }
-									: null,
+								escapeKeydown: null,
 								portal: getPortalDestination(node, $portal),
 							},
 						});
diff --git a/src/lib/builders/listbox/types.ts b/src/lib/builders/listbox/types.ts
index f62a7bc4..ba1bc0a7 100644
--- a/src/lib/builders/listbox/types.ts
+++ b/src/lib/builders/listbox/types.ts
@@ -111,6 +111,14 @@ export type CreateListboxProps<
 	 */
 	closeOnEscape?: boolean;
 
+	/**
+	 * A custom event handler for the "outside click" event, which
+	 * is handled by the `document`.
+	 * If `event.preventDefault()` is called within the function,
+	 * the dialog will not close when the user clicks outside of it.
+	 */
+	onOutsideClick?: (event: PointerEvent) => void;
+
 	/**
 	 * Whether or not to prevent scrolling the page when the
 	 * listbox menu is open.
diff --git a/src/lib/builders/menu/create.ts b/src/lib/builders/menu/create.ts
index a5c14bdb..a393915a 100644
--- a/src/lib/builders/menu/create.ts
+++ b/src/lib/builders/menu/create.ts
@@ -76,6 +76,7 @@ const defaults = {
 	defaultOpen: false,
 	typeahead: true,
 	closeOnItemClick: true,
+	onOutsideClick: undefined,
 } satisfies Defaults<_CreateMenuProps>;
 
 export function createMenuBuilder(opts: _MenuBuilderOptions) {
@@ -94,6 +95,7 @@ export function createMenuBuilder(opts: _MenuBuilderOptions) {
 		closeFocus,
 		disableFocusFirstItem,
 		closeOnItemClick,
+		onOutsideClick,
 	} = opts.rootOptions;
 
 	const rootOpen = opts.rootOpen;
@@ -189,7 +191,22 @@ export function createMenuBuilder(opts: _MenuBuilderOptions) {
 							open: rootOpen,
 							options: {
 								floating: $positioning,
-								clickOutside: $closeOnOutsideClick ? undefined : null,
+								clickOutside: $closeOnOutsideClick
+									? {
+											handler: (e) => {
+												get(onOutsideClick)?.(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,
 							},
diff --git a/src/lib/builders/menu/types.ts b/src/lib/builders/menu/types.ts
index 38e4df10..a5ff239f 100644
--- a/src/lib/builders/menu/types.ts
+++ b/src/lib/builders/menu/types.ts
@@ -60,6 +60,14 @@ export type _CreateMenuProps = {
 	 */
 	closeOnOutsideClick?: boolean;
 
+	/**
+	 * A custom event handler for the "outside click" event, which
+	 * is handled by the `document`.
+	 * If `event.preventDefault()` is called within the function,
+	 * the dialog will not close when the user clicks outside of it.
+	 */
+	onOutsideClick?: (event: PointerEvent) => void;
+
 	/**
 	 * Whether or not to loop the menu navigation.
 	 *
@@ -181,6 +189,7 @@ export type _MenuBuilderOptions = {
 		closeFocus: Writable<FocusProp | undefined>;
 		disableFocusFirstItem: Writable<boolean>;
 		closeOnItemClick: Writable<boolean>;
+		onOutsideClick: Writable<((event: PointerEvent) => void) | undefined>;
 	};
 
 	nextFocusable: Writable<HTMLElement | null>;
diff --git a/src/lib/builders/menubar/create.ts b/src/lib/builders/menubar/create.ts
index 3c923720..22172dbe 100644
--- a/src/lib/builders/menubar/create.ts
+++ b/src/lib/builders/menubar/create.ts
@@ -106,6 +106,7 @@ export function createMenubar(props?: CreateMenubarProps) {
 		closeFocus: undefined,
 		disableFocusFirstItem: false,
 		closeOnItemClick: true,
+		onOutsideClick: undefined,
 	} satisfies CreateMenubarMenuProps;
 
 	const createMenu = (props?: CreateMenubarMenuProps) => {
@@ -115,7 +116,7 @@ export function createMenubar(props?: CreateMenubarProps) {
 
 		// options
 		const options = toWritableStores(withDefaults);
-		const { positioning, portal, forceVisible } = options;
+		const { positioning, portal, forceVisible, closeOnOutsideClick, onOutsideClick } = options;
 
 		const m = createMenuBuilder({
 			rootOptions: options,
@@ -154,8 +155,8 @@ export function createMenubar(props?: CreateMenubarProps) {
 				let unsubPopper = noop;
 
 				const unsubDerived = effect(
-					[rootOpen, rootActiveTrigger, positioning, portal],
-					([$rootOpen, $rootActiveTrigger, $positioning, $portal]) => {
+					[rootOpen, rootActiveTrigger, positioning, portal, closeOnOutsideClick],
+					([$rootOpen, $rootActiveTrigger, $positioning, $portal, $closeOnOutsideClick]) => {
 						unsubPopper();
 						if (!($rootOpen && $rootActiveTrigger)) return;
 
@@ -166,17 +167,21 @@ export function createMenubar(props?: CreateMenubarProps) {
 								options: {
 									floating: $positioning,
 									portal: getPortalDestination(node, $portal),
-									clickOutside: {
-										ignore: (e) => {
-											const target = e.target;
-											const menubarEl = document.getElementById(get(ids.menubar));
-											if (!menubarEl || !isElement(target)) return false;
-											return menubarEl.contains(target);
-										},
-										handler: () => {
-											activeMenu.set('');
-										},
-									},
+									clickOutside: $closeOnOutsideClick
+										? {
+												ignore: (e) => {
+													const target = e.target;
+													const menubarEl = document.getElementById(get(ids.menubar));
+													if (!menubarEl || !isElement(target)) return false;
+													return menubarEl.contains(target);
+												},
+												handler: (e) => {
+													get(onOutsideClick)?.(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 fb5cab2d..485b99af 100644
--- a/src/lib/builders/popover/create.ts
+++ b/src/lib/builders/popover/create.ts
@@ -43,6 +43,7 @@ const defaults = {
 	forceVisible: false,
 	openFocus: undefined,
 	closeFocus: undefined,
+	onOutsideClick: undefined,
 } satisfies Defaults<CreatePopoverProps>;
 
 type PopoverParts = 'trigger' | 'content' | 'arrow' | 'close';
@@ -66,6 +67,7 @@ export function createPopover(args?: CreatePopoverProps) {
 		forceVisible,
 		openFocus,
 		closeFocus,
+		onOutsideClick,
 	} = options;
 
 	const openWritable = withDefaults.open ?? writable(withDefaults.defaultOpen);
@@ -179,6 +181,8 @@ export function createPopover(args?: CreatePopoverProps) {
 	}
 
 	function handleClickOutside(e: PointerEvent) {
+		get(onOutsideClick)?.(e);
+		if (e.defaultPrevented) return;
 		const target = e.target;
 		const triggerEl = document.getElementById(get(ids.trigger));
 
diff --git a/src/lib/builders/popover/types.ts b/src/lib/builders/popover/types.ts
index d4f42779..48731e8e 100644
--- a/src/lib/builders/popover/types.ts
+++ b/src/lib/builders/popover/types.ts
@@ -60,6 +60,14 @@ export type CreatePopoverProps = {
 	 */
 	closeOnOutsideClick?: boolean;
 
+	/**
+	 * A custom event handler for the "outside click" event, which
+	 * is handled by the `document`.
+	 * If `event.preventDefault()` is called within the function,
+	 * the dialog will not close when the user clicks outside of it.
+	 */
+	onOutsideClick?: (event: PointerEvent) => void;
+
 	/**
 	 * Whether or not to prevent scrolling when the popover is open.
 	 *


Test patch:
diff --git a/src/tests/combobox/Combobox.spec.ts b/src/tests/combobox/Combobox.spec.ts
index d47e538a..708b57c9 100644
--- a/src/tests/combobox/Combobox.spec.ts
+++ b/src/tests/combobox/Combobox.spec.ts
@@ -140,6 +140,47 @@ describe('Combobox', () => {
 		expect(label.id).toBe(ids.label);
 	});
 
+	test("Doesn't close on outside click if defaultPrevented on `onOutsideClick` handler", async () => {
+		const user = userEvent.setup();
+		const { getByTestId } = render(ComboboxTest, { onOutsideClick: (e) => e.preventDefault() });
+		const input = getByTestId('input');
+
+		await user.click(input);
+		expect(getByTestId('menu')).toBeVisible();
+
+		const outsideClick = getByTestId('outside-click');
+		await user.click(outsideClick);
+		expect(getByTestId('menu')).toBeVisible();
+	});
+
+	test('Closes on escape by default', async () => {
+		const user = userEvent.setup();
+		const { getByTestId } = render(ComboboxTest);
+
+		const input = getByTestId('input');
+		const menu = getByTestId('menu');
+
+		await user.click(input);
+		expect(menu).toBeVisible();
+
+		await user.keyboard(kbd.ESCAPE);
+		expect(menu).not.toBeVisible();
+	});
+
+	test('Respects the `closeOnEscape` prop', async () => {
+		const user = userEvent.setup();
+		const { getByTestId } = render(ComboboxTest, { closeOnEscape: false });
+
+		const input = getByTestId('input');
+		const menu = getByTestId('menu');
+
+		await user.click(input);
+		expect(menu).toBeVisible();
+
+		await user.keyboard(kbd.ESCAPE);
+		expect(menu).toBeVisible();
+	});
+
 	test.todo('Selects multiple items when `multiple` is true');
 	test.todo('Manually setting the value updates the label');
 	test.todo('Updating options and setting the value updates the label');
diff --git a/src/tests/combobox/ComboboxTest.svelte b/src/tests/combobox/ComboboxTest.svelte
index 50b79ce7..7fe4aa0f 100644
--- a/src/tests/combobox/ComboboxTest.svelte
+++ b/src/tests/combobox/ComboboxTest.svelte
@@ -17,6 +17,8 @@
 	export let multiple = false;
 	export let defaultValue: string | undefined = undefined;
 	export let ids: CreateComboboxProps<unknown>['ids'] = undefined;
+	export let onOutsideClick: CreateComboboxProps<unknown>['onOutsideClick'] = undefined;
+	export let closeOnEscape: CreateComboboxProps<unknown>['closeOnEscape'] = undefined;
 
 	const {
 		elements: { menu, input, option, label },
@@ -31,6 +33,8 @@
 				  }
 				: undefined,
 			ids,
+			onOutsideClick,
+			closeOnEscape,
 		})
 	);
 
@@ -60,4 +64,5 @@
 			{/each}
 		</div>
 	</ul>
+	<div data-testid="outside-click" />
 </main>
diff --git a/src/tests/context-menu/ContextMenu.spec.ts b/src/tests/context-menu/ContextMenu.spec.ts
index 2157e7dd..39b823b9 100644
--- a/src/tests/context-menu/ContextMenu.spec.ts
+++ b/src/tests/context-menu/ContextMenu.spec.ts
@@ -205,4 +205,16 @@ describe('Context Menu', () => {
 		const submenu = getByTestId('submenu');
 		expect(submenu.id).toBe(subIds.menu);
 	});
+
+	test("Doesn't close on outsideClick if defaultPrevented in onOutsideClick", async () => {
+		const { user, menu, getByTestId } = await open({
+			onOutsideClick: (e) => {
+				e.preventDefault();
+			},
+		});
+		expect(menu).toBeVisible();
+		const outsideClick = getByTestId('outside-click');
+		await user.click(outsideClick);
+		expect(menu).toBeVisible();
+	});
 });
diff --git a/src/tests/date-picker/DatePicker.spec.ts b/src/tests/date-picker/DatePicker.spec.ts
index 30570601..cdbe3698 100644
--- a/src/tests/date-picker/DatePicker.spec.ts
+++ b/src/tests/date-picker/DatePicker.spec.ts
@@ -545,4 +545,22 @@ describe('DatePicker', () => {
 		await user.click(label);
 		expect(monthSegment).toHaveFocus();
 	});
+
+	test("doesn't close on outside click if preventDefault called in `onOutsideClick`", async () => {
+		const { getByTestId, trigger, user } = setup({
+			onOutsideClick: (e) => {
+				e.preventDefault();
+			},
+		});
+		const content = getByTestId('content');
+
+		expect(content).not.toBeVisible();
+		await user.click(trigger);
+		await waitFor(() => expect(content).toBeVisible());
+		await user.click(content);
+		await waitFor(() => expect(content).toBeVisible());
+		const outside = getByTestId('inside-value');
+		await user.click(outside);
+		await waitFor(() => expect(content).toBeVisible());
+	});
 });
diff --git a/src/tests/date-picker/DatePickerTest.svelte b/src/tests/date-picker/DatePickerTest.svelte
index 4e30dd2d..fa7c38f3 100644
--- a/src/tests/date-picker/DatePickerTest.svelte
+++ b/src/tests/date-picker/DatePickerTest.svelte
@@ -2,7 +2,6 @@
 	import { createDatePicker, type CreateDatePickerProps } from '$lib/builders';
 	import { ChevronRight, ChevronLeft, Calendar } from 'lucide-svelte';
 	import { melt } from '$lib';
-	import { fade } from 'svelte/transition';
 	import { removeUndefined } from '../utils';
 
 	export let value: CreateDatePickerProps['value'] = undefined;
@@ -28,6 +27,7 @@
 	export let placeholder: CreateDatePickerProps['placeholder'] = undefined;
 	export let weekStartsOn: CreateDatePickerProps['weekStartsOn'] = undefined;
 	export let weekdayFormat: CreateDatePickerProps['weekdayFormat'] = undefined;
+	export let onOutsideClick: CreateDatePickerProps['onOutsideClick'] = undefined;
 
 	const {
 		elements: {
@@ -71,6 +71,7 @@
 			isDateDisabled,
 			weekdayFormat,
 			popoverIds,
+			onOutsideClick,
 		})
 	);
 
@@ -118,7 +119,6 @@
 	</div>
 	<div
 		class="z-10 w-80 rounded-[4px] bg-white p-3 shadow-sm"
-		transition:fade={{ duration: 100 }}
 		use:melt={$content}
 		data-testid="content"
 	>
diff --git a/src/tests/dialog/Dialog.spec.ts b/src/tests/dialog/Dialog.spec.ts
index aedfbb86..d34459ea 100644
--- a/src/tests/dialog/Dialog.spec.ts
+++ b/src/tests/dialog/Dialog.spec.ts
@@ -77,6 +77,25 @@ describe('Dialog', () => {
 		expect(content).not.toBeVisible();
 	});
 
+	it('Prevents closing on outside click if `defaultPrevented` in `onOutsideClick` callback', async () => {
+		const { getByTestId, user, trigger } = setup({
+			onOutsideClick: (e) => {
+				e.preventDefault();
+			},
+		});
+		const overlay = getByTestId('overlay');
+		const content = getByTestId('content');
+
+		expect(trigger).toBeVisible();
+		expect(content).not.toBeVisible();
+		await user.click(trigger);
+		expect(content).toBeVisible();
+		await sleep(100);
+		expect(overlay).toBeVisible();
+		await user.click(overlay);
+		expect(content).toBeVisible();
+	});
+
 	it('Portalled el attaches dialog to body', async () => {
 		const { getByTestId, user, trigger } = setup();
 		await user.click(trigger);
diff --git a/src/tests/dropdown-menu/DropdownMenu.spec.ts b/src/tests/dropdown-menu/DropdownMenu.spec.ts
index 28842689..70ea94ac 100644
--- a/src/tests/dropdown-menu/DropdownMenu.spec.ts
+++ b/src/tests/dropdown-menu/DropdownMenu.spec.ts
@@ -244,6 +244,20 @@ describe('Dropdown Menu (Default)', () => {
 		expect(submenu.id).toBe(subIds.menu);
 	});
 
+	test("Doesn't close on outsideClick if defaultPrevented in onOutsideClick", async () => {
+		const { user, trigger, getByTestId } = setup({
+			onOutsideClick: (e) => {
+				e.preventDefault();
+			},
+		});
+		const menu = getByTestId('menu');
+		await user.click(trigger);
+		expect(menu).toBeVisible();
+		const outsideClick = getByTestId('outside-click');
+		await user.click(outsideClick);
+		expect(menu).toBeVisible();
+	});
+
 	test.todo('Radio items');
 });
 
diff --git a/src/tests/dropdown-menu/DropdownMenuTest.svelte b/src/tests/dropdown-menu/DropdownMenuTest.svelte
index 538ea60f..4afc5b2f 100644
--- a/src/tests/dropdown-menu/DropdownMenuTest.svelte
+++ b/src/tests/dropdown-menu/DropdownMenuTest.svelte
@@ -18,13 +18,15 @@
 	const {
 		elements: { trigger, menu, item, separator, arrow },
 		builders: { createSubmenu, createMenuRadioGroup, createCheckboxItem },
-	} = createDropdownMenu({
-		loop,
-		closeFocus,
-		closeOnEscape,
-		closeOnOutsideClick,
-		...$$restProps,
-	});
+	} = createDropdownMenu(
+		removeUndefined({
+			loop,
+			closeFocus,
+			closeOnEscape,
+			closeOnOutsideClick,
+			...$$restProps,
+		})
+	);
 
 	const {
 		elements: { checkboxItem: settingsSyncCheckbox },
diff --git a/src/tests/link-preview/LinkPreview.spec.ts b/src/tests/link-preview/LinkPreview.spec.ts
index 6287d1ed..6f689bd4 100644
--- a/src/tests/link-preview/LinkPreview.spec.ts
+++ b/src/tests/link-preview/LinkPreview.spec.ts
@@ -74,6 +74,20 @@ describe('LinkPreview (Default)', () => {
 		await waitFor(() => expect(content).not.toBeVisible());
 	});
 
+	test.skip('Closes on outside click by default', async () => {
+		const { getByTestId, trigger, user } = setup();
+		const content = getByTestId('content');
+
+		expect(content).not.toBeVisible();
+		await user.hover(trigger);
+		await waitFor(() => expect(content).toBeVisible());
+		await user.click(content);
+		await waitFor(() => expect(content).toBeVisible());
+		const start = getByTestId('start');
+		await user.click(start);
+		await waitFor(() => expect(content).not.toBeVisible());
+	});
+
 	test('Custom ids are applied when provided', async () => {
 		const ids = {
 			content: 'id-content',
diff --git a/src/tests/popover/Popover.spec.ts b/src/tests/popover/Popover.spec.ts
index 4acb626c..0143c7ca 100644
--- a/src/tests/popover/Popover.spec.ts
+++ b/src/tests/popover/Popover.spec.ts
@@ -57,6 +57,23 @@ describe('Popover (Default)', () => {
 		await waitFor(() => expect(content).not.toBeVisible());
 	});
 
+	test('doesnt close when preventDefault called in `onOutsideClick`', async () => {
+		const { content, trigger, user, getByTestId } = setup({
+			onOutsideClick: (e) => {
+				e.preventDefault();
+			},
+		});
+
+		expect(content).not.toBeVisible();
+		await user.click(trigger);
+		await waitFor(() => expect(content).toBeVisible());
+		await user.click(content);
+		await waitFor(() => expect(content).toBeVisible());
+		const outside = getByTestId('outside');
+		await user.click(outside);
+		await waitFor(() => expect(content).toBeVisible());
+	});
+
 	test('Toggles open state on trigger click', async () => {
 		const { content, trigger, user } = setup();
 
diff --git a/src/tests/select/Select.spec.ts b/src/tests/select/Select.spec.ts
index 4155874d..d68dfc47 100644
--- a/src/tests/select/Select.spec.ts
+++ b/src/tests/select/Select.spec.ts
@@ -14,10 +14,10 @@ describe('Select', () => {
 	});
 
 	test('Opens/Closes when trigger is clicked', async () => {
+		const user = userEvent.setup();
 		const { getByTestId } = render(SelectTest);
 		const trigger = getByTestId('trigger');
 		const menu = getByTestId('menu');
-		const user = userEvent.setup();
 
 		expect(menu).not.toBeVisible();
 		await user.click(trigger);
@@ -27,11 +27,26 @@ describe('Select', () => {
 		expect(menu).not.toBeVisible();
 	});
 
-	test.each(OPEN_KEYS)('Opens when %s is pressed', async (key) => {
+	test('Closes on escape keydown', async () => {
+		const user = userEvent.setup();
 		const { getByTestId } = render(SelectTest);
 		const trigger = getByTestId('trigger');
 		const menu = getByTestId('menu');
+
+		expect(menu).not.toBeVisible();
+		await user.click(trigger);
+		expect(menu).toBeVisible();
+		expect(trigger).toHaveFocus();
+
+		await user.keyboard(kbd.ESCAPE);
+		expect(menu).not.toBeVisible();
+	});
+
+	test.each(OPEN_KEYS)('Opens when %s is pressed', async (key) => {
 		const user = userEvent.setup();
+		const { getByTestId } = render(SelectTest);
+		const trigger = getByTestId('trigger');
+		const menu = getByTestId('menu');
 
 		expect(menu).not.toBeVisible();
 		await act(() => trigger.focus());
@@ -40,10 +55,10 @@ describe('Select', () => {
 	});
 
 	test('Toggles when trigger is clicked', async () => {
+		const user = userEvent.setup();
 		const { getByTestId } = render(SelectTest);
 		const trigger = getByTestId('trigger');
 		const menu = getByTestId('menu');
-		const user = userEvent.setup();
 
 		expect(menu).not.toBeVisible();
 		await user.click(trigger);
@@ -54,10 +69,10 @@ describe('Select', () => {
 	});
 
 	test('Toggles when icon within trigger is clicked', async () => {
+		const user = userEvent.setup();
 		const { getByTestId } = render(SelectTest);
 		const icon = getByTestId('icon');
 		const menu = getByTestId('menu');
-		const user = userEvent.setup();
 
 		expect(menu).not.toBeVisible();
 		await user.click(icon);
@@ -68,10 +83,10 @@ describe('Select', () => {
 	});
 
 	test('Selects item when clicked', async () => {
+		const user = userEvent.setup();
 		const { getByTestId } = render(SelectTest);
 		const trigger = getByTestId('trigger');
 		const menu = getByTestId('menu');
-		const user = userEvent.setup();
 
 		expect(trigger).not.toHaveTextContent('Caramel');
 
@@ -98,10 +113,10 @@ describe('Select', () => {
 	});
 
 	test('Selects multiple items when `multiple` is true', async () => {
+		const user = userEvent.setup();
 		const { getByTestId } = render(SelectTest, { multiple: true });
 		const trigger = getByTestId('trigger');
 		const menu = getByTestId('menu');
-		const user = userEvent.setup();
 
 		expect(trigger).not.toHaveTextContent('Caramel');
 
@@ -156,10 +171,10 @@ describe('Select', () => {
 	});
 
 	test('Respects the `closeOnEscape` prop', async () => {
+		const user = userEvent.setup();
 		const { getByTestId } = render(SelectTest, { closeOnEscape: false });
 		const trigger = getByTestId('trigger');
 		const menu = getByTestId('menu');
-		const user = userEvent.setup();
 
 		expect(menu).not.toBeVisible();
 		await user.click(trigger);
@@ -170,10 +185,29 @@ describe('Select', () => {
 	});
 
 	test('Respects the `closeOnOutsideClick` prop', async () => {
+		const user = userEvent.setup();
 		const { getByTestId } = render(SelectTest, { closeOnOutsideClick: false });
 		const trigger = getByTestId('trigger');
 		const menu = getByTestId('menu');
+
+		expect(menu).not.toBeVisible();
+		await user.click(trigger);
+		expect(menu).toBeVisible();
+
+		const outside = getByTestId('outside');
+		await user.click(outside);
+		expect(menu).toBeVisible();
+	});
+
+	test("doesn't close when preventDefault called in `onOutsideClick`", async () => {
 		const user = userEvent.setup();
+		const { getByTestId } = render(SelectTest, {
+			onOutsideClick: (e) => {
+				e.preventDefault();
+			},
+		});
+		const trigger = getByTestId('trigger');
+		const menu = getByTestId('menu');
 
 		expect(menu).not.toBeVisible();
 		await user.click(trigger);
diff --git a/src/tests/select/SelectTest.svelte b/src/tests/select/SelectTest.svelte
index 984fe574..268a7508 100644
--- a/src/tests/select/SelectTest.svelte
+++ b/src/tests/select/SelectTest.svelte
@@ -9,6 +9,7 @@
 	export let closeOnEscape = true;
 	export let closeOnOutsideClick = true;
 	export let ids: CreateSelectProps['ids'] = undefined;
+	export let onOutsideClick: CreateSelectProps['onOutsideClick'] = undefined;
 	const {
 		elements: { trigger, menu, option, group, groupLabel, label },
 		states: { selected, selectedLabel },
@@ -26,6 +27,7 @@
 			closeOnEscape,
 			closeOnOutsideClick,
 			ids,
+			onOutsideClick,
 		})
 	);