Repository: melt-ui/melt-ui
Pull request #1142 (2024-03-30 15:01:34)

Feat: use stacks in `useEscapeKeydown` & introduce `escapeBehavior` prop
closes https://github.com/melt-ui/melt-ui/issues/1141, https://github.com/melt-ui/melt-ui/issues/1143

This PR fixes how we handle escape presses when dealing with multiple nested floating elements by maintaining a stack of the open elements. This was part of https://github.com/melt-ui/melt-ui/pull/1080 but I realized that it changed too many things and would make it unnecessarily difficult to review. 

Here is a video showing nested floating elements and outside escape presses only closing the top-most floating element.

https://github.com/melt-ui/melt-ui/assets/53095479/d9c2e50c-2062-4a0e-a515-37f12b602cc0

Previously we added `closeOnEscape` to the dependencies in the effect of every builder that was interested in the escape behavior. This breaks as soon as we use stacks, since updating `closeOnEscape` while an element is open removes the element from stack and then adds it to the top of the stack. To prevent that, this PR allows passing the `closeOnEscape` store to `useEscapeKeydown` which therefore allows updating `closeOnEscape` without reseting the position of the element in the stack. I added tests for this as well.

Now that we use a stack to keep track of open elements that are listening to the escape presses, I had to remove a lot of extra keydown event listeners that we had in many builders that would interfere when using multiple nested elements.

This PR also replaces the `closeOnEscape` builder prop with `escapeBehavior` which provides even further fine-grained control over the escape behavior in builders.

Previously when dealing with multiple nested floating elements and passing `closeOnEscape: false` to a child element, when the escape key was pressed, both the parent and the child would close. I would expect neither the parent nor the child to close since I explicitly "asked" the child not to close on escape. So previously there was no way to force an element to stay open when escape is pressed without triggering the parent to close as well.

To allow more fine-grained behaviors, we now introduce the `escapeBehavior` prop:
- `close`: Closes the element immediately.
- `ignore`: Prevents the element from closing and also blocks the parent element from closing in response to the Escape key.
- `defer-otherwise-close`: Delegates the action to the parent element. If no parent is found, it closes the element.
- `defer-otherwise-ignore`: Delegates the action to the parent element. If no parent is found, nothing is done.

Here are videos showing the behavior of adding different `escapeBehavior` values to a popover that is nested inside a dialog. The dialog's `escapeBehavior` is `close`.

### Popover `escapeBehavior: 'close'`

https://github.com/melt-ui/melt-ui/assets/53095479/8e670d2a-370a-4b6a-ba93-8f4d95c40236

### Popover `escapeBehavior: 'ignore'`

https://github.com/melt-ui/melt-ui/assets/53095479/38b7996b-5b1a-4b60-84ec-38fe25280ef1

### Popover `escapeBehavior: 'defer-otherwise-ignore'`

https://github.com/melt-ui/melt-ui/assets/53095479/d19ed8f4-2949-42cf-b749-4fa89f1c45e2

### Popover `escapeBehavior: 'defer-otherwise-close'`

https://github.com/melt-ui/melt-ui/assets/53095479/ec36f7e3-e3ad-403d-9ee9-23f7abc1c027

### Popover `escapeBehavior: 'defer-otherwise-close'` mounted as a single element

https://github.com/melt-ui/melt-ui/assets/53095479/62aa47a2-6b46-40b8-b5b3-e628b3d5b194

changeset-bot[bot]: ###  🦋  Changeset detected

Latest commit: 2877afeb84b2bd795640623a9c4b8534094684b2

**The changes in this PR will be included in the next version bump.**

<details><summary>This PR includes changesets to release 1 package</summary>

  | Name            | Type  |
| --------------- | ----- |
| @melt-ui/svelte | Minor |

</details>

Not sure what this means? [Click here  to learn what changesets are](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add another changeset to this PR](https://github.com/anatolzak/melt-ui/new/fix/escape-keydown-nested-elements?filename=.changeset/fluffy-goats-march.md&value=---%0A%22%40melt-ui%2Fsvelte%22%3A%20patch%0A---%0A%0Afix(escape-keydown)%20correctly%20handling%20escape%20keydown%20with%20nested%20elements%0A)



Patch:
diff --git a/.changeset/beige-actors-provide.md b/.changeset/beige-actors-provide.md
deleted file mode 100644
index dec04381..00000000
--- a/.changeset/beige-actors-provide.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@melt-ui/svelte': patch
----
-
-add `as const` return to more element builders (Date Field "segment", Menubar "menubar", Scroll Area)
diff --git a/.changeset/itchy-spoons-brush.md b/.changeset/itchy-spoons-brush.md
deleted file mode 100644
index 90fbef03..00000000
--- a/.changeset/itchy-spoons-brush.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@melt-ui/svelte": patch
----
-
-chore: internal popover refactors
diff --git a/.changeset/moody-jokes-peel.md b/.changeset/moody-jokes-peel.md
new file mode 100644
index 00000000..8f4e7b8a
--- /dev/null
+++ b/.changeset/moody-jokes-peel.md
@@ -0,0 +1,5 @@
+---
+'@melt-ui/svelte': patch
+---
+
+Fixed bug where tooltip would close while the pointer is still inside trigger (closes #886)
diff --git a/.changeset/new-hairs-vanish.md b/.changeset/new-hairs-vanish.md
deleted file mode 100644
index c0a25729..00000000
--- a/.changeset/new-hairs-vanish.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@melt-ui/svelte': patch
----
-
-slider: add disable swapping option to avoid sorting of values array
diff --git a/.changeset/rude-carpets-rhyme.md b/.changeset/rude-carpets-rhyme.md
deleted file mode 100644
index a2adc6b6..00000000
--- a/.changeset/rude-carpets-rhyme.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@melt-ui/svelte": patch
----
-
-chore: refactor menu internals
diff --git a/.changeset/rude-snakes-allow.md b/.changeset/rude-snakes-allow.md
new file mode 100644
index 00000000..ba76ae4c
--- /dev/null
+++ b/.changeset/rude-snakes-allow.md
@@ -0,0 +1,5 @@
+---
+'@melt-ui/svelte': minor
+---
+
+Fixed escape keydown behavior by using stacks to correctly handle nested floating elements
diff --git a/.changeset/slow-chefs-deliver.md b/.changeset/slow-chefs-deliver.md
deleted file mode 100644
index c6f21980..00000000
--- a/.changeset/slow-chefs-deliver.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@melt-ui/svelte': minor
----
-
-Added new `preventTextSelectionOverflow` prop to builders to prevent text selection overflowing the floating element. (closes #1168)
diff --git a/.changeset/wicked-spies-approve.md b/.changeset/wicked-spies-approve.md
deleted file mode 100644
index 777955d1..00000000
--- a/.changeset/wicked-spies-approve.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@melt-ui/svelte": patch
----
-
-Refactor context menu internals
diff --git a/.changeset/witty-games-beg.md b/.changeset/witty-games-beg.md
new file mode 100644
index 00000000..9ea8bf64
--- /dev/null
+++ b/.changeset/witty-games-beg.md
@@ -0,0 +1,5 @@
+---
+'@melt-ui/svelte': minor
+---
+
+Changed `closeOnEscape` builder prop to `escapeBehavior` to provide even further fine-grained control over escape behavior in builders (closes #1142)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ca262bff..0d904de0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,23 @@
 # @melt-ui/svelte
 
+## 0.77.0
+
+### Minor Changes
+
+- Added new `preventTextSelectionOverflow` prop to builders to prevent text selection overflowing the floating element. (closes #1168) ([#1170](https://github.com/melt-ui/melt-ui/pull/1170))
+
+### Patch Changes
+
+- add `as const` return to more element builders (Date Field "segment", Menubar "menubar", Scroll Area) ([#1172](https://github.com/melt-ui/melt-ui/pull/1172))
+
+- chore: internal popover refactors ([#1055](https://github.com/melt-ui/melt-ui/pull/1055))
+
+- slider: add disable swapping option to avoid sorting of values array ([#1124](https://github.com/melt-ui/melt-ui/pull/1124))
+
+- chore: refactor menu internals ([#1056](https://github.com/melt-ui/melt-ui/pull/1056))
+
+- Refactor context menu internals ([#1054](https://github.com/melt-ui/melt-ui/pull/1054))
+
 ## 0.76.3
 
 ### Patch Changes
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 324a58aa..42ad7e71 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -127,7 +127,7 @@ submitting the pull request.
 
 ## Contribute from a browser IDE
 
-Make changes and contribute to OctoBot in a single click with an **already setup and ready to code
+Make changes and contribute to Melt UI in a single click with an **already setup and ready to code
 developer environment** using Gitpod !
 
 [![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/?autostart=true#https://github.com/melt-ui/melt-ui/tree/develop)
diff --git a/README.md b/README.md
index 5b650d3f..0789e01c 100644
--- a/README.md
+++ b/README.md
@@ -58,12 +58,22 @@ enhance the experience of building with Svelte and improve the ecosystem for eve
 
 Check out our [Contributing guide](./CONTRIBUTING.md) to learn more.
 
+## Sponsors
+
+<p align="center">
+  <a href="https://github.com/sponsors/tglide">
+    <img src='https://github.com/tglide/sponsors/blob/main/sponsors.svg?raw=true' alt="Logos from Sponsors" />
+  </a>
+</p>
+
+## Community
+
 Melt UI is an open-source project built by the community for the community. It wouldn't be possible
 if it wasn't for the work of some amazing people.
 
 [![Contributors](https://contrib.rocks/image?repo=melt-ui/melt-ui)](<[https://github.com/codemaniac-sahil/news-webapp-api](https://github.com/melt-ui/melt-ui)https://github.com/melt-ui/melt-ui/graphs/contributors>)
 
-## Community
+### Discord
 
 Got any questions? Want to talk to the maintainers?
 
diff --git a/package.json b/package.json
index 8367b75f..08a981d1 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
 	"name": "@melt-ui/svelte",
-	"version": "0.76.3",
+	"version": "0.77.0",
 	"license": "MIT",
 	"repository": "github:melt-ui/melt-ui",
 	"author": "Thomas G. Lopes",
diff --git a/src/docs/constants.ts b/src/docs/constants.ts
index 21ff6599..5fe74aee 100644
--- a/src/docs/constants.ts
+++ b/src/docs/constants.ts
@@ -13,6 +13,17 @@ export const SEE = {
 	},
 };
 
+const createBulletsHTML = (bullets: string[]) => {
+	return bullets.map((bullet) => `<li>${bullet}</li>`).join('');
+};
+
+const ESCAPE_BEHAVIOR_BULLETS = (name: string) => [
+	`\`close\`: Closes the ${name} immediately.`,
+	`\`ignore\`: Prevents the ${name} from closing and also blocks the parent element from closing in response to the Escape key.`,
+	`\`defer-otherwise-close\`: Delegates the action to the parent element. If no parent is found, it closes the element.`,
+	`\`defer-otherwise-ignore\`: Delegates the action to the parent element. If no parent is found, nothing is done.`,
+];
+
 export const DESCRIPTIONS = {
 	FLOATING_CONFIG:
 		'A configuration object which determines how the floating element is positioned relative to the trigger.',
@@ -22,8 +33,10 @@ export const DESCRIPTIONS = {
 	ON_SELECT:
 		'A callback which is called when the item is selected. To prevent the default behavior, call `e.preventDefault()` in the callback.',
 	LOOP: 'Whether or not the focus should loop back to the first item when the last item is reached.',
-	CLOSE_ON_ESCAPE: (name = 'element') =>
-		`Whether or not to close the ${name} when the escape key is pressed.`,
+	ESCAPE_BEHAVIOR: (name = 'element') =>
+		`Defines how the ${name} reacts when the Escape key is pressed. ${createBulletsHTML(
+			ESCAPE_BEHAVIOR_BULLETS(name)
+		)}`,
 	CLOSE_ON_CLICK_OUTSIDE: (name = 'element') =>
 		`Whether or not to close the ${name} when the user clicks outside of it.`,
 	BUILDER: (name: string) => `The builder function used to create the ${name} component.`,
@@ -83,11 +96,11 @@ export const PROPS = {
 		description: DESCRIPTIONS.PORTAL(args.name ?? 'floating element'),
 		default: args.default ?? 'body',
 	}),
-	CLOSE_ON_ESCAPE: (args: PropArgs = {}): Prop => ({
-		name: 'closeOnEscape',
-		type: 'boolean',
-		description: DESCRIPTIONS.CLOSE_ON_ESCAPE(args.name ?? 'floating element'),
-		default: args.default ?? 'true',
+	ESCAPE_BEHAVIOR: (args: PropArgs = {}): Prop => ({
+		name: 'escapeBehavior',
+		type: '"close" | "ignore" | "defer-otherwise-close" | "defer-otherwise-ignore"',
+		description: DESCRIPTIONS.ESCAPE_BEHAVIOR(args.name ?? 'floating element'),
+		default: args.default ?? "'close'",
 	}),
 	CLOSE_ON_OUTSIDE_CLICK: (args: PropArgs = {}): Prop => ({
 		name: 'closeOnOutsideClick',
diff --git a/src/docs/content/builders/dialog.md b/src/docs/content/builders/dialog.md
index a1d5e168..1dde6ba3 100644
--- a/src/docs/content/builders/dialog.md
+++ b/src/docs/content/builders/dialog.md
@@ -99,11 +99,11 @@ const {
 ### Disable Close on Escape
 
 By default, pressing the escape key will close the dialog. You can disable this behavior by setting
-the `closeOnEscape` builder prop to `false`.
+the `escapeBehavior` builder prop to `ignore`.
 
 ```ts {2}
 const dialog = createDialog({
-	closeOnEscape: false
+	escapeBehavior: 'ignore'
 })
 ```
 
diff --git a/src/docs/data/builders/combobox.ts b/src/docs/data/builders/combobox.ts
index 9428f956..7e057994 100644
--- a/src/docs/data/builders/combobox.ts
+++ b/src/docs/data/builders/combobox.ts
@@ -18,7 +18,7 @@ const OPTION_PROPS = [
 	},
 	PROPS.LOOP,
 	PROPS.CLOSE_ON_OUTSIDE_CLICK,
-	PROPS.CLOSE_ON_ESCAPE,
+	PROPS.ESCAPE_BEHAVIOR,
 	PROPS.PREVENT_SCROLL,
 	PROPS.PREVENT_TEXT_SELECTION_OVERFLOW,
 	PROPS.PORTAL,
@@ -183,8 +183,8 @@ const input = elementSchema('input', {
 	events: comboboxEvents['input'],
 });
 
-const item = elementSchema('item', {
-	description: 'The menu item element',
+const option = elementSchema('option', {
+	description: 'The individual combobox item',
 	props: [
 		{
 			name: 'value',
@@ -201,25 +201,25 @@ const item = elementSchema('item', {
 			name: 'disabled',
 			type: 'boolean',
 			default: 'false',
-			description: 'Whether or not the `item` is disabled.',
+			description: 'Whether or not the `option` is disabled.',
 		},
 	],
 	dataAttributes: [
 		{
 			name: 'data-disabled',
-			value: ATTRS.DISABLED('`item`'),
+			value: ATTRS.DISABLED('`option`'),
 		},
 		{
 			name: 'data-selected',
-			value: ATTRS.SELECTED('`item`'),
+			value: ATTRS.SELECTED('`option`'),
 		},
 		{
 			name: 'data-highlighted',
 			value: ATTRS.HIGHLIGHTED(),
 		},
 		{
-			name: 'data-melt-combobox-item',
-			value: ATTRS.MELT('item'),
+			name: 'data-melt-combobox-option',
+			value: ATTRS.MELT('option'),
 		},
 	],
 	events: comboboxEvents['item'],
@@ -317,7 +317,7 @@ const keyboard: KeyboardSchema = [
 	},
 ];
 
-const schemas = [builder, menu, input, item, label, group, groupLabel, arrow, hiddenInput];
+const schemas = [builder, menu, input, option, label, group, groupLabel, arrow, hiddenInput];
 
 const features = [
 	'Full keyboard navigation',
diff --git a/src/docs/data/builders/dialog.ts b/src/docs/data/builders/dialog.ts
index 3c866d90..43a9cb0c 100644
--- a/src/docs/data/builders/dialog.ts
+++ b/src/docs/data/builders/dialog.ts
@@ -16,7 +16,7 @@ const OPTION_PROPS = [
 		description: 'The `role` attribute of the dialog element.',
 	},
 	PROPS.PREVENT_SCROLL,
-	PROPS.CLOSE_ON_ESCAPE,
+	PROPS.ESCAPE_BEHAVIOR,
 	PROPS.CLOSE_ON_OUTSIDE_CLICK,
 	PROPS.PORTAL,
 	PROPS.FORCE_VISIBLE,
diff --git a/src/docs/data/builders/link-preview.ts b/src/docs/data/builders/link-preview.ts
index 3139d838..a2023b1d 100644
--- a/src/docs/data/builders/link-preview.ts
+++ b/src/docs/data/builders/link-preview.ts
@@ -24,7 +24,7 @@ const OPTION_PROPS = [
 	PROPS.POSITIONING({ default: "placement: 'bottom'" }),
 	PROPS.ARROW_SIZE,
 	PROPS.CLOSE_ON_OUTSIDE_CLICK,
-	PROPS.CLOSE_ON_ESCAPE,
+	PROPS.ESCAPE_BEHAVIOR,
 	PROPS.PREVENT_TEXT_SELECTION_OVERFLOW,
 	PROPS.FORCE_VISIBLE,
 	PROPS.PORTAL,
diff --git a/src/docs/data/builders/menu.ts b/src/docs/data/builders/menu.ts
index fe029402..2abc9dd5 100644
--- a/src/docs/data/builders/menu.ts
+++ b/src/docs/data/builders/menu.ts
@@ -17,7 +17,7 @@ export const menuBuilderProps = [
 	PROPS.ARROW_SIZE,
 	PROPS.DIR,
 	PROPS.PREVENT_SCROLL,
-	PROPS.CLOSE_ON_ESCAPE,
+	PROPS.ESCAPE_BEHAVIOR,
 	PROPS.PREVENT_TEXT_SELECTION_OVERFLOW,
 	PROPS.PORTAL,
 	PROPS.CLOSE_ON_OUTSIDE_CLICK,
@@ -39,7 +39,7 @@ export const menuBuilderOptions = [
 	PROPS.ARROW_SIZE,
 	PROPS.DIR,
 	PROPS.PREVENT_SCROLL,
-	PROPS.CLOSE_ON_ESCAPE,
+	PROPS.ESCAPE_BEHAVIOR,
 	PROPS.PREVENT_TEXT_SELECTION_OVERFLOW,
 	PROPS.PORTAL,
 	PROPS.CLOSE_ON_OUTSIDE_CLICK,
diff --git a/src/docs/data/builders/menubar.ts b/src/docs/data/builders/menubar.ts
index 32b2f87a..bbc0b5d0 100644
--- a/src/docs/data/builders/menubar.ts
+++ b/src/docs/data/builders/menubar.ts
@@ -7,7 +7,7 @@ import { builder as dropdownBuilder } from './dropdown-menu.js';
 import type { BuilderData } from './index.js';
 import { getMenuSchemas, getMenuTriggerDataAttrs } from './menu.js';
 
-const OPTION_PROPS = [PROPS.CLOSE_ON_ESCAPE, PROPS.LOOP, PROPS.PREVENT_SCROLL];
+const OPTION_PROPS = [PROPS.ESCAPE_BEHAVIOR, PROPS.LOOP, PROPS.PREVENT_SCROLL];
 const BUILDER_NAME = 'menubar';
 
 const builder = builderSchema(BUILDER_NAME, {
diff --git a/src/docs/data/builders/popover.ts b/src/docs/data/builders/popover.ts
index 35154fa7..fec468e7 100644
--- a/src/docs/data/builders/popover.ts
+++ b/src/docs/data/builders/popover.ts
@@ -22,7 +22,7 @@ const OPTION_PROPS = [
 		description: 'Whether to disable the focus trap.',
 	},
 	PROPS.ARROW_SIZE,
-	PROPS.CLOSE_ON_ESCAPE,
+	PROPS.ESCAPE_BEHAVIOR,
 	PROPS.CLOSE_ON_OUTSIDE_CLICK,
 	PROPS.PREVENT_SCROLL,
 	PROPS.PREVENT_TEXT_SELECTION_OVERFLOW,
diff --git a/src/docs/data/builders/select.ts b/src/docs/data/builders/select.ts
index 4fd1afc7..cdbc08c6 100644
--- a/src/docs/data/builders/select.ts
+++ b/src/docs/data/builders/select.ts
@@ -19,7 +19,7 @@ const OPTION_PROPS = [
 	PROPS.ARROW_SIZE,
 	PROPS.PREVENT_SCROLL,
 	PROPS.LOOP,
-	PROPS.CLOSE_ON_ESCAPE,
+	PROPS.ESCAPE_BEHAVIOR,
 	PROPS.CLOSE_ON_OUTSIDE_CLICK,
 	PROPS.PREVENT_TEXT_SELECTION_OVERFLOW,
 	PROPS.PORTAL,
diff --git a/src/docs/data/builders/tooltip.ts b/src/docs/data/builders/tooltip.ts
index 05243a89..63508b47 100644
--- a/src/docs/data/builders/tooltip.ts
+++ b/src/docs/data/builders/tooltip.ts
@@ -16,7 +16,7 @@ import type { BuilderData } from './index.js';
 const OPTION_PROPS = [
 	PROPS.POSITIONING({ default: "position: 'top'" }),
 	PROPS.ARROW_SIZE,
-	PROPS.CLOSE_ON_ESCAPE,
+	PROPS.ESCAPE_BEHAVIOR,
 	PROPS.FORCE_VISIBLE,
 	PROPS.PORTAL,
 	{
diff --git a/src/lib/builders/combobox/create.ts b/src/lib/builders/combobox/create.ts
index 115e65e2..f86ed69c 100644
--- a/src/lib/builders/combobox/create.ts
+++ b/src/lib/builders/combobox/create.ts
@@ -1,4 +1,3 @@
-import { useEscapeKeydown } from '$lib/internal/actions/index.js';
 import {
 	addEventListener,
 	addMeltEventListener,
@@ -9,7 +8,6 @@ import {
 	isContentEditable,
 	isHTMLInputElement,
 	kbd,
-	noop,
 	omit,
 } from '$lib/internal/helpers/index.js';
 import type { MeltActionReturn } from '$lib/internal/types.js';
@@ -71,24 +69,12 @@ export function createCombobox<
 				})
 			);
 
-			let unsubEscapeKeydown = noop;
-
-			const escape = useEscapeKeydown(node, {
-				handler: () => {
-					listbox.helpers.closeMenu();
-				},
-			});
-			if (escape && escape.destroy) {
-				unsubEscapeKeydown = escape.destroy;
-			}
-
 			const { destroy } = listbox.elements.trigger(node);
 
 			return {
 				destroy() {
 					destroy?.();
 					unsubscribe();
-					unsubEscapeKeydown();
 				},
 			};
 		},
diff --git a/src/lib/builders/context-menu/create.ts b/src/lib/builders/context-menu/create.ts
index 56d68738..3cd5ac40 100644
--- a/src/lib/builders/context-menu/create.ts
+++ b/src/lib/builders/context-menu/create.ts
@@ -46,7 +46,7 @@ const defaults = {
 		placement: 'bottom-start',
 	},
 	preventScroll: true,
-	closeOnEscape: true,
+	escapeBehavior: 'close',
 	closeOnOutsideClick: true,
 	portal: undefined,
 	loop: false,
@@ -72,7 +72,7 @@ export function createContextMenu(props?: CreateContextMenuProps) {
 		closeOnOutsideClick,
 		portal,
 		forceVisible,
-		closeOnEscape,
+		escapeBehavior,
 		loop,
 		preventTextSelectionOverflow,
 	} = rootOptions;
@@ -149,15 +149,8 @@ export function createContextMenu(props?: CreateContextMenuProps) {
 			let unsubPopper = noop;
 
 			const unsubDerived = effect(
-				[isVisible, rootActiveTrigger, positioning, closeOnOutsideClick, portal, closeOnEscape],
-				([
-					$isVisible,
-					$rootActiveTrigger,
-					$positioning,
-					$closeOnOutsideClick,
-					$portal,
-					$closeOnEscape,
-				]) => {
+				[isVisible, rootActiveTrigger, positioning, closeOnOutsideClick, portal],
+				([$isVisible, $rootActiveTrigger, $positioning, $closeOnOutsideClick, $portal]) => {
 					unsubPopper();
 					if (!$isVisible || !$rootActiveTrigger) return;
 					tick().then(() => {
@@ -177,7 +170,7 @@ export function createContextMenu(props?: CreateContextMenuProps) {
 									shouldCloseOnInteractOutside: handleClickOutside,
 								},
 								portal: getPortalDestination(node, $portal),
-								escapeKeydown: $closeOnEscape ? undefined : null,
+								escapeKeydown: { behaviorType: escapeBehavior },
 								preventTextSelectionOverflow: { enabled: preventTextSelectionOverflow },
 							},
 						}).destroy;
diff --git a/src/lib/builders/date-picker/create.ts b/src/lib/builders/date-picker/create.ts
index 2bb4e2aa..15facacb 100644
--- a/src/lib/builders/date-picker/create.ts
+++ b/src/lib/builders/date-picker/create.ts
@@ -25,7 +25,7 @@ const defaults = {
 	positioning: {
 		placement: 'bottom',
 	},
-	closeOnEscape: true,
+	escapeBehavior: 'close',
 	closeOnOutsideClick: true,
 	onOutsideClick: undefined,
 	preventScroll: false,
@@ -77,7 +77,7 @@ export function createDatePicker(props?: CreateDatePickerProps) {
 		defaultOpen: withDefaults.defaultOpen,
 		open: withDefaults.open,
 		disableFocusTrap: withDefaults.disableFocusTrap,
-		closeOnEscape: withDefaults.closeOnEscape,
+		escapeBehavior: withDefaults.escapeBehavior,
 		preventScroll: withDefaults.preventScroll,
 		onOpenChange: withDefaults.onOpenChange,
 		closeOnOutsideClick: withDefaults.closeOnOutsideClick,
diff --git a/src/lib/builders/date-range-picker/create.ts b/src/lib/builders/date-range-picker/create.ts
index 66664ca4..71dedc4c 100644
--- a/src/lib/builders/date-range-picker/create.ts
+++ b/src/lib/builders/date-range-picker/create.ts
@@ -25,7 +25,7 @@ const defaults = {
 	positioning: {
 		placement: 'bottom',
 	},
-	closeOnEscape: true,
+	escapeBehavior: 'close',
 	closeOnOutsideClick: true,
 	preventScroll: false,
 	forceVisible: false,
@@ -61,7 +61,7 @@ export function createDateRangePicker(props?: CreateDateRangePickerProps) {
 		defaultOpen: withDefaults.defaultOpen,
 		open: withDefaults.open,
 		disableFocusTrap: withDefaults.disableFocusTrap,
-		closeOnEscape: withDefaults.closeOnEscape,
+		escapeBehavior: withDefaults.escapeBehavior,
 		preventScroll: withDefaults.preventScroll,
 		onOpenChange: withDefaults.onOpenChange,
 		closeOnOutsideClick: withDefaults.closeOnOutsideClick,
diff --git a/src/lib/builders/dialog/create.ts b/src/lib/builders/dialog/create.ts
index 8fe4d9ea..1dbeaea7 100644
--- a/src/lib/builders/dialog/create.ts
+++ b/src/lib/builders/dialog/create.ts
@@ -39,7 +39,7 @@ const { name } = createElHelpers<DialogParts>('dialog');
 
 const defaults = {
 	preventScroll: true,
-	closeOnEscape: true,
+	escapeBehavior: 'close',
 	closeOnOutsideClick: true,
 	role: 'dialog',
 	defaultOpen: false,
@@ -60,7 +60,7 @@ export function createDialog(props?: CreateDialogProps) {
 
 	const {
 		preventScroll,
-		closeOnEscape,
+		escapeBehavior,
 		closeOnOutsideClick,
 		role,
 		portal,
@@ -135,26 +135,6 @@ export function createDialog(props?: CreateDialogProps) {
 				'data-state': $open ? 'open' : 'closed',
 			} as const;
 		},
-		action: (node: HTMLElement) => {
-			let unsubEscapeKeydown = noop;
-
-			if (closeOnEscape.get()) {
-				const escapeKeydown = useEscapeKeydown(node, {
-					handler: () => {
-						handleClose();
-					},
-				});
-				if (escapeKeydown && escapeKeydown.destroy) {
-					unsubEscapeKeydown = escapeKeydown.destroy;
-				}
-			}
-
-			return {
-				destroy() {
-					unsubEscapeKeydown();
-				},
-			};
-		},
 	});
 
 	const content = makeElement(name('content'), {
@@ -174,13 +154,13 @@ export function createDialog(props?: CreateDialogProps) {
 		},
 
 		action: (node: HTMLElement) => {
-			let unsubModal = noop;
 			let unsubEscape = noop;
+			let unsubModal = noop;
 			let unsubFocusTrap = noop;
 
 			const unsubDerived = effect(
-				[isVisible, closeOnOutsideClick, closeOnEscape],
-				([$isVisible, $closeOnOutsideClick, $closeOnEscape]) => {
+				[isVisible, closeOnOutsideClick],
+				([$isVisible, $closeOnOutsideClick]) => {
 					unsubModal();
 					unsubEscape();
 					unsubFocusTrap();
@@ -198,7 +178,7 @@ export function createDialog(props?: CreateDialogProps) {
 
 					unsubEscape = useEscapeKeydown(node, {
 						handler: handleClose,
-						enabled: $closeOnEscape,
+						behaviorType: escapeBehavior,
 					}).destroy;
 
 					unsubFocusTrap = useFocusTrap(node, { fallbackFocus: node }).destroy;
diff --git a/src/lib/builders/dialog/types.ts b/src/lib/builders/dialog/types.ts
index 734e7263..39cc9562 100644
--- a/src/lib/builders/dialog/types.ts
+++ b/src/lib/builders/dialog/types.ts
@@ -2,6 +2,7 @@ import type { ChangeFn, FocusProp, IdObj } from '$lib/internal/helpers/index.js'
 import type { BuilderReturn } from '$lib/internal/types.js';
 import type { Writable } from 'svelte/store';
 import type { DialogIdParts, createDialog } from './create.js';
+import type { EscapeBehaviorType } from '$lib/internal/actions/index.js';
 export type { DialogComponentEvents } from './events.js';
 export type CreateDialogProps = {
 	/**
@@ -13,11 +14,15 @@ export type CreateDialogProps = {
 	preventScroll?: boolean;
 
 	/**
-	 * If true, the dialog will close when the user presses the escape key.
+	 * Escape behavior type.
+	 * `close`: Closes the element immediately.
+	 * `defer-otherwise-close`: Delegates the action to the parent element. If no parent is found, it closes the element.
+	 * `defer-otherwise-ignore`: Delegates the action to the parent element. If no parent is found, nothing is done.
+	 * `ignore`: Prevents the element from closing and also blocks the parent element from closing in response to the Escape key.
 	 *
-	 * @default true
+	 * @defaultValue `close`
 	 */
-	closeOnEscape?: boolean;
+	escapeBehavior?: EscapeBehaviorType;
 
 	/**
 	 * If true, the dialog will close when the user clicks outside of it.
diff --git a/src/lib/builders/dropdown-menu/create.ts b/src/lib/builders/dropdown-menu/create.ts
index 536bab35..d6f41228 100644
--- a/src/lib/builders/dropdown-menu/create.ts
+++ b/src/lib/builders/dropdown-menu/create.ts
@@ -11,7 +11,7 @@ const defaults = {
 		placement: 'bottom',
 	},
 	preventScroll: true,
-	closeOnEscape: true,
+	escapeBehavior: 'close',
 	closeOnOutsideClick: true,
 	portal: undefined,
 	loop: false,
diff --git a/src/lib/builders/link-preview/create.ts b/src/lib/builders/link-preview/create.ts
index a7756c34..6739cc9b 100644
--- a/src/lib/builders/link-preview/create.ts
+++ b/src/lib/builders/link-preview/create.ts
@@ -43,7 +43,7 @@ const defaults = {
 	closeOnOutsideClick: true,
 	forceVisible: false,
 	portal: undefined,
-	closeOnEscape: true,
+	escapeBehavior: 'close',
 	onOutsideClick: undefined,
 	preventTextSelectionOverflow: true,
 } satisfies CreateLinkPreviewProps;
@@ -74,7 +74,7 @@ export function createLinkPreview(props: CreateLinkPreviewProps = {}) {
 		closeOnOutsideClick,
 		forceVisible,
 		portal,
-		closeOnEscape,
+		escapeBehavior,
 		onOutsideClick,
 		preventTextSelectionOverflow,
 	} = options;
@@ -177,15 +177,8 @@ export function createLinkPreview(props: CreateLinkPreviewProps = {}) {
 			let unsubPopper = noop;
 
 			const unsubDerived = effect(
-				[isVisible, activeTrigger, positioning, closeOnOutsideClick, portal, closeOnEscape],
-				([
-					$isVisible,
-					$activeTrigger,
-					$positioning,
-					$closeOnOutsideClick,
-					$portal,
-					$closeOnEscape,
-				]) => {
+				[isVisible, activeTrigger, positioning, closeOnOutsideClick, portal],
+				([$isVisible, $activeTrigger, $positioning, $closeOnOutsideClick, $portal]) => {
 					unsubPopper();
 					if (!$isVisible || !$activeTrigger) return;
 
@@ -215,7 +208,7 @@ export function createLinkPreview(props: CreateLinkPreviewProps = {}) {
 								},
 								portal: getPortalDestination(node, $portal),
 								focusTrap: null,
-								escapeKeydown: $closeOnEscape ? undefined : null,
+								escapeKeydown: { behaviorType: escapeBehavior },
 								preventTextSelectionOverflow: { enabled: preventTextSelectionOverflow },
 							},
 						}).destroy;
diff --git a/src/lib/builders/link-preview/types.ts b/src/lib/builders/link-preview/types.ts
index 323879a7..ef6cae54 100644
--- a/src/lib/builders/link-preview/types.ts
+++ b/src/lib/builders/link-preview/types.ts
@@ -1,4 +1,8 @@
-import type { FloatingConfig, InteractOutsideEvent } from '$lib/internal/actions/index.js';
+import type {
+	EscapeBehaviorType,
+	FloatingConfig,
+	InteractOutsideEvent,
+} from '$lib/internal/actions/index.js';
 import type { ChangeFn, IdObj } from '$lib/internal/helpers/index.js';
 import type { BuilderReturn } from '$lib/internal/types.js';
 import type { Writable } from 'svelte/store';
@@ -66,12 +70,15 @@ export type CreateLinkPreviewProps = {
 	onOutsideClick?: (event: InteractOutsideEvent) => void;
 
 	/**
-	 * Whether or not to close the linkpreview when the escape key is pressed
-	 * while it is open.
+	 * Escape behavior type.
+	 * `close`: Closes the element immediately.
+	 * `defer-otherwise-close`: Delegates the action to the parent element. If no parent is found, it closes the element.
+	 * `defer-otherwise-ignore`: Delegates the action to the parent element. If no parent is found, nothing is done.
+	 * `ignore`: Prevents the element from closing and also blocks the parent element from closing in response to the Escape key.
 	 *
-	 * @default true
+	 * @defaultValue `close`
 	 */
-	closeOnEscape?: boolean;
+	escapeBehavior?: EscapeBehaviorType;
 
 	/**
 	 * Whether should prevent text selection overflowing the element when the element is the top layer.
diff --git a/src/lib/builders/listbox/create.ts b/src/lib/builders/listbox/create.ts
index c56f532d..3a2dd285 100644
--- a/src/lib/builders/listbox/create.ts
+++ b/src/lib/builders/listbox/create.ts
@@ -1,4 +1,4 @@
-import { useEscapeKeydown, usePopper } from '$lib/internal/actions/index.js';
+import { usePopper } from '$lib/internal/actions/index.js';
 import {
 	FIRST_LAST_KEYS,
 	addHighlight,
@@ -66,7 +66,7 @@ const defaults = {
 	defaultOpen: false,
 	closeOnOutsideClick: true,
 	preventScroll: true,
-	closeOnEscape: true,
+	escapeBehavior: 'close',
 	forceVisible: false,
 	portal: undefined,
 	builder: 'listbox',
@@ -134,7 +134,7 @@ export function createListbox<
 		scrollAlignment,
 		loop,
 		closeOnOutsideClick,
-		closeOnEscape,
+		escapeBehavior,
 		preventScroll,
 		portal,
 		forceVisible,
@@ -433,23 +433,10 @@ export function createListbox<
 				})
 			);
 
-			let unsubEscapeKeydown = noop;
-
-			const escape = useEscapeKeydown(node, {
-				handler: closeMenu,
-				enabled: derived([open, closeOnEscape], ([$open, $closeOnEscape]) => {
-					return $open && $closeOnEscape;
-				}),
-			});
-			if (escape && escape.destroy) {
-				unsubEscapeKeydown = escape.destroy;
-			}
-
 			return {
 				destroy() {
 					activeTrigger.set(null);
 					unsubscribe();
-					unsubEscapeKeydown();
 				},
 			};
 		},
@@ -506,7 +493,7 @@ export function createListbox<
 										},
 									},
 
-									escapeKeydown: null,
+									escapeKeydown: { handler: closeMenu, behaviorType: escapeBehavior },
 									portal: getPortalDestination(node, $portal),
 									preventTextSelectionOverflow: { enabled: preventTextSelectionOverflow },
 								},
diff --git a/src/lib/builders/listbox/types.ts b/src/lib/builders/listbox/types.ts
index 274a14b9..62f14a9a 100644
--- a/src/lib/builders/listbox/types.ts
+++ b/src/lib/builders/listbox/types.ts
@@ -1,4 +1,8 @@
-import type { FloatingConfig, InteractOutsideEvent } from '$lib/internal/actions/index.js';
+import type {
+	EscapeBehaviorType,
+	FloatingConfig,
+	InteractOutsideEvent,
+} from '$lib/internal/actions/index.js';
 import type { ChangeFn, IdObj } from '$lib/internal/helpers/index.js';
 import type { BuilderReturn, WhenTrue } from '$lib/internal/types.js';
 import type { Writable } from 'svelte/store';
@@ -104,12 +108,15 @@ export type CreateListboxProps<
 	closeOnOutsideClick?: boolean;
 
 	/**
-	 * Whether or not to close the listbox menu when the user presses
-	 * the escape key.
+	 * Escape behavior type.
+	 * `close`: Closes the element immediately.
+	 * `defer-otherwise-close`: Delegates the action to the parent element. If no parent is found, it closes the element.
+	 * `defer-otherwise-ignore`: Delegates the action to the parent element. If no parent is found, nothing is done.
+	 * `ignore`: Prevents the element from closing and also blocks the parent element from closing in response to the Escape key.
 	 *
-	 * @default true
+	 * @defaultValue `close`
 	 */
-	closeOnEscape?: boolean;
+	escapeBehavior?: EscapeBehaviorType;
 
 	/**
 	 * A custom event handler for the "outside click" event, which
diff --git a/src/lib/builders/menu/create.ts b/src/lib/builders/menu/create.ts
index 2557537a..f34a9e38 100644
--- a/src/lib/builders/menu/create.ts
+++ b/src/lib/builders/menu/create.ts
@@ -1,5 +1,5 @@
 import { createSeparator } from '$lib/builders/index.js';
-import { useEscapeKeydown, usePopper, usePortal } from '$lib/internal/actions/index.js';
+import { usePopper, usePortal } from '$lib/internal/actions/index.js';
 import {
 	FIRST_LAST_KEYS,
 	SELECTION_KEYS,
@@ -71,7 +71,7 @@ const defaults = {
 		placement: 'bottom',
 	},
 	preventScroll: true,
-	closeOnEscape: true,
+	escapeBehavior: 'close',
 	closeOnOutsideClick: true,
 	portal: undefined,
 	loop: false,
@@ -90,7 +90,7 @@ export function createMenuBuilder(opts: _MenuBuilderOptions) {
 		preventScroll,
 		arrowSize,
 		positioning,
-		closeOnEscape,
+		escapeBehavior,
 		closeOnOutsideClick,
 		portal,
 		forceVisible,
@@ -182,15 +182,8 @@ export function createMenuBuilder(opts: _MenuBuilderOptions) {
 			let unsubPopper = noop;
 
 			const unsubDerived = effect(
-				[isVisible, rootActiveTrigger, positioning, closeOnOutsideClick, portal, closeOnEscape],
-				([
-					$isVisible,
-					$rootActiveTrigger,
-					$positioning,
-					$closeOnOutsideClick,
-					$portal,
-					$closeOnEscape,
-				]) => {
+				[isVisible, rootActiveTrigger, positioning, closeOnOutsideClick, portal],
+				([$isVisible, $rootActiveTrigger, $positioning, $closeOnOutsideClick, $portal]) => {
 					unsubPopper();
 					if (!$isVisible || !$rootActiveTrigger) return;
 					tick().then(() => {
@@ -218,7 +211,7 @@ export function createMenuBuilder(opts: _MenuBuilderOptions) {
 									onClose: () => rootOpen.set(false),
 								},
 								portal: getPortalDestination(node, $portal),
-								escapeKeydown: $closeOnEscape ? undefined : null,
+								escapeKeydown: { behaviorType: escapeBehavior },
 								preventTextSelectionOverflow: { enabled: preventTextSelectionOverflow },
 							},
 						}).destroy;
@@ -354,17 +347,6 @@ export function createMenuBuilder(opts: _MenuBuilderOptions) {
 			} as const;
 		},
 		action: (node: HTMLElement) => {
-			let unsubEscapeKeydown = noop;
-
-			if (closeOnEscape.get()) {
-				const escapeKeydown = useEscapeKeydown(node, {
-					handler: () => rootOpen.set(false),
-				});
-				if (escapeKeydown && escapeKeydown.destroy) {
-					unsubEscapeKeydown = escapeKeydown.destroy;
-				}
-			}
-
 			const unsubPortal = effect([portal], ([$portal]) => {
 				if ($portal === null) return noop;
 				const portalDestination = getPortalDestination(node, $portal);
@@ -374,7 +356,6 @@ export function createMenuBuilder(opts: _MenuBuilderOptions) {
 
 			return {
 				destroy() {
-					unsubEscapeKeydown();
 					unsubPortal();
 				},
 			};
@@ -1126,14 +1107,7 @@ export function createMenuBuilder(opts: _MenuBuilderOptions) {
 			);
 		};
 
-		const keydownListener = (e: KeyboardEvent) => {
-			if (e.key === kbd.ESCAPE && closeOnEscape.get()) {
-				rootOpen.set(false);
-				return;
-			}
-		};
 		unsubs.push(addEventListener(document, 'keydown', handleKeyDown, { capture: true }));
-		unsubs.push(addEventListener(document, 'keydown', keydownListener));
 
 		return () => {
 			unsubs.forEach((unsub) => unsub());
@@ -1191,25 +1165,6 @@ export function createMenuBuilder(opts: _MenuBuilderOptions) {
 		};
 	});
 
-	effect(rootOpen, ($rootOpen) => {
-		if (!isBrowser) return;
-
-		const handlePointer = () => isUsingKeyboard.set(false);
-		const handleKeyDown = (e: KeyboardEvent) => {
-			isUsingKeyboard.set(true);
-			if (e.key === kbd.ESCAPE && $rootOpen && closeOnEscape.get()) {
-				rootOpen.set(false);
-				return;
-			}
-		};
-
-		return executeCallbacks(
-			addEventListener(document, 'pointerdown', handlePointer, { capture: true, once: true }),
-			addEventListener(document, 'pointermove', handlePointer, { capture: true, once: true }),
-			addEventListener(document, 'keydown', handleKeyDown, { capture: true })
-		);
-	});
-
 	function handleOpen(triggerEl: HTMLElement) {
 		rootOpen.update((prev) => {
 			const isOpen = !prev;
diff --git a/src/lib/builders/menu/types.ts b/src/lib/builders/menu/types.ts
index 4dfdb5fc..1c7682ce 100644
--- a/src/lib/builders/menu/types.ts
+++ b/src/lib/builders/menu/types.ts
@@ -1,4 +1,8 @@
-import type { FloatingConfig, InteractOutsideEvent } from '$lib/internal/actions/index.js';
+import type {
+	EscapeBehaviorType,
+	FloatingConfig,
+	InteractOutsideEvent,
+} from '$lib/internal/actions/index.js';
 import type { TextDirection } from '$lib/internal/types.js';
 import type { ChangeFn, FocusProp, IdObj } from '$lib/internal/helpers/index.js';
 import type { Writable } from 'svelte/store';
@@ -34,11 +38,15 @@ export type _CreateMenuProps = {
 	preventScroll?: boolean;
 
 	/**
-	 * Whether or not to close the menu when the escape key is pressed.
+	 * Escape behavior type.
+	 * `close`: Closes the element immediately.
+	 * `defer-otherwise-close`: Delegates the action to the parent element. If no parent is found, it closes the element.
+	 * `defer-otherwise-ignore`: Delegates the action to the parent element. If no parent is found, nothing is done.
+	 * `ignore`: Prevents the element from closing and also blocks the parent element from closing in response to the Escape key.
 	 *
-	 * @default true
+	 * @defaultValue `close`
 	 */
-	closeOnEscape?: boolean;
+	escapeBehavior?: EscapeBehaviorType;
 
 	/**
 	 * Whether or not to close the menu when an internal item is clicked.
@@ -189,7 +197,7 @@ export type _MenuBuilderOptions = {
 		preventScroll: WithGet<Writable<boolean | undefined>>;
 		loop: WithGet<Writable<boolean | undefined>>;
 		dir: WithGet<Writable<TextDirection>>;
-		closeOnEscape: WithGet<Writable<boolean>>;
+		escapeBehavior: WithGet<Writable<EscapeBehaviorType>>;
 		closeOnOutsideClick: WithGet<Writable<boolean>>;
 		preventTextSelectionOverflow: WithGet<Writable<boolean>>;
 		portal: WithGet<Writable<string | HTMLElement | undefined | null>>;
diff --git a/src/lib/builders/menubar/create.ts b/src/lib/builders/menubar/create.ts
index 7aa0e332..6873819c 100644
--- a/src/lib/builders/menubar/create.ts
+++ b/src/lib/builders/menubar/create.ts
@@ -2,7 +2,6 @@ import { usePopper } from '$lib/internal/actions/index.js';
 import {
 	FIRST_LAST_KEYS,
 	SELECTION_KEYS,
-	addEventListener,
 	addHighlight,
 	addMeltEventListener,
 	makeElement,
@@ -49,7 +48,7 @@ const { name } = createElHelpers<_MenuParts | 'menu'>('menubar');
 
 const defaults = {
 	loop: true,
-	closeOnEscape: true,
+	escapeBehavior: 'close',
 	preventScroll: false,
 } satisfies CreateMenubarProps;
 
@@ -60,7 +59,7 @@ export function createMenubar(props?: CreateMenubarProps) {
 	const withDefaults = { ...defaults, ...props } satisfies CreateMenubarProps;
 
 	const options = toWritableStores(omit(withDefaults, 'ids'));
-	const { loop, closeOnEscape, preventScroll } = options;
+	const { loop, escapeBehavior, preventScroll } = options;
 	const activeMenu = withGet(writable<string>(''));
 
 	const nextFocusable = withGet(writable<HTMLElement | null>(null));
@@ -99,7 +98,7 @@ export function createMenubar(props?: CreateMenubarProps) {
 		arrowSize: 8,
 		dir: 'ltr',
 		loop: false,
-		closeOnEscape: true,
+		escapeBehavior: 'close',
 		closeOnOutsideClick: true,
 		portal: undefined,
 		forceVisible: false,
@@ -207,6 +206,10 @@ export function createMenubar(props?: CreateMenubarProps) {
 											activeMenu.set('');
 										},
 									},
+									escapeKeydown: {
+										behaviorType: escapeBehavior,
+										handler: () => activeMenu.set(''),
+									},
 									preventTextSelectionOverflow: { enabled: preventTextSelectionOverflow },
 								},
 							}).destroy;
@@ -585,12 +588,6 @@ export function createMenubar(props?: CreateMenubarProps) {
 				if (MENUBAR_NAV_KEYS.includes(e.key)) {
 					handleMenubarNavigation(e);
 				}
-			}),
-			addEventListener(document, 'keydown', (e) => {
-				if (closeOnEscape.get() && e.key === kbd.ESCAPE) {
-					window.clearTimeout(closeTimer.get());
-					activeMenu.set('');
-				}
 			})
 		);
 
diff --git a/src/lib/builders/menubar/types.ts b/src/lib/builders/menubar/types.ts
index 4e12e494..a579e87b 100644
--- a/src/lib/builders/menubar/types.ts
+++ b/src/lib/builders/menubar/types.ts
@@ -1,3 +1,4 @@
+import type { EscapeBehaviorType } from '$lib/internal/actions/index.js';
 import type { IdObj } from '$lib/internal/helpers/id.js';
 import type { BuilderReturn } from '$lib/internal/types.js';
 import type { _Menu } from '../menu/index.js';
@@ -15,11 +16,15 @@ export type CreateMenubarProps = {
 	loop?: boolean;
 
 	/**
-	 * Whether to close the active menu when the escape key is pressed.
+	 * Escape behavior type.
+	 * `close`: Closes the element immediately.
+	 * `defer-otherwise-close`: Delegates the action to the parent element. If no parent is found, it closes the element.
+	 * `defer-otherwise-ignore`: Delegates the action to the parent element. If no parent is found, nothing is done.
+	 * `ignore`: Prevents the element from closing and also blocks the parent element from closing in response to the Escape key.
 	 *
-	 * @default true
+	 * @defaultValue `close`
 	 */
-	closeOnEscape?: boolean;
+	escapeBehavior?: EscapeBehaviorType;
 
 	/**
 	 * Whether to prevent scrolling the body when any menu within
diff --git a/src/lib/builders/popover/create.ts b/src/lib/builders/popover/create.ts
index 64ba76f1..a24cf17e 100644
--- a/src/lib/builders/popover/create.ts
+++ b/src/lib/builders/popover/create.ts
@@ -21,12 +21,7 @@ import {
 	withGet,
 } from '$lib/internal/helpers/index.js';
 
-import {
-	useEscapeKeydown,
-	usePopper,
-	usePortal,
-	type InteractOutsideEvent,
-} from '$lib/internal/actions/index.js';
+import { usePopper, usePortal, type InteractOutsideEvent } from '$lib/internal/actions/index.js';
 import type { Defaults, MeltActionReturn } from '$lib/internal/types.js';
 import { writable } from 'svelte/store';
 import type { PopoverEvents } from './events.js';
@@ -40,7 +35,7 @@ const defaults = {
 	arrowSize: 8,
 	defaultOpen: false,
 	disableFocusTrap: false,
-	closeOnEscape: true,
+	escapeBehavior: 'close',
 	preventScroll: false,
 	onOpenChange: undefined,
 	closeOnOutsideClick: true,
@@ -67,7 +62,7 @@ export function createPopover(args?: CreatePopoverProps) {
 		arrowSize,
 		disableFocusTrap,
 		preventScroll,
-		closeOnEscape,
+		escapeBehavior,
 		closeOnOutsideClick,
 		portal,
 		forceVisible,
@@ -106,21 +101,12 @@ export function createPopover(args?: CreatePopoverProps) {
 			let unsubPopper = noop;
 
 			const unsubDerived = effect(
-				[
-					isVisible,
-					activeTrigger,
-					positioning,
-					disableFocusTrap,
-					closeOnEscape,
-					closeOnOutsideClick,
-					portal,
-				],
+				[isVisible, activeTrigger, positioning, disableFocusTrap, closeOnOutsideClick, portal],
 				([
 					$isVisible,
 					$activeTrigger,
 					$positioning,
 					$disableFocusTrap,
-					$closeOnEscape,
 					$closeOnOutsideClick,
 					$portal,
 				]) => {
@@ -140,7 +126,7 @@ export function createPopover(args?: CreatePopoverProps) {
 									onClose: handleClose,
 									closeOnInteractOutside: $closeOnOutsideClick,
 								},
-								escapeKeydown: $closeOnEscape ? { handler: handleClose } : null,
+								escapeKeydown: { behaviorType: escapeBehavior },
 								portal: getPortalDestination(node, $portal),
 								preventTextSelectionOverflow: { enabled: preventTextSelectionOverflow },
 							},
@@ -219,21 +205,9 @@ export function createPopover(args?: CreatePopoverProps) {
 			} as const;
 		},
 		action: (node: HTMLElement) => {
-			let unsubEscapeKeydown = noop;
 			let unsubDerived = noop;
 			let unsubPortal = noop;
 
-			if (closeOnEscape.get()) {
-				const escapeKeydown = useEscapeKeydown(node, {
-					handler: () => {
-						handleClose();
-					},
-				});
-				if (escapeKeydown && escapeKeydown.destroy) {
-					unsubEscapeKeydown = escapeKeydown.destroy;
-				}
-			}
-
 			unsubDerived = effect([portal], ([$portal]) => {
 				unsubPortal();
 				if ($portal === null) return;
@@ -244,7 +218,6 @@ export function createPopover(args?: CreatePopoverProps) {
 
 			return {
 				destroy() {
-					unsubEscapeKeydown();
 					unsubDerived();
 					unsubPortal();
 				},
diff --git a/src/lib/builders/popover/types.ts b/src/lib/builders/popover/types.ts
index 5e6427ac..7b528197 100644
--- a/src/lib/builders/popover/types.ts
+++ b/src/lib/builders/popover/types.ts
@@ -1,4 +1,8 @@
-import type { FloatingConfig, InteractOutsideEvent } from '$lib/internal/actions/index.js';
+import type {
+	EscapeBehaviorType,
+	FloatingConfig,
+	InteractOutsideEvent,
+} from '$lib/internal/actions/index.js';
 import type { ChangeFn, FocusProp, IdObj } from '$lib/internal/helpers/index.js';
 import type { BuilderReturn } from '$lib/internal/types.js';
 import type { Writable } from 'svelte/store';
@@ -47,11 +51,15 @@ export type CreatePopoverProps = {
 	disableFocusTrap?: boolean;
 
 	/**
-	 * Whether or not to close the popover when the escape key is pressed.
+	 * Escape behavior type.
+	 * `close`: Closes the element immediately.
+	 * `defer-otherwise-close`: Delegates the action to the parent element. If no parent is found, it closes the element.
+	 * `defer-otherwise-ignore`: Delegates the action to the parent element. If no parent is found, nothing is done.
+	 * `ignore`: Prevents the element from closing and also blocks the parent element from closing in response to the Escape key.
 	 *
-	 * @default true
+	 * @defaultValue `close`
 	 */
-	closeOnEscape?: boolean;
+	escapeBehavior?: EscapeBehaviorType;
 
 	/**
 	 * Whether or not to close the popover when the escape key is pressed.
diff --git a/src/lib/builders/tooltip/create.ts b/src/lib/builders/tooltip/create.ts
index 48c8f016..d78b620a 100644
--- a/src/lib/builders/tooltip/create.ts
+++ b/src/lib/builders/tooltip/create.ts
@@ -10,7 +10,6 @@ import {
 	isDocument,
 	isElement,
 	isTouch,
-	kbd,
 	makeHullFromElements,
 	noop,
 	omit,
@@ -21,7 +20,7 @@ import {
 	isPointerInGraceArea,
 } from '$lib/internal/helpers/index.js';
 
-import { useFloating, usePortal } from '$lib/internal/actions/index.js';
+import { useEscapeKeydown, useFloating, usePortal } from '$lib/internal/actions/index.js';
 import type { MeltActionReturn } from '$lib/internal/types.js';
 import { derived, writable, type Writable } from 'svelte/store';
 import { generateIds } from '../../internal/helpers/id.js';
@@ -40,7 +39,7 @@ const defaults = {
 	closeDelay: 0,
 	forceVisible: false,
 	portal: undefined,
-	closeOnEscape: true,
+	escapeBehavior: 'close',
 	disableHoverableContent: false,
 	group: undefined,
 } satisfies CreateTooltipProps;
@@ -66,7 +65,7 @@ export function createTooltip(props?: CreateTooltipProps) {
 		closeDelay,
 		forceVisible,
 		portal,
-		closeOnEscape,
+		escapeBehavior,
 		disableHoverableContent,
 		group,
 	} = options;
@@ -80,6 +79,8 @@ export function createTooltip(props?: CreateTooltipProps) {
 	const ids = toWritableStores({ ...generateIds(tooltipIdParts), ...withDefaults.ids });
 
 	let clickedTrigger = false;
+	let isPointerInsideTrigger = false;
+	let isPointerInsideContent = false;
 
 	const getEl = (part: keyof typeof ids) => {
 		if (!isBrowser) return null;
@@ -144,17 +145,6 @@ export function createTooltip(props?: CreateTooltipProps) {
 			} as const;
 		},
 		action: (node: HTMLElement): MeltActionReturn<TooltipEvents['trigger']> => {
-			const keydownHandler = (e: KeyboardEvent) => {
-				if (closeOnEscape.get() && e.key === kbd.ESCAPE) {
-					if (openTimeout) {
-						window.clearTimeout(openTimeout);
-						openTimeout = null;
-					}
-
-					open.set(false);
-				}
-			};
-
 			const unsub = executeCallbacks(
 				addMeltEventListener(node, 'pointerdown', () => {
 					const $closeOnPointerDown = closeOnPointerDown.get();
@@ -167,10 +157,12 @@ export function createTooltip(props?: CreateTooltipProps) {
 					}
 				}),
 				addMeltEventListener(node, 'pointerenter', (e) => {
+					isPointerInsideTrigger = true;
 					if (isTouch(e)) return;
 					openTooltip('pointer');
 				}),
 				addMeltEventListener(node, 'pointerleave', (e) => {
+					isPointerInsideTrigger = false;
 					if (isTouch(e)) return;
 					if (openTimeout) {
 						window.clearTimeout(openTimeout);
@@ -181,13 +173,14 @@ export function createTooltip(props?: CreateTooltipProps) {
 					if (clickedTrigger) return;
 					openTooltip('focus');
 				}),
-				addMeltEventListener(node, 'blur', () => closeTooltip(true)),
-				addMeltEventListener(node, 'keydown', keydownHandler),
-				addEventListener(document, 'keydown', keydownHandler)
+				addMeltEventListener(node, 'blur', () => closeTooltip(true))
 			);
 
 			return {
-				destroy: unsub,
+				destroy() {
+					unsub();
+					isPointerInsideTrigger = false;
+				},
 			};
 		},
 	});
@@ -208,21 +201,37 @@ export function createTooltip(props?: CreateTooltipProps) {
 		action: (node: HTMLElement): MeltActionReturn<TooltipEvents['content']> => {
 			let unsubFloating = noop;
 			let unsubPortal = noop;
+			let unsubEscapeKeydown = noop;
 
 			const unsubDerived = effect(
 				[isVisible, positioning, portal],
 				([$isVisible, $positioning, $portal]) => {
 					unsubPortal();
 					unsubFloating();
+					unsubEscapeKeydown();
 					const triggerEl = getEl('trigger');
 					if (!$isVisible || !triggerEl) return;
 
 					tick().then(() => {
 						unsubPortal();
 						unsubFloating();
+						unsubEscapeKeydown();
 						const portalDest = getPortalDestination(node, $portal);
 						if (portalDest) unsubPortal = usePortal(node, portalDest).destroy;
 						unsubFloating = useFloating(triggerEl, node, $positioning).destroy;
+
+						const onEscapeKeyDown = () => {
+							if (openTimeout) {
+								window.clearTimeout(openTimeout);
+								openTimeout = null;
+							}
+							open.set(false);
+						};
+
+						unsubEscapeKeydown = useEscapeKeydown(node, {
+							behaviorType: escapeBehavior,
+							handler: onEscapeKeyDown,
+						}).destroy;
 					});
 				}
 			);
@@ -242,13 +251,20 @@ export function createTooltip(props?: CreateTooltipProps) {
 			}
 
 			const unsubEvents = executeCallbacks(
-				addMeltEventListener(node, 'pointerenter', () => openTooltip('pointer')),
+				addMeltEventListener(node, 'pointerenter', () => {
+					isPointerInsideContent = true;
+					openTooltip('pointer');
+				}),
+				addMeltEventListener(node, 'pointerleave', () => {
+					isPointerInsideContent = false;
+				}),
 				addMeltEventListener(node, 'pointerdown', () => openTooltip('pointer')),
 				addEventListener(window, 'scroll', handleScroll, { capture: true })
 			);
 
 			return {
 				destroy() {
+					isPointerInsideContent = false;
 					unsubEvents();
 					unsubPortal();
 					unsubFloating();
@@ -306,7 +322,15 @@ export function createTooltip(props?: CreateTooltipProps) {
 					? [triggerEl]
 					: [triggerEl, contentEl];
 				const polygon = makeHullFromElements(polygonElements);
-				isMouseInTooltipArea = isPointerInGraceArea(e, polygon);
+
+				/**
+				 * This takes into account the potential discrepancy between the
+				 * pointer's coordinates (`clientX` and `clientY`) and the
+				 * exact boundaries of the trigger element's rectangle due to
+				 * sub-pixel rendering and rounding errors.
+				 */
+				isMouseInTooltipArea =
+					isPointerInsideTrigger || isPointerInsideContent || isPointerInGraceArea(e, polygon);
 
 				if ($openReason !== 'pointer') return;
 
diff --git a/src/lib/builders/tooltip/types.ts b/src/lib/builders/tooltip/types.ts
index fb137f44..224ad831 100644
--- a/src/lib/builders/tooltip/types.ts
+++ b/src/lib/builders/tooltip/types.ts
@@ -1,4 +1,4 @@
-import type { FloatingConfig } from '$lib/internal/actions/index.js';
+import type { EscapeBehaviorType, FloatingConfig } from '$lib/internal/actions/index.js';
 import type { ChangeFn, IdObj } from '$lib/internal/helpers/index.js';
 import type { BuilderReturn } from '$lib/internal/types.js';
 import type { Writable } from 'svelte/store';
@@ -14,7 +14,17 @@ export type CreateTooltipProps = {
 	openDelay?: number;
 	closeDelay?: number;
 	forceVisible?: boolean;
-	closeOnEscape?: boolean;
+	/**
+	 * Escape behavior type.
+	 * `close`: Closes the element immediately.
+	 * `defer-otherwise-close`: Delegates the action to the parent element. If no parent is found, it closes the element.
+	 * `defer-otherwise-ignore`: Delegates the action to the parent element. If no parent is found, nothing is done.
+	 * `ignore`: Prevents the element from closing and also blocks the parent element from closing in response to the Escape key.
+	 *
+	 * @defaultValue `close`
+	 */
+	escapeBehavior?: EscapeBehaviorType;
+
 	disableHoverableContent?: boolean;
 	/**
 	 * If set to `true`, whenever you open this tooltip, all other tooltips
diff --git a/src/lib/internal/actions/escape-keydown/action.ts b/src/lib/internal/actions/escape-keydown/action.ts
index 05bd15e3..e266670e 100644
--- a/src/lib/internal/actions/escape-keydown/action.ts
+++ b/src/lib/internal/actions/escape-keydown/action.ts
@@ -1,72 +1,49 @@
 import { addEventListener } from '$lib/internal/helpers/event.js';
 import { isFunction, isHTMLElement, isReadable } from '$lib/internal/helpers/is.js';
-import { get, readable, type Readable } from 'svelte/store';
-import { effect, executeCallbacks, kbd, noop } from '../../helpers/index.js';
-import type { EscapeKeydownConfig } from './types.js';
+import { readable, type Readable } from 'svelte/store';
+import { effect, executeCallbacks, kbd, noop, withGet, type WithGet } from '../../helpers/index.js';
+import type { EscapeBehaviorType, EscapeKeydownConfig } from './types.js';
 import type { Action } from 'svelte/action';
 
-/**
- * Creates a readable store that tracks the latest Escape Keydown that occurred on the document.
- *
- * @returns A function to unsubscribe from the event listener and stop tracking keydown events.
- */
-const documentEscapeKeyStore = readable<KeyboardEvent | undefined>(
-	undefined,
-	(set): (() => void) => {
-		/**
-		 * Event handler for keydown events on the document.
-		 * Updates the store's value with the latest Escape Keydown event and then resets it to undefined.
-		 */
-		function keydown(event: KeyboardEvent | undefined) {
-			if (event && event.key === kbd.ESCAPE) {
-				set(event);
-			}
-
-			// New subscriptions will not trigger immediately
-			set(undefined);
-		}
-
-		// Adds a keydown event listener to the document, calling the keydown function when triggered.
-		const unsubscribe = addEventListener(document, 'keydown', keydown, {
-			passive: false,
-		});
-
-		// Returns a function to unsubscribe from the event listener and stop tracking keydown events.
-		return unsubscribe;
-	}
-);
+const layers = new Map<HTMLElement, WithGet<Readable<EscapeBehaviorType>>>();
 
 export const useEscapeKeydown = ((node, config = {}) => {
 	let unsub = noop;
+
 	function update(config: EscapeKeydownConfig = {}) {
 		unsub();
+		const options = { behaviorType: 'close', ...config } satisfies EscapeKeydownConfig;
+		const behaviorType = isReadable(options.behaviorType)
+			? options.behaviorType
+			: withGet(readable(options.behaviorType));
 
-		const options = { enabled: true, ...config };
-		const enabled = (
-			isReadable(options.enabled) ? options.enabled : readable(options.enabled)
-		) as Readable<boolean>;
-
-		unsub = executeCallbacks(
-			// Handle escape keydowns
-			documentEscapeKeyStore.subscribe((e) => {
-				if (!e || !get(enabled)) return;
-				const target = e.target;
+		layers.set(node, behaviorType);
 
-				if (!isHTMLElement(target) || target.closest('[data-escapee]') !== node) {
-					return;
-				}
+		const onKeyDown = (e: KeyboardEvent) => {
+			if (e.key !== kbd.ESCAPE || !isResponsibleEscapeLayer(node)) return;
+			const target = e.target;
+			if (!isHTMLElement(target)) return;
 
-				e.preventDefault();
-				if (shouldIgnoreEvent(e, options.ignore)) return;
+			e.preventDefault();
+			if (shouldIgnoreEvent(e, options.ignore)) return;
+			if (shouldInvokeResponsibleLayerHandler(behaviorType.get())) {
 				options.handler?.(e);
-			}),
-			effect(enabled, ($enabled) => {
-				if ($enabled) {
+			}
+		};
+
+		unsub = executeCallbacks(
+			addEventListener(document, 'keydown', onKeyDown, { passive: false }),
+			effect(behaviorType, ($behaviorType) => {
+				if (
+					$behaviorType === 'close' ||
+					($behaviorType === 'defer-otherwise-close' && [...layers.keys()][0] === node)
+				) {
 					node.dataset.escapee = '';
 				} else {
 					delete node.dataset.escapee;
 				}
-			})
+			}),
+			behaviorType.destroy || noop
 		);
 	}
 
@@ -75,12 +52,30 @@ export const useEscapeKeydown = ((node, config = {}) => {
 	return {
 		update,
 		destroy() {
-			node.removeAttribute('data-escapee');
+			layers.delete(node);
+			delete node.dataset.escapee;
 			unsub();
 		},
 	};
 }) satisfies Action<HTMLElement, EscapeKeydownConfig>;
 
+const isResponsibleEscapeLayer = (node: HTMLElement): boolean => {
+	const layersArr = [...layers];
+	/**
+	 * We first check if we can find a top layer with `close` or `ignore`.
+	 * If that top layer was found and matches the provided node, then the node is
+	 * responsible for the escape. Otherwise, we know that all layers defer so
+	 * the first layer is the responsible one.
+	 */
+	const topMostLayer = layersArr.findLast(([_, behaviorType]) => {
+		const $behaviorType = behaviorType.get();
+		return $behaviorType === 'close' || $behaviorType === 'ignore';
+	});
+	if (topMostLayer) return topMostLayer[0] === node;
+	const [firstLayerNode] = layersArr[0];
+	return firstLayerNode === node;
+};
+
 const shouldIgnoreEvent = (e: KeyboardEvent, ignore: EscapeKeydownConfig['ignore']): boolean => {
 	if (!ignore) return false;
 	if (isFunction(ignore) && ignore(e)) return true;
@@ -89,3 +84,7 @@ const shouldIgnoreEvent = (e: KeyboardEvent, ignore: EscapeKeydownConfig['ignore
 	}
 	return false;
 };
+
+const shouldInvokeResponsibleLayerHandler = (behaviorType: EscapeBehaviorType) => {
+	return behaviorType === 'close' || behaviorType === 'defer-otherwise-close';
+};
diff --git a/src/lib/internal/actions/escape-keydown/types.ts b/src/lib/internal/actions/escape-keydown/types.ts
index 5348c5d5..23d02a37 100644
--- a/src/lib/internal/actions/escape-keydown/types.ts
+++ b/src/lib/internal/actions/escape-keydown/types.ts
@@ -1,12 +1,23 @@
+import type { WithGet } from '$lib/internal/helpers/withGet.js';
 import type { Readable } from 'svelte/store';
 
+export type EscapeBehaviorType =
+	| 'close'
+	| 'defer-otherwise-close'
+	| 'defer-otherwise-ignore'
+	| 'ignore';
+
 export type EscapeKeydownConfig = {
 	/**
-	 * Whether the listener is active.
+	 * Escape behavior type.
+	 * `close`: Closes the element immediately.
+	 * `defer-otherwise-close`: Delegates the action to the parent element. If no parent is found, it closes the element.
+	 * `defer-otherwise-ignore`: Delegates the action to the parent element. If no parent is found, nothing is done.
+	 * `ignore`: Prevents the element from closing and also blocks the parent element from closing in response to the Escape key.
 	 *
-	 * @defaultValue `true`
+	 * @defaultValue `close`
 	 */
-	enabled?: boolean | Readable<boolean>;
+	behaviorType?: EscapeBehaviorType | WithGet<Readable<EscapeBehaviorType>>;
 
 	/**
 	 * Callback when user presses the escape key element.
diff --git a/src/lib/internal/actions/popper/action.ts b/src/lib/internal/actions/popper/action.ts
index d657769f..cac6ffe9 100644
--- a/src/lib/internal/actions/popper/action.ts
+++ b/src/lib/internal/actions/popper/action.ts
@@ -76,7 +76,6 @@ export const usePopper = ((popperElement, args) => {
 	if (opts.escapeKeydown !== null) {
 		callbacks.push(
 			useEscapeKeydown(popperElement, {
-				enabled: open,
 				handler: () => {
 					open.set(false);
 				},
diff --git a/src/routes/(landing-ui)/popover.svelte b/src/routes/(landing-ui)/popover.svelte
index a2a791d4..cae33270 100644
--- a/src/routes/(landing-ui)/popover.svelte
+++ b/src/routes/(landing-ui)/popover.svelte
@@ -16,7 +16,7 @@
 		forceVisible: true,
 		defaultOpen: true,
 		closeOnOutsideClick: false,
-		closeOnEscape: false,
+		escapeBehavior: 'ignore',
 		disableFocusTrap: true,
 	});
 


Test patch:
diff --git a/src/lib/internal/helpers/tests/escape-keydown.spec.ts b/src/lib/internal/helpers/tests/escape-keydown.spec.ts
new file mode 100644
index 00000000..962a05cd
--- /dev/null
+++ b/src/lib/internal/helpers/tests/escape-keydown.spec.ts
@@ -0,0 +1,208 @@
+import { kbd } from '$lib/internal/helpers/keyboard.js';
+import { vi } from 'vitest';
+import { useEscapeKeydown } from '../../actions/escape-keydown/action.js';
+import { noop } from '$lib/internal/helpers/callbacks.js';
+import type {
+	EscapeBehaviorType,
+	EscapeKeydownConfig,
+} from '../../actions/escape-keydown/types.js';
+import { withGet } from '$lib/internal/helpers/withGet.js';
+
+const dispatchKeydownEvent = (key: string) => {
+	const event = new KeyboardEvent('keydown', { key, bubbles: true });
+	document.documentElement.dispatchEvent(event);
+};
+
+const dispatchEscape = () => dispatchKeydownEvent(kbd.ESCAPE);
+
+const mountLayer = (config?: EscapeKeydownConfig) => {
+	const node = document.createElement('div');
+	const handler = vi.fn(noop);
+	const action = useEscapeKeydown(node, { ...config, handler });
+	return { action, handler };
+};
+
+const singleEscapeAssertions = [
+	{
+		behavior: 'close',
+		shouldClose: true,
+	},
+	{
+		behavior: 'ignore',
+		shouldClose: false,
+	},
+	{
+		behavior: 'defer-otherwise-close',
+		shouldClose: true,
+	},
+	{
+		behavior: 'defer-otherwise-ignore',
+		shouldClose: false,
+	},
+] satisfies { behavior: EscapeBehaviorType; shouldClose: boolean }[];
+
+const nestedEscapeAssertions = [
+	{
+		behaviors: ['close', 'close'],
+		expectedInvocations: [false, true],
+	},
+	{
+		behaviors: ['close', 'defer-otherwise-close'],
+		expectedInvocations: [true, false],
+	},
+	{
+		behaviors: ['close', 'defer-otherwise-ignore'],
+		expectedInvocations: [true, false],
+	},
+	{
+		behaviors: ['close', 'ignore'],
+		expectedInvocations: [false, false],
+	},
+	{
+		behaviors: ['defer-otherwise-close', 'defer-otherwise-close'],
+		expectedInvocations: [true, false],
+	},
+	{
+		behaviors: ['defer-otherwise-ignore', 'defer-otherwise-ignore'],
+		expectedInvocations: [false, false],
+	},
+] satisfies { behaviors: EscapeBehaviorType[]; expectedInvocations: boolean[] }[];
+
+describe('escape keydown', () => {
+	describe('single layers', () => {
+		for (const { behavior, shouldClose } of singleEscapeAssertions) {
+			it(`provided '${behavior}', on escape ${shouldClose ? 'should' : 'should not'} close`, () => {
+				const { handler, action } = mountLayer({ behaviorType: behavior });
+				expect(handler).not.toHaveBeenCalled();
+				dispatchEscape();
+				expect(handler).toHaveBeenCalledTimes(shouldClose ? 1 : 0);
+				action.destroy();
+			});
+		}
+	});
+
+	describe('nested layers', () => {
+		for (const { behaviors, expectedInvocations } of nestedEscapeAssertions) {
+			it(`provided behaviors '${behaviors}', expected invocations '${expectedInvocations}'`, () => {
+				const layers = behaviors.map((behaviorType) => mountLayer({ behaviorType }));
+				layers.forEach(({ handler }) => expect(handler).not.toHaveBeenCalled());
+
+				dispatchEscape();
+				layers.forEach(({ handler }, idx) => {
+					const shouldInvoke = expectedInvocations[idx];
+					expect(handler).toHaveBeenCalledTimes(shouldInvoke ? 1 : 0);
+				});
+				layers.forEach(({ action }) => action.destroy());
+			});
+		}
+	});
+
+	it('does not call handler after unmounting', () => {
+		const { handler, action } = mountLayer({ behaviorType: 'close' });
+		expect(handler).toHaveBeenCalledTimes(0);
+		action.destroy();
+		dispatchEscape();
+		expect(handler).toHaveBeenCalledTimes(0);
+	});
+
+	it('does not call handler when other key is pressed', () => {
+		const { handler, action } = mountLayer({ behaviorType: 'close' });
+		dispatchKeydownEvent(kbd.SPACE);
+		expect(handler).toHaveBeenCalledTimes(0);
+		action.destroy();
+	});
+
+	it('correctly unmounts event listener and calls handler of top-most layer', () => {
+		const { handler: handler1, action: action1 } = mountLayer({ behaviorType: 'close' });
+		const { handler: handler2, action: action2 } = mountLayer({ behaviorType: 'close' });
+		dispatchEscape();
+		expect(handler1).toHaveBeenCalledTimes(0);
+		expect(handler2).toHaveBeenCalledTimes(1);
+
+		action2.destroy();
+		dispatchEscape();
+		expect(handler1).toHaveBeenCalledTimes(1);
+		expect(handler2).toHaveBeenCalledTimes(1);
+		action1.destroy();
+	});
+
+	it('does not change position of layer in the stack when options update', () => {
+		const { handler: handler1, action: action1 } = mountLayer({ behaviorType: 'close' });
+		const { handler: handler2, action: action2 } = mountLayer({ behaviorType: 'close' });
+		dispatchEscape();
+		expect(handler1).toHaveBeenCalledTimes(0);
+		expect(handler2).toHaveBeenCalledTimes(1);
+
+		action1.update({ behaviorType: 'ignore', handler: handler1 });
+		dispatchEscape();
+		expect(handler1).toHaveBeenCalledTimes(0);
+		expect(handler2).toHaveBeenCalledTimes(2);
+		action2.destroy();
+		action1.destroy();
+	});
+
+	it('does not change position of layer in the stack when updating behaviorType store', () => {
+		const w1 = withGet.writable<EscapeBehaviorType>('close');
+		const { handler: handler1, action: action1 } = mountLayer({ behaviorType: w1 });
+		const { handler: handler2, action: action2 } = mountLayer({ behaviorType: 'close' });
+		dispatchEscape();
+		expect(handler1).toHaveBeenCalledTimes(0);
+		expect(handler2).toHaveBeenCalledTimes(1);
+
+		w1.set('ignore');
+		dispatchEscape();
+		expect(handler1).toHaveBeenCalledTimes(0);
+		expect(handler2).toHaveBeenCalledTimes(2);
+		action2.destroy();
+		action1.destroy();
+	});
+
+	it('respects updated behaviorType', () => {
+		const { handler: handler1, action: action1 } = mountLayer({ behaviorType: 'close' });
+		const { handler: handler2, action: action2 } = mountLayer({
+			behaviorType: 'defer-otherwise-close',
+		});
+		dispatchEscape();
+		expect(handler1).toHaveBeenCalledTimes(1);
+		expect(handler2).toHaveBeenCalledTimes(0);
+
+		action2.update({ behaviorType: 'close', handler: handler2 });
+		dispatchEscape();
+		expect(handler1).toHaveBeenCalledTimes(1);
+		expect(handler2).toHaveBeenCalledTimes(1);
+		action2.destroy();
+		action1.destroy();
+	});
+
+	it('respects updated behaviorType store', () => {
+		const w2 = withGet.writable<EscapeBehaviorType>('defer-otherwise-close');
+		const { handler: handler1, action: action1 } = mountLayer({ behaviorType: 'close' });
+		const { handler: handler2, action: action2 } = mountLayer({ behaviorType: w2 });
+		dispatchEscape();
+		expect(handler1).toHaveBeenCalledTimes(1);
+		expect(handler2).toHaveBeenCalledTimes(0);
+
+		w2.set('close');
+		dispatchEscape();
+		expect(handler1).toHaveBeenCalledTimes(1);
+		expect(handler2).toHaveBeenCalledTimes(1);
+		action2.destroy();
+		action1.destroy();
+	});
+
+	it('respects updating behaviorType from string value to store', () => {
+		const { handler: handler1, action: action1 } = mountLayer({ behaviorType: 'close' });
+		const { handler: handler2, action: action2 } = mountLayer({ behaviorType: 'close' });
+		dispatchEscape();
+		expect(handler1).toHaveBeenCalledTimes(0);
+		expect(handler2).toHaveBeenCalledTimes(1);
+
+		const w2 = withGet.writable<EscapeBehaviorType>('ignore');
+		action2.update({ behaviorType: w2, handler: handler2 });
+		dispatchEscape();
+		expect(handler1).toHaveBeenCalledTimes(0);
+		expect(handler2).toHaveBeenCalledTimes(1);
+		action2.destroy();
+		action1.destroy();
+	});
+});
diff --git a/src/tests/combobox/Combobox.spec.ts b/src/tests/combobox/Combobox.spec.ts
index 725c6f21..5e721044 100644
--- a/src/tests/combobox/Combobox.spec.ts
+++ b/src/tests/combobox/Combobox.spec.ts
@@ -182,9 +182,9 @@ describe('Combobox', () => {
 		expect(menu).not.toBeVisible();
 	});
 
-	test('Respects the `closeOnEscape` prop', async () => {
+	test('Respects the `escapeBehavior` prop', async () => {
 		const user = userEvent.setup();
-		const { getByTestId } = render(ComboboxTest, { closeOnEscape: false });
+		const { getByTestId } = render(ComboboxTest, { escapeBehavior: 'ignore' });
 
 		const input = getByTestId('input');
 		const menu = getByTestId('menu');
@@ -211,7 +211,7 @@ describe('Combobox', () => {
 
 	test('should not prevent focusing on another input on outside interaction of combobox', async () => {
 		const user = userEvent.setup();
-		const { getByTestId } = render(ComboboxTest, { closeOnEscape: false });
+		const { getByTestId } = render(ComboboxTest);
 
 		const input = getByTestId('input');
 		const menu = getByTestId('menu');
@@ -422,10 +422,10 @@ describe('Combobox (forceVisible)', () => {
 		await waitFor(() => expect(getMenu()).toBeNull());
 	});
 
-	test('Respects the `closeOnEscape` prop', async () => {
+	test('Respects the `escapeBehavior` prop', async () => {
 		const user = userEvent.setup();
 		const { getByTestId, queryByTestId } = render(ComboboxForceVisibleTest, {
-			closeOnEscape: false,
+			escapeBehavior: 'ignore',
 		});
 
 		const input = getByTestId('input');
diff --git a/src/tests/combobox/ComboboxForceVisibleTest.svelte b/src/tests/combobox/ComboboxForceVisibleTest.svelte
index d0673b04..83942b0b 100644
--- a/src/tests/combobox/ComboboxForceVisibleTest.svelte
+++ b/src/tests/combobox/ComboboxForceVisibleTest.svelte
@@ -18,7 +18,7 @@
 	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;
+	export let escapeBehavior: CreateComboboxProps<unknown>['escapeBehavior'] = undefined;
 
 	const {
 		elements: { menu, input, option, label },
@@ -34,7 +34,7 @@
 				: undefined,
 			ids,
 			onOutsideClick,
-			closeOnEscape,
+			escapeBehavior,
 			forceVisible: true,
 		})
 	);
diff --git a/src/tests/combobox/ComboboxTest.svelte b/src/tests/combobox/ComboboxTest.svelte
index bcacbc13..44b30ba1 100644
--- a/src/tests/combobox/ComboboxTest.svelte
+++ b/src/tests/combobox/ComboboxTest.svelte
@@ -18,7 +18,7 @@
 	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;
+	export let escapeBehavior: CreateComboboxProps<unknown>['escapeBehavior'] = undefined;
 
 	const {
 		elements: { menu, input, option, label },
@@ -34,7 +34,7 @@
 				: undefined,
 			ids,
 			onOutsideClick,
-			closeOnEscape,
+			escapeBehavior,
 		})
 	);
 
diff --git a/src/tests/context-menu/ContextMenu.spec.ts b/src/tests/context-menu/ContextMenu.spec.ts
index c9bf99e7..3b33dac1 100644
--- a/src/tests/context-menu/ContextMenu.spec.ts
+++ b/src/tests/context-menu/ContextMenu.spec.ts
@@ -158,9 +158,9 @@ describe('Context Menu', () => {
 		await waitFor(() => expect(queryByTestId('menu')).toBeNull());
 	});
 
-	test('respects `closeOnEscape` prop', async () => {
+	test('respects `escapeBehavior` prop', async () => {
 		const { user, getByTestId } = await open({
-			closeOnEscape: false,
+			escapeBehavior: 'ignore',
 		});
 		await user.keyboard(kbd.ESCAPE);
 		expect(getByTestId('menu')).toBeVisible();
diff --git a/src/tests/context-menu/ContextMenuTest.svelte b/src/tests/context-menu/ContextMenuTest.svelte
index ec481d4d..aa850c5d 100644
--- a/src/tests/context-menu/ContextMenuTest.svelte
+++ b/src/tests/context-menu/ContextMenuTest.svelte
@@ -12,7 +12,7 @@
 
 	export let loop = false;
 	export let closeFocus: CreateContextMenuProps['closeFocus'] = undefined;
-	export let closeOnEscape: CreateContextMenuProps['closeOnEscape'] = true;
+	export let escapeBehavior: CreateContextMenuProps['escapeBehavior'] = 'close';
 	export let closeOnOutsideClick: CreateContextMenuProps['closeOnOutsideClick'] = true;
 	export let submenuIds: CreateContextMenuProps['ids'] = undefined;
 
@@ -23,7 +23,7 @@
 	} = createContextMenu({
 		loop,
 		closeFocus,
-		closeOnEscape,
+		escapeBehavior,
 		closeOnOutsideClick,
 		...$$restProps,
 		forceVisible: true,
diff --git a/src/tests/dialog/Dialog.spec.ts b/src/tests/dialog/Dialog.spec.ts
index c039b0d5..5069dbff 100644
--- a/src/tests/dialog/Dialog.spec.ts
+++ b/src/tests/dialog/Dialog.spec.ts
@@ -175,9 +175,9 @@ describe('Dialog', () => {
 		await waitFor(() => expect(content).not.toBeVisible());
 	});
 
-	it('Respects the `closeOnEscape` prop', async () => {
+	it('Respects the `escapeBehavior` prop', async () => {
 		const { user, content } = await open({
-			closeOnEscape: false,
+			escapeBehavior: 'ignore',
 		});
 
 		await user.keyboard(kbd.ESCAPE);
@@ -225,8 +225,8 @@ describe('Dialog', () => {
 		await waitFor(() => expect(content).not.toBeVisible());
 	});
 
-	it("Doesn't deactivate focus trap on escape provided `closeOnEscape` false", async () => {
-		const { user, content } = await open({ closeOnEscape: false });
+	it("Doesn't deactivate focus trap on escape provided `escapeBehavior` false", async () => {
+		const { user, content } = await open({ escapeBehavior: 'ignore' });
 		await user.keyboard(kbd.ESCAPE);
 		expect(content).toBeVisible();
 		await assertActiveFocusTrap(user, content);
diff --git a/src/tests/dropdown-menu/DropdownMenu.spec.ts b/src/tests/dropdown-menu/DropdownMenu.spec.ts
index cddde6f5..dd5250eb 100644
--- a/src/tests/dropdown-menu/DropdownMenu.spec.ts
+++ b/src/tests/dropdown-menu/DropdownMenu.spec.ts
@@ -203,9 +203,9 @@ describe('Dropdown Menu (Default)', () => {
 		expect(closeFocus).toHaveFocus();
 	});
 
-	test('respects `closeOnEscape` prop', async () => {
+	test('respects `escapeBehavior` prop', async () => {
 		const { user, trigger, getByTestId } = setup({
-			closeOnEscape: false,
+			escapeBehavior: 'ignore',
 		});
 		const menu = getByTestId('menu');
 		await user.click(trigger);
diff --git a/src/tests/dropdown-menu/DropdownMenuTest.svelte b/src/tests/dropdown-menu/DropdownMenuTest.svelte
index ac04e2f9..20a526f0 100644
--- a/src/tests/dropdown-menu/DropdownMenuTest.svelte
+++ b/src/tests/dropdown-menu/DropdownMenuTest.svelte
@@ -11,7 +11,7 @@
 
 	export let loop = false;
 	export let closeFocus: CreateDropdownMenuProps['closeFocus'] = undefined;
-	export let closeOnEscape: CreateDropdownMenuProps['closeOnEscape'] = true;
+	export let escapeBehavior: CreateDropdownMenuProps['escapeBehavior'] = 'close';
 	export let closeOnOutsideClick: CreateDropdownMenuProps['closeOnOutsideClick'] = true;
 	export let submenuIds: CreateDropdownMenuProps['ids'] = undefined;
 
@@ -22,7 +22,7 @@
 		removeUndefined({
 			loop,
 			closeFocus,
-			closeOnEscape,
+			escapeBehavior,
 			closeOnOutsideClick,
 			...$$restProps,
 		})
diff --git a/src/tests/escape-keydown/ComboboxTest.svelte b/src/tests/escape-keydown/ComboboxTest.svelte
new file mode 100644
index 00000000..0a465d6a
--- /dev/null
+++ b/src/tests/escape-keydown/ComboboxTest.svelte
@@ -0,0 +1,23 @@
+<script lang="ts">
+	import { createCombobox, melt, type CreateComboboxProps } from '$lib/index.js';
+
+	type $$Props = CreateComboboxProps & { setRootEscapeBehaviorIgnore: () => void };
+	export let setRootEscapeBehaviorIgnore: () => void;
+
+	const {
+		elements: { input, menu },
+		states: { open },
+	} = createCombobox({ ...$$restProps, forceVisible: true });
+</script>
+
+<input use:melt={$input} data-testid="combobox-trigger" />
+{#if $open}
+	<div use:melt={$menu} data-testid="combobox-content">
+		<button
+			data-testid="combobox-set-parent-escape-behavior-ignore"
+			on:click={setRootEscapeBehaviorIgnore}
+		>
+			set root escapeBehavior: ignore
+		</button>
+	</div>
+{/if}
diff --git a/src/tests/escape-keydown/DialogTest.svelte b/src/tests/escape-keydown/DialogTest.svelte
new file mode 100644
index 00000000..5fd01c69
--- /dev/null
+++ b/src/tests/escape-keydown/DialogTest.svelte
@@ -0,0 +1,27 @@
+<script lang="ts">
+	import { createDialog, melt, type CreateDialogProps } from '$lib/index.js';
+
+	type $$Props = CreateDialogProps & { setRootEscapeBehaviorIgnore: () => void };
+	export let setRootEscapeBehaviorIgnore: () => void;
+
+	export let escapeBehavior: CreateDialogProps['escapeBehavior'] = 'close';
+
+	const {
+		elements: { trigger, content, portalled },
+		states: { open },
+	} = createDialog({ escapeBehavior, forceVisible: true });
+</script>
+
+<button use:melt={$trigger} data-testid="dialog-trigger">Open</button>
+{#if $open}
+	<div use:melt={$portalled}>
+		<div use:melt={$content} data-testid="dialog-content">
+			<button
+				data-testid="dialog-set-parent-escape-behavior-ignore"
+				on:click={setRootEscapeBehaviorIgnore}
+			>
+				set root escapeBehavior: ignore
+			</button>
+		</div>
+	</div>
+{/if}
diff --git a/src/tests/escape-keydown/EscapeKeydown.spec.ts b/src/tests/escape-keydown/EscapeKeydown.spec.ts
new file mode 100644
index 00000000..c06aaca1
--- /dev/null
+++ b/src/tests/escape-keydown/EscapeKeydown.spec.ts
@@ -0,0 +1,137 @@
+import { describe, it } from 'vitest';
+import { render, waitFor } from '@testing-library/svelte';
+import { userEvent } from '@testing-library/user-event';
+import EscapeKeydownRoot from './EscapeKeydownRoot.svelte';
+import type { CreateDialogProps } from '$lib/index.js';
+import { testKbd } from '../utils.js';
+
+async function setup(props: CreateDialogProps) {
+	const user = userEvent.setup();
+	const returned = render(EscapeKeydownRoot, props);
+	const rootTrigger = returned.getByTestId('root-dialog-trigger');
+	const getRootContent = () => returned.queryByTestId('root-dialog-content');
+	expect(getRootContent()).toBeNull();
+	await user.click(rootTrigger);
+	expect(getRootContent()).toBeVisible();
+	return { user, ...returned, rootTrigger, getRootContent };
+}
+
+const components = [
+	'dialog',
+	'combobox',
+	'link-preview',
+	'menubar',
+	'menu',
+	'popover',
+	'select',
+	'tooltip',
+] as const;
+
+const componentsToHover = new Set<(typeof components)[number]>(['tooltip', 'link-preview']);
+
+describe('Nested Escape Keydown Behaviors', () => {
+	describe.each(components)('dialog + %s', (componentName) => {
+		it('`escapeBehavior: close` on child should only close child', async () => {
+			const { user, getByTestId, queryByTestId, getRootContent } = await setup({
+				escapeBehavior: 'close',
+			});
+			const trigger = getByTestId(`${componentName}-trigger`);
+			const getContent = () => queryByTestId(`${componentName}-content`);
+
+			if (componentsToHover.has(componentName)) {
+				await user.hover(trigger);
+			} else {
+				await user.click(trigger);
+			}
+
+			expect(getContent()).toBeVisible();
+			await user.keyboard(testKbd.ESCAPE);
+			expect(getContent()).toBeNull();
+			expect(getRootContent()).toBeVisible();
+		});
+
+		it('`escapeBehavior: ignore` on child should neither close child nor parent', async () => {
+			const { user, getByTestId, queryByTestId, getRootContent } = await setup({
+				escapeBehavior: 'ignore',
+			});
+			const trigger = getByTestId(`${componentName}-trigger`);
+			const getContent = () => queryByTestId(`${componentName}-content`);
+
+			if (componentsToHover.has(componentName)) {
+				await user.hover(trigger);
+			} else {
+				await user.click(trigger);
+			}
+
+			expect(getContent()).toBeVisible();
+			await user.keyboard(testKbd.ESCAPE);
+			expect(getContent()).toBeVisible();
+			expect(getRootContent()).toBeVisible();
+		});
+
+		it('`escapeBehavior: defer-otherwise-close` on child should close both parent and child', async () => {
+			const { user, getByTestId, queryByTestId, getRootContent } = await setup({
+				escapeBehavior: 'defer-otherwise-close',
+			});
+			const trigger = getByTestId(`${componentName}-trigger`);
+			const getContent = () => queryByTestId(`${componentName}-content`);
+
+			if (componentsToHover.has(componentName)) {
+				await user.hover(trigger);
+			} else {
+				await user.click(trigger);
+			}
+
+			expect(getContent()).toBeVisible();
+			await user.keyboard(testKbd.ESCAPE);
+			await waitFor(() => expect(getContent()).toBeNull());
+			expect(getRootContent()).toBeNull();
+		});
+
+		it('`escapeBehavior: defer-otherwise-ignore` on child should close both parent and child', async () => {
+			const { user, getByTestId, queryByTestId, getRootContent } = await setup({
+				escapeBehavior: 'defer-otherwise-ignore',
+			});
+			const trigger = getByTestId(`${componentName}-trigger`);
+			const getContent = () => queryByTestId(`${componentName}-content`);
+
+			if (componentsToHover.has(componentName)) {
+				await user.hover(trigger);
+			} else {
+				await user.click(trigger);
+			}
+
+			expect(getContent()).toBeVisible();
+			await user.keyboard(testKbd.ESCAPE);
+			await waitFor(() => expect(getContent()).toBeNull());
+			expect(getRootContent()).toBeNull();
+		});
+
+		it('updating parent `escapeBehavior` should not intercept escape key press', async () => {
+			const { user, getByTestId, queryByTestId, getRootContent } = await setup({
+				escapeBehavior: 'close',
+			});
+			const trigger = getByTestId(`${componentName}-trigger`);
+			const getContent = () => queryByTestId(`${componentName}-content`);
+
+			if (componentsToHover.has(componentName)) {
+				await user.hover(trigger);
+			} else {
+				await user.click(trigger);
+			}
+
+			expect(getContent()).toBeVisible();
+
+			/**
+			 * If we update parent `escapeBehaivor` to `ignore` and escape is pressed,
+			 * parent element should stay open and child element should close.
+			 * If the parent element's position in the stack was reset, on escape no element would be closed
+			 * because the parent would be at the "top" of the stack and it's set to ignore escape key presses.
+			 */
+			await user.click(getByTestId(`${componentName}-set-parent-escape-behavior-ignore`));
+			await user.keyboard(testKbd.ESCAPE);
+			expect(getContent()).toBeNull();
+			expect(getRootContent()).toBeVisible();
+		});
+	});
+});
diff --git a/src/tests/escape-keydown/EscapeKeydownRoot.svelte b/src/tests/escape-keydown/EscapeKeydownRoot.svelte
new file mode 100644
index 00000000..931de31a
--- /dev/null
+++ b/src/tests/escape-keydown/EscapeKeydownRoot.svelte
@@ -0,0 +1,38 @@
+<script lang="ts">
+	import { createDialog, melt, type CreateDialogProps } from '$lib/index.js';
+	import Dialog from './DialogTest.svelte';
+	import Combobox from './ComboboxTest.svelte';
+	import LinkPreview from './LinkPreviewTest.svelte';
+	import Menubar from './MenubarTest.svelte';
+	import Menu from './MenuTest.svelte';
+	import Popover from './PopoverTest.svelte';
+	import Select from './SelectTest.svelte';
+	import Tooltip from './TooltipTest.svelte';
+
+	type $$Props = CreateDialogProps;
+	export let escapeBehavior: CreateDialogProps['escapeBehavior'] = 'close';
+
+	const {
+		elements: { trigger, content, portalled },
+		states: { open },
+		options: { escapeBehavior: rootEscapeBehavior },
+	} = createDialog({ forceVisible: true });
+
+	const setRootEscapeBehaviorIgnore = () => rootEscapeBehavior.set('ignore');
+</script>
+
+<button use:melt={$trigger} data-testid="root-dialog-trigger">Open</button>
+{#if $open}
+	<div use:melt={$portalled}>
+		<div use:melt={$content} data-testid="root-dialog-content">
+			<Dialog {escapeBehavior} {setRootEscapeBehaviorIgnore} />
+			<Combobox {escapeBehavior} {setRootEscapeBehaviorIgnore} />
+			<LinkPreview {escapeBehavior} {setRootEscapeBehaviorIgnore} />
+			<Menubar {escapeBehavior} {setRootEscapeBehaviorIgnore} />
+			<Menu {escapeBehavior} {setRootEscapeBehaviorIgnore} />
+			<Popover {escapeBehavior} {setRootEscapeBehaviorIgnore} />
+			<Select {escapeBehavior} {setRootEscapeBehaviorIgnore} />
+			<Tooltip {escapeBehavior} {setRootEscapeBehaviorIgnore} />
+		</div>
+	</div>
+{/if}
diff --git a/src/tests/escape-keydown/LinkPreviewTest.svelte b/src/tests/escape-keydown/LinkPreviewTest.svelte
new file mode 100644
index 00000000..1834cc72
--- /dev/null
+++ b/src/tests/escape-keydown/LinkPreviewTest.svelte
@@ -0,0 +1,23 @@
+<script lang="ts">
+	import { createLinkPreview, melt, type CreateLinkPreviewProps } from '$lib/index.js';
+
+	type $$Props = CreateLinkPreviewProps & { setRootEscapeBehaviorIgnore: () => void };
+	export let setRootEscapeBehaviorIgnore: () => void;
+
+	const {
+		elements: { trigger, content },
+		states: { open },
+	} = createLinkPreview({ ...$$restProps, forceVisible: true, openDelay: 0, closeDelay: 0 });
+</script>
+
+<button use:melt={$trigger} data-testid="link-preview-trigger">trigger</button>
+{#if $open}
+	<div use:melt={$content} data-testid="link-preview-content">
+		<button
+			data-testid="link-preview-set-parent-escape-behavior-ignore"
+			on:click={setRootEscapeBehaviorIgnore}
+		>
+			set root escapeBehavior: ignore
+		</button>
+	</div>
+{/if}
diff --git a/src/tests/escape-keydown/MenuTest.svelte b/src/tests/escape-keydown/MenuTest.svelte
new file mode 100644
index 00000000..c57441fa
--- /dev/null
+++ b/src/tests/escape-keydown/MenuTest.svelte
@@ -0,0 +1,23 @@
+<script lang="ts">
+	import { createDropdownMenu, melt, type CreateDropdownMenuProps } from '$lib/index.js';
+
+	type $$Props = CreateDropdownMenuProps & { setRootEscapeBehaviorIgnore: () => void };
+	export let setRootEscapeBehaviorIgnore: () => void;
+
+	const {
+		elements: { trigger, menu },
+		states: { open },
+	} = createDropdownMenu({ ...$$restProps, forceVisible: true });
+</script>
+
+<button use:melt={$trigger} data-testid="menu-trigger">trigger</button>
+{#if $open}
+	<div use:melt={$menu} data-testid="menu-content">
+		<button
+			data-testid="menu-set-parent-escape-behavior-ignore"
+			on:click={setRootEscapeBehaviorIgnore}
+		>
+			set root escapeBehavior: ignore
+		</button>
+	</div>
+{/if}
diff --git a/src/tests/escape-keydown/MenubarTest.svelte b/src/tests/escape-keydown/MenubarTest.svelte
new file mode 100644
index 00000000..9fc82fa0
--- /dev/null
+++ b/src/tests/escape-keydown/MenubarTest.svelte
@@ -0,0 +1,31 @@
+<script lang="ts">
+	import { createMenubar, melt, type CreateMenubarMenuProps } from '$lib/index.js';
+
+	type $$Props = CreateMenubarMenuProps & { setRootEscapeBehaviorIgnore: () => void };
+	export let escapeBehavior: CreateMenubarMenuProps['escapeBehavior'] = 'close';
+	export let setRootEscapeBehaviorIgnore: () => void;
+
+	const {
+		elements: { menubar },
+		builders: { createMenu },
+	} = createMenubar({ escapeBehavior });
+
+	const {
+		elements: { trigger, menu },
+		states: { open },
+	} = createMenu({ ...$$restProps, escapeBehavior, forceVisible: true });
+</script>
+
+<div use:melt={$menubar}>
+	<button use:melt={$trigger} data-testid="menubar-trigger">trigger</button>
+	{#if $open}
+		<div use:melt={$menu} data-testid="menubar-content">
+			<button
+				data-testid="menubar-set-parent-escape-behavior-ignore"
+				on:click={setRootEscapeBehaviorIgnore}
+			>
+				set root escapeBehavior: ignore
+			</button>
+		</div>
+	{/if}
+</div>
diff --git a/src/tests/escape-keydown/PopoverTest.svelte b/src/tests/escape-keydown/PopoverTest.svelte
new file mode 100644
index 00000000..e71d6535
--- /dev/null
+++ b/src/tests/escape-keydown/PopoverTest.svelte
@@ -0,0 +1,23 @@
+<script lang="ts">
+	import { createPopover, melt, type CreatePopoverProps } from '$lib/index.js';
+
+	type $$Props = CreatePopoverProps & { setRootEscapeBehaviorIgnore: () => void };
+	export let setRootEscapeBehaviorIgnore: () => void;
+
+	const {
+		elements: { trigger, content },
+		states: { open },
+	} = createPopover({ ...$$restProps, forceVisible: true });
+</script>
+
+<button use:melt={$trigger} data-testid="popover-trigger">trigger</button>
+{#if $open}
+	<div use:melt={$content} data-testid="popover-content">
+		<button
+			data-testid="popover-set-parent-escape-behavior-ignore"
+			on:click={setRootEscapeBehaviorIgnore}
+		>
+			set root escapeBehavior: ignore
+		</button>
+	</div>
+{/if}
diff --git a/src/tests/escape-keydown/SelectTest.svelte b/src/tests/escape-keydown/SelectTest.svelte
new file mode 100644
index 00000000..7e82060e
--- /dev/null
+++ b/src/tests/escape-keydown/SelectTest.svelte
@@ -0,0 +1,23 @@
+<script lang="ts">
+	import { createSelect, melt, type CreateSelectProps } from '$lib/index.js';
+
+	type $$Props = CreateSelectProps & { setRootEscapeBehaviorIgnore: () => void };
+	export let setRootEscapeBehaviorIgnore: () => void;
+
+	const {
+		elements: { trigger, menu },
+		states: { open },
+	} = createSelect({ ...$$restProps, forceVisible: true });
+</script>
+
+<button use:melt={$trigger} data-testid="select-trigger">trigger</button>
+{#if $open}
+	<div use:melt={$menu} data-testid="select-content">
+		<button
+			data-testid="select-set-parent-escape-behavior-ignore"
+			on:click={setRootEscapeBehaviorIgnore}
+		>
+			set root escapeBehavior: ignore
+		</button>
+	</div>
+{/if}
diff --git a/src/tests/escape-keydown/TooltipTest.svelte b/src/tests/escape-keydown/TooltipTest.svelte
new file mode 100644
index 00000000..8c4558ba
--- /dev/null
+++ b/src/tests/escape-keydown/TooltipTest.svelte
@@ -0,0 +1,23 @@
+<script lang="ts">
+	import { createTooltip, melt, type CreateTooltipProps } from '$lib/index.js';
+
+	type $$Props = CreateTooltipProps & { setRootEscapeBehaviorIgnore: () => void };
+	export let setRootEscapeBehaviorIgnore: () => void;
+
+	const {
+		elements: { trigger, content },
+		states: { open },
+	} = createTooltip({ ...$$restProps, forceVisible: true, openDelay: 0, closeDelay: 0 });
+</script>
+
+<button use:melt={$trigger} data-testid="tooltip-trigger">trigger</button>
+{#if $open}
+	<div use:melt={$content} data-testid="tooltip-content">
+		<button
+			data-testid="tooltip-set-parent-escape-behavior-ignore"
+			on:click={setRootEscapeBehaviorIgnore}
+		>
+			set root escapeBehavior: ignore
+		</button>
+	</div>
+{/if}
diff --git a/src/tests/link-preview/LinkPreview.spec.ts b/src/tests/link-preview/LinkPreview.spec.ts
index 7d2de121..57b04074 100644
--- a/src/tests/link-preview/LinkPreview.spec.ts
+++ b/src/tests/link-preview/LinkPreview.spec.ts
@@ -45,9 +45,9 @@ describe('LinkPreview (Default)', () => {
 		await user.keyboard(kbd.ESCAPE);
 		await waitFor(() => expect(content).not.toBeVisible());
 	});
-	test('Respects the `closeOnEscape` prop', async () => {
+	test('Respects the `escapeBehavior` prop', async () => {
 		const { getByTestId, trigger, user } = setup({
-			closeOnEscape: false,
+			escapeBehavior: 'ignore',
 		});
 		const content = getByTestId('content');
 
diff --git a/src/tests/popover/Popover.spec.ts b/src/tests/popover/Popover.spec.ts
index 545462f3..05f49cbd 100644
--- a/src/tests/popover/Popover.spec.ts
+++ b/src/tests/popover/Popover.spec.ts
@@ -109,8 +109,8 @@ describe('Popover (Default)', () => {
 		expect(content.id).toBe(ids.content);
 	});
 
-	it("Doesn't deactivate focus trap on escape provided `closeOnEscape` false", async () => {
-		const { getByTestId, user, content } = await open({ closeOnEscape: false });
+	it("Doesn't deactivate focus trap on escape provided `escapeBehavior` false", async () => {
+		const { getByTestId, user, content } = await open({ escapeBehavior: 'ignore' });
 		await user.keyboard(kbd.ESCAPE);
 		expect(content).toBeVisible();
 		expect(getByTestId('content')).toHaveFocus();
diff --git a/src/tests/select/Select.spec.ts b/src/tests/select/Select.spec.ts
index 9a7d6d3b..720a5404 100644
--- a/src/tests/select/Select.spec.ts
+++ b/src/tests/select/Select.spec.ts
@@ -170,9 +170,9 @@ describe('Select', () => {
 		await waitFor(() => expect(trigger).toHaveTextContent('Vanilla'), { timeout: 500 });
 	});
 
-	test('Respects the `closeOnEscape` prop', async () => {
+	test('Respects the `escapeBehavior` prop', async () => {
 		const user = userEvent.setup();
-		const { getByTestId } = render(SelectTest, { closeOnEscape: false });
+		const { getByTestId } = render(SelectTest, { escapeBehavior: 'ignore' });
 		const trigger = getByTestId('trigger');
 		const menu = getByTestId('menu');
 
@@ -238,7 +238,7 @@ describe('Select', () => {
 	});
 
 	test('Applies type="button" to the select trigger', async () => {
-		const { getByTestId } = render(SelectTest, { closeOnEscape: false });
+		const { getByTestId } = render(SelectTest, { escapeBehavior: 'ignore' });
 		const trigger = getByTestId('trigger');
 		expect(trigger).toHaveAttribute('type', 'button');
 	});
diff --git a/src/tests/select/SelectTest.svelte b/src/tests/select/SelectTest.svelte
index 0059c569..8fafb1ae 100644
--- a/src/tests/select/SelectTest.svelte
+++ b/src/tests/select/SelectTest.svelte
@@ -6,7 +6,7 @@
 
 	export let multiple = false;
 	export let defaultValue: string | undefined = undefined;
-	export let closeOnEscape = true;
+	export let escapeBehavior: CreateSelectProps['escapeBehavior'] = 'close';
 	export let closeOnOutsideClick = true;
 	export let ids: CreateSelectProps['ids'] = undefined;
 	export let onOutsideClick: CreateSelectProps['onOutsideClick'] = undefined;
@@ -24,7 +24,7 @@
 						label: defaultValue,
 				  }
 				: undefined,
-			closeOnEscape,
+			escapeBehavior,
 			closeOnOutsideClick,
 			ids,
 			onOutsideClick,