Repository: melt-ui/melt-ui
Pull request #654 (2023-10-21 03:09:26)

fix context menu trigger bug
Closes: #653 

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

Latest commit: b18c5280cc97941f41cd1de6643efbf88e1f2bc3

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/fix/context-menu-bug?filename=.changeset/afraid-peaches-eat.md&value=---%0A%22%40melt-ui%2Fsvelte%22%3A%20patch%0A---%0A%0Afix%20context%20menu%20trigger%20bug%0A)



Patch:
diff --git a/.changeset/afraid-peaches-eat.md b/.changeset/afraid-peaches-eat.md
new file mode 100644
index 00000000..4d627113
--- /dev/null
+++ b/.changeset/afraid-peaches-eat.md
@@ -0,0 +1,5 @@
+---
+"@melt-ui/svelte": patch
+---
+
+Fix bug where context menu wasn't treating elements within the trigger as part of the trigger
diff --git a/.changeset/eleven-geese-hope.md b/.changeset/eleven-geese-hope.md
new file mode 100644
index 00000000..f8b3dbd2
--- /dev/null
+++ b/.changeset/eleven-geese-hope.md
@@ -0,0 +1,5 @@
+---
+"@melt-ui/svelte": patch
+---
+
+fix: loop prop in menus
diff --git a/.eslintrc.cjs b/.eslintrc.cjs
index f5f31e70..237fd826 100644
--- a/.eslintrc.cjs
+++ b/.eslintrc.cjs
@@ -45,7 +45,6 @@ module.exports = {
 		// eslint-plugin-svelte
 		'svelte/no-target-blank': 'error',
 		'svelte/no-immutable-reactive-statements': 'error',
-		'svelte/no-trailing-spaces': 'error',
 		'svelte/prefer-style-directive': 'error',
 		'svelte/no-reactive-literals': 'error',
 		'svelte/no-useless-mustaches': 'error',
diff --git a/src/docs/data/builders/table-of-contents.ts b/src/docs/data/builders/table-of-contents.ts
index 25fd3ab4..f2a4a569 100644
--- a/src/docs/data/builders/table-of-contents.ts
+++ b/src/docs/data/builders/table-of-contents.ts
@@ -60,7 +60,7 @@ const builder: APISchema = {
 			name: 'item',
 			description: 'The builder store used to create a table of contents item.',
 			link: '#item',
-		}
+		},
 	],
 	states: [
 		{
@@ -72,7 +72,7 @@ const builder: APISchema = {
 			name: 'headingsTree',
 			type: 'Writable<TableOfContentsItem[]>',
 			description: 'A writable store that lists all the headings within the specified container.',
-		}
+		},
 	],
 };
 
@@ -82,14 +82,14 @@ const item = elementSchema('item', {
 	dataAttributes: [
 		{
 			name: 'data-melt-table-of-contents-item',
-			value: ATTRS.MELT('item')
+			value: ATTRS.MELT('item'),
 		},
 		{
 			name: 'data-id',
 			value: 'The id of the heading element the item links to.',
 		},
 	],
-	events: tableOfContentsEvents['item']
+	events: tableOfContentsEvents['item'],
 });
 
 const schemas = [builder, item];
diff --git a/src/docs/previews/context-menu/main/tailwind/index.svelte b/src/docs/previews/context-menu/main/tailwind/index.svelte
index 5790e340..cb7c07c9 100644
--- a/src/docs/previews/context-menu/main/tailwind/index.svelte
+++ b/src/docs/previews/context-menu/main/tailwind/index.svelte
@@ -9,7 +9,9 @@
 	const {
 		elements: { trigger, menu, item, separator },
 		builders: { createSubmenu, createMenuRadioGroup, createCheckboxItem },
-	} = createContextMenu();
+	} = createContextMenu({
+		loop: true,
+	});
 
 	const {
 		elements: { subMenu: subMenuA, subTrigger: subTriggerA },
diff --git a/src/docs/previews/dropdown-menu/main/tailwind/index.svelte b/src/docs/previews/dropdown-menu/main/tailwind/index.svelte
index 55b8337a..61d0bf80 100644
--- a/src/docs/previews/dropdown-menu/main/tailwind/index.svelte
+++ b/src/docs/previews/dropdown-menu/main/tailwind/index.svelte
@@ -13,6 +13,7 @@
 		states: { open },
 	} = createDropdownMenu({
 		forceVisible: true,
+		loop: true,
 	});
 
 	const {
diff --git a/src/lib/builders/context-menu/create.ts b/src/lib/builders/context-menu/create.ts
index 799d5bc1..44f1f474 100644
--- a/src/lib/builders/context-menu/create.ts
+++ b/src/lib/builders/context-menu/create.ts
@@ -59,7 +59,8 @@ export function createContextMenu(props?: CreateContextMenuProps) {
 	const withDefaults = { ...defaults, ...props } satisfies CreateContextMenuProps;
 
 	const rootOptions = toWritableStores(withDefaults);
-	const { positioning, closeOnOutsideClick, portal, forceVisible, closeOnEscape } = rootOptions;
+	const { positioning, closeOnOutsideClick, portal, forceVisible, closeOnEscape, loop } =
+		rootOptions;
 
 	const openWritable = withDefaults.open ?? writable(withDefaults.defaultOpen);
 	const rootOpen = overridable(openWritable, withDefaults?.onOpenChange);
@@ -111,13 +112,11 @@ export function createContextMenu(props?: CreateContextMenuProps) {
 		const target = e.target;
 		if (!(target instanceof Element)) return;
 
-		if (target.id === ids.trigger && isLeftClick(e)) {
-			rootOpen.set(false);
-			return;
-		}
+		const isClickInsideTrigger = target.closest(`#${ids.trigger}`) !== null;
 
-		if (target.id !== ids.trigger && !target.closest(selector())) {
+		if (!isClickInsideTrigger || isLeftClick(e)) {
 			rootOpen.set(false);
+			return;
 		}
 	}
 
@@ -195,7 +194,7 @@ export function createContextMenu(props?: CreateContextMenuProps) {
 					const isKeyDownInside = target.closest("[role='menu']") === menuEl;
 					if (!isKeyDownInside) return;
 					if (FIRST_LAST_KEYS.includes(e.key)) {
-						handleMenuNavigation(e);
+						handleMenuNavigation(e, get(loop));
 					}
 
 					/**
diff --git a/src/lib/builders/menu/create.ts b/src/lib/builders/menu/create.ts
index 7fc150de..cab926df 100644
--- a/src/lib/builders/menu/create.ts
+++ b/src/lib/builders/menu/create.ts
@@ -83,6 +83,7 @@ export function createMenuBuilder(opts: _MenuBuilderOptions) {
 		portal,
 		forceVisible,
 		typeahead,
+		loop,
 	} = opts.rootOptions;
 
 	const rootOpen = opts.rootOpen;
@@ -208,7 +209,7 @@ export function createMenuBuilder(opts: _MenuBuilderOptions) {
 
 					if (!isKeyDownInside) return;
 					if (FIRST_LAST_KEYS.includes(e.key)) {
-						handleMenuNavigation(e);
+						handleMenuNavigation(e, get(loop) ?? false);
 					}
 
 					/**
@@ -733,7 +734,7 @@ export function createMenuBuilder(opts: _MenuBuilderOptions) {
 						if (FIRST_LAST_KEYS.includes(e.key)) {
 							// prevent events from bubbling
 							e.stopImmediatePropagation();
-							handleMenuNavigation(e);
+							handleMenuNavigation(e, get(loop) ?? false);
 							return;
 						}
 
@@ -1412,7 +1413,7 @@ export function setMeltMenuAttribute(element: HTMLElement | null, selector: Sele
  * Keyboard event handler for menu navigation
  * @param e The keyboard event
  */
-export function handleMenuNavigation(e: KeyboardEvent) {
+export function handleMenuNavigation(e: KeyboardEvent, loop: boolean) {
 	e.preventDefault();
 
 	// currently focused menu item
@@ -1441,11 +1442,19 @@ export function handleMenuNavigation(e: KeyboardEvent) {
 	let nextIndex: number;
 	switch (e.key) {
 		case kbd.ARROW_DOWN:
-			nextIndex = currentIndex < candidateNodes.length - 1 ? currentIndex + 1 : currentIndex;
+			if (loop) {
+				nextIndex = currentIndex < candidateNodes.length - 1 ? currentIndex + 1 : 0;
+			} else {
+				nextIndex = currentIndex < candidateNodes.length - 1 ? currentIndex + 1 : currentIndex;
+			}
 			break;
 		case kbd.ARROW_UP:
-			nextIndex =
-				currentIndex < 0 ? candidateNodes.length - 1 : currentIndex > 0 ? currentIndex - 1 : 0;
+			if (loop) {
+				nextIndex = currentIndex > 0 ? currentIndex - 1 : candidateNodes.length - 1;
+			} else {
+				nextIndex =
+					currentIndex < 0 ? candidateNodes.length - 1 : currentIndex > 0 ? currentIndex - 1 : 0;
+			}
 			break;
 		case kbd.HOME:
 			nextIndex = 0;
diff --git a/src/lib/builders/switch/create.ts b/src/lib/builders/switch/create.ts
index a5a93a20..6469c5d7 100644
--- a/src/lib/builders/switch/create.ts
+++ b/src/lib/builders/switch/create.ts
@@ -48,8 +48,8 @@ export function createSwitch(props?: CreateSwitchProps) {
 				'data-state': $checked ? 'checked' : 'unchecked',
 				type: 'button',
 				role: 'switch',
-				'aria-checked': $checked,
-				'aria-required': $required,
+				'aria-checked': $checked ? 'true' : 'false',
+				'aria-required': $required ? 'true' : undefined,
 			} as const;
 		},
 		action(node: HTMLElement): MeltActionReturn<SwitchEvents['root']> {
diff --git a/src/lib/builders/table-of-contents/events.ts b/src/lib/builders/table-of-contents/events.ts
index 9983b0d9..275f4a4f 100644
--- a/src/lib/builders/table-of-contents/events.ts
+++ b/src/lib/builders/table-of-contents/events.ts
@@ -1,9 +1,8 @@
-import type { GroupedEvents, MeltComponentEvents } from "$lib/internal/types";
-
+import type { GroupedEvents, MeltComponentEvents } from '$lib/internal/types';
 
 export const tableOfContentsEvents = {
-    item: ['click'] as const
+	item: ['click'] as const,
 };
 
 export type TableOfContentsEvents = GroupedEvents<typeof tableOfContentsEvents>;
-export type TableOfContentsComponentEvents = MeltComponentEvents<TableOfContentsEvents>;
\ No newline at end of file
+export type TableOfContentsComponentEvents = MeltComponentEvents<TableOfContentsEvents>;


Test patch:
diff --git a/src/tests/dropdown-menu/DropdownMenu.spec.ts b/src/tests/dropdown-menu/DropdownMenu.spec.ts
index 8cd024f7..61c9348f 100644
--- a/src/tests/dropdown-menu/DropdownMenu.spec.ts
+++ b/src/tests/dropdown-menu/DropdownMenu.spec.ts
@@ -12,108 +12,108 @@ const OPEN_KEYS = [kbd.ENTER, kbd.ARROW_DOWN, kbd.SPACE];
 
 describe('Dropdown Menu (Default)', () => {
 	test('No accessibility violations', async () => {
-		const { container } = await render(DropdownMenuTest);
+		const { container } = render(DropdownMenuTest);
 
 		expect(await axe(container)).toHaveNoViolations();
 	});
 
 	test('Opens when trigger is clicked', async () => {
-		const { getByTestId } = await render(DropdownMenuTest);
+		const { getByTestId } = render(DropdownMenuTest);
 		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(menu).toBeVisible();
+		expect(menu).toBeVisible();
 
 		const arrow = getByTestId('arrow');
-		await expect(arrow).toBeVisible();
+		expect(arrow).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(DropdownMenuTest);
+		const { getByTestId } = render(DropdownMenuTest);
 		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();
+		expect(menu).toBeVisible();
 
 		const firstItem = getByTestId('item1');
-		await expect(firstItem).toHaveFocus();
+		expect(firstItem).toHaveFocus();
 	});
 
 	test('Focus when opened with click', async () => {
-		const { getByTestId } = await render(DropdownMenuTest);
+		const { getByTestId } = render(DropdownMenuTest);
 		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(menu).toBeVisible();
+		expect(menu).toBeVisible();
 
 		const firstItem = getByTestId('item1');
-		await expect(firstItem).not.toHaveFocus();
+		expect(firstItem).not.toHaveFocus();
 
 		await user.keyboard(`{${kbd.ARROW_DOWN}}`);
 
 		// focuses first item after arrow down
-		await expect(firstItem).toHaveFocus();
+		expect(firstItem).toHaveFocus();
 
 		// Doesnt focus disabled menu items
 		await user.keyboard(`{${kbd.ARROW_DOWN}}`);
 		const disabledItem = getByTestId('item2');
-		await expect(disabledItem).not.toHaveFocus();
-		await expect(getByTestId('checkboxItem1')).toHaveFocus();
+		expect(disabledItem).not.toHaveFocus();
+		expect(getByTestId('checkboxItem1')).toHaveFocus();
 	});
 
 	test.each(OPEN_KEYS)('Opens when %s is pressed', async (key) => {
-		const { getByTestId } = await render(DropdownMenuTest);
+		const { getByTestId } = render(DropdownMenuTest);
 		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();
+		expect(menu).toBeVisible();
 	});
 
 	test('Displays proper initial checked state for checkbox items', async () => {
-		const { getByTestId, queryByTestId } = await render(DropdownMenuTest);
+		const { getByTestId, queryByTestId } = render(DropdownMenuTest);
 		const trigger = getByTestId('trigger');
 		const user = userEvent.setup();
 
 		await act(() => trigger.focus());
 		await user.keyboard(`{${kbd.ENTER}}`);
 		const checked1 = getByTestId('check1');
-		await expect(checked1).toBeVisible();
-		await expect(queryByTestId('check2')).toBeNull();
+		expect(checked1).toBeVisible();
+		expect(queryByTestId('check2')).toBeNull();
 	});
 
 	test('Toggles checked to false for default checked checkbox items', async () => {
-		const { getByTestId, queryByTestId } = await render(DropdownMenuTest);
+		const { getByTestId, queryByTestId } = render(DropdownMenuTest);
 		const trigger = getByTestId('trigger');
 		const user = userEvent.setup();
 
 		await act(() => trigger.focus());
 		await user.keyboard(`{${kbd.ENTER}}`);
 		await user.keyboard(`{${kbd.ARROW_DOWN}}`);
-		await expect(queryByTestId('check1')).not.toBeNull();
+		expect(queryByTestId('check1')).not.toBeNull();
 		await user.keyboard(`{${kbd.ENTER}}`);
 		await user.keyboard(`{${kbd.ENTER}}`);
-		await expect(queryByTestId('check1')).toBeNull();
+		expect(queryByTestId('check1')).toBeNull();
 	});
 
 	test('Toggles checked to true for default unchecked checkbox items', async () => {
-		const { getByTestId, queryByTestId } = await render(DropdownMenuTest);
+		const { getByTestId, queryByTestId } = render(DropdownMenuTest);
 		const trigger = getByTestId('trigger');
 		const user = userEvent.setup();
 
@@ -121,25 +121,25 @@ describe('Dropdown Menu (Default)', () => {
 		await user.keyboard(`{${kbd.ENTER}}`);
 		await user.keyboard(`{${kbd.ARROW_DOWN}}`);
 		await user.keyboard(`{${kbd.ARROW_DOWN}}`);
-		await expect(queryByTestId('check2')).toBeNull();
+		expect(queryByTestId('check2')).toBeNull();
 		await user.keyboard(`{${kbd.ENTER}}`);
 		await user.keyboard(`{${kbd.ENTER}}`);
-		await expect(queryByTestId('check2')).not.toBeNull();
+		expect(queryByTestId('check2')).not.toBeNull();
 	});
 
 	test('Hovering over subtrigger opens submenu', async () => {
-		const { getByTestId } = await render(DropdownMenuTest);
+		const { getByTestId } = render(DropdownMenuTest);
 		const trigger = getByTestId('trigger');
 		const user = userEvent.setup();
 
 		await user.click(trigger);
 		const submenu = getByTestId('submenu');
-		await expect(submenu).not.toBeVisible();
+		expect(submenu).not.toBeVisible();
 		const subtrigger = getByTestId('subtrigger');
 		await user.hover(subtrigger);
 		await sleep(100);
-		await expect(submenu).toBeVisible();
-		await expect(subtrigger).toHaveFocus();
+		expect(submenu).toBeVisible();
+		expect(subtrigger).toHaveFocus();
 	});
 
 	test.each([
@@ -148,7 +148,7 @@ describe('Dropdown Menu (Default)', () => {
 	])(
 		'Pressing arrow keys while hovering over item goes to next/prev item',
 		async (key, hoverItem, endItem) => {
-			const { getByTestId } = await render(DropdownMenuTest);
+			const { getByTestId } = render(DropdownMenuTest);
 			const trigger = getByTestId('trigger');
 			const user = userEvent.setup();
 
@@ -156,32 +156,32 @@ describe('Dropdown Menu (Default)', () => {
 			const item = getByTestId(hoverItem);
 			await user.hover(item);
 			await user.keyboard(`{${key}}`);
-			await expect(item).not.toHaveFocus();
-			await expect(getByTestId(`${endItem}`)).toHaveFocus();
+			expect(item).not.toHaveFocus();
+			expect(getByTestId(`${endItem}`)).toHaveFocus();
 		}
 	);
 
 	test('Arrow right when subtrigger hover focuses first item in submenu', async () => {
-		const { getByTestId } = await render(DropdownMenuTest);
+		const { getByTestId } = render(DropdownMenuTest);
 		const trigger = getByTestId('trigger');
 		const user = userEvent.setup();
 
 		await user.click(trigger);
 		const submenu = getByTestId('submenu');
-		await expect(submenu).not.toBeVisible();
+		expect(submenu).not.toBeVisible();
 		const subtrigger = getByTestId('subtrigger');
 		await user.hover(subtrigger);
 		await sleep(100);
 		await user.keyboard(`{${kbd.ARROW_RIGHT}}`);
 		const subItem0 = getByTestId('subitem0');
-		await expect(subItem0).toHaveFocus();
+		expect(subItem0).toHaveFocus();
 		await user.keyboard(`{${kbd.ARROW_DOWN}}`);
 		const subItem1 = getByTestId('subitem1');
-		await expect(subItem1).toHaveFocus();
+		expect(subItem1).toHaveFocus();
 		await user.keyboard(`{${kbd.ARROW_LEFT}}`);
-		await expect(subItem0).not.toHaveFocus();
-		await expect(submenu).not.toBeVisible();
-		await expect(subtrigger).toHaveFocus();
+		expect(subItem0).not.toHaveFocus();
+		expect(submenu).not.toBeVisible();
+		expect(subtrigger).toHaveFocus();
 	});
 
 	test.todo('Radio items');
@@ -199,71 +199,71 @@ describe('Dropdown Menu (forceVisible)', () => {
 	});
 
 	test('No accessibility violations', async () => {
-		const { container } = await render(DropdownMenuForceVisible);
+		const { container } = render(DropdownMenuForceVisible);
 
 		expect(await axe(container)).toHaveNoViolations();
 	});
 
 	test('Opens when trigger is clicked', async () => {
-		const { getByTestId } = await render(DropdownMenuTest);
+		const { getByTestId } = render(DropdownMenuTest);
 		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(menu).toBeVisible();
+		expect(menu).toBeVisible();
 
 		const arrow = getByTestId('arrow');
-		await expect(arrow).toBeVisible();
+		expect(arrow).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, queryByTestId } = await render(DropdownMenuForceVisible);
+		const { getByTestId, queryByTestId } = render(DropdownMenuForceVisible);
 		const trigger = getByTestId('trigger');
-		await expect(queryByTestId('menu')).toBeNull();
+		expect(queryByTestId('menu')).toBeNull();
 		const user = userEvent.setup();
 
 		await act(() => trigger.focus());
 		await user.keyboard(`{${key}}`);
-		await expect(getByTestId('menu')).toBeVisible();
+		expect(getByTestId('menu')).toBeVisible();
 	});
 
 	test('Focus when opened with click', async () => {
-		const { getByTestId, queryByTestId } = await render(DropdownMenuForceVisible);
+		const { getByTestId, queryByTestId } = render(DropdownMenuForceVisible);
 		const trigger = getByTestId('trigger');
 		const user = userEvent.setup();
 
-		await expect(queryByTestId('menu')).toBeNull();
+		expect(queryByTestId('menu')).toBeNull();
 		await user.click(trigger);
 		await tick();
-		await expect(queryByTestId('menu')).not.toBeNull();
-		await expect(getByTestId('menu')).toBeVisible();
+		expect(queryByTestId('menu')).not.toBeNull();
+		expect(getByTestId('menu')).toBeVisible();
 
 		const firstItem = getByTestId('item1');
-		await expect(firstItem).not.toHaveFocus();
+		expect(firstItem).not.toHaveFocus();
 
 		await user.keyboard(`{${kbd.ARROW_DOWN}}`);
 
 		// focuses first item after arrow down
-		await expect(firstItem).toHaveFocus();
+		expect(firstItem).toHaveFocus();
 
 		// Doesnt focus disabled menu items
 		await user.keyboard(`{${kbd.ARROW_DOWN}}`);
 		const disabledItem = getByTestId('item2');
-		await expect(disabledItem).not.toHaveFocus();
-		await expect(getByTestId('checkboxItem1')).toHaveFocus();
+		expect(disabledItem).not.toHaveFocus();
+		expect(getByTestId('checkboxItem1')).toHaveFocus();
 	});
 
 	test.todo('Doesnt focus disabled menu items', async () => {
-		const { getByTestId, queryByTestId } = await render(DropdownMenuForceVisible);
+		const { getByTestId, queryByTestId } = render(DropdownMenuForceVisible);
 		const trigger = getByTestId('trigger');
 		const user = userEvent.setup();
 
-		await expect(queryByTestId('menu')).toBeNull();
+		expect(queryByTestId('menu')).toBeNull();
 		await act(() => trigger.focus());
 		await user.keyboard(`{${kbd.ENTER}}`);
 		expect(getByTestId('menu')).toBeVisible();
@@ -273,38 +273,38 @@ describe('Dropdown Menu (forceVisible)', () => {
 		// Doesnt focus disabled menu items
 		await user.keyboard(`{${kbd.ARROW_DOWN}}`);
 		const disabledItem = getByTestId('item2');
-		await expect(disabledItem).not.toHaveFocus();
-		await expect(getByTestId('checkboxItem1')).toHaveFocus();
+		expect(disabledItem).not.toHaveFocus();
+		expect(getByTestId('checkboxItem1')).toHaveFocus();
 	});
 
 	test('Displays proper initial checked state for checkbox items', async () => {
-		const { getByTestId, queryByTestId } = await render(DropdownMenuForceVisible);
+		const { getByTestId, queryByTestId } = render(DropdownMenuForceVisible);
 		const trigger = getByTestId('trigger');
 		const user = userEvent.setup();
 
 		await act(() => trigger.focus());
 		await user.keyboard(`{${kbd.ENTER}}`);
 		const checked1 = getByTestId('check1');
-		await expect(checked1).toBeVisible();
-		await expect(queryByTestId('check2')).toBeNull();
+		expect(checked1).toBeVisible();
+		expect(queryByTestId('check2')).toBeNull();
 	});
 
 	test('Toggles checked to false for default checked checkbox items', async () => {
-		const { getByTestId, queryByTestId } = await render(DropdownMenuForceVisible);
+		const { getByTestId, queryByTestId } = render(DropdownMenuForceVisible);
 		const trigger = getByTestId('trigger');
 		const user = userEvent.setup();
 
 		await act(() => trigger.focus());
 		await user.keyboard(`{${kbd.ENTER}}`);
 		await user.keyboard(`{${kbd.ARROW_DOWN}}`);
-		await expect(queryByTestId('check1')).not.toBeNull();
+		expect(queryByTestId('check1')).not.toBeNull();
 		await user.keyboard(`{${kbd.ENTER}}`);
 		await user.keyboard(`{${kbd.ENTER}}`);
-		await expect(queryByTestId('check1')).toBeNull();
+		expect(queryByTestId('check1')).toBeNull();
 	});
 
 	test('Toggles checked to true for default unchecked checkbox items', async () => {
-		const { getByTestId, queryByTestId } = await render(DropdownMenuForceVisible);
+		const { getByTestId, queryByTestId } = render(DropdownMenuForceVisible);
 		const trigger = getByTestId('trigger');
 		const user = userEvent.setup();
 
@@ -312,24 +312,24 @@ describe('Dropdown Menu (forceVisible)', () => {
 		await user.keyboard(`{${kbd.ENTER}}`);
 		await user.keyboard(`{${kbd.ARROW_DOWN}}`);
 		await user.keyboard(`{${kbd.ARROW_DOWN}}`);
-		await expect(queryByTestId('check2')).toBeNull();
+		expect(queryByTestId('check2')).toBeNull();
 		await user.keyboard(`{${kbd.ENTER}}`);
 		await user.keyboard(`{${kbd.ENTER}}`);
-		await expect(queryByTestId('check2')).not.toBeNull();
+		expect(queryByTestId('check2')).not.toBeNull();
 	});
 
 	test('Hovering over subtrigger opens submenu', async () => {
-		const { getByTestId, queryByTestId } = await render(DropdownMenuForceVisible);
+		const { getByTestId, queryByTestId } = render(DropdownMenuForceVisible);
 		const trigger = getByTestId('trigger');
 		const user = userEvent.setup();
 
 		await user.click(trigger);
-		await expect(queryByTestId('submenu')).toBeNull();
+		expect(queryByTestId('submenu')).toBeNull();
 		const subtrigger = getByTestId('subtrigger');
 		await user.hover(subtrigger);
 		await sleep(100);
-		await expect(getByTestId('submenu')).toBeVisible();
-		await expect(subtrigger).toHaveFocus();
+		expect(getByTestId('submenu')).toBeVisible();
+		expect(subtrigger).toHaveFocus();
 	});
 
 	test.each([
@@ -338,7 +338,7 @@ describe('Dropdown Menu (forceVisible)', () => {
 	])(
 		'Pressing arrow keys while hovering over item goes to next/prev item',
 		async (key, hoverItem, endItem) => {
-			const { getByTestId } = await render(DropdownMenuForceVisible);
+			const { getByTestId } = render(DropdownMenuForceVisible);
 			const trigger = getByTestId('trigger');
 			const user = userEvent.setup();
 
@@ -346,36 +346,86 @@ describe('Dropdown Menu (forceVisible)', () => {
 			const item = getByTestId(hoverItem);
 			await user.hover(item);
 			await user.keyboard(`{${key}}`);
-			await expect(item).not.toHaveFocus();
-			await expect(getByTestId(`${endItem}`)).toHaveFocus();
+			expect(item).not.toHaveFocus();
+			expect(getByTestId(`${endItem}`)).toHaveFocus();
 		}
 	);
 
 	test('Arrow right when subtrigger hover focuses first item in submenu', async () => {
-		const { getByTestId, queryByTestId } = await render(DropdownMenuForceVisible);
+		const { getByTestId, queryByTestId } = render(DropdownMenuForceVisible);
 		const trigger = getByTestId('trigger');
 		const user = userEvent.setup();
 
 		await user.click(trigger);
-		await expect(queryByTestId('submenu')).toBeNull();
+		expect(queryByTestId('submenu')).toBeNull();
 		const subtrigger = getByTestId('subtrigger');
 		await user.hover(subtrigger);
 		await sleep(100);
 		await user.keyboard(`{${kbd.ARROW_RIGHT}}`);
 		const subItem0 = getByTestId('subitem0');
-		await expect(subItem0).toHaveFocus();
+		expect(subItem0).toHaveFocus();
 
 		// Arrow down inside submenu moves to next submenu item
 		await user.keyboard(`{${kbd.ARROW_DOWN}}`);
 		const subItem1 = getByTestId('subitem1');
-		await expect(subItem1).toHaveFocus();
+		expect(subItem1).toHaveFocus();
 
 		await user.hover(subItem1);
 
 		// Arrow left inside submenu closes submenu and focuses subtrigger
 		await user.keyboard(`{${kbd.ARROW_LEFT}}`);
-		await expect(subItem0).not.toHaveFocus();
-		await expect(subtrigger).toHaveFocus();
+		expect(subItem0).not.toHaveFocus();
+		expect(subtrigger).toHaveFocus();
+	});
+
+	test('loops through items when loop prop true', async () => {
+		const { queryByTestId, getByTestId } = render(DropdownMenuTest, {
+			loop: true,
+		});
+		const trigger = getByTestId('trigger');
+		const user = userEvent.setup();
+
+		const menuItems = ['item1', 'checkboxItem1', 'checkboxItem2', 'subtrigger'];
+
+		await user.click(trigger);
+		expect(queryByTestId('menu')).not.toBeNull();
+
+		for (const item of menuItems) {
+			await user.keyboard(`{${kbd.ARROW_DOWN}}`);
+			expect(getByTestId(item)).toHaveFocus();
+		}
+		await user.keyboard(`{${kbd.ARROW_DOWN}}`);
+		expect(getByTestId('item1')).toHaveFocus();
+		await user.keyboard(`{${kbd.ARROW_UP}}`);
+		expect(getByTestId('subtrigger')).toHaveFocus();
+	});
+
+	test('does not loop through items when loop prop false/undefined', async () => {
+		const { queryByTestId, getByTestId } = render(DropdownMenuTest);
+		const trigger = getByTestId('trigger');
+		const user = userEvent.setup();
+
+		const menuItems = ['item1', 'checkboxItem1', 'checkboxItem2', 'subtrigger'];
+
+		await user.click(trigger);
+		expect(queryByTestId('menu')).not.toBeNull();
+
+		for (const item of menuItems) {
+			await user.keyboard(`{${kbd.ARROW_DOWN}}`);
+			expect(getByTestId(item)).toHaveFocus();
+		}
+		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}}`);
+		expect(getByTestId('item1')).toHaveFocus();
+
+		await user.keyboard(`{${kbd.ARROW_UP}}`);
+		expect(getByTestId('subtrigger')).not.toHaveFocus();
+		expect(getByTestId('item1')).toHaveFocus();
 	});
 
 	test.todo('Radio items');
diff --git a/src/tests/dropdown-menu/DropdownMenuForceVisibleTest.svelte b/src/tests/dropdown-menu/DropdownMenuForceVisibleTest.svelte
index 4bcbed83..048d15f4 100644
--- a/src/tests/dropdown-menu/DropdownMenuForceVisibleTest.svelte
+++ b/src/tests/dropdown-menu/DropdownMenuForceVisibleTest.svelte
@@ -7,12 +7,15 @@
 	const settingsSync = writable(true);
 	const hideMeltUI = writable(false);
 
+	export let loop: boolean = false;
+
 	const {
 		elements: { trigger, menu, item, separator, arrow },
 		builders: { createSubmenu, createMenuRadioGroup, createCheckboxItem },
 		states: { open },
 	} = createDropdownMenu({
 		forceVisible: true,
+		loop,
 	});
 
 	const {
@@ -107,7 +110,7 @@
 					<div class="rightSlot">⇧⌘N</div>
 				</div>
 				<div use:melt={$separator} class="separator" />
-				<div class="item" use:melt={$item}>
+				<div class="item" use:melt={$item} data-testid="item3">
 					Quit Melt UI
 					<div class="rightSlot">⌘Q</div>
 				</div>
diff --git a/src/tests/dropdown-menu/DropdownMenuTest.svelte b/src/tests/dropdown-menu/DropdownMenuTest.svelte
index a10ea117..fbff43b6 100644
--- a/src/tests/dropdown-menu/DropdownMenuTest.svelte
+++ b/src/tests/dropdown-menu/DropdownMenuTest.svelte
@@ -6,10 +6,14 @@
 	const settingsSync = writable(true);
 	const hideMeltUI = writable(false);
 
+	export let loop: boolean = false;
+
 	const {
 		elements: { trigger, menu, item, separator, arrow },
 		builders: { createSubmenu, createMenuRadioGroup, createCheckboxItem },
-	} = createDropdownMenu();
+	} = createDropdownMenu({
+		loop,
+	});
 
 	const {
 		elements: { checkboxItem: settingsSyncCheckbox },
@@ -47,7 +51,6 @@
 		<AlignJustify class="h-4 w-4" />
 		<span class="sr-only">Open Popover</span>
 	</button>
-
 	<div class="menu" use:melt={$menu} data-testid="menu">
 		<div class="item" use:melt={$item} data-testid="item1">Item 1</div>
 		<div class="item" use:melt={$item} data-testid="item2" data-disabled>Item 2</div>
@@ -97,10 +100,6 @@
 			<div class="rightSlot">⇧⌘N</div>
 		</div>
 		<div use:melt={$separator} class="separator" />
-		<div class="item" use:melt={$item}>
-			Quit Melt UI
-			<div class="rightSlot">⌘Q</div>
-		</div>
 		<div use:melt={$arrow} data-testid="arrow" />
 	</div>
 </main>
diff --git a/src/tests/label/Label.spec.ts b/src/tests/label/Label.spec.ts
index eb2adbfd..d80774a9 100644
--- a/src/tests/label/Label.spec.ts
+++ b/src/tests/label/Label.spec.ts
@@ -1,9 +1,19 @@
 import { render } from '@testing-library/svelte';
+import { it, describe } from 'vitest';
 import LabelTest from './LabelTest.svelte';
+import userEvent from '@testing-library/user-event';
 
 describe('LabelTest', () => {
 	it('renders the label with the correct text', () => {
 		const { getByText } = render(LabelTest, { props: { inputId: 'test' } });
 		expect(getByText('Test')).toBeInTheDocument();
 	});
+
+	it('focuses the input when the label is clicked', async () => {
+		const { getByTestId } = render(LabelTest);
+		const label = getByTestId('label');
+		const input = getByTestId('input');
+		await userEvent.click(label);
+		expect(input).toHaveFocus();
+	});
 });
diff --git a/src/tests/label/LabelTest.svelte b/src/tests/label/LabelTest.svelte
index 8a87c2f7..8e5986f8 100644
--- a/src/tests/label/LabelTest.svelte
+++ b/src/tests/label/LabelTest.svelte
@@ -10,12 +10,9 @@
 
 <form>
 	<div>
-		<label use:root for={inputId}>
-			<span
-				>Test
-				<span>*</span>
-			</span>
-			<input type="text" id={inputId} />
+		<label use:root for={inputId} data-testid="label">
+			<span>Test</span>
+			<input type="text" id={inputId} data-testid="input" />
 		</label>
 	</div>
 </form>
diff --git a/src/tests/switch/Switch.spec.ts b/src/tests/switch/Switch.spec.ts
index 0bc88315..293603c8 100644
--- a/src/tests/switch/Switch.spec.ts
+++ b/src/tests/switch/Switch.spec.ts
@@ -1,12 +1,156 @@
 import { render } from '@testing-library/svelte';
 import { axe } from 'jest-axe';
-import { describe, test } from 'vitest';
-import Switch from './Switch.svelte';
+import { describe, it } from 'vitest';
+import SwitchTest from './SwitchTest.svelte';
+import userEvent from '@testing-library/user-event';
+import { kbd } from '$lib/internal/helpers';
+import { writable } from 'svelte/store';
+import { tick } from 'svelte';
 
 describe('Switch', () => {
-	test('No accessibility violations', async () => {
-		const { container } = render(Switch);
-
+	it('No accessibility violations', async () => {
+		const { container } = render(SwitchTest);
 		expect(await axe(container)).toHaveNoViolations();
 	});
+
+	it('toggles when clicked', async () => {
+		const { getByTestId } = render(SwitchTest);
+		const switchEl = getByTestId('switch');
+
+		await userEvent.click(switchEl);
+		expect(switchEl).toHaveAttribute('data-state', 'checked');
+		expect(switchEl).toHaveAttribute('aria-checked', 'true');
+
+		await userEvent.click(switchEl);
+		expect(switchEl).toHaveAttribute('data-state', 'unchecked');
+		expect(switchEl).toHaveAttribute('aria-checked', 'false');
+	});
+
+	it('toggles on enter/space', async () => {
+		const { getByTestId } = render(SwitchTest);
+		const switchEl = getByTestId('switch');
+
+		const keys = [kbd.SPACE, kbd.ENTER];
+		switchEl.focus();
+		expect(switchEl).toHaveFocus();
+
+		for (const key of keys) {
+			await userEvent.keyboard(`{${key}}`);
+			expect(switchEl).toHaveAttribute('data-state', 'checked');
+			expect(switchEl).toHaveAttribute('aria-checked', 'true');
+
+			await userEvent.keyboard(`{${key}}`);
+			expect(switchEl).toHaveAttribute('data-state', 'unchecked');
+			expect(switchEl).toHaveAttribute('aria-checked', 'false');
+		}
+	});
+
+	it('does not toggle when disabled', async () => {
+		const { getByTestId } = render(SwitchTest, {
+			disabled: true,
+		});
+		const switchEl = getByTestId('switch');
+
+		expect(switchEl).toHaveAttribute('data-state', 'unchecked');
+		expect(switchEl).toHaveAttribute('aria-checked', 'false');
+
+		await userEvent.click(switchEl);
+		expect(switchEl).toHaveAttribute('data-state', 'unchecked');
+		expect(switchEl).toHaveAttribute('aria-checked', 'false');
+
+		switchEl.focus();
+		expect(switchEl).not.toHaveFocus();
+	});
+
+	it('does not focus when disabled', async () => {
+		const { getByTestId } = render(SwitchTest, {
+			disabled: true,
+		});
+		const switchEl = getByTestId('switch');
+
+		switchEl.focus();
+		expect(switchEl).not.toHaveFocus();
+	});
+
+	it('applies the name prop to the input', async () => {
+		const { getByTestId } = render(SwitchTest, {
+			name: 'test',
+		});
+
+		const input = getByTestId('input');
+		expect(input).toHaveAttribute('name', 'test');
+	});
+
+	it('applies the required prop to the input', async () => {
+		const { getByTestId } = render(SwitchTest, {
+			required: true,
+		});
+
+		const input = getByTestId('input');
+		expect(input).toHaveAttribute('required');
+	});
+
+	it('respects the defaultChecked prop', async () => {
+		const { getByTestId } = render(SwitchTest, {
+			defaultChecked: true,
+		});
+
+		const switchEl = getByTestId('switch');
+		expect(switchEl).toHaveAttribute('data-state', 'checked');
+		expect(switchEl).toHaveAttribute('aria-checked', 'true');
+	});
+
+	it('respects the checked store prop', async () => {
+		const { getByTestId } = render(SwitchTest, {
+			checked: writable(true),
+		});
+
+		const switchEl = getByTestId('switch');
+		expect(switchEl).toHaveAttribute('data-state', 'checked');
+		expect(switchEl).toHaveAttribute('aria-checked', 'true');
+	});
+
+	it('favors the checked store value over the defaultChecked value', async () => {
+		const checkedStore = writable(false);
+		const { getByTestId } = render(SwitchTest, {
+			defaultChecked: true,
+			checked: checkedStore,
+		});
+
+		const switchEl = getByTestId('switch');
+		expect(switchEl).toHaveAttribute('data-state', 'unchecked');
+		expect(switchEl).toHaveAttribute('aria-checked', 'false');
+	});
+
+	it('respects onCheckedChange prop', async () => {
+		const { getByTestId } = render(SwitchTest, {
+			onCheckedChange: () => {
+				return false;
+			},
+		});
+
+		const switchEl = getByTestId('switch');
+		expect(switchEl).toHaveAttribute('data-state', 'unchecked');
+		expect(switchEl).toHaveAttribute('aria-checked', 'false');
+
+		await userEvent.click(switchEl);
+		expect(switchEl).toHaveAttribute('data-state', 'unchecked');
+		expect(switchEl).toHaveAttribute('aria-checked', 'false');
+	});
+
+	it('allows programmatically setting the checked state via the store', async () => {
+		const checkedStore = writable(true);
+		const { getByTestId } = render(SwitchTest, {
+			checked: checkedStore,
+		});
+
+		const switchEl = getByTestId('switch');
+		expect(switchEl).toHaveAttribute('data-state', 'checked');
+		expect(switchEl).toHaveAttribute('aria-checked', 'true');
+
+		checkedStore.set(false);
+		await tick();
+		expect(switchEl).toHaveAttribute('data-state', 'unchecked');
+		expect(switchEl).toHaveAttribute('aria-checked', 'false');
+	});
 });
diff --git a/src/tests/switch/Switch.svelte b/src/tests/switch/Switch.svelte
deleted file mode 100644
index 18c4e0f5..00000000
--- a/src/tests/switch/Switch.svelte
+++ /dev/null
@@ -1,16 +0,0 @@
-<script lang="ts">
-	import { createSwitch, melt } from '$lib/index.js';
-
-	const {
-		elements: { root, input },
-	} = createSwitch();
-</script>
-
-<main>
-	<form>
-		<label id="airplane-mode-label" for="airplane-mode"> Airplane mode </label>
-		<button use:melt={$root} id="airplane-mode" aria-labelledby="airplane-mode-label" />
-
-		<input use:melt={$input} />
-	</form>
-</main>
diff --git a/src/tests/switch/SwitchTest.svelte b/src/tests/switch/SwitchTest.svelte
new file mode 100644
index 00000000..a1f6cd10
--- /dev/null
+++ b/src/tests/switch/SwitchTest.svelte
@@ -0,0 +1,31 @@
+<script lang="ts">
+	import { createSwitch, melt, type CreateSwitchProps } from '$lib/index.js';
+
+	export let checked: CreateSwitchProps['checked'] = undefined;
+	export let defaultChecked: CreateSwitchProps['defaultChecked'] = undefined;
+	export let disabled: CreateSwitchProps['disabled'] = undefined;
+	export let name: CreateSwitchProps['name'] = undefined;
+	export let onCheckedChange: CreateSwitchProps['onCheckedChange'] = undefined;
+	export let required: CreateSwitchProps['required'] = undefined;
+	export let value: CreateSwitchProps['value'] = undefined;
+
+	const {
+		elements: { root, input },
+	} = createSwitch({
+		checked,
+		defaultChecked,
+		disabled,
+		name,
+		onCheckedChange,
+		required,
+		value,
+	});
+</script>
+
+<main>
+	<form>
+		<label id="switch-label" for="switch" data-testid="label"> Airplane mode </label>
+		<button use:melt={$root} id="switch" aria-labelledby="switch-label" data-testid="switch" />
+		<input use:melt={$input} data-testid="input" />
+	</form>
+</main>