Repository: skeletonlabs/floating-ui-svelte (ts)

Issue:
Add: useDismiss hook
## Reference

https://floating-ui.com/docs/useDismiss

## Description

Closes the floating element when a dismissal is requested — by default, when the user presses the escape key or outside of the floating element with their pointer. This is useful to ensure the floating element is closed when the user is finished interacting with it, including keyboard support.

## Props

- [x] `enabled`
- [x] `escapeKey`
- [x] `referencePress`
- [x] `referencePressEvent`
- [x] `outsidePress`
- [x] `outsidePressEvent`
- [x] `ancestorScroll`
- [ ] `bubbles`
- [ ] `capture`

Pull request description:
Feat: `useDimiss` hook
Closes #8 

Patch:
diff --git a/package.json b/package.json
index ecf2641..3dc04d0 100644
--- a/package.json
+++ b/package.json
@@ -13,7 +13,8 @@
 		"test:watch": "vitest run --coverage --watch",
 		"format": "prettier --write .",
 		"lint": "prettier --check . && eslint .",
-		"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
+		"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
+		"before-commit": "pnpm format && pnpm lint && pnpm check && pnpm test"
 	},
 	"exports": {
 		".": {
diff --git a/src/docs/components/Navigation/Navigation.svelte b/src/docs/components/Navigation/Navigation.svelte
index d049d0b..271de1b 100644
--- a/src/docs/components/Navigation/Navigation.svelte
+++ b/src/docs/components/Navigation/Navigation.svelte
@@ -14,6 +14,7 @@
 	import IconUseHover from 'lucide-svelte/icons/square-mouse-pointer';
 	import IconUseClick from 'lucide-svelte/icons/mouse-pointer-2';
 	import IconUseRole from 'lucide-svelte/icons/person-standing';
+	import IconUseDismiss from 'lucide-svelte/icons/circle-x';
 	import IconFloatingArrow from 'lucide-svelte/icons/triangle';
 	import IconUtils from 'lucide-svelte/icons/wand-sparkles';
 	// Components
@@ -45,6 +46,7 @@
 				{ icon: IconUseHover, href: '/api/use-hover', label: 'useHover' },
 				{ icon: IconUseClick, href: '/api/use-click', label: 'useClick' },
 				{ icon: IconUseRole, href: '/api/use-role', label: 'useRole' },
+				{ icon: IconUseDismiss, href: '/api/use-dismiss', label: 'useDismiss' },
 				{ icon: IconFloatingArrow, href: '/api/floating-arrow', label: 'Floating Arrow' },
 				{ icon: IconUtils, href: '/api/utilities', label: 'Utilities' },
 			],
diff --git a/src/lib/hooks/useDismiss/index.svelte.ts b/src/lib/hooks/useDismiss/index.svelte.ts
new file mode 100644
index 0000000..1cb9c7c
--- /dev/null
+++ b/src/lib/hooks/useDismiss/index.svelte.ts
@@ -0,0 +1,404 @@
+import { createAttribute } from '$lib/utils/create-attribute.js';
+import { getDocument } from '$lib/utils/get-document.js';
+import { getTarget } from '$lib/utils/get-target.js';
+import {
+	getOverflowAncestors,
+	getParentNode,
+	isElement,
+	isHTMLElement,
+	isLastTraversableNode,
+} from '@floating-ui/utils/dom';
+import type { FloatingContext } from '../useFloating/index.svelte.js';
+import { isRootElement } from '$lib/utils/is-root-element.js';
+import { contains } from '$lib/utils/contains.js';
+import { isEventTargetWithin } from '$lib/utils/is-event-target-within.js';
+
+const bubbleHandlerKeys = {
+	pointerdown: 'onpointerdown',
+	mousedown: 'onmousedown',
+	click: 'onclick',
+};
+
+const captureHandlerKeys = {
+	pointerdown: 'onpointerdowncapture',
+	mousedown: 'onmousedowncapture',
+	click: 'onclickcapture',
+};
+
+const normalizeProp = (
+	normalizable?: boolean | { escapeKey?: boolean; outsidePress?: boolean },
+) => {
+	return {
+		escapeKey: typeof normalizable === 'boolean' ? normalizable : normalizable?.escapeKey ?? false,
+		outsidePress:
+			typeof normalizable === 'boolean' ? normalizable : normalizable?.outsidePress ?? true,
+	};
+};
+
+interface UseDismissOptions {
+	/**
+	 * Whether the Hook is enabled, including all internal Effects and event
+	 * handlers.
+	 * @default true
+	 */
+	enabled?: boolean;
+	/**
+	 * Whether to dismiss the floating element upon pressing the `esc` key.
+	 * @default true
+	 */
+	escapeKey?: boolean;
+	/**
+	 * Whether to dismiss the floating element upon pressing the reference
+	 * element. You likely want to ensure the `move` option in the `useHover()`
+	 * Hook has been disabled when this is in use.
+	 * @default false
+	 */
+	referencePress?: boolean;
+	/**
+	 * The type of event to use to determine a “press”.
+	 * - `pointerdown` is eager on both mouse + touch input.
+	 * - `mousedown` is eager on mouse input, but lazy on touch input.
+	 * - `click` is lazy on both mouse + touch input.
+	 * @default 'pointerdown'
+	 */
+	referencePressEvent?: 'pointerdown' | 'mousedown' | 'click';
+	/**
+	 * Whether to dismiss the floating element upon pressing outside of the
+	 * floating element.
+	 * If you have another element, like a toast, that is rendered outside the
+	 * floating element’s React tree and don’t want the floating element to close
+	 * when pressing it, you can guard the check like so:
+	 * ```jsx
+	 * useDismiss(context, {
+	 *   outsidePress: (event) => !event.target.closest('.toast'),
+	 * });
+	 * ```
+	 * @default true
+	 */
+	outsidePress?: boolean | ((event: MouseEvent) => boolean);
+	/**
+	 * The type of event to use to determine an outside “press”.
+	 * - `pointerdown` is eager on both mouse + touch input.
+	 * - `mousedown` is eager on mouse input, but lazy on touch input.
+	 * - `click` is lazy on both mouse + touch input.
+	 * @default 'pointerdown'
+	 */
+	outsidePressEvent?: 'pointerdown' | 'mousedown' | 'click';
+	/**
+	 * Whether to dismiss the floating element upon scrolling an overflow
+	 * ancestor.
+	 * @default false
+	 */
+	ancestorScroll?: boolean;
+	/**
+	 * Determines whether event listeners bubble upwards through a tree of
+	 * floating elements.
+	 */
+	bubbles?: boolean | { escapeKey?: boolean; outsidePress?: boolean };
+	/**
+	 * Determines whether to use capture phase event listeners.
+	 */
+	capture?: boolean | { escapeKey?: boolean; outsidePress?: boolean };
+}
+
+function useDismiss(context: FloatingContext, options: UseDismissOptions = {}) {
+	const {
+		open,
+		onOpenChange,
+		// nodeId,
+		elements: { reference, floating },
+		data,
+	} = $derived(context);
+
+	const {
+		enabled = true,
+		escapeKey = true,
+		outsidePress: unstable_outsidePress = true,
+		outsidePressEvent = 'pointerdown',
+		referencePress = false,
+		referencePressEvent = 'pointerdown',
+		ancestorScroll = false,
+		bubbles,
+		capture,
+	} = $derived(options);
+
+	// const tree = useFloatingTree();
+	const outsidePressFn = $derived(
+		typeof unstable_outsidePress === 'function' ? unstable_outsidePress : () => false,
+	);
+	const outsidePress = $derived(
+		typeof unstable_outsidePress === 'function' ? outsidePressFn : unstable_outsidePress,
+	);
+	let insideReactTree = false;
+	let endedOrStartedInside = false;
+	const { escapeKey: escapeKeyBubbles, outsidePress: outsidePressBubbles } = normalizeProp(bubbles);
+	const { escapeKey: escapeKeyCapture, outsidePress: outsidePressCapture } = normalizeProp(capture);
+
+	const closeOnEscapeKeyDown = (event: KeyboardEvent) => {
+		if (!open || !enabled || !escapeKey || event.key !== 'Escape') {
+			return;
+		}
+
+		// const children = tree ? getChildren(tree.nodesRef.current, nodeId) : [];
+
+		if (!escapeKeyBubbles) {
+			event.stopPropagation();
+
+			// if (children.length > 0) {
+			// 	let shouldDismiss = true;
+
+			// 	children.forEach((child) => {
+			// 		if (child.context?.open && !child.context.dataRef.current.__escapeKeyBubbles) {
+			// 			shouldDismiss = false;
+			// 			return;
+			// 		}
+			// 	});
+
+			// 	if (!shouldDismiss) {
+			// 		return;
+			// 	}
+			// }
+		}
+
+		onOpenChange(false, event, 'escape-key');
+	};
+
+	const closeOnEscapeKeyDownCapture = (event: KeyboardEvent) => {
+		const callback = () => {
+			closeOnEscapeKeyDown(event);
+			getTarget(event)?.removeEventListener('keydown', callback);
+		};
+		getTarget(event)?.addEventListener('keydown', callback);
+	};
+
+	const closeOnPressOutside = (event: MouseEvent) => {
+		// Given developers can stop the propagation of the synthetic event,
+		// we can only be confident with a positive value.
+		const insideReactTreeLocal = insideReactTree;
+		insideReactTree = false;
+
+		// When click outside is lazy (`click` event), handle dragging.
+		// Don't close if:
+		// - The click started inside the floating element.
+		// - The click ended inside the floating element.
+		const endedOrStartedInsideLocal = endedOrStartedInside;
+		endedOrStartedInside = false;
+
+		if (outsidePressEvent === 'click' && endedOrStartedInsideLocal) {
+			return;
+		}
+
+		if (insideReactTreeLocal) {
+			return;
+		}
+
+		if (typeof outsidePress === 'function' && !outsidePress(event)) {
+			return;
+		}
+
+		const target = getTarget(event);
+		const inertSelector = `[${createAttribute('inert')}]`;
+		const markers = getDocument(floating).querySelectorAll(inertSelector);
+
+		let targetRootAncestor = isElement(target) ? target : null;
+		while (targetRootAncestor && !isLastTraversableNode(targetRootAncestor)) {
+			const nextParent = getParentNode(targetRootAncestor);
+			if (isLastTraversableNode(nextParent) || !isElement(nextParent)) {
+				break;
+			}
+
+			targetRootAncestor = nextParent;
+		}
+
+		// Check if the click occurred on a third-party element injected after the
+		// floating element rendered.
+		if (
+			markers.length &&
+			isElement(target) &&
+			!isRootElement(target) &&
+			// Clicked on a direct ancestor (e.g. FloatingOverlay).
+			!contains(target, floating) &&
+			// If the target root element contains none of the markers, then the
+			// element was injected after the floating element rendered.
+			Array.from(markers).every((marker) => !contains(targetRootAncestor, marker))
+		) {
+			return;
+		}
+
+		// Check if the click occurred on the scrollbar
+		if (isHTMLElement(target) && floating) {
+			// In Firefox, `target.scrollWidth > target.clientWidth` for inline
+			// elements.
+			const canScrollX = target.clientWidth > 0 && target.scrollWidth > target.clientWidth;
+			const canScrollY = target.clientHeight > 0 && target.scrollHeight > target.clientHeight;
+
+			let xCond = canScrollY && event.offsetX > target.clientWidth;
+
+			// In some browsers it is possible to change the <body> (or window)
+			// scrollbar to the left side, but is very rare and is difficult to
+			// check for. Plus, for modal dialogs with backdrops, it is more
+			// important that the backdrop is checked but not so much the window.
+			if (canScrollY) {
+				const isRTL = getComputedStyle(target).direction === 'rtl';
+
+				if (isRTL) {
+					xCond = event.offsetX <= target.offsetWidth - target.clientWidth;
+				}
+			}
+
+			if (xCond || (canScrollX && event.offsetY > target.clientHeight)) {
+				return;
+			}
+		}
+
+		// const targetIsInsideChildren =
+		// 	tree &&
+		// 	getChildren(tree.nodesRef.current, nodeId).some((node) =>
+		// 		isEventTargetWithin(event, node.context?.elements.floating),
+		// 	);
+
+		if (
+			isEventTargetWithin(event, floating) ||
+			// @ts-expect-error - FIXME
+			isEventTargetWithin(event, reference)
+			// targetIsInsideChildren
+		) {
+			return;
+		}
+
+		// const children = tree ? getChildren(tree.nodesRef.current, nodeId) : [];
+		// if (children.length > 0) {
+		// 	let shouldDismiss = true;
+
+		// 	children.forEach((child) => {
+		// 		if (child.context?.open && !child.context.dataRef.current.__outsidePressBubbles) {
+		// 			shouldDismiss = false;
+		// 			return;
+		// 		}
+		// 	});
+
+		// 	if (!shouldDismiss) {
+		// 		return;
+		// 	}
+		// }
+
+		onOpenChange(false, event, 'outside-press');
+	};
+
+	const closeOnPressOutsideCapture = (event: MouseEvent) => {
+		const callback = () => {
+			closeOnPressOutside(event);
+			getTarget(event)?.removeEventListener(outsidePressEvent, callback);
+		};
+		getTarget(event)?.addEventListener(outsidePressEvent, callback);
+	};
+
+	$effect(() => {
+		if (!open || !enabled) {
+			return;
+		}
+
+		data.__escapeKeyBubbles = escapeKeyBubbles;
+		data.__outsidePressBubbles = outsidePressBubbles;
+
+		function onScroll(event: Event) {
+			onOpenChange(false, event, 'ancestor-scroll');
+		}
+
+		const doc = getDocument(floating);
+		escapeKey &&
+			doc.addEventListener(
+				'keydown',
+				escapeKeyCapture ? closeOnEscapeKeyDownCapture : closeOnEscapeKeyDown,
+				escapeKeyCapture,
+			);
+		outsidePress &&
+			doc.addEventListener(
+				outsidePressEvent,
+				outsidePressCapture ? closeOnPressOutsideCapture : closeOnPressOutside,
+				outsidePressCapture,
+			);
+
+		let ancestors: (Element | Window | VisualViewport)[] = [];
+
+		if (ancestorScroll) {
+			if (isElement(reference)) {
+				ancestors = getOverflowAncestors(reference);
+			}
+
+			if (isElement(floating)) {
+				ancestors = ancestors.concat(getOverflowAncestors(floating));
+			}
+
+			if (!isElement(reference) && reference && reference.contextElement) {
+				ancestors = ancestors.concat(getOverflowAncestors(reference.contextElement));
+			}
+		}
+
+		// Ignore the visual viewport for scrolling dismissal (allow pinch-zoom)
+		ancestors = ancestors.filter((ancestor) => ancestor !== doc.defaultView?.visualViewport);
+
+		ancestors.forEach((ancestor) => {
+			ancestor.addEventListener('scroll', onScroll, { passive: true });
+		});
+
+		return () => {
+			escapeKey &&
+				doc.removeEventListener(
+					'keydown',
+					escapeKeyCapture ? closeOnEscapeKeyDownCapture : closeOnEscapeKeyDown,
+					escapeKeyCapture,
+				);
+			outsidePress &&
+				doc.removeEventListener(
+					outsidePressEvent,
+					outsidePressCapture ? closeOnPressOutsideCapture : closeOnPressOutside,
+					outsidePressCapture,
+				);
+			ancestors.forEach((ancestor) => {
+				ancestor.removeEventListener('scroll', onScroll);
+			});
+		};
+	});
+
+	$effect(() => {
+		[outsidePress, outsidePressEvent];
+		insideReactTree = false;
+	});
+
+	return {
+		get reference() {
+			if (!enabled) {
+				return {};
+			}
+			return {
+				onKeyDown: closeOnEscapeKeyDown,
+				[bubbleHandlerKeys[referencePressEvent]]: (event: Event) => {
+					if (referencePress) {
+						onOpenChange(false, event, 'reference-press');
+					}
+				},
+			};
+		},
+
+		get floating() {
+			if (!enabled) {
+				return {};
+			}
+			return {
+				onKeyDown: closeOnEscapeKeyDown,
+				onMouseDown() {
+					endedOrStartedInside = true;
+				},
+				onMouseUp() {
+					endedOrStartedInside = true;
+				},
+				[captureHandlerKeys[outsidePressEvent]]: () => {
+					insideReactTree = true;
+				},
+			};
+		},
+	};
+}
+
+export { useDismiss, type UseDismissOptions };
diff --git a/src/lib/hooks/useFloating/index.svelte.ts b/src/lib/hooks/useFloating/index.svelte.ts
index b2863f4..9f664b1 100644
--- a/src/lib/hooks/useFloating/index.svelte.ts
+++ b/src/lib/hooks/useFloating/index.svelte.ts
@@ -133,6 +133,11 @@ interface ContextData {
 	 * The latest even that caused the open state to change.
 	 */
 	openEvent?: Event;
+
+	/**
+	 * Arbitrary data produced and consumed by other hooks.
+	 */
+	[key: string]: unknown;
 }
 
 interface FloatingContext extends UseFloatingData {
diff --git a/src/lib/index.ts b/src/lib/index.ts
index 4d9b2b7..0131dfe 100644
--- a/src/lib/index.ts
+++ b/src/lib/index.ts
@@ -5,6 +5,7 @@ export * from '$lib/hooks/useFloating/index.svelte.js';
 export * from '$lib/hooks/useInteractions/index.svelte.js';
 export * from '$lib/hooks/useHover/index.svelte.js';
 export * from '$lib/hooks/useClick/index.svelte.js';
+export * from '$lib/hooks/useDismiss/index.svelte.js';
 export * from '$lib/hooks/useRole/index.svelte.js';
 export * from '$lib/hooks/useId/index.js';
 
diff --git a/src/lib/utils/get-target.ts b/src/lib/utils/get-target.ts
new file mode 100644
index 0000000..0742f4a
--- /dev/null
+++ b/src/lib/utils/get-target.ts
@@ -0,0 +1,12 @@
+/**
+ * Get the target of an event.
+ */
+export function getTarget(event: Event) {
+	if ('composedPath' in event) {
+		return event.composedPath()[0];
+	}
+
+	// TS thinks `event` is of type never as it assumes all browsers support
+	// `composedPath()`, but browsers without shadow DOM don't.
+	return (event as Event).target;
+}
diff --git a/src/lib/utils/is-event-target-within.ts b/src/lib/utils/is-event-target-within.ts
new file mode 100644
index 0000000..2eb2a2f
--- /dev/null
+++ b/src/lib/utils/is-event-target-within.ts
@@ -0,0 +1,16 @@
+/**
+ * Check if an event target is within a given node.
+ */
+export function isEventTargetWithin(event: Event, node: Node | null | undefined) {
+	if (node == null) {
+		return false;
+	}
+
+	if ('composedPath' in event) {
+		return event.composedPath().includes(node);
+	}
+
+	// TS thinks `event` is of type never as it assumes all browsers support composedPath, but browsers without shadow dom don't
+	const e = event as Event;
+	return e.target != null && node.contains(e.target as Node);
+}
diff --git a/src/lib/utils/is-root-element.ts b/src/lib/utils/is-root-element.ts
new file mode 100644
index 0000000..b46c6d9
--- /dev/null
+++ b/src/lib/utils/is-root-element.ts
@@ -0,0 +1,6 @@
+/**
+ * Check if the given element is the root element.
+ */
+export function isRootElement(element: Element): boolean {
+	return element.matches('html,body');
+}
diff --git a/src/routes/(inner)/api/use-dismiss/+page.svelte b/src/routes/(inner)/api/use-dismiss/+page.svelte
new file mode 100644
index 0000000..2c100f4
--- /dev/null
+++ b/src/routes/(inner)/api/use-dismiss/+page.svelte
@@ -0,0 +1,67 @@
+<script lang="ts">
+	import CodeBlock from '$docs/components/CodeBlock/CodeBlock.svelte';
+	import ExampleRaw from './Example.svelte?raw';
+	import Table from '$docs/components/Table/Table.svelte';
+	import { tableOptions } from './data.js';
+</script>
+
+<div class="space-y-10">
+	<!-- Header -->
+	<header class="card card-gradient space-y-8">
+		<h1 class="h1"><span>useDismiss</span></h1>
+		<p>
+			Closes the floating element when a dismissal is requested — by default, when the user presses
+			the <kbd class="kbd">escape</kbd> key or outside of the floating element with their pointer.
+		</p>
+		<CodeBlock lang="ts" code={`import { useDismiss } from '@skeletonlabs/floating-ui-svelte';`} />
+	</header>
+	<!-- Usage -->
+	<section class="space-y-8">
+		<h2 class="h2">Usage</h2>
+		<CodeBlock code={ExampleRaw} lang="svelte" />
+	</section>
+	<!-- Table: Options -->
+	<section class="space-y-8">
+		<h2 class="h2">Options</h2>
+		<Table data={tableOptions} />
+	</section>
+	<!-- Reacting to dismissal -->
+	<section class="space-y-8">
+		<h2 class="h2">Reacting to dismissal</h2>
+		<p>
+			To react to the dismissal event, you can check for the <kbd class="kbd">reason</kbd> string in
+			the <kbd class="kbd">onOpenChange</kbd> callback:
+		</p>
+		<CodeBlock
+			lang="ts"
+			code={`
+const floating = useFloating({
+	get open() {
+		return open;
+	},
+	onOpenChange: (value, event, reason) => {
+		open = value;
+		if (reason === 'escape-key' || reason === 'outside-press') {
+			console.log('dismissed');
+		}
+	},
+});   	
+		`}
+		/>
+	</section>
+	<!-- FIXME: consider enabling this when FloatingOverlay is ready. -->
+	<!-- Troubleshooting -->
+	<!-- <section class="space-y-8">
+		<h2 class="h2">Troubleshooting</h2>
+		<h3 class="h3">Does not close when clicking in an iframe</h3>
+		<p>You can use the <a class="anchor" href="/api/floatingoverlay">FloatingOverlay component</a> which will “cover” iframes to ensure clicks are captured in the same document as the floating element, as the click occurs on the overlay backdrop. This guarantees “outside press” detection will work.</p>
+	</section> -->
+	<!-- Compare -->
+	<section class="space-y-8">
+		<h2 class="h2">Compare</h2>
+		<!-- prettier-ignore -->
+		<p>
+			Compare to <a class="anchor" href="https://floating-ui.com/docs/useDismiss" target="_blank">Floating UI React</a>.
+		</p>
+	</section>
+</div>
diff --git a/src/routes/(inner)/api/use-dismiss/Example.svelte b/src/routes/(inner)/api/use-dismiss/Example.svelte
new file mode 100644
index 0000000..b4151f3
--- /dev/null
+++ b/src/routes/(inner)/api/use-dismiss/Example.svelte
@@ -0,0 +1,21 @@
+<script lang="ts">
+	import { useFloating, useInteractions, useDismiss } from '@skeletonlabs/floating-ui-svelte';
+
+	const floating = useFloating();
+	const dismiss = useDismiss(floating.context);
+	const interactions = useInteractions([dismiss]);
+</script>
+
+<!-- Reference Element -->
+<button bind:this={floating.elements.reference} {...interactions.getReferenceProps()}>
+	Reference
+</button>
+
+<!-- Floating Element -->
+<div
+	bind:this={floating.elements.floating}
+	style={floating.floatingStyles}
+	{...interactions.getFloatingProps()}
+>
+	Tooltip
+</div>
diff --git a/src/routes/(inner)/api/use-dismiss/data.ts b/src/routes/(inner)/api/use-dismiss/data.ts
new file mode 100644
index 0000000..1ab8595
--- /dev/null
+++ b/src/routes/(inner)/api/use-dismiss/data.ts
@@ -0,0 +1,61 @@
+// Hook: useDismiss
+
+import type { TableData } from '$docs/types.js';
+
+// Options
+export const tableOptions: TableData[] = [
+	{
+		property: `enabled`,
+		description: `Whether the Hook is enabled, including all internal Effects and event handlers.`,
+		type: `boolean`,
+		default: `true`,
+	},
+	{
+		property: `escapeKey`,
+		description: `Whether to dismiss the floating element upon pressing the \`esc\` key.`,
+		type: `boolean`,
+		default: `true`,
+	},
+	{
+		property: `referencePress`,
+		description: `Whether to dismiss the floating element upon pressing the reference element. You likely want to ensure the \`move\` option in the \`useHover()\` Hook has been disabled when this is in use.`,
+		type: `boolean`,
+		default: `false`,
+	},
+	{
+		property: `referencePressEvent`,
+		description: `The type of event to use to determine a “press”.`,
+		type: `'pointerdown' | 'mousedown' | 'click'`,
+		default: `'pointerdown'`,
+	},
+	{
+		property: `outsidePress`,
+		description: `Whether to dismiss the floating element upon pressing outside of the floating element.`,
+		type: `boolean | ((event: MouseEvent) => boolean)`,
+		default: `true`,
+	},
+	{
+		property: `outsidePressEvent`,
+		description: `The type of event to use to determine an outside “press”.`,
+		type: `'pointerdown' | 'mousedown' | 'click'`,
+		default: `'pointerdown'`,
+	},
+	{
+		property: `ancestorScroll`,
+		description: `Whether to dismiss the floating element upon scrolling an overflow ancestor.`,
+		type: `boolean`,
+		default: `false`,
+	},
+	{
+		property: `bubbles`,
+		description: `Determines whether event listeners bubble upwards through a tree of floating elements.`,
+		type: `boolean | { escapeKey?: boolean; outsidePress?: boolean }`,
+		default: `undefined`,
+	},
+	{
+		property: `capture`,
+		description: `Determines whether to use capture phase event listeners.`,
+		type: `boolean | { escapeKey?: boolean; outsidePress?: boolean }`,
+		default: `undefined`,
+	},
+];
diff --git a/src/routes/(inner)/examples/popovers/+page.svelte b/src/routes/(inner)/examples/popovers/+page.svelte
index 24b5031..dee5179 100644
--- a/src/routes/(inner)/examples/popovers/+page.svelte
+++ b/src/routes/(inner)/examples/popovers/+page.svelte
@@ -1,7 +1,24 @@
+<script>
+	import CodeBlock from '$docs/components/CodeBlock/CodeBlock.svelte';
+	import Preview from '$docs/components/Preview/Preview.svelte';
+	import Example from './Example.svelte';
+	import ExampleRaw from './Example.svelte?raw';
+</script>
+
 <div class="space-y-10">
 	<!-- Header -->
 	<header class="card card-gradient space-y-8">
 		<h1 class="h1"><span>Popovers</span></h1>
-		<p>Coming Soon!</p>
+		<p>
+			This is a functional Popover that uses a combination of hooks and components, each of which is
+			described in the sections below.
+		</p>
 	</header>
+	<!-- Example -->
+	<section class="space-y-8">
+		<Preview>
+			{#snippet preview()}<Example />{/snippet}
+			{#snippet code()}<CodeBlock code={ExampleRaw} lang="svelte" />{/snippet}
+		</Preview>
+	</section>
 </div>
diff --git a/src/routes/(inner)/examples/popovers/Example.svelte b/src/routes/(inner)/examples/popovers/Example.svelte
new file mode 100644
index 0000000..619f888
--- /dev/null
+++ b/src/routes/(inner)/examples/popovers/Example.svelte
@@ -0,0 +1,69 @@
+<script lang="ts">
+	import { fade } from 'svelte/transition';
+	import {
+		autoUpdate,
+		offset,
+		flip,
+		arrow,
+		useFloating,
+		FloatingArrow,
+		useInteractions,
+		useRole,
+		useDismiss,
+		useClick,
+	} from '$lib/index.js';
+
+	// State
+	let open = $state(false);
+	let elemArrow: HTMLElement | null = $state(null);
+
+	// Use Floating
+	const floating = useFloating({
+		whileElementsMounted: autoUpdate,
+		get open() {
+			return open;
+		},
+		onOpenChange: (v) => (open = v),
+		placement: 'top',
+		get middleware() {
+			return [offset(10), flip(), elemArrow && arrow({ element: elemArrow })];
+		},
+	});
+
+	// Interactions
+	const role = useRole(floating.context);
+	const click = useClick(floating.context);
+	const dismiss = useDismiss(floating.context);
+	const interactions = useInteractions([role, click, dismiss]);
+</script>
+
+<div>
+	<!-- Reference Element -->
+	<button
+		bind:this={floating.elements.reference}
+		{...interactions.getReferenceProps()}
+		class="btn-cta"
+	>
+		Click Me
+	</button>
+	<!-- Floating Element -->
+	<div
+		bind:this={floating.elements.floating}
+		style={floating.floatingStyles}
+		{...interactions.getFloatingProps()}
+		class="floating"
+	>
+		{#if open}
+			<div
+				class="bg-surface-500 text-white p-8 max-w-sm rounded shadow-xl"
+				transition:fade={{ duration: 200 }}
+			>
+				<p>
+					You can press the <kbd class="kbd">esc</kbd> key or click outside to
+					<strong>*dismiss*</strong> this floating element.
+				</p>
+				<FloatingArrow bind:ref={elemArrow} context={floating.context} class="fill-surface-500" />
+			</div>
+		{/if}
+	</div>
+</div>
diff --git a/src/routes/(inner)/examples/tooltips/+page.svelte b/src/routes/(inner)/examples/tooltips/+page.svelte
index 903ed80..1946eb4 100644
--- a/src/routes/(inner)/examples/tooltips/+page.svelte
+++ b/src/routes/(inner)/examples/tooltips/+page.svelte
@@ -28,8 +28,8 @@
 				when the reference element receives keyboard focus, the tooltip opens. When the mouse
 				leaves, or the reference is blurred, the tooltip closes.
 			</li>
-			<li class="opacity-50 line-through">
-				COMING SOON: <span class="highlight">Dismissal</span>: When the user presses the
+			<li>
+				<span class="highlight">Dismissal</span>: When the user presses the
 				<kbd class="kbd">esc</kbd> key while the tooltip is open, it closes.
 			</li>
 			<li>
@@ -97,31 +97,33 @@
 		</p>
 		<CodeBlock
 			code={`
-const hover = useHover(floating.context);
 const role = useRole(floating.context, { role: 'tooltip' });
-const interactions = useInteractions([hover, role]);
+const hover = useHover(floating.context, { move: false });
+const dismiss = useDismiss(floating.context);
+const interactions = useInteractions([role, hover, dismiss]);
 		`}
 			lang="ts"
 		/>
 		<ul class="ul">
+			<li>
+				<code class="code">useRole()</code>: adds the correct ARIA attributes for a
+				<code class="code">tooltip</code> to the tooltip and reference elements.
+			</li>
 			<li>
 				<code class="code">useHover()</code>: adds the ability to toggle the tooltip open or closed
 				when the reference element is hovered over. The <code class="code">move</code> option is set
 				to false so that
 				<code class="code">mousemove</code> events are ignored.
 			</li>
+			<li>
+				<code class="code">useDismiss()</code>: adds the ability to dismiss the tooltip when the
+				user presses the <kbd class="kbd">esc</kbd> key.
+			</li>
+
 			<li class="opacity-50 line-through">
 				COMING SOON: <code class="code">useFocus()</code>: adds the ability to toggle the tooltip
 				open or closed when the reference element is focused.
 			</li>
-			<li class="opacity-50 line-through">
-				COMING SOON: <code class="code">useDismiss()</code>: adds the ability to dismiss the tooltip
-				when the user presses the <kbd class="kbd">esc</kbd> key.
-			</li>
-			<li>
-				<code class="code">useRole()</code>: adds the correct ARIA attributes for a
-				<code class="code">tooltip</code> to the tooltip and reference elements.
-			</li>
 		</ul>
 	</section>
 	<!-- Rendering -->
diff --git a/src/routes/(inner)/examples/tooltips/Example.svelte b/src/routes/(inner)/examples/tooltips/Example.svelte
index cfc3dcc..a297af3 100644
--- a/src/routes/(inner)/examples/tooltips/Example.svelte
+++ b/src/routes/(inner)/examples/tooltips/Example.svelte
@@ -10,6 +10,7 @@
 		useHover,
 		useInteractions,
 		useRole,
+		useDismiss,
 	} from '$lib/index.js';
 
 	// State
@@ -30,9 +31,10 @@
 	});
 
 	// Interactions
-	const hover = useHover(floating.context);
 	const role = useRole(floating.context, { role: 'tooltip' });
-	const interactions = useInteractions([hover, role]);
+	const hover = useHover(floating.context, { move: false });
+	const dismiss = useDismiss(floating.context);
+	const interactions = useInteractions([role, hover, dismiss]);
 </script>
 
 <div>


Test patch:
diff --git a/src/lib/hooks/useDismiss/App.test.svelte b/src/lib/hooks/useDismiss/App.test.svelte
new file mode 100644
index 0000000..5fa7283
--- /dev/null
+++ b/src/lib/hooks/useDismiss/App.test.svelte
@@ -0,0 +1,47 @@
+<!-- Component used to test `useHover` -->
+<script lang="ts">
+	import { autoUpdate } from '@floating-ui/dom';
+	import { useFloating } from '../useFloating/index.svelte.js';
+	import { useInteractions } from '../useInteractions/index.svelte.js';
+	import { useDismiss, type UseDismissOptions } from './index.svelte.js';
+
+	interface Props extends UseDismissOptions {
+		open?: boolean;
+	}
+
+	let { open = false, ...useDismissOptions }: Props = $props();
+
+	const elements: { reference: HTMLElement | null; floating: HTMLElement | null } = $state({
+		reference: null,
+		floating: null,
+	});
+	const floating = useFloating({
+		whileElementsMounted: autoUpdate,
+		get open() {
+			return open;
+		},
+		onOpenChange(v) {
+			open = v;
+		},
+		elements,
+	});
+
+	const dismiss = useDismiss(floating.context, useDismissOptions);
+
+	const interactions = useInteractions([dismiss]);
+</script>
+
+<div data-testid="reference" bind:this={elements.reference} {...interactions.getReferenceProps()}>
+	Reference
+</div>
+
+{#if open}
+	<div
+		data-testid="floating"
+		bind:this={elements.floating}
+		style={floating.floatingStyles}
+		{...interactions.getFloatingProps()}
+	>
+		Floating
+	</div>
+{/if}
diff --git a/src/lib/hooks/useDismiss/index.test.ts b/src/lib/hooks/useDismiss/index.test.ts
new file mode 100644
index 0000000..c0da01b
--- /dev/null
+++ b/src/lib/hooks/useDismiss/index.test.ts
@@ -0,0 +1,247 @@
+import { fireEvent, render, screen } from '@testing-library/svelte/svelte5';
+import { describe, expect, it, vi } from 'vitest';
+import App from './App.test.svelte';
+
+describe('useDismiss', () => {
+	describe('default', () => {
+		it('does dismiss on outside pointerdown', async () => {
+			render(App, { open: true });
+
+			expect(screen.queryByTestId('floating')).toBeInTheDocument();
+
+			await fireEvent.pointerDown(document);
+
+			expect(screen.queryByTestId('floating')).not.toBeInTheDocument();
+		});
+
+		it('does dismiss on `Escape` key press', async () => {
+			render(App, { open: true });
+
+			expect(screen.queryByTestId('floating')).toBeInTheDocument();
+
+			await fireEvent.keyDown(document, { key: 'Escape' });
+
+			expect(screen.queryByTestId('floating')).not.toBeInTheDocument();
+		});
+	});
+
+	describe('enabled', () => {
+		it('does dismiss when set to `true`', async () => {
+			render(App, { open: true, enabled: true });
+
+			expect(screen.queryByTestId('floating')).toBeInTheDocument();
+
+			await fireEvent.pointerDown(document);
+
+			expect(screen.queryByTestId('floating')).not.toBeInTheDocument();
+		});
+
+		it('does not dismiss when set to `false`', async () => {
+			render(App, { open: true, enabled: false });
+
+			expect(screen.queryByTestId('floating')).toBeInTheDocument();
+
+			await fireEvent.pointerDown(document);
+
+			expect(screen.queryByTestId('floating')).toBeInTheDocument();
+		});
+	});
+
+	describe('escapeKey', () => {
+		it('does dismiss on `Escape` key press when set to `true`', async () => {
+			render(App, { open: true, escapeKey: true });
+
+			expect(screen.queryByTestId('floating')).toBeInTheDocument();
+
+			await fireEvent.keyDown(document, { key: 'Escape' });
+
+			expect(screen.queryByTestId('floating')).not.toBeInTheDocument();
+		});
+
+		it('does not dismiss on `Escape` key press when set to `false`', async () => {
+			render(App, { open: true, escapeKey: false });
+
+			expect(screen.queryByTestId('floating')).toBeInTheDocument();
+
+			await fireEvent.keyDown(document, { key: 'Escape' });
+
+			expect(screen.queryByTestId('floating')).toBeInTheDocument();
+		});
+	});
+
+	describe('outsidePress', () => {
+		describe('boolean', () => {
+			it('does dismiss on outside press when set to `true`', async () => {
+				render(App, { open: true, outsidePress: true });
+
+				expect(screen.queryByTestId('floating')).toBeInTheDocument();
+
+				await fireEvent.pointerDown(document);
+
+				expect(screen.queryByTestId('floating')).not.toBeInTheDocument();
+			});
+
+			it('does not dismiss on outside press when set to `false`', async () => {
+				render(App, { open: true, outsidePress: false });
+
+				expect(screen.queryByTestId('floating')).toBeInTheDocument();
+
+				await fireEvent.pointerDown(document);
+
+				expect(screen.queryByTestId('floating')).toBeInTheDocument();
+			});
+		});
+		describe('function', () => {
+			it('does dismiss on outside press when the function returns `true`', async () => {
+				const outsidePress = vi.fn(() => true);
+				render(App, { open: true, outsidePress });
+
+				expect(outsidePress).not.toHaveBeenCalled();
+
+				expect(screen.queryByTestId('floating')).toBeInTheDocument();
+
+				await fireEvent.pointerDown(document);
+
+				expect(outsidePress).toHaveBeenCalledOnce();
+
+				expect(screen.queryByTestId('floating')).not.toBeInTheDocument();
+			});
+
+			it('does not dismiss on outside press when the function returns `false`', async () => {
+				const outsidePress = vi.fn(() => false);
+				render(App, { open: true, outsidePress: outsidePress });
+
+				expect(outsidePress).not.toHaveBeenCalled();
+
+				expect(screen.queryByTestId('floating')).toBeInTheDocument();
+
+				await fireEvent.pointerDown(document);
+
+				expect(outsidePress).toHaveBeenCalledOnce();
+
+				expect(screen.queryByTestId('floating')).toBeInTheDocument();
+			});
+
+			it.skip('passes the corresponding event as argument', async () => {
+				const outsidePress = vi.fn(() => true);
+				render(App, { open: true, outsidePress });
+
+				expect(outsidePress).not.toHaveBeenCalled();
+
+				const event = new MouseEvent('pointerdown');
+
+				await fireEvent.pointerDown(document, event);
+
+				expect(outsidePress.mock.calls.at(0)?.at(0)).toBe(event);
+			});
+		});
+	});
+
+	describe('outsidePressEvent', () => {
+		it('does dismiss on outside `pointerdown` event when set to `pointerdown`', async () => {
+			render(App, { open: true, outsidePress: true, outsidePressEvent: 'pointerdown' });
+
+			expect(screen.queryByTestId('floating')).toBeInTheDocument();
+
+			await fireEvent.pointerDown(document);
+
+			expect(screen.queryByTestId('floating')).not.toBeInTheDocument();
+		});
+
+		it('does dismiss on outside `mousedown` event when set to `mousedown`', async () => {
+			render(App, { open: true, outsidePress: true, outsidePressEvent: 'mousedown' });
+
+			expect(screen.queryByTestId('floating')).toBeInTheDocument();
+
+			await fireEvent.mouseDown(document);
+
+			expect(screen.queryByTestId('floating')).not.toBeInTheDocument();
+		});
+
+		it('does dismiss on outside `click` event when set to `click`', async () => {
+			render(App, { open: true, outsidePress: true, outsidePressEvent: 'click' });
+
+			expect(screen.queryByTestId('floating')).toBeInTheDocument();
+
+			await fireEvent.click(document);
+
+			expect(screen.queryByTestId('floating')).not.toBeInTheDocument();
+		});
+	});
+
+	describe('referencePress', () => {
+		it('does dismiss on reference press when set to `true`', async () => {
+			render(App, { open: true, referencePress: true });
+
+			expect(screen.queryByTestId('floating')).toBeInTheDocument();
+
+			await fireEvent.pointerDown(screen.getByTestId('reference'));
+
+			expect(screen.queryByTestId('floating')).not.toBeInTheDocument();
+		});
+
+		it('does not dismiss on reference press when set to `false`', async () => {
+			render(App, { open: true, referencePress: false });
+
+			expect(screen.queryByTestId('floating')).toBeInTheDocument();
+
+			await fireEvent.pointerDown(screen.getByTestId('reference'));
+
+			expect(screen.queryByTestId('floating')).toBeInTheDocument();
+		});
+	});
+
+	describe('referencePressEvent', () => {
+		it('does dismiss on reference `pointerdown` event when set to `pointerdown`', async () => {
+			render(App, { open: true, referencePress: true, referencePressEvent: 'pointerdown' });
+
+			expect(screen.queryByTestId('floating')).toBeInTheDocument();
+
+			await fireEvent.pointerDown(screen.getByTestId('reference'));
+
+			expect(screen.queryByTestId('floating')).not.toBeInTheDocument();
+		});
+
+		it('does dismiss on reference `mousedown` event when set to `mousedown`', async () => {
+			render(App, { open: true, referencePress: true, referencePressEvent: 'mousedown' });
+
+			expect(screen.queryByTestId('floating')).toBeInTheDocument();
+
+			await fireEvent.mouseDown(screen.getByTestId('reference'));
+
+			expect(screen.queryByTestId('floating')).not.toBeInTheDocument();
+		});
+
+		it('does dismiss on reference `click` event when set to `click`', async () => {
+			render(App, { open: true, referencePress: true, referencePressEvent: 'click' });
+
+			expect(screen.queryByTestId('floating')).toBeInTheDocument();
+
+			await fireEvent.click(screen.getByTestId('reference'));
+
+			expect(screen.queryByTestId('floating')).not.toBeInTheDocument();
+		});
+	});
+
+	describe('ancestorScroll', () => {
+		it('does dismiss on ancestor scroll when set to `true`', async () => {
+			render(App, { open: true, ancestorScroll: true });
+
+			expect(screen.queryByTestId('floating')).toBeInTheDocument();
+
+			await fireEvent.scroll(window);
+
+			expect(screen.queryByTestId('floating')).not.toBeInTheDocument();
+		});
+
+		it('does not dismiss on ancestor scroll when set to `false`', async () => {
+			render(App, { open: true, ancestorScroll: false });
+
+			expect(screen.queryByTestId('floating')).toBeInTheDocument();
+
+			await fireEvent.scroll(window);
+
+			expect(screen.queryByTestId('floating')).toBeInTheDocument();
+		});
+	});
+});