Repository: melt-ui/melt-ui
Pull request #676 (2023-10-27 20:55:47)

Menu & Listbox: improvements, bug fixes, tests
- Fixes a bug where we would refocus the subtrigger even if focus was within its submenu (double focusing).
- Add controlled `open` prop to submenus.
- Fixes a bug where the menus would not respect the `closeOnEscape` prop when it is set to false due to a submenu listener.
- Add tests for the `closeOnEscape` and `closeOnOutsideClick` props.
- Remove incorrect aria attributes from the context menu's trigger
- Fixed a bug where the `listbox` wasn't respecting the `closeOnEscape` prop & added a test for it

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

Latest commit: 523c8bb411cf73c062119382654e6fa2eb460f78

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. **If these changes should result in a version bump, you need to add a changeset.**

<details><summary>This PR includes no changesets</summary>

  When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

</details>

[Click here to learn what changesets are, and how to add one](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add a changeset to this PR](https://github.com/huntabyte/melt-ui/new/controlled-submenus?filename=.changeset/unlucky-dogs-hug.md&value=---%0A%22%40melt-ui%2Fsvelte%22%3A%20patch%0A---%0A%0Aadd%20controlled%20submenu%20props%0A)



Patch:
diff --git a/.changeset/great-wasps-kneel.md b/.changeset/great-wasps-kneel.md
new file mode 100644
index 00000000..ea5e6d3c
--- /dev/null
+++ b/.changeset/great-wasps-kneel.md
@@ -0,0 +1,6 @@
+---
+"@melt-ui/svelte": patch
+---
+
+Menu: add controlled submenu props
+Listbox: Respect `closeOnEscape`
diff --git a/src/lib/builders/context-menu/create.ts b/src/lib/builders/context-menu/create.ts
index eec38b31..6175a24c 100644
--- a/src/lib/builders/context-menu/create.ts
+++ b/src/lib/builders/context-menu/create.ts
@@ -232,8 +232,6 @@ export function createContextMenu(props?: CreateContextMenuProps) {
 		stores: rootOpen,
 		returned: ($rootOpen) => {
 			return {
-				'aria-controls': ids.menu,
-				'aria-expanded': $rootOpen,
 				'data-state': $rootOpen ? 'open' : 'closed',
 				id: ids.trigger,
 				style: styleToString({
diff --git a/src/lib/builders/listbox/create.ts b/src/lib/builders/listbox/create.ts
index 840833b8..e4151a44 100644
--- a/src/lib/builders/listbox/create.ts
+++ b/src/lib/builders/listbox/create.ts
@@ -339,7 +339,7 @@ export function createListbox<
 					 * When the menu is open...
 					 */
 					// Pressing `esc` should close the menu.
-					if (e.key === kbd.TAB || e.key === kbd.ESCAPE) {
+					if (e.key === kbd.TAB || (e.key === kbd.ESCAPE && get(closeOnEscape))) {
 						closeMenu();
 						return;
 					}
@@ -416,7 +416,9 @@ export function createListbox<
 
 			const escape = useEscapeKeydown(node, {
 				handler: () => {
-					closeMenu();
+					if (get(closeOnEscape)) {
+						closeMenu();
+					}
 				},
 			});
 			if (escape && escape.destroy) {
diff --git a/src/lib/builders/menu/create.ts b/src/lib/builders/menu/create.ts
index 2a421580..157a0e67 100644
--- a/src/lib/builders/menu/create.ts
+++ b/src/lib/builders/menu/create.ts
@@ -639,8 +639,8 @@ export function createMenuBuilder(opts: _MenuBuilderOptions) {
 	const createSubmenu = (args?: _CreateSubmenuProps) => {
 		const withDefaults = { ...subMenuDefaults, ...args } satisfies _CreateSubmenuProps;
 
-		const subOpen = writable(false);
-
+		const subOpenWritable = withDefaults.open ?? writable(false);
+		const subOpen = overridable(subOpenWritable, withDefaults?.onOpenChange);
 		// options
 		const options = toWritableStores(withDefaults);
 
@@ -684,6 +684,9 @@ export function createMenuBuilder(opts: _MenuBuilderOptions) {
 					id: subIds.menu,
 					'aria-labelledby': subIds.trigger,
 					'data-state': $subIsVisible ? 'open' : 'closed',
+					// unit tests fail on `.closest` if the id starts with a number
+					// so using a data attribute
+					'data-id': subIds.menu,
 					tabindex: -1,
 				} as const;
 			},
@@ -708,6 +711,7 @@ export function createMenuBuilder(opts: _MenuBuilderOptions) {
 									portal: isHTMLElement(parentMenuEl) ? parentMenuEl : undefined,
 									clickOutside: null,
 									focusTrap: null,
+									escapeKeydown: null,
 								},
 							});
 
@@ -884,7 +888,9 @@ export function createMenuBuilder(opts: _MenuBuilderOptions) {
 
 						const triggerEl = e.currentTarget;
 						if (!isHTMLElement(triggerEl)) return;
-						handleRovingFocus(triggerEl);
+						if (!isFocusWithinSubmenu(subIds.menu)) {
+							handleRovingFocus(triggerEl);
+						}
 
 						const openTimer = get(subOpenTimer);
 						if (!get(subOpen) && !openTimer && !isElementDisabled(triggerEl)) {
@@ -1144,7 +1150,7 @@ export function createMenuBuilder(opts: _MenuBuilderOptions) {
 		const handlePointer = () => isUsingKeyboard.set(false);
 		const handleKeyDown = (e: KeyboardEvent) => {
 			isUsingKeyboard.set(true);
-			if (e.key === kbd.ESCAPE && $rootOpen) {
+			if (e.key === kbd.ESCAPE && $rootOpen && get(closeOnEscape)) {
 				rootOpen.set(false);
 				return;
 			}
@@ -1499,3 +1505,12 @@ function isPointInPolygon(point: Point, polygon: Polygon) {
 
 	return inside;
 }
+
+function isFocusWithinSubmenu(submenuId: string) {
+	const activeEl = document.activeElement;
+	if (!isHTMLElement(activeEl)) return false;
+	// unit tests don't allow `.closest(#id)` to start with a number
+	// so we're using a data attribute.
+	const submenuEl = activeEl.closest(`[data-id="${submenuId}"]`);
+	return isHTMLElement(submenuEl);
+}
diff --git a/src/lib/builders/menu/types.ts b/src/lib/builders/menu/types.ts
index 828becfc..60afe081 100644
--- a/src/lib/builders/menu/types.ts
+++ b/src/lib/builders/menu/types.ts
@@ -112,7 +112,10 @@ export type _CreateMenuProps = {
 	disableFocusFirstItem?: boolean;
 };
 
-export type _CreateSubmenuProps = Pick<_CreateMenuProps, 'arrowSize' | 'positioning'> & {
+export type _CreateSubmenuProps = Pick<
+	_CreateMenuProps,
+	'arrowSize' | 'positioning' | 'open' | 'onOpenChange'
+> & {
 	disabled?: boolean;
 };
 


Test patch:
diff --git a/scripts/setupTest.ts b/scripts/setupTest.ts
index f5a1d463..6b17bfed 100644
--- a/scripts/setupTest.ts
+++ b/scripts/setupTest.ts
@@ -85,3 +85,5 @@ vi.mock('$app/stores', (): typeof stores => {
 });
 
 global.ResizeObserver = require('resize-observer-polyfill');
+
+Element.prototype.scrollIntoView = () => {};
diff --git a/src/tests/dropdown-menu/DropdownMenu.spec.ts b/src/tests/dropdown-menu/DropdownMenu.spec.ts
index 129e97f2..b811ad98 100644
--- a/src/tests/dropdown-menu/DropdownMenu.spec.ts
+++ b/src/tests/dropdown-menu/DropdownMenu.spec.ts
@@ -3,7 +3,7 @@ import { axe } from 'jest-axe';
 import { describe, vi } from 'vitest';
 import userEvent from '@testing-library/user-event';
 import { sleep } from '$lib/internal/helpers/index.js';
-import { kbd } from '$lib/internal/helpers/index.js';
+import { testKbd as kbd } from '../utils.js';
 import DropdownMenuTest from './DropdownMenuTest.svelte';
 import DropdownMenuForceVisible from './DropdownMenuForceVisibleTest.svelte';
 import { tick } from 'svelte';
@@ -42,7 +42,7 @@ describe('Dropdown Menu (Default)', () => {
 
 		expect(menu).not.toBeVisible();
 		await act(() => trigger.focus());
-		await user.keyboard(`{${key}}`);
+		await user.keyboard(key);
 		expect(menu).toBeVisible();
 
 		const firstItem = getByTestId('item1');
@@ -62,13 +62,13 @@ describe('Dropdown Menu (Default)', () => {
 		const firstItem = getByTestId('item1');
 		expect(firstItem).not.toHaveFocus();
 
-		await user.keyboard(`{${kbd.ARROW_DOWN}}`);
+		await user.keyboard(kbd.ARROW_DOWN);
 
 		// focuses first item after arrow down
 		expect(firstItem).toHaveFocus();
 
 		// Doesnt focus disabled menu items
-		await user.keyboard(`{${kbd.ARROW_DOWN}}`);
+		await user.keyboard(kbd.ARROW_DOWN);
 		const disabledItem = getByTestId('item2');
 		expect(disabledItem).not.toHaveFocus();
 		expect(getByTestId('checkboxItem1')).toHaveFocus();
@@ -82,7 +82,7 @@ describe('Dropdown Menu (Default)', () => {
 
 		expect(menu).not.toBeVisible();
 		await act(() => trigger.focus());
-		await user.keyboard(`{${key}}`);
+		await user.keyboard(key);
 		expect(menu).toBeVisible();
 	});
 
@@ -92,7 +92,7 @@ describe('Dropdown Menu (Default)', () => {
 		const user = userEvent.setup();
 
 		await act(() => trigger.focus());
-		await user.keyboard(`{${kbd.ENTER}}`);
+		await user.keyboard(kbd.ENTER);
 		const checked1 = getByTestId('check1');
 		expect(checked1).toBeVisible();
 		expect(queryByTestId('check2')).toBeNull();
@@ -104,11 +104,11 @@ describe('Dropdown Menu (Default)', () => {
 		const user = userEvent.setup();
 
 		await act(() => trigger.focus());
-		await user.keyboard(`{${kbd.ENTER}}`);
-		await user.keyboard(`{${kbd.ARROW_DOWN}}`);
+		await user.keyboard(kbd.ENTER);
+		await user.keyboard(kbd.ARROW_DOWN);
 		expect(queryByTestId('check1')).not.toBeNull();
-		await user.keyboard(`{${kbd.ENTER}}`);
-		await user.keyboard(`{${kbd.ENTER}}`);
+		await user.keyboard(kbd.ENTER);
+		await user.keyboard(kbd.ENTER);
 		expect(queryByTestId('check1')).toBeNull();
 	});
 
@@ -118,12 +118,12 @@ describe('Dropdown Menu (Default)', () => {
 		const user = userEvent.setup();
 
 		await act(() => trigger.focus());
-		await user.keyboard(`{${kbd.ENTER}}`);
-		await user.keyboard(`{${kbd.ARROW_DOWN}}`);
-		await user.keyboard(`{${kbd.ARROW_DOWN}}`);
+		await user.keyboard(kbd.ENTER);
+		await user.keyboard(kbd.ARROW_DOWN);
+		await user.keyboard(kbd.ARROW_DOWN);
 		expect(queryByTestId('check2')).toBeNull();
-		await user.keyboard(`{${kbd.ENTER}}`);
-		await user.keyboard(`{${kbd.ENTER}}`);
+		await user.keyboard(kbd.ENTER);
+		await user.keyboard(kbd.ENTER);
 		expect(queryByTestId('check2')).not.toBeNull();
 	});
 
@@ -155,7 +155,7 @@ describe('Dropdown Menu (Default)', () => {
 			await user.click(trigger);
 			const item = getByTestId(hoverItem);
 			await user.hover(item);
-			await user.keyboard(`{${key}}`);
+			await user.keyboard(key);
 			expect(item).not.toHaveFocus();
 			expect(getByTestId(`${endItem}`)).toHaveFocus();
 		}
@@ -172,13 +172,13 @@ describe('Dropdown Menu (Default)', () => {
 		const subtrigger = getByTestId('subtrigger');
 		await user.hover(subtrigger);
 		await sleep(100);
-		await user.keyboard(`{${kbd.ARROW_RIGHT}}`);
+		await user.keyboard(kbd.ARROW_RIGHT);
 		const subItem0 = getByTestId('subitem0');
 		expect(subItem0).toHaveFocus();
-		await user.keyboard(`{${kbd.ARROW_DOWN}}`);
+		await user.keyboard(kbd.ARROW_DOWN);
 		const subItem1 = getByTestId('subitem1');
 		expect(subItem1).toHaveFocus();
-		await user.keyboard(`{${kbd.ARROW_LEFT}}`);
+		await user.keyboard(kbd.ARROW_LEFT);
 		expect(subItem0).not.toHaveFocus();
 		expect(submenu).not.toBeVisible();
 		expect(subtrigger).toHaveFocus();
@@ -195,11 +195,39 @@ describe('Dropdown Menu (Default)', () => {
 		expect(menu).not.toBeVisible();
 		await act(() => userEvent.click(trigger));
 		expect(menu).toBeVisible();
-		await act(() => userEvent.keyboard(`{${kbd.ESCAPE}}`));
+		await act(() => userEvent.keyboard(kbd.ESCAPE));
 		const closeFocus = getByTestId('closeFocus');
 		expect(closeFocus).toHaveFocus();
 	});
 
+	test('respects `closeOnEscape` prop', async () => {
+		const user = userEvent.setup();
+		const { getByTestId } = render(DropdownMenuTest, {
+			closeOnEscape: false,
+		});
+		const trigger = getByTestId('trigger');
+		const menu = getByTestId('menu');
+		await user.click(trigger);
+		expect(menu).toBeVisible();
+		await user.keyboard(kbd.ESCAPE);
+		expect(menu).toBeVisible();
+		await user.keyboard(kbd.ARROW_DOWN);
+	});
+
+	test('respects close on outside click prop', async () => {
+		const user = userEvent.setup();
+		const { getByTestId } = render(DropdownMenuTest, {
+			closeOnOutsideClick: false,
+		});
+		const trigger = getByTestId('trigger');
+		const menu = getByTestId('menu');
+		await user.click(trigger);
+		expect(menu).toBeVisible();
+		const outsideClick = getByTestId('outside-click');
+		await user.click(outsideClick);
+		expect(menu).toBeVisible();
+	});
+
 	test.todo('Radio items');
 });
 
@@ -244,7 +272,7 @@ describe('Dropdown Menu (forceVisible)', () => {
 		const user = userEvent.setup();
 
 		await act(() => trigger.focus());
-		await user.keyboard(`{${key}}`);
+		await user.keyboard(key);
 		expect(getByTestId('menu')).toBeVisible();
 	});
 
@@ -262,13 +290,13 @@ describe('Dropdown Menu (forceVisible)', () => {
 		const firstItem = getByTestId('item1');
 		expect(firstItem).not.toHaveFocus();
 
-		await user.keyboard(`{${kbd.ARROW_DOWN}}`);
+		await user.keyboard(kbd.ARROW_DOWN);
 
 		// focuses first item after arrow down
 		expect(firstItem).toHaveFocus();
 
 		// Doesnt focus disabled menu items
-		await user.keyboard(`{${kbd.ARROW_DOWN}}`);
+		await user.keyboard(kbd.ARROW_DOWN);
 		const disabledItem = getByTestId('item2');
 		expect(disabledItem).not.toHaveFocus();
 		expect(getByTestId('checkboxItem1')).toHaveFocus();
@@ -281,13 +309,13 @@ describe('Dropdown Menu (forceVisible)', () => {
 
 		expect(queryByTestId('menu')).toBeNull();
 		await act(() => trigger.focus());
-		await user.keyboard(`{${kbd.ENTER}}`);
+		await user.keyboard(kbd.ENTER);
 		expect(getByTestId('menu')).toBeVisible();
 		// focuses first item when opened with a key
 		expect(getByTestId('item1')).toHaveFocus();
 
 		// Doesnt focus disabled menu items
-		await user.keyboard(`{${kbd.ARROW_DOWN}}`);
+		await user.keyboard(kbd.ARROW_DOWN);
 		const disabledItem = getByTestId('item2');
 		expect(disabledItem).not.toHaveFocus();
 		expect(getByTestId('checkboxItem1')).toHaveFocus();
@@ -299,7 +327,7 @@ describe('Dropdown Menu (forceVisible)', () => {
 		const user = userEvent.setup();
 
 		await act(() => trigger.focus());
-		await user.keyboard(`{${kbd.ENTER}}`);
+		await user.keyboard(kbd.ENTER);
 		const checked1 = getByTestId('check1');
 		expect(checked1).toBeVisible();
 		expect(queryByTestId('check2')).toBeNull();
@@ -311,11 +339,11 @@ describe('Dropdown Menu (forceVisible)', () => {
 		const user = userEvent.setup();
 
 		await act(() => trigger.focus());
-		await user.keyboard(`{${kbd.ENTER}}`);
-		await user.keyboard(`{${kbd.ARROW_DOWN}}`);
+		await user.keyboard(kbd.ENTER);
+		await user.keyboard(kbd.ARROW_DOWN);
 		expect(queryByTestId('check1')).not.toBeNull();
-		await user.keyboard(`{${kbd.ENTER}}`);
-		await user.keyboard(`{${kbd.ENTER}}`);
+		await user.keyboard(kbd.ENTER);
+		await user.keyboard(kbd.ENTER);
 		expect(queryByTestId('check1')).toBeNull();
 	});
 
@@ -325,12 +353,12 @@ describe('Dropdown Menu (forceVisible)', () => {
 		const user = userEvent.setup();
 
 		await act(() => trigger.focus());
-		await user.keyboard(`{${kbd.ENTER}}`);
-		await user.keyboard(`{${kbd.ARROW_DOWN}}`);
-		await user.keyboard(`{${kbd.ARROW_DOWN}}`);
+		await user.keyboard(kbd.ENTER);
+		await user.keyboard(kbd.ARROW_DOWN);
+		await user.keyboard(kbd.ARROW_DOWN);
 		expect(queryByTestId('check2')).toBeNull();
-		await user.keyboard(`{${kbd.ENTER}}`);
-		await user.keyboard(`{${kbd.ENTER}}`);
+		await user.keyboard(kbd.ENTER);
+		await user.keyboard(kbd.ENTER);
 		expect(queryByTestId('check2')).not.toBeNull();
 	});
 
@@ -361,7 +389,7 @@ describe('Dropdown Menu (forceVisible)', () => {
 			await user.click(trigger);
 			const item = getByTestId(hoverItem);
 			await user.hover(item);
-			await user.keyboard(`{${key}}`);
+			await user.keyboard(key);
 			expect(item).not.toHaveFocus();
 			expect(getByTestId(`${endItem}`)).toHaveFocus();
 		}
@@ -377,19 +405,19 @@ describe('Dropdown Menu (forceVisible)', () => {
 		const subtrigger = getByTestId('subtrigger');
 		await user.hover(subtrigger);
 		await sleep(100);
-		await user.keyboard(`{${kbd.ARROW_RIGHT}}`);
+		await user.keyboard(kbd.ARROW_RIGHT);
 		const subItem0 = getByTestId('subitem0');
 		expect(subItem0).toHaveFocus();
 
 		// Arrow down inside submenu moves to next submenu item
-		await user.keyboard(`{${kbd.ARROW_DOWN}}`);
+		await user.keyboard(kbd.ARROW_DOWN);
 		const subItem1 = getByTestId('subitem1');
 		expect(subItem1).toHaveFocus();
 
 		await user.hover(subItem1);
 
 		// Arrow left inside submenu closes submenu and focuses subtrigger
-		await user.keyboard(`{${kbd.ARROW_LEFT}}`);
+		await user.keyboard(kbd.ARROW_LEFT);
 		expect(subItem0).not.toHaveFocus();
 		expect(subtrigger).toHaveFocus();
 	});
@@ -407,12 +435,12 @@ describe('Dropdown Menu (forceVisible)', () => {
 		expect(queryByTestId('menu')).not.toBeNull();
 
 		for (const item of menuItems) {
-			await user.keyboard(`{${kbd.ARROW_DOWN}}`);
+			await user.keyboard(kbd.ARROW_DOWN);
 			expect(getByTestId(item)).toHaveFocus();
 		}
-		await user.keyboard(`{${kbd.ARROW_DOWN}}`);
+		await user.keyboard(kbd.ARROW_DOWN);
 		expect(getByTestId('item1')).toHaveFocus();
-		await user.keyboard(`{${kbd.ARROW_UP}}`);
+		await user.keyboard(kbd.ARROW_UP);
 		expect(getByTestId('subtrigger')).toHaveFocus();
 	});
 
@@ -427,19 +455,19 @@ describe('Dropdown Menu (forceVisible)', () => {
 		expect(queryByTestId('menu')).not.toBeNull();
 
 		for (const item of menuItems) {
-			await user.keyboard(`{${kbd.ARROW_DOWN}}`);
+			await user.keyboard(kbd.ARROW_DOWN);
 			expect(getByTestId(item)).toHaveFocus();
 		}
-		await user.keyboard(`{${kbd.ARROW_DOWN}}`);
+		await user.keyboard(kbd.ARROW_DOWN);
 		expect(getByTestId('item1')).not.toHaveFocus();
 		expect(getByTestId('subtrigger')).toHaveFocus();
 
-		await user.keyboard(`{${kbd.ARROW_UP}}`);
-		await user.keyboard(`{${kbd.ARROW_UP}}`);
-		await user.keyboard(`{${kbd.ARROW_UP}}`);
+		await user.keyboard(kbd.ARROW_UP);
+		await user.keyboard(kbd.ARROW_UP);
+		await user.keyboard(kbd.ARROW_UP);
 		expect(getByTestId('item1')).toHaveFocus();
 
-		await user.keyboard(`{${kbd.ARROW_UP}}`);
+		await user.keyboard(kbd.ARROW_UP);
 		expect(getByTestId('subtrigger')).not.toHaveFocus();
 		expect(getByTestId('item1')).toHaveFocus();
 	});
diff --git a/src/tests/dropdown-menu/DropdownMenuTest.svelte b/src/tests/dropdown-menu/DropdownMenuTest.svelte
index af534890..a41138f7 100644
--- a/src/tests/dropdown-menu/DropdownMenuTest.svelte
+++ b/src/tests/dropdown-menu/DropdownMenuTest.svelte
@@ -8,6 +8,8 @@
 
 	export let loop = false;
 	export let closeFocus: CreateDropdownMenuProps['closeFocus'] = undefined;
+	export let closeOnEscape: CreateDropdownMenuProps['closeOnEscape'] = true;
+	export let closeOnOutsideClick: CreateDropdownMenuProps['closeOnOutsideClick'] = true;
 
 	const {
 		elements: { trigger, menu, item, separator, arrow },
@@ -15,6 +17,8 @@
 	} = createDropdownMenu({
 		loop,
 		closeFocus,
+		closeOnEscape,
+		closeOnOutsideClick,
 	});
 
 	const {
@@ -43,6 +47,7 @@
 </script>
 
 <main>
+	<div data-testid="outside-click">outside</div>
 	<button id="closeFocus" data-testid="closeFocus">close focus</button>
 	<button
 		type="button"
diff --git a/src/tests/select/Select.spec.ts b/src/tests/select/Select.spec.ts
index dc107fde..43c0f9e8 100644
--- a/src/tests/select/Select.spec.ts
+++ b/src/tests/select/Select.spec.ts
@@ -1,151 +1,116 @@
-import '@testing-library/jest-dom';
-import { render, act, fireEvent, waitFor } from '@testing-library/svelte';
+import { render, act, waitFor } from '@testing-library/svelte';
 import { axe } from 'jest-axe';
 import { describe } from 'vitest';
 import userEvent from '@testing-library/user-event';
-import { kbd, sleep } from '$lib/internal/helpers/index.js';
+import { testKbd as kbd } from '../utils';
 import SelectTest from './SelectTest.svelte';
-import { tick } from 'svelte';
 
 const OPEN_KEYS = [kbd.ENTER, kbd.SPACE];
 
-describe('Select (Default)', () => {
+describe('Select', () => {
 	test('No accessibility violations', async () => {
-		const { container } = await render(SelectTest);
-
+		const { container } = render(SelectTest);
 		expect(await axe(container)).toHaveNoViolations();
 	});
 
 	test('Opens/Closes when trigger is clicked', async () => {
-		const { getByTestId } = await render(SelectTest);
+		const { getByTestId } = render(SelectTest);
 		const trigger = getByTestId('trigger');
 		const menu = getByTestId('menu');
 		const user = userEvent.setup();
 
-		await expect(menu).not.toBeVisible();
+		expect(menu).not.toBeVisible();
 		await user.click(trigger);
-		await expect(getByTestId('menu')).toBeVisible();
+		expect(getByTestId('menu')).toBeVisible();
 
 		await user.click(trigger);
-		await expect(menu).not.toBeVisible();
+		expect(menu).not.toBeVisible();
 	});
 
 	test.each(OPEN_KEYS)('Opens when %s is pressed', async (key) => {
-		const { getByTestId } = await render(SelectTest);
+		const { getByTestId } = render(SelectTest);
 		const trigger = getByTestId('trigger');
 		const menu = getByTestId('menu');
 		const user = userEvent.setup();
 
-		await expect(menu).not.toBeVisible();
+		expect(menu).not.toBeVisible();
 		await act(() => trigger.focus());
-		await user.keyboard(`{${key}}`);
-		await expect(menu).toBeVisible();
-	});
-
-	test.skip('Focus when opened with click', async () => {
-		const { getByTestId } = await render(SelectTest);
-		await tick();
-		const trigger = getByTestId('trigger');
-		const menu = getByTestId('menu');
-		const user = userEvent.setup();
-
-		await expect(menu).not.toBeVisible();
-		await expect(trigger).toBeVisible();
-		await sleep(100);
-		await fireEvent.click(trigger);
-		await expect(menu).toBeVisible();
-
-		const firstItem = getByTestId('sweet-option-0');
-		await expect(firstItem).not.toHaveFocus();
-
-		await user.keyboard(`{${kbd.ARROW_DOWN}}`);
-
-		// Focuses first item after arrow down
-		await expect(firstItem).toHaveFocus();
-
-		// Focuses next item after arrow down
-		await user.keyboard(`{${kbd.ARROW_DOWN}}`);
-		const secondItem = getByTestId('sweet-option-1');
-		await expect(secondItem).toHaveFocus();
-
-		// Doesn't focus disabled items
-		await user.keyboard(`{${kbd.ARROW_DOWN}}`);
-		const thirdItem = getByTestId('sweet-option-2');
-		await expect(thirdItem).not.toHaveFocus();
+		await user.keyboard(key);
+		expect(menu).toBeVisible();
 	});
 
 	test('Selects item when clicked', async () => {
-		const { getByTestId } = await render(SelectTest);
+		const { getByTestId } = render(SelectTest);
 		const trigger = getByTestId('trigger');
 		const menu = getByTestId('menu');
 		const user = userEvent.setup();
 
-		await expect(trigger).not.toHaveTextContent('Caramel');
+		expect(trigger).not.toHaveTextContent('Caramel');
 
-		await expect(menu).not.toBeVisible();
+		expect(menu).not.toBeVisible();
 		await user.click(trigger);
-		await expect(menu).toBeVisible();
+		expect(menu).toBeVisible();
 
 		const firstItem = menu.querySelector('[data-melt-select-option]');
 		if (!firstItem) throw new Error('No option found');
 		await user.click(firstItem);
 
-		await expect(menu).not.toBeVisible();
-		await expect(trigger).toHaveTextContent('Caramel');
+		expect(menu).not.toBeVisible();
+		expect(trigger).toHaveTextContent('Caramel');
 
 		await user.click(trigger);
-		await expect(menu).toBeVisible();
+		expect(menu).toBeVisible();
 
 		const secondItem = menu.querySelectorAll('[data-melt-select-option]')[1];
 		if (!secondItem) throw new Error('No option found');
 		await user.click(secondItem);
 
-		await expect(menu).not.toBeVisible();
-		await expect(trigger).toHaveTextContent('Chocolate');
+		expect(menu).not.toBeVisible();
+		expect(trigger).toHaveTextContent('Chocolate');
 	});
 
 	test('Selects multiple items when `multiple` is true', async () => {
-		const { getByTestId } = await render(SelectTest, { multiple: true });
+		const { getByTestId } = render(SelectTest, { multiple: true });
 		const trigger = getByTestId('trigger');
 		const menu = getByTestId('menu');
 		const user = userEvent.setup();
 
-		await expect(trigger).not.toHaveTextContent('Caramel');
+		expect(trigger).not.toHaveTextContent('Caramel');
 
-		await expect(menu).not.toBeVisible();
+		expect(menu).not.toBeVisible();
 		await user.click(trigger);
-		await expect(menu).toBeVisible();
+		expect(menu).toBeVisible();
 
 		const firstItem = menu.querySelector('[data-melt-select-option]');
 		if (!firstItem) throw new Error('No option found');
 		await user.click(firstItem);
 
-		await expect(firstItem).toHaveAttribute('data-selected');
-		await expect(firstItem).toHaveAttribute('aria-selected', 'true');
+		expect(firstItem).toHaveAttribute('data-selected');
+		expect(firstItem).toHaveAttribute('aria-selected', 'true');
 
-		await expect(menu).toBeVisible();
-		await expect(trigger).toHaveTextContent('Caramel');
+		expect(menu).toBeVisible();
+		expect(trigger).toHaveTextContent('Caramel');
 
 		const secondItem = menu.querySelectorAll('[data-melt-select-option]')[1];
 		if (!secondItem) throw new Error('No option found');
 		await user.click(secondItem);
 
-		await expect(secondItem).toHaveAttribute('data-selected');
-		await expect(secondItem).toHaveAttribute('aria-selected', 'true');
+		expect(secondItem).toHaveAttribute('data-selected');
+		expect(secondItem).toHaveAttribute('aria-selected', 'true');
 
-		await expect(menu).toBeVisible();
-		await expect(trigger).toHaveTextContent('Caramel, Chocolate');
+		expect(menu).toBeVisible();
+		expect(trigger).toHaveTextContent('Caramel, Chocolate');
 	});
 
 	test('Shows correct label when defaultValue is provided', async () => {
-		const { getByTestId } = await render(SelectTest, { defaultValue: 'Chocolate' });
+		const { getByTestId } = render(SelectTest, { defaultValue: 'Chocolate' });
 		const trigger = getByTestId('trigger');
 
-		await expect(trigger).toHaveTextContent('Chocolate');
+		expect(trigger).toHaveTextContent('Chocolate');
 	});
 
 	test('Manually setting the value updates the label', async () => {
-		const { getByTestId } = await render(SelectTest);
+		const { getByTestId } = render(SelectTest);
 		const manualBtn = getByTestId('manual-btn');
 		const trigger = getByTestId('trigger');
 
@@ -154,7 +119,7 @@ describe('Select (Default)', () => {
 	});
 
 	test('Updating options and setting the value updates the label', async () => {
-		const { getByTestId } = await render(SelectTest);
+		const { getByTestId } = render(SelectTest);
 		const updateBtn = getByTestId('update-btn');
 		const trigger = getByTestId('trigger');
 
@@ -162,6 +127,35 @@ describe('Select (Default)', () => {
 		await waitFor(() => expect(trigger).toHaveTextContent('Vanilla'), { timeout: 500 });
 	});
 
+	test('Respects the `closeOnEscape` prop', async () => {
+		const { getByTestId } = render(SelectTest, { closeOnEscape: false });
+		const trigger = getByTestId('trigger');
+		const menu = getByTestId('menu');
+		const user = userEvent.setup();
+
+		expect(menu).not.toBeVisible();
+		await user.click(trigger);
+		expect(menu).toBeVisible();
+
+		await user.keyboard(kbd.ESCAPE);
+		expect(menu).toBeVisible();
+	});
+
+	test('Respects the `closeOnOutsideClick` prop', async () => {
+		const { getByTestId } = render(SelectTest, { closeOnOutsideClick: false });
+		const trigger = getByTestId('trigger');
+		const menu = getByTestId('menu');
+		const user = userEvent.setup();
+
+		expect(menu).not.toBeVisible();
+		await user.click(trigger);
+		expect(menu).toBeVisible();
+
+		const outside = getByTestId('outside');
+		await user.click(outside);
+		expect(menu).toBeVisible();
+	});
+
 	test.todo('Disabled select cannot be opened');
 	test.todo('Options loop when loop prop is set');
 });
diff --git a/src/tests/select/SelectTest.svelte b/src/tests/select/SelectTest.svelte
index 676f21b2..a9843802 100644
--- a/src/tests/select/SelectTest.svelte
+++ b/src/tests/select/SelectTest.svelte
@@ -5,7 +5,8 @@
 
 	export let multiple = false;
 	export let defaultValue: string | undefined = undefined;
-
+	export let closeOnEscape = true;
+	export let closeOnOutsideClick = true;
 	const {
 		elements: { trigger, menu, option, group, groupLabel },
 		states: { selected, selectedLabel },
@@ -18,6 +19,8 @@
 					label: defaultValue,
 			  }
 			: undefined,
+		closeOnEscape,
+		closeOnOutsideClick,
 	});
 
 	let options = {
@@ -78,4 +81,5 @@
 			</div>
 		{/each}
 	</div>
+	<div data-testid="outside" />
 </main>
diff --git a/src/tests/utils.ts b/src/tests/utils.ts
new file mode 100644
index 00000000..693004d6
--- /dev/null
+++ b/src/tests/utils.ts
@@ -0,0 +1,11 @@
+import { kbd } from '$lib/internal/helpers';
+
+type KbdKeys = keyof typeof kbd;
+/**
+ * A wrapper around the internal kbd object to make it easier to use
+ * in tests which require the key names to be wrapped in curly braces.
+ */
+export const testKbd: Record<KbdKeys, string> = Object.entries(kbd).reduce((acc, [key, value]) => {
+	acc[key as KbdKeys] = `{${value}}`;
+	return acc;
+}, {} as Record<KbdKeys, string>);