Repository: melt-ui/melt-ui
Pull request #995 (2024-02-17 20:54:36)

fix: portal issues
I'm not sure how an issue hasn't been created for this yet, but we were handling nested portals incorrectly and doing some unnecessary searching when a custom prop was provided. 

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

Latest commit: 54506da55d24fbd28459c84707bc961990630054

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/portal-issues?filename=.changeset/fair-forks-serve.md&value=---%0A%22%40melt-ui%2Fsvelte%22%3A%20patch%0A---%0A%0Afix%3A%20portal%20issues%0A)


github-actions[bot]: <!-- refined-cf-pages-action:deployment-summary:melt-ui -->

###### built with [Refined Cloudflare Pages Action](https://github.com/AdrianGonz97/refined-cf-pages-action)
## ⚡ Cloudflare Pages Deployment
| Name | Status | Preview | Last Commit |
| :--- | :----- | :------ | :---------- |
| **melt-ui** | 🔨 Building ([View Log](https://github.com/melt-ui/melt-ui/actions/runs/7944214473)) |  | 54506da55d24fbd28459c84707bc961990630054 |


Patch:
diff --git a/.changeset/empty-peas-smoke.md b/.changeset/empty-peas-smoke.md
new file mode 100644
index 00000000..fccc1092
--- /dev/null
+++ b/.changeset/empty-peas-smoke.md
@@ -0,0 +1,5 @@
+---
+'@melt-ui/svelte': patch
+---
+
+Fix custom portal issues
diff --git a/.changeset/forty-beers-reply.md b/.changeset/forty-beers-reply.md
new file mode 100644
index 00000000..0aa3e0de
--- /dev/null
+++ b/.changeset/forty-beers-reply.md
@@ -0,0 +1,5 @@
+---
+"@melt-ui/svelte": patch
+---
+
+Dialog: fixed interact outside pointerup/mouseup issue where the dialog would close if you clicked and dragged outside of the dialog before releasing the press (Closes: #750)
diff --git a/.changeset/popular-dolls-attend.md b/.changeset/popular-dolls-attend.md
new file mode 100644
index 00000000..bfc013a7
--- /dev/null
+++ b/.changeset/popular-dolls-attend.md
@@ -0,0 +1,5 @@
+---
+'@melt-ui/svelte': patch
+---
+
+Toggle Group: fixed bug where `single` groups wouldn't focus (Closes #810)
diff --git a/src/lib/builders/context-menu/create.ts b/src/lib/builders/context-menu/create.ts
index dc50ea0e..7f683c17 100644
--- a/src/lib/builders/context-menu/create.ts
+++ b/src/lib/builders/context-menu/create.ts
@@ -20,6 +20,7 @@ import {
 	toWritableStores,
 	withGet,
 	type WithGet,
+	portalAttr,
 } from '$lib/internal/helpers/index.js';
 import type { MeltActionReturn } from '$lib/internal/types.js';
 import type { VirtualElement } from '@floating-ui/core';
@@ -147,7 +148,7 @@ export function createContextMenu(props?: CreateContextMenuProps) {
 				id: $menuId,
 				'aria-labelledby': $triggerId,
 				'data-state': $isVisible ? 'open' : 'closed',
-				'data-portal': $portal ? '' : undefined,
+				'data-portal': portalAttr($portal),
 				tabindex: -1,
 			} as const;
 		},
diff --git a/src/lib/builders/dialog/create.ts b/src/lib/builders/dialog/create.ts
index 662467e4..9f028bb2 100644
--- a/src/lib/builders/dialog/create.ts
+++ b/src/lib/builders/dialog/create.ts
@@ -17,6 +17,7 @@ import {
 	removeScroll,
 	styleToString,
 	toWritableStores,
+	portalAttr,
 } from '$lib/internal/helpers/index.js';
 import { withGet } from '$lib/internal/helpers/withGet.js';
 import type { Defaults, MeltActionReturn } from '$lib/internal/types.js';
@@ -42,7 +43,7 @@ const defaults = {
 	closeOnOutsideClick: true,
 	role: 'dialog',
 	defaultOpen: false,
-	portal: 'body',
+	portal: undefined,
 	forceVisible: false,
 	openFocus: undefined,
 	closeFocus: undefined,
@@ -256,11 +257,11 @@ export function createDialog(props?: CreateDialogProps) {
 	const portalled = makeElement(name('portalled'), {
 		stores: portal,
 		returned: ($portal) => ({
-			'data-portal': $portal ? '' : undefined,
+			'data-portal': portalAttr($portal),
 		}),
 		action: (node: HTMLElement) => {
 			const unsubPortal = effect([portal], ([$portal]) => {
-				if (!$portal) return noop;
+				if ($portal === null) return noop;
 				const portalDestination = getPortalDestination(node, $portal);
 				if (portalDestination === null) return noop;
 				const portalAction = usePortal(node, portalDestination);
diff --git a/src/lib/builders/link-preview/create.ts b/src/lib/builders/link-preview/create.ts
index 5ab7d08b..4e6555d4 100644
--- a/src/lib/builders/link-preview/create.ts
+++ b/src/lib/builders/link-preview/create.ts
@@ -18,6 +18,7 @@ import {
 	sleep,
 	styleToString,
 	toWritableStores,
+	portalAttr,
 } from '$lib/internal/helpers/index.js';
 import { safeOnMount } from '$lib/internal/helpers/lifecycle.js';
 import { withGet, type WithGet } from '$lib/internal/helpers/withGet.js';
@@ -41,7 +42,7 @@ const defaults = {
 	arrowSize: 8,
 	closeOnOutsideClick: true,
 	forceVisible: false,
-	portal: 'body',
+	portal: undefined,
 	closeOnEscape: true,
 	onOutsideClick: undefined,
 } satisfies CreateLinkPreviewProps;
@@ -161,7 +162,7 @@ export function createLinkPreview(props: CreateLinkPreviewProps = {}) {
 				}),
 				id: $contentId,
 				'data-state': $isVisible ? 'open' : 'closed',
-				'data-portal': $portal ? '' : undefined,
+				'data-portal': portalAttr($portal),
 			};
 		},
 		action: (node: HTMLElement): MeltActionReturn<LinkPreviewEvents['content']> => {
diff --git a/src/lib/builders/menu/create.ts b/src/lib/builders/menu/create.ts
index 79b6481a..40899a27 100644
--- a/src/lib/builders/menu/create.ts
+++ b/src/lib/builders/menu/create.ts
@@ -31,6 +31,7 @@ import {
 	sleep,
 	styleToString,
 	toWritableStores,
+	portalAttr,
 } from '$lib/internal/helpers/index.js';
 import type { Defaults, MeltActionReturn, TextDirection } from '$lib/internal/types.js';
 import { tick } from 'svelte';
@@ -70,7 +71,7 @@ const defaults = {
 	preventScroll: true,
 	closeOnEscape: true,
 	closeOnOutsideClick: true,
-	portal: 'body',
+	portal: undefined,
 	loop: false,
 	dir: 'ltr',
 	defaultOpen: false,
@@ -164,7 +165,7 @@ export function createMenuBuilder(opts: _MenuBuilderOptions) {
 				id: $rootMenuId,
 				'aria-labelledby': $rootTriggerId,
 				'data-state': $isVisible ? 'open' : 'closed',
-				'data-portal': $portal ? '' : undefined,
+				'data-portal': portalAttr($portal),
 				tabindex: -1,
 			} as const;
 		},
diff --git a/src/lib/builders/menubar/create.ts b/src/lib/builders/menubar/create.ts
index e3eafda3..82d1d32a 100644
--- a/src/lib/builders/menubar/create.ts
+++ b/src/lib/builders/menubar/create.ts
@@ -25,6 +25,7 @@ import {
 	removeScroll,
 	styleToString,
 	toWritableStores,
+	portalAttr,
 } from '$lib/internal/helpers/index.js';
 import { safeOnDestroy, safeOnMount } from '$lib/internal/helpers/lifecycle.js';
 import type { MeltActionReturn } from '$lib/internal/types.js';
@@ -148,7 +149,7 @@ export function createMenubar(props?: CreateMenubarProps) {
 					'aria-labelledby': $triggerId,
 					'data-state': $isVisible ? 'open' : 'closed',
 					'data-melt-scope': $menubarId,
-					'data-portal': $portal ? '' : undefined,
+					'data-portal': portalAttr($portal),
 					tabindex: -1,
 				} as const;
 			},
diff --git a/src/lib/builders/popover/create.ts b/src/lib/builders/popover/create.ts
index 8fa0b2f5..248d6658 100644
--- a/src/lib/builders/popover/create.ts
+++ b/src/lib/builders/popover/create.ts
@@ -17,6 +17,7 @@ import {
 	removeScroll,
 	styleToString,
 	toWritableStores,
+	portalAttr,
 } from '$lib/internal/helpers/index.js';
 
 import { usePopper } from '$lib/internal/actions/index.js';
@@ -100,7 +101,7 @@ export function createPopover(args?: CreatePopoverProps) {
 				}),
 				id: $contentId,
 				'data-state': $isVisible ? 'open' : 'closed',
-				'data-portal': $portal ? '' : undefined,
+				'data-portal': portalAttr($portal),
 			};
 		},
 		action: (node: HTMLElement) => {
diff --git a/src/lib/builders/toggle-group/create.ts b/src/lib/builders/toggle-group/create.ts
index 5fe06c9c..59017dc8 100644
--- a/src/lib/builders/toggle-group/create.ts
+++ b/src/lib/builders/toggle-group/create.ts
@@ -41,7 +41,7 @@ export const createToggleGroup = <T extends ToggleGroupType = 'single'>(
 	const defaultValue = withDefaults.defaultValue
 		? withDefaults.defaultValue
 		: withDefaults.type === 'single'
-		? 'undefined'
+		? undefined
 		: [];
 
 	const valueWritable = withDefaults.value ?? writable(defaultValue);
@@ -90,7 +90,7 @@ export const createToggleGroup = <T extends ToggleGroupType = 'single'>(
 
 			const items = Array.from(parentGroup.querySelectorAll(selector('item')));
 			const $value = value.get();
-			const anyPressed = Array.isArray($value) ? $value.length > 0 : $value !== null;
+			const anyPressed = Array.isArray($value) ? $value.length > 0 : $value ? true : false;
 
 			if (!anyPressed && items[0] === node) {
 				node.tabIndex = 0;
diff --git a/src/lib/builders/tooltip/create.ts b/src/lib/builders/tooltip/create.ts
index a4383ddb..28a465c7 100644
--- a/src/lib/builders/tooltip/create.ts
+++ b/src/lib/builders/tooltip/create.ts
@@ -18,6 +18,7 @@ import {
 	pointInPolygon,
 	styleToString,
 	toWritableStores,
+	portalAttr,
 } from '$lib/internal/helpers/index.js';
 
 import { useFloating, usePortal } from '$lib/internal/actions/index.js';
@@ -37,7 +38,7 @@ const defaults = {
 	openDelay: 1000,
 	closeDelay: 0,
 	forceVisible: false,
-	portal: 'body',
+	portal: undefined,
 	closeOnEscape: true,
 	disableHoverableContent: false,
 	group: undefined,
@@ -201,7 +202,7 @@ export function createTooltip(props?: CreateTooltipProps) {
 					display: $isVisible ? undefined : 'none',
 				}),
 				id: $contentId,
-				'data-portal': $portal ? '' : undefined,
+				'data-portal': portalAttr($portal),
 				'data-state': $open ? 'open' : 'closed',
 			};
 		},
@@ -221,7 +222,7 @@ export function createTooltip(props?: CreateTooltipProps) {
 
 					const floatingReturn = useFloating(triggerEl, node, $positioning);
 					unsubFloating = floatingReturn.destroy;
-					if (!$portal) {
+					if ($portal === null) {
 						unsubPortal();
 						return;
 					}
diff --git a/src/lib/internal/actions/interact-outside/action.ts b/src/lib/internal/actions/interact-outside/action.ts
index 9f6f6712..4fcdbb2d 100644
--- a/src/lib/internal/actions/interact-outside/action.ts
+++ b/src/lib/internal/actions/interact-outside/action.ts
@@ -9,19 +9,26 @@ import type { InteractOutsideConfig, InteractOutsideEvent } from './types.js';
 export function useInteractOutside(node: HTMLElement, config: InteractOutsideConfig) {
 	let unsub = noop;
 
+	let isPointerDown = false;
+	let isPointerDownInside = false;
+	let ignoreEmulatedMouseEvents = false;
+
 	function update(config: InteractOutsideConfig) {
 		unsub();
 		const { onInteractOutside, onInteractOutsideStart, enabled } = config;
 
 		if (!enabled) return;
 
-		let isPointerDown = false;
-		let ignoreEmulatedMouseEvents = false;
-
 		function onPointerDown(e: PointerEvent | MouseEvent | TouchEvent) {
 			if (onInteractOutside && isValidEvent(e, node)) {
 				onInteractOutsideStart?.(e);
 			}
+			const target = e.target;
+
+			if (isElement(target) && isOrContainsTarget(node, target)) {
+				isPointerDownInside = true;
+			}
+
 			isPointerDown = true;
 		}
 
@@ -34,10 +41,10 @@ export function useInteractOutside(node: HTMLElement, config: InteractOutsideCon
 		// Use pointer events if available, otherwise use mouse/touch events
 		if (typeof PointerEvent !== 'undefined') {
 			const onPointerUp = (e: PointerEvent) => {
-				if (isPointerDown && isValidEvent(e, node)) {
+				if (shouldTriggerInteractOutside(e)) {
 					triggerInteractOutside(e);
 				}
-				isPointerDown = false;
+				resetPointerState();
 			};
 
 			unsub = executeCallbacks(
@@ -48,18 +55,18 @@ export function useInteractOutside(node: HTMLElement, config: InteractOutsideCon
 			const onMouseUp = (e: MouseEvent) => {
 				if (ignoreEmulatedMouseEvents) {
 					ignoreEmulatedMouseEvents = false;
-				} else if (isPointerDown && isValidEvent(e, node)) {
+				} else if (shouldTriggerInteractOutside(e)) {
 					triggerInteractOutside(e);
 				}
-				isPointerDown = false;
+				resetPointerState();
 			};
 
 			const onTouchEnd = (e: TouchEvent) => {
 				ignoreEmulatedMouseEvents = true;
-				if (isPointerDown && isValidEvent(e, node)) {
+				if (shouldTriggerInteractOutside(e)) {
 					triggerInteractOutside(e);
 				}
-				isPointerDown = false;
+				resetPointerState();
 			};
 
 			unsub = executeCallbacks(
@@ -71,6 +78,18 @@ export function useInteractOutside(node: HTMLElement, config: InteractOutsideCon
 		}
 	}
 
+	function shouldTriggerInteractOutside(e: InteractOutsideEvent) {
+		if (isPointerDown && !isPointerDownInside && isValidEvent(e, node)) {
+			return true;
+		}
+		return false;
+	}
+
+	function resetPointerState() {
+		isPointerDown = false;
+		isPointerDownInside = false;
+	}
+
 	update(config);
 
 	return {
@@ -90,7 +109,11 @@ function isValidEvent(e: InteractOutsideEvent, node: HTMLElement): boolean {
 		return false;
 	}
 
-	return node && !node.contains(target);
+	return node && !isOrContainsTarget(node, target);
+}
+
+function isOrContainsTarget(node: HTMLElement, target: Element) {
+	return node === target || node.contains(target);
 }
 
 function getOwnerDocument(el: Element | null | undefined) {
diff --git a/src/lib/internal/helpers/attr.ts b/src/lib/internal/helpers/attr.ts
index fc71525e..621863a9 100644
--- a/src/lib/internal/helpers/attr.ts
+++ b/src/lib/internal/helpers/attr.ts
@@ -17,3 +17,14 @@ export const hiddenInputAttrs = {
 		transform: 'translateX(-100%)',
 	}),
 };
+
+/**
+ * @param portal The value of the `portal` option store.
+ * @returns the value of the `data-portal` attribute.
+ */
+export function portalAttr(portal: string | HTMLElement | null | undefined) {
+	if (portal !== null) {
+		return portal;
+	}
+	return undefined;
+}
diff --git a/src/lib/internal/helpers/elements.ts b/src/lib/internal/helpers/elements.ts
index 507fae26..af6d254a 100644
--- a/src/lib/internal/helpers/elements.ts
+++ b/src/lib/internal/helpers/elements.ts
@@ -16,8 +16,7 @@ export function getPortalDestination(
 	node: HTMLElement,
 	portalProp: string | HTMLElement | undefined | null
 ) {
-	const portalParent = getPortalParent(node);
 	if (portalProp !== undefined) return portalProp;
-	if (portalParent === 'body') return document.body;
-	return null;
+	if (portalProp === null) return null;
+	return getPortalParent(node);
 }


Test patch:
diff --git a/src/tests/dialog/Dialog.spec.ts b/src/tests/dialog/Dialog.spec.ts
index 2fd27dc6..c9dedae1 100644
--- a/src/tests/dialog/Dialog.spec.ts
+++ b/src/tests/dialog/Dialog.spec.ts
@@ -247,4 +247,34 @@ describe('Dialog', () => {
 		expect(title.id).toBe(ids.title);
 		expect(description.id).toBe(ids.description);
 	});
+
+	it("Doesn't close on pointerup if the previous pointerdown didn't occur inside the dialog", async () => {
+		const { getByTestId, user, trigger } = setup();
+		const overlay = getByTestId('overlay');
+		const content = getByTestId('content');
+
+		expect(content).not.toBeVisible();
+		await user.click(trigger);
+		expect(content).toBeVisible();
+		await sleep(100);
+		expect(overlay).toBeVisible();
+		await user.pointer({ target: content, offset: 2, keys: '[MouseLeft>]' });
+		await user.pointer({ target: overlay, offset: 2, keys: '[/MouseLeft]' });
+		expect(content).toBeVisible();
+	});
+
+	it('Closes on pointerup if the previous pointerdown occurred outside the dialog', async () => {
+		const { getByTestId, user, trigger } = setup();
+		const overlay = getByTestId('overlay');
+		const content = getByTestId('content');
+
+		expect(content).not.toBeVisible();
+		await user.click(trigger);
+		expect(content).toBeVisible();
+		await sleep(100);
+		expect(overlay).toBeVisible();
+		await user.pointer({ target: overlay, offset: 2, keys: '[MouseLeft>]' });
+		await user.pointer({ target: overlay, offset: 2, keys: '[/MouseLeft]' });
+		expect(content).not.toBeVisible();
+	});
 });
diff --git a/src/tests/toggle-group/ToggleGroup.spec.ts b/src/tests/toggle-group/ToggleGroup.spec.ts
index 1016d6e1..42bbeac3 100644
--- a/src/tests/toggle-group/ToggleGroup.spec.ts
+++ b/src/tests/toggle-group/ToggleGroup.spec.ts
@@ -1,4 +1,3 @@
-import { kbd } from '$lib/internal/helpers/keyboard.js';
 import { render } from '@testing-library/svelte';
 import { userEvent } from '@testing-library/user-event';
 import { axe } from 'jest-axe';
@@ -6,8 +5,25 @@ import { describe } from 'vitest';
 import ToggleGroupTest from './ToggleGroupTest.svelte';
 import { tick } from 'svelte';
 import { writable } from 'svelte/store';
+import type { CreateToggleGroupProps } from '$lib/index.js';
+import { testKbd as kbd } from '../utils.js';
 
 const items = ['item-1', 'item-2', 'item-3'];
+const defaults: { items: string[]; type: 'single' | 'multiple' } = {
+	items,
+	type: 'single',
+};
+
+function setup<T extends 'single' | 'multiple'>(
+	props?: CreateToggleGroupProps<T> & { items?: string[]; type: 'single' | 'multiple' }
+) {
+	const withDefaults = { ...defaults, ...props };
+	const user = userEvent.setup();
+	const result = render(ToggleGroupTest, withDefaults);
+	const tabButton = result.getByTestId('tab-btn');
+	const root = result.getByTestId('root');
+	return { user, ...result, tabButton, root };
+}
 
 describe('Toggle Group', () => {
 	test('has no accessibility violations', async () => {
@@ -17,11 +33,7 @@ describe('Toggle Group', () => {
 	});
 
 	test('tab does not navigate between items', async () => {
-		const user = userEvent.setup();
-		const { getByTestId } = render(ToggleGroupTest, {
-			type: 'single',
-			items,
-		});
+		const { user, getByTestId } = setup();
 
 		getByTestId(items[0]).focus();
 		expect(getByTestId(items[0])).toHaveFocus();
@@ -34,30 +46,27 @@ describe('Toggle Group', () => {
 	});
 
 	test('keyboard navigation between items', async () => {
-		const user = userEvent.setup();
-		const { getByTestId } = render(ToggleGroupTest, {
+		const { getByTestId, user } = setup({
 			type: 'single',
-			items,
+			loop: false,
 		});
 
 		getByTestId(items[0]).focus();
 
 		for (const item of items) {
 			expect(getByTestId(item)).toHaveFocus();
-			await user.keyboard(`{${kbd.ARROW_RIGHT}}`);
+			await user.keyboard(kbd.ARROW_RIGHT);
 		}
 
 		for (const item of [...items].reverse()) {
 			expect(getByTestId(item)).toHaveFocus();
-			await user.keyboard(`{${kbd.ARROW_LEFT}}`);
+			await user.keyboard(kbd.ARROW_LEFT);
 		}
 	});
 
 	test('keyboard navigation between items - loop', async () => {
-		const user = userEvent.setup();
-		const { getByTestId } = render(ToggleGroupTest, {
+		const { getByTestId, user } = setup({
 			type: 'single',
-			items,
 			loop: true,
 		});
 
@@ -65,30 +74,29 @@ describe('Toggle Group', () => {
 
 		for (const item of items) {
 			expect(getByTestId(item)).toHaveFocus();
-			await user.keyboard(`{${kbd.ARROW_RIGHT}}`);
+			await user.keyboard(kbd.ARROW_RIGHT);
 		}
 
 		for (const item of items) {
 			expect(getByTestId(item)).toHaveFocus();
-			await user.keyboard(`{${kbd.ARROW_RIGHT}}`);
+			await user.keyboard(kbd.ARROW_RIGHT);
 		}
 	});
 
 	test('only one item can be selected in single mode', async () => {
-		const user = userEvent.setup();
-		const { getByTestId } = render(ToggleGroupTest, {
+		const { getByTestId, user } = setup({
 			type: 'single',
 			items,
 		});
 
 		getByTestId(items[0]).focus();
 		expect(getByTestId(items[0])).toHaveFocus();
-		await user.keyboard(`{${kbd.SPACE}}`);
+		await user.keyboard(kbd.SPACE);
 		expect(getByTestId(items[0])).toHaveAttribute('aria-checked', 'true');
 
-		await user.keyboard(`{${kbd.ARROW_RIGHT}}`);
+		await user.keyboard(kbd.ARROW_RIGHT);
 		expect(getByTestId(items[1])).toHaveFocus();
-		await user.keyboard(`{${kbd.SPACE}}`);
+		await user.keyboard(kbd.SPACE);
 
 		const root = getByTestId('root');
 		const checkedItems = root.querySelectorAll<HTMLElement>('[aria-checked="true"]');
@@ -96,20 +104,18 @@ describe('Toggle Group', () => {
 	});
 
 	test('multiple items can be selected in multiple mode', async () => {
-		const user = userEvent.setup();
-		const { getByTestId } = render(ToggleGroupTest, {
+		const { getByTestId, user } = setup({
 			type: 'multiple',
-			items,
 		});
 
 		getByTestId(items[0]).focus();
 		expect(getByTestId(items[0])).toHaveFocus();
-		await user.keyboard(`{${kbd.SPACE}}`);
+		await user.keyboard(kbd.SPACE);
 		expect(getByTestId(items[0])).toHaveAttribute('aria-pressed', 'true');
 
-		await user.keyboard(`{${kbd.ARROW_RIGHT}}`);
+		await user.keyboard(kbd.ARROW_RIGHT);
 		expect(getByTestId(items[1])).toHaveFocus();
-		await user.keyboard(`{${kbd.SPACE}}`);
+		await user.keyboard(kbd.SPACE);
 		expect(getByTestId(items[1])).toHaveAttribute('aria-pressed', 'true');
 
 		const root = getByTestId('root');
@@ -118,39 +124,35 @@ describe('Toggle Group', () => {
 	});
 
 	test('can uncheck items in single mode', async () => {
-		const user = userEvent.setup();
-		const { getByTestId } = render(ToggleGroupTest, {
+		const { getByTestId, user } = setup({
 			type: 'single',
-			items,
 		});
 
 		getByTestId(items[0]).focus();
 		expect(getByTestId(items[0])).toHaveFocus();
-		await user.keyboard(`{${kbd.SPACE}}`);
+		await user.keyboard(kbd.SPACE);
 		expect(getByTestId(items[0])).toHaveAttribute('aria-checked', 'true');
-		await user.keyboard(`{${kbd.SPACE}}`);
+		await user.keyboard(kbd.SPACE);
 		expect(getByTestId(items[0])).toHaveAttribute('aria-checked', 'false');
 	});
 
 	test('can uncheck items in multiple mode', async () => {
-		const user = userEvent.setup();
-		const { getByTestId } = render(ToggleGroupTest, {
+		const { getByTestId, user } = setup({
 			type: 'multiple',
 			items,
 		});
 
 		getByTestId(items[0]).focus();
 		expect(getByTestId(items[0])).toHaveFocus();
-		await user.keyboard(`{${kbd.SPACE}}`);
+		await user.keyboard(kbd.SPACE);
 		expect(getByTestId(items[0])).toHaveAttribute('aria-pressed', 'true');
-		await user.keyboard(`{${kbd.SPACE}}`);
+		await user.keyboard(kbd.SPACE);
 		expect(getByTestId(items[0])).toHaveAttribute('aria-pressed', 'false');
 	});
 
 	test('defaultValue is respected in single mode', async () => {
-		const { getByTestId } = render(ToggleGroupTest, {
+		const { getByTestId } = setup({
 			type: 'single',
-			items,
 			defaultValue: items[1],
 		});
 
@@ -160,9 +162,8 @@ describe('Toggle Group', () => {
 	});
 
 	test('defaultValue is respected in multiple mode', async () => {
-		const { getByTestId } = render(ToggleGroupTest, {
+		const { getByTestId } = setup({
 			type: 'multiple',
-			items,
 			defaultValue: [items[1], items[2]],
 		});
 
@@ -173,9 +174,8 @@ describe('Toggle Group', () => {
 
 	test('value prop overrides defaultValue - single', async () => {
 		const valueStore = writable<string>(items[1]);
-		const { getByTestId } = render(ToggleGroupTest, {
+		const { getByTestId } = setup({
 			type: 'single',
-			items,
 			defaultValue: items[0],
 			value: valueStore,
 		});
@@ -187,9 +187,8 @@ describe('Toggle Group', () => {
 
 	test('value prop overrides defaultValue - multiple', async () => {
 		const valueStore = writable<string[]>([items[1], items[2]]);
-		const { getByTestId } = render(ToggleGroupTest, {
+		const { getByTestId } = setup({
 			type: 'multiple',
-			items,
 			defaultValue: [items[0]],
 			value: valueStore,
 		});
@@ -201,9 +200,8 @@ describe('Toggle Group', () => {
 
 	test('value prop programmatically updates - single', async () => {
 		const valueStore = writable<string>(items[1]);
-		const { getByTestId } = render(ToggleGroupTest, {
+		const { getByTestId } = setup({
 			type: 'single',
-			items,
 			value: valueStore,
 		});
 
@@ -221,9 +219,8 @@ describe('Toggle Group', () => {
 
 	test('value prop programmatically updates - multiple', async () => {
 		const valueStore = writable<string[]>([items[1], items[2]]);
-		const { getByTestId } = render(ToggleGroupTest, {
+		const { getByTestId } = setup({
 			type: 'multiple',
-			items,
 			value: valueStore,
 		});
 
@@ -240,10 +237,8 @@ describe('Toggle Group', () => {
 	});
 
 	test('change function is respected - single', async () => {
-		const user = userEvent.setup();
-		const { getByTestId } = render(ToggleGroupTest, {
+		const { getByTestId, user } = setup({
 			type: 'single',
-			items,
 			onValueChange: () => items[0],
 		});
 
@@ -258,10 +253,8 @@ describe('Toggle Group', () => {
 	});
 
 	test('change function is respected - multiple', async () => {
-		const user = userEvent.setup();
-		const { getByTestId } = render(ToggleGroupTest, {
+		const { user, getByTestId } = setup({
 			type: 'multiple',
-			items,
 			onValueChange: () => [items[0], items[1]],
 		});
 
@@ -275,4 +268,22 @@ describe('Toggle Group', () => {
 		expect(getByTestId(items[0])).toHaveAttribute('aria-pressed', 'true');
 		expect(getByTestId(items[1])).toHaveAttribute('aria-pressed', 'true');
 	});
+
+	test('toggle group focusable when in single mode', async () => {
+		const { user, tabButton, getByTestId } = setup();
+
+		tabButton.focus();
+		expect(tabButton).toHaveFocus();
+		await user.keyboard(kbd.TAB);
+		expect(getByTestId(items[0])).toHaveFocus();
+	});
+
+	test('toggle group focusable when in multiple mode', async () => {
+		const { user, tabButton, getByTestId } = setup({ type: 'multiple', items });
+
+		tabButton.focus();
+		expect(tabButton).toHaveFocus();
+		await user.keyboard(kbd.TAB);
+		expect(getByTestId(items[0])).toHaveFocus();
+	});
 });
diff --git a/src/tests/toggle-group/ToggleGroupTest.svelte b/src/tests/toggle-group/ToggleGroupTest.svelte
index 801dee21..c70df696 100644
--- a/src/tests/toggle-group/ToggleGroupTest.svelte
+++ b/src/tests/toggle-group/ToggleGroupTest.svelte
@@ -1,7 +1,11 @@
-<script lang="ts">
-	import { createToggleGroup, melt, type CreateToggleGroupProps } from '$lib/index.js';
+<script lang="ts" context="module">
+	type T = unknown;
+</script>
 
-	type T = $$Generic<'single' | 'multiple'>;
+<script lang="ts" generics="T extends 'single' | 'multiple'">
+	import { removeUndefined } from '../utils.js';
+
+	import { createToggleGroup, melt, type CreateToggleGroupProps } from '$lib/index.js';
 
 	export let type: T;
 	export let defaultValue: CreateToggleGroupProps<T>['defaultValue'] = undefined;
@@ -12,20 +16,29 @@
 	export let items: string[] = ['item-1', 'item-2', 'item-3'];
 	export let value: CreateToggleGroupProps<T>['value'] = undefined;
 
+	type $$Props = CreateToggleGroupProps<T> & {
+		items: string[];
+		type: T;
+	};
+
 	const {
 		elements: { root, item },
 	} = createToggleGroup<T>({
-		type,
-		defaultValue,
-		disabled,
-		loop,
-		onValueChange,
-		orientation,
-		value,
+		...removeUndefined({
+			type,
+			defaultValue,
+			disabled,
+			loop,
+			onValueChange,
+			orientation,
+			value,
+			...$$restProps,
+		}),
 	});
 </script>
 
 <main>
+	<button data-testid="tab-btn">Tab Focus Step button</button>
 	<div use:melt={$root} aria-label="root-1" data-testid="root">
 		{#each items as tItem}
 			<button use:melt={$item(tItem)} data-testid={tItem}>