Repository: melt-ui/melt-ui
Pull request #663 (2023-10-24 18:06:34)

Controlled `openFocus` & `closeFocus` props
This PR introduces two new props, `openFocus`, and `closeFocus` to a few of the builders where you might want to override how we handle focus on open and close.

They accept a `FocusProp` type, which looks like this:
```ts
export type FocusTarget = string | HTMLElement | SVGElement | null;
export type FocusProp = FocusTarget | ((defaultEl?: HTMLElement) => FocusTarget);
```

Whatever is returned from this function will be focused instead of our default behavior. If `null` is passed, we don't focus on anything and expect you to handle it outside of the builder altogether.

We provide the `defaultEl` argument as a fallback mechanism, which could be implemented like so:

```ts
const customOpenFocus: FocusProp = (defaultEl) => {
 	const customElToFocus = document.getElementById('focusMe')
	if (!customElToFocus) {
		return defaultEl
	}
	return customElToFocus
}
```

In this scenario, if we aren't able to select our custom element, we'll fall back to the default, ensuring we keep the a11y box checked.

The following builders have both the `openFocus` and `closeFocus` props:
- Dialog
- Popover

These builders only have the `closeFocus`:
- Dropdown Menu
- Menubar (`createMenu` returned from the Menubar)
- Context Menu

This PR also removes the need for the `disableTriggerRefocus` prop added to the menus in a previous PR.


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

Latest commit: d704e0a2d77edb4b27b010c6244b26a9993f6a47

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/controlled-autofocus?filename=.changeset/large-pandas-fold.md&value=---%0A%22%40melt-ui%2Fsvelte%22%3A%20patch%0A---%0A%0AControlled%20%60openFocus%60%20%26%20%60closeFocus%60%20props%0A)



Patch:
diff --git a/.changeset/large-pandas-fold.md b/.changeset/large-pandas-fold.md
new file mode 100644
index 00000000..4fcb2aa2
--- /dev/null
+++ b/.changeset/large-pandas-fold.md
@@ -0,0 +1,5 @@
+---
+"@melt-ui/svelte": minor
+---
+
+Add `openFocus` & `closeFocus` props for controlled focus
diff --git a/src/docs/constants.ts b/src/docs/constants.ts
index 12056c34..7c08c502 100644
--- a/src/docs/constants.ts
+++ b/src/docs/constants.ts
@@ -1,4 +1,5 @@
 import rawFloatingConfig from '$docs/data/long-types/floating-config.js?raw';
+import rawFocusProp from '$docs/data/long-types/focus-prop.js?raw';
 import type { Prop } from './types.js';
 
 export const SEE = {
@@ -46,11 +47,21 @@ export const PROPS = {
 		description: DESCRIPTIONS.FORCE_VISIBLE(args.name ?? 'element'),
 		default: args.default ?? 'false',
 	}),
-	DISABLE_TRIGGER_REFOCUS: (args: PropArgs = {}): Prop => ({
-		name: 'disableTriggerRefocus',
-		type: 'boolean',
-		description: `Whether or not to refocus the trigger when the ${args.name ?? 'element'} closes`,
-		default: args.default ?? 'false',
+	OPEN_FOCUS: (): Prop => ({
+		name: 'openFocus',
+		type: 'FocusProp',
+		description: 'Override the default focus behavior on open',
+		longType: {
+			rawCode: rawFocusProp,
+		},
+	}),
+	CLOSE_FOCUS: (): Prop => ({
+		name: 'closeFocus',
+		type: 'FocusProp',
+		description: 'Override the default focus behavior on close',
+		longType: {
+			rawCode: rawFocusProp,
+		},
 	}),
 	DISABLED: (args: PropArgs = {}): Prop => ({
 		name: 'disabled',
diff --git a/src/docs/data/builders/dialog.ts b/src/docs/data/builders/dialog.ts
index 7ff5aa55..3e1a2503 100644
--- a/src/docs/data/builders/dialog.ts
+++ b/src/docs/data/builders/dialog.ts
@@ -19,6 +19,8 @@ const OPTION_PROPS = [
 	PROPS.CLOSE_ON_OUTSIDE_CLICK,
 	PROPS.PORTAL,
 	PROPS.FORCE_VISIBLE,
+	PROPS.OPEN_FOCUS,
+	PROPS.CLOSE_FOCUS,
 ];
 
 const BUILDER_NAME = 'dialog';
diff --git a/src/docs/data/builders/menu.ts b/src/docs/data/builders/menu.ts
index 25029686..d3deb06c 100644
--- a/src/docs/data/builders/menu.ts
+++ b/src/docs/data/builders/menu.ts
@@ -23,7 +23,7 @@ export const menuBuilderProps = [
 	PROPS.DEFAULT_OPEN,
 	PROPS.OPEN,
 	PROPS.ON_OPEN_CHANGE,
-	PROPS.DISABLE_TRIGGER_REFOCUS
+	PROPS.CLOSE_FOCUS,
 ];
 
 export const menuBuilderOptions = [
@@ -36,6 +36,7 @@ export const menuBuilderOptions = [
 	PROPS.CLOSE_ON_OUTSIDE_CLICK,
 	PROPS.LOOP(),
 	PROPS.FORCE_VISIBLE,
+	PROPS.CLOSE_FOCUS,
 ];
 type Menu = 'context menu' | 'dropdown menu' | 'menubar menu';
 
diff --git a/src/docs/data/builders/popover.ts b/src/docs/data/builders/popover.ts
index 07c0780d..63e34a0a 100644
--- a/src/docs/data/builders/popover.ts
+++ b/src/docs/data/builders/popover.ts
@@ -21,6 +21,8 @@ const OPTION_PROPS = [
 	PROPS.PREVENT_SCROLL,
 	PROPS.PORTAL,
 	PROPS.FORCE_VISIBLE,
+	PROPS.OPEN_FOCUS,
+	PROPS.CLOSE_FOCUS,
 ];
 
 const BUILDER_NAME = 'popover';
diff --git a/src/docs/data/long-types/focus-prop.ts b/src/docs/data/long-types/focus-prop.ts
new file mode 100644
index 00000000..7341711f
--- /dev/null
+++ b/src/docs/data/long-types/focus-prop.ts
@@ -0,0 +1,3 @@
+type FocusTarget = string | HTMLElement | SVGElement | null;
+
+export type FocusProp = FocusTarget | ((defaultEl?: HTMLElement) => FocusTarget);
diff --git a/src/lib/builders/context-menu/create.ts b/src/lib/builders/context-menu/create.ts
index 44f1f474..eb2970ad 100644
--- a/src/lib/builders/context-menu/create.ts
+++ b/src/lib/builders/context-menu/create.ts
@@ -51,6 +51,8 @@ const defaults = {
 	defaultOpen: false,
 	forceVisible: false,
 	typeahead: true,
+	disableFocusFirstItem: true,
+	closeFocus: undefined,
 } satisfies CreateContextMenuProps;
 
 const { name, selector } = createElHelpers<_MenuParts>('context-menu');
@@ -85,8 +87,6 @@ export function createContextMenu(props?: CreateContextMenuProps) {
 		rootOptions,
 		nextFocusable,
 		prevFocusable,
-		disableFocusFirstItem: true,
-		disableTriggerRefocus: true,
 		selector: 'context-menu',
 		removeScroll: true,
 	});
diff --git a/src/lib/builders/dialog/create.ts b/src/lib/builders/dialog/create.ts
index 95a05c4b..64749229 100644
--- a/src/lib/builders/dialog/create.ts
+++ b/src/lib/builders/dialog/create.ts
@@ -22,6 +22,7 @@ import {
 	sleep,
 	styleToString,
 	toWritableStores,
+	handleFocus,
 } from '$lib/internal/helpers/index.js';
 import type { Defaults, MeltActionReturn } from '$lib/internal/types.js';
 import { onMount, tick } from 'svelte';
@@ -47,6 +48,8 @@ const defaults = {
 	defaultOpen: false,
 	portal: 'body',
 	forceVisible: false,
+	openFocus: undefined,
+	closeFocus: undefined,
 } satisfies Defaults<CreateDialogProps>;
 
 const openDialogIds = writable<string[]>([]);
@@ -55,7 +58,16 @@ export function createDialog(props?: CreateDialogProps) {
 	const withDefaults = { ...defaults, ...props } satisfies CreateDialogProps;
 
 	const options = toWritableStores(withDefaults);
-	const { preventScroll, closeOnEscape, closeOnOutsideClick, role, portal, forceVisible } = options;
+	const {
+		preventScroll,
+		closeOnEscape,
+		closeOnOutsideClick,
+		role,
+		portal,
+		forceVisible,
+		openFocus,
+		closeFocus,
+	} = options;
 
 	const activeTrigger = writable<HTMLElement | null>(null);
 
@@ -83,11 +95,10 @@ export function createDialog(props?: CreateDialogProps) {
 	function handleClose() {
 		open.set(false);
 		const triggerEl = document.getElementById(ids.trigger);
-		if (triggerEl) {
-			tick().then(() => {
-				triggerEl.focus();
-			});
-		}
+		handleFocus({
+			prop: get(closeFocus),
+			defaultEl: triggerEl,
+		});
 	}
 
 	onMount(() => {
@@ -196,6 +207,7 @@ export function createDialog(props?: CreateDialogProps) {
 			const destroy = executeCallbacks(
 				effect([open], ([$open]) => {
 					if (!$open) return;
+
 					const focusTrap = createFocusTrap({
 						immediate: false,
 						escapeDeactivates: false,
@@ -324,6 +336,11 @@ export function createDialog(props?: CreateDialogProps) {
 
 		if ($preventScroll && $open) unsubs.push(removeScroll());
 
+		if ($open) {
+			const contentEl = document.getElementById(ids.content);
+			handleFocus({ prop: get(openFocus), defaultEl: contentEl });
+		}
+
 		return () => {
 			unsubs.forEach((unsub) => unsub());
 		};
diff --git a/src/lib/builders/dialog/types.ts b/src/lib/builders/dialog/types.ts
index 62d2e1da..aedfcf7a 100644
--- a/src/lib/builders/dialog/types.ts
+++ b/src/lib/builders/dialog/types.ts
@@ -1,7 +1,7 @@
 import type { BuilderReturn } from '$lib/internal/types.js';
 import type { Writable } from 'svelte/store';
 import type { createDialog } from './create.js';
-import type { ChangeFn } from '$lib/internal/helpers/index.js';
+import type { ChangeFn, FocusProp } from '$lib/internal/helpers/index.js';
 export type { DialogComponentEvents } from './events.js';
 export type CreateDialogProps = {
 	preventScroll?: boolean;
@@ -19,6 +19,18 @@ export type CreateDialogProps = {
 	portal?: HTMLElement | string | null;
 
 	forceVisible?: boolean;
+
+	/**
+	 * Override the default autofocus behavior of the dialog
+	 * on open.
+	 */
+	openFocus?: FocusProp;
+
+	/**
+	 * Override the default autofocus behavior of the dialog
+	 * on close.
+	 */
+	closeFocus?: FocusProp;
 };
 
 export type Dialog = BuilderReturn<typeof createDialog>;
diff --git a/src/lib/builders/dropdown-menu/create.ts b/src/lib/builders/dropdown-menu/create.ts
index aa4f12f2..dbed3e46 100644
--- a/src/lib/builders/dropdown-menu/create.ts
+++ b/src/lib/builders/dropdown-menu/create.ts
@@ -17,6 +17,8 @@ const defaults = {
 	defaultOpen: false,
 	forceVisible: false,
 	typeahead: true,
+	closeFocus: undefined,
+	disableFocusFirstItem: false,
 } satisfies CreateDropdownMenuProps;
 
 export function createDropdownMenu(props?: CreateDropdownMenuProps) {
@@ -48,7 +50,6 @@ export function createDropdownMenu(props?: CreateDropdownMenuProps) {
 		rootActiveTrigger,
 		nextFocusable,
 		prevFocusable,
-		disableTriggerRefocus: props?.disableTriggerRefocus ?? false,
 		selector: 'dropdown-menu',
 		removeScroll: true,
 	});
diff --git a/src/lib/builders/menu/create.ts b/src/lib/builders/menu/create.ts
index 904e43cc..2a421580 100644
--- a/src/lib/builders/menu/create.ts
+++ b/src/lib/builders/menu/create.ts
@@ -18,6 +18,7 @@ import {
 	getNextFocusable,
 	getPortalDestination,
 	getPreviousFocusable,
+	handleFocus,
 	handleRovingFocus,
 	isBrowser,
 	isElementDisabled,
@@ -84,6 +85,8 @@ export function createMenuBuilder(opts: _MenuBuilderOptions) {
 		forceVisible,
 		typeahead,
 		loop,
+		closeFocus,
+		disableFocusFirstItem,
 	} = opts.rootOptions;
 
 	const rootOpen = opts.rootOpen;
@@ -1099,14 +1102,24 @@ export function createMenuBuilder(opts: _MenuBuilderOptions) {
 				unsubs.push(removeScroll());
 			}
 
-			if (!$rootOpen && $rootActiveTrigger && opts.disableTriggerRefocus === false) {
-				handleRovingFocus($rootActiveTrigger);
+			const $closeFocus = get(closeFocus);
+
+			if (!$rootOpen) {
+				if ($rootActiveTrigger) {
+					// If we already have a reference to the trigger, focus it
+					handleFocus({ prop: $closeFocus, defaultEl: $rootActiveTrigger });
+				} else {
+					// otherwise we'll get the trigger el and focus it
+					handleFocus({ prop: $closeFocus, defaultEl: document.getElementById(rootIds.trigger) });
+				}
 			}
 
+			// if the menu is open, we'll sleep for a sec so the menu can render
+			// before we focus on either the first item or the menu itself.
 			sleep(1).then(() => {
 				const menuEl = document.getElementById(rootIds.menu);
 				if (menuEl && $rootOpen && get(isUsingKeyboard)) {
-					if (opts.disableFocusFirstItem) {
+					if (get(disableFocusFirstItem)) {
 						handleRovingFocus(menuEl);
 						return;
 					}
@@ -1116,19 +1129,6 @@ export function createMenuBuilder(opts: _MenuBuilderOptions) {
 
 					// Focus on first menu item
 					handleRovingFocus(menuItems[0]);
-				} else if ($rootActiveTrigger) {
-					// Focus on active trigger trigger
-					if (opts.disableTriggerRefocus) {
-						return;
-					}
-					handleRovingFocus($rootActiveTrigger);
-				} else {
-					if (opts.disableTriggerRefocus) {
-						return;
-					}
-					const triggerEl = document.getElementById(rootIds.trigger);
-					if (!triggerEl) return;
-					handleRovingFocus(triggerEl);
 				}
 			});
 
@@ -1343,14 +1343,14 @@ export function handleTabNavigation(
 		const $prevFocusable = get(prevFocusable);
 		if ($prevFocusable) {
 			e.preventDefault();
-			$prevFocusable.focus();
+			sleep(1).then(() => $prevFocusable.focus());
 			prevFocusable.set(null);
 		}
 	} else {
 		const $nextFocusable = get(nextFocusable);
 		if ($nextFocusable) {
 			e.preventDefault();
-			$nextFocusable.focus();
+			sleep(1).then(() => $nextFocusable.focus());
 			nextFocusable.set(null);
 		}
 	}
diff --git a/src/lib/builders/menu/types.ts b/src/lib/builders/menu/types.ts
index e4bdbeed..828becfc 100644
--- a/src/lib/builders/menu/types.ts
+++ b/src/lib/builders/menu/types.ts
@@ -1,6 +1,6 @@
 import type { FloatingConfig } from '$lib/internal/actions/index.js';
 import type { TextDirection } from '$lib/internal/types.js';
-import type { ChangeFn } from '$lib/internal/helpers/index.js';
+import type { ChangeFn, FocusProp } from '$lib/internal/helpers/index.js';
 import type { Writable } from 'svelte/store';
 import type { createMenuBuilder } from './create.js';
 
@@ -101,11 +101,15 @@ export type _CreateMenuProps = {
 	typeahead?: boolean;
 
 	/**
-	 * Whether to disable the functionality that the trigger element is refocused when the menu is closed.
-	 *
-	 * @type {boolean}
+	 * Override the default autofocus behavior of the menu
+	 * on close.
+	 */
+	closeFocus?: FocusProp;
+
+	/**
+	 * Optionally prevent focusing the first item in the menu
 	 */
-	disableTriggerRefocus?: boolean;
+	disableFocusFirstItem?: boolean;
 };
 
 export type _CreateSubmenuProps = Pick<_CreateMenuProps, 'arrowSize' | 'positioning'> & {
@@ -159,9 +163,10 @@ export type _MenuBuilderOptions = {
 		portal: Writable<string | HTMLElement | undefined | null>;
 		forceVisible: Writable<boolean>;
 		typeahead: Writable<boolean>;
+		closeFocus: Writable<FocusProp | undefined>;
+		disableFocusFirstItem: Writable<boolean>;
 	};
-	disableTriggerRefocus?: boolean;
-	disableFocusFirstItem?: boolean;
+
 	nextFocusable: Writable<HTMLElement | null>;
 	prevFocusable: Writable<HTMLElement | null>;
 	selector: string;
diff --git a/src/lib/builders/popover/create.ts b/src/lib/builders/popover/create.ts
index 3092c4a7..fca8d623 100644
--- a/src/lib/builders/popover/create.ts
+++ b/src/lib/builders/popover/create.ts
@@ -6,6 +6,7 @@ import {
 	effect,
 	generateId,
 	getPortalDestination,
+	handleFocus,
 	isBrowser,
 	isHTMLElement,
 	kbd,
@@ -20,7 +21,7 @@ import {
 import { usePopper } from '$lib/internal/actions/index.js';
 import type { Defaults, MeltActionReturn } from '$lib/internal/types.js';
 import { onMount, tick } from 'svelte';
-import { writable } from 'svelte/store';
+import { get, writable } from 'svelte/store';
 import { executeCallbacks } from '../../internal/helpers/callbacks.js';
 import type { PopoverEvents } from './events.js';
 import type { CreatePopoverProps } from './types.js';
@@ -38,6 +39,8 @@ const defaults = {
 	closeOnOutsideClick: true,
 	portal: undefined,
 	forceVisible: false,
+	openFocus: undefined,
+	closeFocus: undefined,
 } satisfies Defaults<CreatePopoverProps>;
 
 type PopoverParts = 'trigger' | 'content' | 'arrow' | 'close';
@@ -56,6 +59,8 @@ export function createPopover(args?: CreatePopoverProps) {
 		closeOnOutsideClick,
 		portal,
 		forceVisible,
+		openFocus,
+		closeFocus,
 	} = options;
 
 	const openWritable = withDefaults.open ?? writable(withDefaults.defaultOpen);
@@ -75,11 +80,7 @@ export function createPopover(args?: CreatePopoverProps) {
 	function handleClose() {
 		open.set(false);
 		const triggerEl = document.getElementById(ids.trigger);
-		if (triggerEl) {
-			tick().then(() => {
-				triggerEl.focus();
-			});
-		}
+		handleFocus({ prop: get(closeFocus), defaultEl: triggerEl });
 	}
 
 	const isVisible = derivedVisible({ open, activeTrigger, forceVisible });
@@ -128,7 +129,11 @@ export function createPopover(args?: CreatePopoverProps) {
 						open,
 						options: {
 							floating: $positioning,
-							focusTrap: $disableFocusTrap ? null : undefined,
+							focusTrap: $disableFocusTrap
+								? null
+								: {
+										returnFocusOnDeactivate: false,
+								  },
 							clickOutside: $closeOnOutsideClick ? undefined : null,
 							escapeKeydown: $closeOnEscape
 								? {
@@ -247,6 +252,9 @@ export function createPopover(args?: CreatePopoverProps) {
 			if ($preventScroll) {
 				unsubs.push(removeScroll());
 			}
+
+			const triggerEl = $activeTrigger ?? document.getElementById(ids.trigger);
+			handleFocus({ prop: get(openFocus), defaultEl: triggerEl });
 		}
 
 		return () => {
diff --git a/src/lib/builders/popover/types.ts b/src/lib/builders/popover/types.ts
index f13c0cbd..255c280f 100644
--- a/src/lib/builders/popover/types.ts
+++ b/src/lib/builders/popover/types.ts
@@ -1,5 +1,5 @@
 import type { FloatingConfig } from '$lib/internal/actions/index.js';
-import type { ChangeFn } from '$lib/internal/helpers/index.js';
+import type { ChangeFn, FocusProp } from '$lib/internal/helpers/index.js';
 import type { Writable } from 'svelte/store';
 import type { createPopover } from './create.js';
 import type { BuilderReturn } from '$lib/internal/types.js';
@@ -83,6 +83,18 @@ export type CreatePopoverProps = {
 	 * @default false
 	 */
 	forceVisible?: boolean;
+
+	/**
+	 * Override the default autofocus behavior of the popover
+	 * on open.
+	 */
+	openFocus?: FocusProp;
+
+	/**
+	 * Override the default autofocus behavior of the popover
+	 * on close.
+	 */
+	closeFocus?: FocusProp;
 };
 
 export type Popover = BuilderReturn<typeof createPopover>;
diff --git a/src/lib/index.ts b/src/lib/index.ts
index a9e0f373..104c9f8e 100644
--- a/src/lib/index.ts
+++ b/src/lib/index.ts
@@ -1,2 +1,3 @@
 export * from './builders/index.js';
+export * from './shared/index.js';
 export { melt } from './internal/actions/index.js';
diff --git a/src/lib/internal/helpers/focus.ts b/src/lib/internal/helpers/focus.ts
new file mode 100644
index 00000000..1ed69dbc
--- /dev/null
+++ b/src/lib/internal/helpers/focus.ts
@@ -0,0 +1,29 @@
+import { isFunction, isHTMLElement, sleep } from '$lib/internal/helpers/index.js';
+
+export type FocusTarget = string | HTMLElement | SVGElement | null;
+export type FocusProp = FocusTarget | ((defaultEl?: HTMLElement | null) => FocusTarget);
+
+type HandleFocusArgs = {
+	prop?: FocusProp;
+	defaultEl: HTMLElement | null;
+};
+
+export function handleFocus(args: HandleFocusArgs): void {
+	const { prop, defaultEl } = args;
+	if (prop === undefined) {
+		sleep(1).then(() => defaultEl?.focus());
+		return;
+	}
+
+	const returned = isFunction(prop) ? prop(defaultEl) : prop;
+
+	if (typeof returned === 'string') {
+		// Get el by selector, focus it
+		const el = document.querySelector(returned);
+		if (!isHTMLElement(el)) return;
+		sleep(1).then(() => el.focus());
+	} else if (isHTMLElement(returned)) {
+		// Focus it
+		sleep(1).then(() => returned.focus());
+	}
+}
diff --git a/src/lib/internal/helpers/index.ts b/src/lib/internal/helpers/index.ts
index 72a19988..b2c8c39e 100644
--- a/src/lib/internal/helpers/index.ts
+++ b/src/lib/internal/helpers/index.ts
@@ -23,3 +23,4 @@ export * from './rovingFocus.js';
 export * from './typeahead.js';
 export * from './elements.js';
 export * from './ignore.js';
+export * from './focus.js';
diff --git a/src/lib/internal/helpers/rovingFocus.ts b/src/lib/internal/helpers/rovingFocus.ts
index a1f6602b..38711b8d 100644
--- a/src/lib/internal/helpers/rovingFocus.ts
+++ b/src/lib/internal/helpers/rovingFocus.ts
@@ -10,16 +10,16 @@ import { sleep } from './sleep.js';
 export function handleRovingFocus(nextElement: HTMLElement) {
 	if (!isBrowser) return;
 
-	const currentFocusedElement = document.activeElement;
-	if (!isHTMLElement(currentFocusedElement)) return;
-
-	if (currentFocusedElement === nextElement) return;
-
-	currentFocusedElement.tabIndex = -1;
-	if (nextElement) {
-		nextElement.tabIndex = 0;
-		sleep(1).then(() => nextElement.focus());
-	}
+	sleep(1).then(() => {
+		const currentFocusedElement = document.activeElement;
+		if (!isHTMLElement(currentFocusedElement) || currentFocusedElement === nextElement) return;
+
+		currentFocusedElement.tabIndex = -1;
+		if (nextElement) {
+			nextElement.tabIndex = 0;
+			nextElement.focus();
+		}
+	});
 }
 
 function getFocusableElements() {
diff --git a/src/lib/shared/index.ts b/src/lib/shared/index.ts
new file mode 100644
index 00000000..a26fc87e
--- /dev/null
+++ b/src/lib/shared/index.ts
@@ -0,0 +1,6 @@
+/**
+ * Exports that are shared between multiple builders
+ * can be exported here to prevent conflicts.
+ */
+import type { FocusProp, FocusTarget } from '$lib/internal/helpers/index.js';
+export type { FocusProp, FocusTarget };


Test patch:
diff --git a/src/tests/dialog/Dialog.spec.ts b/src/tests/dialog/Dialog.spec.ts
index 75cc4648..dd04b7df 100644
--- a/src/tests/dialog/Dialog.spec.ts
+++ b/src/tests/dialog/Dialog.spec.ts
@@ -5,147 +5,174 @@ import DialogTest from './DialogTest.svelte';
 import userEvent from '@testing-library/user-event';
 import { sleep } from '$lib/internal/helpers/index.js';
 import { kbd } from '$lib/internal/helpers/index.js';
+import { tick } from 'svelte';
 
 describe('Dialog', () => {
 	it('No accessibility violations', async () => {
-		const { container } = await render(DialogTest);
+		const { container } = render(DialogTest);
 
 		expect(await axe(container)).toHaveNoViolations();
 	});
 
 	it('Opens when trigger is clicked', async () => {
-		await render(DialogTest);
+		render(DialogTest);
 
 		const trigger = screen.getByTestId('trigger');
 		const content = screen.getByTestId('content');
 
-		await expect(content).not.toBeVisible();
+		expect(content).not.toBeVisible();
 		await userEvent.click(trigger);
 		const now = performance.now();
-		await expect(content).toBeVisible();
+		expect(content).toBeVisible();
 		const elapsed = performance.now() - now;
 		expect(elapsed).toBeLessThan(10);
 	});
 
 	it('Closes when closer is clicked', async () => {
-		await render(DialogTest);
+		render(DialogTest);
 
-		const user = userEvent.setup();
 		const trigger = screen.getByTestId('trigger');
 		const closer = screen.getByTestId('closer');
 		const content = screen.getByTestId('content');
 
-		await expect(trigger).toBeVisible();
-		await expect(content).not.toBeVisible();
-		await user.click(trigger);
-		await expect(content).toBeVisible();
-		await user.click(closer);
-		await expect(content).not.toBeVisible();
+		expect(trigger).toBeVisible();
+		expect(content).not.toBeVisible();
+		await userEvent.click(trigger);
+		expect(content).toBeVisible();
+		await userEvent.click(closer);
+		expect(content).not.toBeVisible();
 	});
 
 	it('Closes when Escape is hit', async () => {
-		await render(DialogTest);
+		render(DialogTest);
 
-		const user = userEvent.setup();
 		const trigger = screen.getByTestId('trigger');
 		const content = screen.getByTestId('content');
 
-		await expect(trigger).toBeVisible();
-		await expect(content).not.toBeVisible();
-		await user.click(trigger);
-		await expect(content).toBeVisible();
-		await user.keyboard(`{${kbd.ESCAPE}}`);
-		await expect(content).not.toBeVisible();
+		expect(trigger).toBeVisible();
+		expect(content).not.toBeVisible();
+		await userEvent.click(trigger);
+		expect(content).toBeVisible();
+		await userEvent.keyboard(`{${kbd.ESCAPE}}`);
+		expect(content).not.toBeVisible();
 	});
 
 	it('Closes when overlay is clicked', async () => {
-		await render(DialogTest);
+		render(DialogTest);
 
-		const user = userEvent.setup();
 		const trigger = screen.getByTestId('trigger');
 		const overlay = screen.getByTestId('overlay');
 		const content = screen.getByTestId('content');
 
-		await expect(trigger).toBeVisible();
-		await expect(content).not.toBeVisible();
-		await user.click(trigger);
-		await expect(content).toBeVisible();
+		expect(trigger).toBeVisible();
+		expect(content).not.toBeVisible();
+		await userEvent.click(trigger);
+		expect(content).toBeVisible();
 		await sleep(100);
-		await expect(overlay).toBeVisible();
-		await user.click(overlay);
+		expect(overlay).toBeVisible();
+		await userEvent.click(overlay);
 		await sleep(100);
-		await expect(content).not.toBeVisible();
+		expect(content).not.toBeVisible();
 	});
 
 	it('Portalled el attaches dialog to body', async () => {
-		await render(DialogTest);
+		render(DialogTest);
 
-		const user = userEvent.setup();
 		const trigger = screen.getByTestId('trigger');
-		await user.click(trigger);
+		await userEvent.click(trigger);
 
 		const portalled = screen.getByTestId('portalled');
 
-		await expect(portalled.parentElement).toEqual(document.body);
+		expect(portalled.parentElement).toEqual(document.body);
 	});
 
 	it('Focuses first focusable item upon opening', async () => {
-		await render(DialogTest);
+		render(DialogTest);
 
-		const user = userEvent.setup();
 		const trigger = screen.getByTestId('trigger');
 		const content = screen.getByTestId('content');
 
-		await expect(trigger).toBeVisible();
-		await expect(content).not.toBeVisible();
-		await user.click(trigger);
-		await expect(content).toBeVisible();
+		expect(trigger).toBeVisible();
+		expect(content).not.toBeVisible();
+		await userEvent.click(trigger);
+		expect(content).toBeVisible();
 		// Testing focus-trap is a bit flaky. So the focusable element is
 		// always content here.
-		await expect(document.activeElement).toBe(content);
+		expect(document.activeElement).toBe(content);
 	});
 
 	it('Tabbing on last item focuses first item', async () => {
-		await render(DialogTest);
+		render(DialogTest);
 
-		const user = userEvent.setup();
 		const trigger = screen.getByTestId('trigger');
 		const content = screen.getByTestId('content');
 
-		await expect(trigger).toBeVisible();
-		await expect(content).not.toBeVisible();
-		await user.click(trigger);
-		await expect(content).toBeVisible();
+		expect(trigger).toBeVisible();
+		expect(content).not.toBeVisible();
+		await userEvent.click(trigger);
+		expect(content).toBeVisible();
 		// Testing focus-trap is a bit flaky. So the focusable element is
 		// always content here.
-		await expect(document.activeElement).toBe(content);
-		await user.tab();
-		await expect(document.activeElement).toBe(content);
+		expect(document.activeElement).toBe(content);
+		await userEvent.tab();
+		expect(document.activeElement).toBe(content);
 	});
 
 	it("Doesn't close when cliking content", async () => {
-		await render(DialogTest);
+		render(DialogTest);
 
-		const user = userEvent.setup();
 		const trigger = screen.getByTestId('trigger');
 		const content = screen.getByTestId('content');
 		const closer = screen.getByTestId('closer');
 
-		await expect(trigger).toBeVisible();
-		await expect(content).not.toBeVisible();
-		await user.click(trigger);
-		await expect(content).toBeVisible();
-		await user.click(content);
-		await expect(content).toBeVisible();
+		expect(trigger).toBeVisible();
+		expect(content).not.toBeVisible();
+		await userEvent.click(trigger);
+		expect(content).toBeVisible();
+		await userEvent.click(content);
+		expect(content).toBeVisible();
 
 		// Close
-		await user.click(closer);
-		await expect(content).not.toBeVisible();
+		await userEvent.click(closer);
+		expect(content).not.toBeVisible();
 
 		// Open again to retest
-		await user.click(trigger);
-		await expect(content).toBeVisible();
-		await user.click(content);
-		await expect(content).toBeVisible();
+		await userEvent.click(trigger);
+		expect(content).toBeVisible();
+		await userEvent.click(content);
+		expect(content).toBeVisible();
+	});
+
+	it('Respects the `openFocus` prop', async () => {
+		render(DialogTest, {
+			openFocus: () => {
+				return document.getElementById('openFocus');
+			},
+		});
+
+		const trigger = screen.getByTestId('trigger');
+
+		await userEvent.click(trigger);
+		await tick();
+		const openFocus = screen.getByTestId('openFocus');
+
+		expect(openFocus).toHaveFocus();
+	});
+
+	it('Respects the `closeFocus` prop', async () => {
+		render(DialogTest, {
+			closeFocus: () => {
+				return document.getElementById('closeFocus');
+			},
+		});
+
+		const closeFocus = screen.getByTestId('closeFocus');
+		const trigger = screen.getByTestId('trigger');
+
+		await userEvent.click(trigger);
+		await tick();
+		await userEvent.keyboard(`{${kbd.ESCAPE}}`);
+
+		expect(closeFocus).toHaveFocus();
 	});
 });
diff --git a/src/tests/dialog/DialogTest.svelte b/src/tests/dialog/DialogTest.svelte
index 343c2832..43134850 100644
--- a/src/tests/dialog/DialogTest.svelte
+++ b/src/tests/dialog/DialogTest.svelte
@@ -1,12 +1,19 @@
 <script lang="ts">
-	import { createDialog, melt } from '$lib/index.js';
+	import { createDialog, melt, type CreateDialogProps } from '$lib/index.js';
+
+	export let closeFocus: CreateDialogProps['closeFocus'] = undefined;
+	export let openFocus: CreateDialogProps['openFocus'] = undefined;
 
 	const {
 		elements: { trigger, overlay, content, title, description, close, portalled },
-	} = createDialog();
+	} = createDialog({
+		closeFocus,
+		openFocus,
+	});
 </script>
 
 <main>
+	<button id="closeFocus" data-testid="closeFocus">Focus Me</button>
 	<button use:melt={$trigger} data-testid="trigger">Open</button>
 	<div use:melt={$portalled} data-testid="portalled">
 		<div use:melt={$overlay} data-testid="overlay" />
@@ -16,6 +23,7 @@
 
 			<button use:melt={$close} data-testid="closer">Close</button>
 			<button use:melt={$close} data-testid="last">Close</button>
+			<div tabindex="-1" role="button" id="openFocus" data-testid="openFocus">hello world</div>
 		</div>
 	</div>
 </main>
diff --git a/src/tests/dropdown-menu/DropdownMenu.spec.ts b/src/tests/dropdown-menu/DropdownMenu.spec.ts
index 61c9348f..129e97f2 100644
--- a/src/tests/dropdown-menu/DropdownMenu.spec.ts
+++ b/src/tests/dropdown-menu/DropdownMenu.spec.ts
@@ -184,6 +184,22 @@ describe('Dropdown Menu (Default)', () => {
 		expect(subtrigger).toHaveFocus();
 	});
 
+	test('Respects the `closeFocus` prop', async () => {
+		const { getByTestId } = render(DropdownMenuTest, {
+			closeFocus: () => {
+				return document.getElementById('closeFocus');
+			},
+		});
+		const trigger = getByTestId('trigger');
+		const menu = getByTestId('menu');
+		expect(menu).not.toBeVisible();
+		await act(() => userEvent.click(trigger));
+		expect(menu).toBeVisible();
+		await act(() => userEvent.keyboard(`{${kbd.ESCAPE}}`));
+		const closeFocus = getByTestId('closeFocus');
+		expect(closeFocus).toHaveFocus();
+	});
+
 	test.todo('Radio items');
 });
 
diff --git a/src/tests/dropdown-menu/DropdownMenuTest.svelte b/src/tests/dropdown-menu/DropdownMenuTest.svelte
index df7ed57e..af534890 100644
--- a/src/tests/dropdown-menu/DropdownMenuTest.svelte
+++ b/src/tests/dropdown-menu/DropdownMenuTest.svelte
@@ -1,5 +1,5 @@
 <script lang="ts">
-	import { createDropdownMenu, melt } from '$lib/index.js';
+	import { createDropdownMenu, melt, type CreateDropdownMenuProps } from '$lib/index.js';
 	import { writable } from 'svelte/store';
 	import { AlignJustify, ChevronRight } from 'lucide-svelte';
 
@@ -7,12 +7,14 @@
 	const hideMeltUI = writable(false);
 
 	export let loop = false;
+	export let closeFocus: CreateDropdownMenuProps['closeFocus'] = undefined;
 
 	const {
 		elements: { trigger, menu, item, separator, arrow },
 		builders: { createSubmenu, createMenuRadioGroup, createCheckboxItem },
 	} = createDropdownMenu({
 		loop,
+		closeFocus,
 	});
 
 	const {
@@ -41,6 +43,7 @@
 </script>
 
 <main>
+	<button id="closeFocus" data-testid="closeFocus">close focus</button>
 	<button
 		type="button"
 		class="trigger"
diff --git a/src/tests/popover/Popover.spec.ts b/src/tests/popover/Popover.spec.ts
index 8bd759af..787369e0 100644
--- a/src/tests/popover/Popover.spec.ts
+++ b/src/tests/popover/Popover.spec.ts
@@ -6,51 +6,82 @@ import { kbd } from '$lib/internal/helpers/index.js';
 
 describe('Popover (Default)', () => {
 	test('No accessibility violations', async () => {
-		const { container } = await render(PopoverTest);
+		const { container } = render(PopoverTest);
 
 		expect(await axe(container)).toHaveNoViolations();
 	});
 
 	test('Opens on click', async () => {
-		const { getByTestId } = await render(PopoverTest);
-		const user = userEvent.setup();
+		const { getByTestId } = render(PopoverTest);
 		const trigger = getByTestId('trigger');
 		const content = getByTestId('content');
 
-		await expect(trigger).toBeVisible();
-		await expect(content).not.toBeVisible();
-		await user.click(trigger);
+		expect(trigger).toBeVisible();
+		expect(content).not.toBeVisible();
+		await userEvent.click(trigger);
 		await waitFor(() => expect(content).toBeVisible());
 	});
 
 	test('Closes on escape', async () => {
-		const { getByTestId } = await render(PopoverTest);
-		const user = userEvent.setup();
+		const { getByTestId } = render(PopoverTest);
 		const trigger = getByTestId('trigger');
 		const content = getByTestId('content');
 
-		await expect(trigger).toBeVisible();
-		await expect(content).not.toBeVisible();
-		await user.click(trigger);
+		expect(trigger).toBeVisible();
+		expect(content).not.toBeVisible();
+		await userEvent.click(trigger);
 		await waitFor(() => expect(content).toBeVisible());
-		await user.keyboard(`{${kbd.ESCAPE}}`);
+		await userEvent.keyboard(`{${kbd.ESCAPE}}`);
 		await waitFor(() => expect(content).not.toBeVisible());
 	});
 
 	test('Closes when click outside content', async () => {
-		const { getByTestId } = await render(PopoverTest);
-		const user = userEvent.setup();
+		const { getByTestId } = render(PopoverTest);
 		const trigger = getByTestId('trigger');
 		const content = getByTestId('content');
 
-		await expect(trigger).toBeVisible();
-		await expect(content).not.toBeVisible();
-		await user.click(trigger);
+		expect(trigger).toBeVisible();
+		expect(content).not.toBeVisible();
+		await userEvent.click(trigger);
 		await waitFor(() => expect(content).toBeVisible());
-		await user.click(content);
+		await userEvent.click(content);
 		await waitFor(() => expect(content).toBeVisible());
 		const outside = getByTestId('outside');
-		await user.click(outside);
+		await userEvent.click(outside);
 		await waitFor(() => expect(content).not.toBeVisible());
 	});
+
+	test('Respects `openFocus` prop', async () => {
+		const { getByTestId } = render(PopoverTest, {
+			openFocus: () => {
+				return document.getElementById('openFocus');
+			},
+		});
+		const trigger = getByTestId('trigger');
+		const content = getByTestId('content');
+
+		expect(trigger).toBeVisible();
+		await userEvent.click(trigger);
+		await waitFor(() => expect(content).toBeVisible());
+		const openFocus = getByTestId('openFocus');
+		expect(openFocus).toHaveFocus();
+	});
+
+	test('Respects `closeFocus` prop', async () => {
+		const { getByTestId } = render(PopoverTest, {
+			closeFocus: () => {
+				return document.getElementById('closeFocus');
+			},
+		});
+		const trigger = getByTestId('trigger');
+		const content = getByTestId('content');
+
+		expect(trigger).toBeVisible();
+		await userEvent.click(trigger);
+		await waitFor(() => expect(content).toBeVisible());
+		await userEvent.keyboard(`{${kbd.ESCAPE}}`);
+		await waitFor(() => expect(content).not.toBeVisible());
+		const closeFocus = getByTestId('closeFocus');
+		expect(closeFocus).toHaveFocus();
+	});
 });
diff --git a/src/tests/popover/PopoverTest.svelte b/src/tests/popover/PopoverTest.svelte
index e5fb035a..c6f0dddc 100644
--- a/src/tests/popover/PopoverTest.svelte
+++ b/src/tests/popover/PopoverTest.svelte
@@ -1,12 +1,20 @@
 <script lang="ts">
-	import { createPopover, melt } from '$lib/index.js';
+	import { createPopover, melt, type CreatePopoverProps } from '$lib/index.js';
 	import { Settings2, X } from 'lucide-svelte';
 
+	export let openFocus: CreatePopoverProps['openFocus'] = undefined;
+	export let closeFocus: CreatePopoverProps['closeFocus'] = undefined;
+
 	const {
 		elements: { trigger, content, arrow, close },
-	} = createPopover();
+	} = createPopover({
+		openFocus,
+		closeFocus,
+	});
 </script>
 
+<button data-testid="closeFocus" id="closeFocus"> focus me on close </button>
+
 <button
 	type="button"
 	class="trigger"
@@ -42,6 +50,7 @@
 	<button class="close" use:melt={$close} data-testid="close">
 		<X class="h-4 w-4 " />
 	</button>
+	<button data-testid="openFocus" id="openFocus"> focus me on open </button>
 </div>
 <div data-testid="outside" />