Repository: melt-ui/melt-ui
Pull request #1003 (2024-02-23 00:31:55)

fix(Calendar/Range Calendar): use `'button'` for button elements' `type`
Closes: #996

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

Latest commit: 599141368a0d2d5138a46a051e95e5e0765087e8

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

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

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

</details>

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

[Click here if you're a maintainer who wants to add another changeset to this PR](https://github.com/ksmoore17/melt-ui/new/fix-calendar-buttons-type-attribute?filename=.changeset/eight-roses-occur.md&value=---%0A%22%40melt-ui%2Fsvelte%22%3A%20patch%0A---%0A%0Afix(Calendar)%3A%20use%20%60'button'%60%20for%20button%20elements'%20%60type%60%0A)



Patch:
diff --git a/.changeset/happy-cheetahs-appear.md b/.changeset/happy-cheetahs-appear.md
new file mode 100644
index 00000000..b900ec5a
--- /dev/null
+++ b/.changeset/happy-cheetahs-appear.md
@@ -0,0 +1,5 @@
+---
+'@melt-ui/svelte': patch
+---
+
+fix(Calendar/Range Calendar): use `'button'` for button elements' `type`
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d32d859c..85e1bd31 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,13 @@
 # @melt-ui/svelte
 
+## 0.74.3
+
+### Patch Changes
+
+- Revert regressions caused by PRs #977 and #995 ([#1016](https://github.com/melt-ui/melt-ui/pull/1016))
+
+- Toolbar: set correct tabindex for buttons and links ([#1004](https://github.com/melt-ui/melt-ui/pull/1004))
+
 ## 0.74.2
 
 ### Patch Changes
diff --git a/package.json b/package.json
index a7aa43ee..f4031476 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
 	"name": "@melt-ui/svelte",
-	"version": "0.74.2",
+	"version": "0.74.3",
 	"license": "MIT",
 	"repository": "github:melt-ui/melt-ui",
 	"author": "Thomas G. Lopes",
diff --git a/src/docs/components/tooltip.svelte b/src/docs/components/tooltip.svelte
index a0cdb4d7..be92e102 100644
--- a/src/docs/components/tooltip.svelte
+++ b/src/docs/components/tooltip.svelte
@@ -36,7 +36,7 @@
 	[data-melt-tooltip-content] {
 		opacity: 0;
 		visibility: hidden;
-		transition: 150ms ease;
+		transition: opacity 150ms ease;
 
 		&[data-open] {
 			opacity: 1;
diff --git a/src/lib/builders/calendar/create.ts b/src/lib/builders/calendar/create.ts
index 98406db8..b3073b0a 100644
--- a/src/lib/builders/calendar/create.ts
+++ b/src/lib/builders/calendar/create.ts
@@ -360,6 +360,7 @@ export function createCalendar<
 			const disabled = $isPrevButtonDisabled;
 			return {
 				role: 'button',
+				type: 'button' as const,
 				'aria-label': 'Previous',
 				'aria-disabled': disabled ? ('true' as const) : undefined,
 				'data-disabled': disabled ? '' : undefined,
@@ -391,6 +392,7 @@ export function createCalendar<
 			const disabled = $isNextButtonDisabled;
 			return {
 				role: 'button',
+				type: 'button' as const,
 				'aria-label': 'Next',
 				'aria-disabled': disabled ? ('true' as const) : undefined,
 				'data-disabled': disabled ? '' : undefined,
diff --git a/src/lib/builders/context-menu/create.ts b/src/lib/builders/context-menu/create.ts
index f7a0867e..dc50ea0e 100644
--- a/src/lib/builders/context-menu/create.ts
+++ b/src/lib/builders/context-menu/create.ts
@@ -1,4 +1,4 @@
-import { usePopper, type InteractOutsideEvent } from '$lib/internal/actions/index.js';
+import { usePopper } from '$lib/internal/actions/index.js';
 import {
 	FIRST_LAST_KEYS,
 	addMeltEventListener,
@@ -11,6 +11,7 @@ import {
 	getPortalDestination,
 	getPreviousFocusable,
 	isHTMLElement,
+	isLeftClick,
 	kbd,
 	noop,
 	omit,
@@ -19,7 +20,6 @@ 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';
@@ -113,20 +113,19 @@ export function createContextMenu(props?: CreateContextMenuProps) {
 	);
 	const longPressTimer = withGet.writable(0);
 
-	function handleClickOutside(e: InteractOutsideEvent) {
+	function handleClickOutside(e: PointerEvent) {
 		rootOptions.onOutsideClick.get()?.(e);
-		if (e.defaultPrevented) return false;
+		if (e.defaultPrevented) return;
 
 		const target = e.target;
-		if (!(target instanceof Element)) return false;
+		if (!(target instanceof Element)) return;
 
 		const isClickInsideTrigger = target.closest(`[data-id="${ids.trigger.get()}"]`) !== null;
 
 		if (!isClickInsideTrigger || isLeftClick(e)) {
-			return true;
+			rootOpen.set(false);
+			return;
 		}
-
-		return false;
 	}
 
 	const isVisible = derivedVisible({
@@ -148,7 +147,7 @@ export function createContextMenu(props?: CreateContextMenuProps) {
 				id: $menuId,
 				'aria-labelledby': $triggerId,
 				'data-state': $isVisible ? 'open' : 'closed',
-				'data-portal': portalAttr($portal),
+				'data-portal': $portal ? '' : undefined,
 				tabindex: -1,
 			} as const;
 		},
@@ -175,14 +174,11 @@ export function createContextMenu(props?: CreateContextMenuProps) {
 							open: rootOpen,
 							options: {
 								floating: $positioning,
-								modal: {
-									closeOnInteractOutside: $closeOnOutsideClick,
-									onClose: () => {
-										rootOpen.set(false);
-									},
-									shouldCloseOnInteractOutside: handleClickOutside,
-									open: $isVisible,
-								},
+								clickOutside: $closeOnOutsideClick
+									? {
+											handler: handleClickOutside,
+									  }
+									: null,
 								portal: getPortalDestination(node, $portal),
 								escapeKeydown: $closeOnEscape ? undefined : null,
 							},
@@ -344,10 +340,3 @@ export function createContextMenu(props?: CreateContextMenuProps) {
 function isTouchOrPen(e: PointerEvent) {
 	return e.pointerType !== 'mouse';
 }
-
-export function isLeftClick(event: InteractOutsideEvent): boolean {
-	if ('button' in event) {
-		return event.button === 0 && event.ctrlKey === false && event.metaKey === false;
-	}
-	return true;
-}
diff --git a/src/lib/builders/dialog/create.ts b/src/lib/builders/dialog/create.ts
index 9f028bb2..662467e4 100644
--- a/src/lib/builders/dialog/create.ts
+++ b/src/lib/builders/dialog/create.ts
@@ -17,7 +17,6 @@ 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';
@@ -43,7 +42,7 @@ const defaults = {
 	closeOnOutsideClick: true,
 	role: 'dialog',
 	defaultOpen: false,
-	portal: undefined,
+	portal: 'body',
 	forceVisible: false,
 	openFocus: undefined,
 	closeFocus: undefined,
@@ -257,11 +256,11 @@ export function createDialog(props?: CreateDialogProps) {
 	const portalled = makeElement(name('portalled'), {
 		stores: portal,
 		returned: ($portal) => ({
-			'data-portal': portalAttr($portal),
+			'data-portal': $portal ? '' : undefined,
 		}),
 		action: (node: HTMLElement) => {
 			const unsubPortal = effect([portal], ([$portal]) => {
-				if ($portal === null) return noop;
+				if (!$portal) 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 bb013909..5ab7d08b 100644
--- a/src/lib/builders/link-preview/create.ts
+++ b/src/lib/builders/link-preview/create.ts
@@ -18,7 +18,6 @@ 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';
@@ -42,7 +41,7 @@ const defaults = {
 	arrowSize: 8,
 	closeOnOutsideClick: true,
 	forceVisible: false,
-	portal: undefined,
+	portal: 'body',
 	closeOnEscape: true,
 	onOutsideClick: undefined,
 } satisfies CreateLinkPreviewProps;
@@ -162,7 +161,7 @@ export function createLinkPreview(props: CreateLinkPreviewProps = {}) {
 				}),
 				id: $contentId,
 				'data-state': $isVisible ? 'open' : 'closed',
-				'data-portal': portalAttr($portal),
+				'data-portal': $portal ? '' : undefined,
 			};
 		},
 		action: (node: HTMLElement): MeltActionReturn<LinkPreviewEvents['content']> => {
@@ -194,21 +193,22 @@ export function createLinkPreview(props: CreateLinkPreviewProps = {}) {
 						open: open,
 						options: {
 							floating: $positioning,
-							modal: {
-								closeOnInteractOutside: $closeOnOutsideClick,
-								onClose: () => {
-									open.set(false);
-									$activeTrigger.focus();
-								},
-								shouldCloseOnInteractOutside: (e) => {
-									onOutsideClick.get()?.(e);
-									if (e.defaultPrevented) return false;
-									if (isHTMLElement($activeTrigger) && $activeTrigger.contains(e.target as Element))
-										return false;
-									return true;
-								},
-								open: $isVisible,
-							},
+							clickOutside: $closeOnOutsideClick
+								? {
+										handler: (e) => {
+											onOutsideClick.get()?.(e);
+											if (e.defaultPrevented) return;
+
+											if (
+												isHTMLElement($activeTrigger) &&
+												!$activeTrigger.contains(e.target as Element)
+											) {
+												open.set(false);
+												$activeTrigger.focus();
+											}
+										},
+								  }
+								: null,
 							portal: getPortalDestination(node, $portal),
 							focusTrap: null,
 							escapeKeydown: $closeOnEscape ? undefined : null,
diff --git a/src/lib/builders/link-preview/types.ts b/src/lib/builders/link-preview/types.ts
index 665b5600..21094a75 100644
--- a/src/lib/builders/link-preview/types.ts
+++ b/src/lib/builders/link-preview/types.ts
@@ -1,4 +1,4 @@
-import type { FloatingConfig, InteractOutsideEvent } from '$lib/internal/actions/index.js';
+import type { FloatingConfig } from '$lib/internal/actions/index.js';
 import type { ChangeFn, IdObj } from '$lib/internal/helpers/index.js';
 import type { BuilderReturn } from '$lib/internal/types.js';
 import type { Writable } from 'svelte/store';
@@ -63,7 +63,7 @@ export type CreateLinkPreviewProps = {
 	 * If `event.preventDefault()` is called within the function,
 	 * the dialog will not close when the user clicks outside of it.
 	 */
-	onOutsideClick?: (event: InteractOutsideEvent) => void;
+	onOutsideClick?: (event: PointerEvent) => void;
 
 	/**
 	 * Whether or not to close the linkpreview when the escape key is pressed
diff --git a/src/lib/builders/listbox/create.ts b/src/lib/builders/listbox/create.ts
index 24518e51..705466a9 100644
--- a/src/lib/builders/listbox/create.ts
+++ b/src/lib/builders/listbox/create.ts
@@ -487,22 +487,22 @@ export function createListbox<
 							options: {
 								floating: $positioning,
 								focusTrap: null,
-								modal: {
-									closeOnInteractOutside: $closeOnOutsideClick,
-									onClose: closeMenu,
-									open: $isVisible,
-									shouldCloseOnInteractOutside: (e) => {
-										onOutsideClick.get()?.(e);
-										if (e.defaultPrevented) return false;
-										const target = e.target;
-										if (!isElement(target)) return false;
-										if (target === $activeTrigger || $activeTrigger.contains(target)) {
-											return false;
-										}
-										// return opposite of the result of the ignoreHandler
-										return !ignoreHandler(e);
-									},
-								},
+								clickOutside: $closeOnOutsideClick
+									? {
+											handler: (e) => {
+												onOutsideClick.get()?.(e);
+												if (e.defaultPrevented) return;
+
+												const target = e.target;
+												if (!isElement(target)) return;
+												if (target === $activeTrigger || $activeTrigger.contains(target)) {
+													return;
+												}
+												closeMenu();
+											},
+											ignore: ignoreHandler,
+									  }
+									: null,
 								escapeKeydown: null,
 								portal: getPortalDestination(node, $portal),
 							},
diff --git a/src/lib/builders/listbox/types.ts b/src/lib/builders/listbox/types.ts
index a35c9a01..ba1bc0a7 100644
--- a/src/lib/builders/listbox/types.ts
+++ b/src/lib/builders/listbox/types.ts
@@ -1,4 +1,4 @@
-import type { FloatingConfig, InteractOutsideEvent } from '$lib/internal/actions/index.js';
+import type { FloatingConfig } from '$lib/internal/actions/index.js';
 import type { ChangeFn, IdObj } from '$lib/internal/helpers/index.js';
 import type { BuilderReturn, WhenTrue } from '$lib/internal/types.js';
 import type { Writable } from 'svelte/store';
@@ -117,7 +117,7 @@ export type CreateListboxProps<
 	 * If `event.preventDefault()` is called within the function,
 	 * the dialog will not close when the user clicks outside of it.
 	 */
-	onOutsideClick?: (event: InteractOutsideEvent) => void;
+	onOutsideClick?: (event: PointerEvent) => void;
 
 	/**
 	 * Whether or not to prevent scrolling the page when the
diff --git a/src/lib/builders/menu/create.ts b/src/lib/builders/menu/create.ts
index c42e929f..79b6481a 100644
--- a/src/lib/builders/menu/create.ts
+++ b/src/lib/builders/menu/create.ts
@@ -31,7 +31,6 @@ 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';
@@ -71,7 +70,7 @@ const defaults = {
 	preventScroll: true,
 	closeOnEscape: true,
 	closeOnOutsideClick: true,
-	portal: undefined,
+	portal: 'body',
 	loop: false,
 	dir: 'ltr',
 	defaultOpen: false,
@@ -165,7 +164,7 @@ export function createMenuBuilder(opts: _MenuBuilderOptions) {
 				id: $rootMenuId,
 				'aria-labelledby': $rootTriggerId,
 				'data-state': $isVisible ? 'open' : 'closed',
-				'data-portal': portalAttr($portal),
+				'data-portal': $portal ? '' : undefined,
 				tabindex: -1,
 			} as const;
 		},
@@ -191,26 +190,22 @@ export function createMenuBuilder(opts: _MenuBuilderOptions) {
 							open: rootOpen,
 							options: {
 								floating: $positioning,
-								modal: {
-									closeOnInteractOutside: $closeOnOutsideClick,
-									shouldCloseOnInteractOutside: (e) => {
-										onOutsideClick.get()?.(e);
-										if (e.defaultPrevented) return false;
-
-										if (
-											isHTMLElement($rootActiveTrigger) &&
-											$rootActiveTrigger.contains(e.target as Element)
-										) {
-											return false;
-										}
-										return true;
-									},
-									onClose: () => {
-										rootOpen.set(false);
-										$rootActiveTrigger.focus();
-									},
-									open: $isVisible,
-								},
+								clickOutside: $closeOnOutsideClick
+									? {
+											handler: (e) => {
+												onOutsideClick.get()?.(e);
+												if (e.defaultPrevented) return;
+
+												if (
+													isHTMLElement($rootActiveTrigger) &&
+													!$rootActiveTrigger.contains(e.target as Element)
+												) {
+													rootOpen.set(false);
+													$rootActiveTrigger.focus();
+												}
+											},
+									  }
+									: null,
 								portal: getPortalDestination(node, $portal),
 								escapeKeydown: $closeOnEscape ? undefined : null,
 							},
@@ -750,7 +745,7 @@ export function createMenuBuilder(opts: _MenuBuilderOptions) {
 								options: {
 									floating: $positioning,
 									portal: isHTMLElement(parentMenuEl) ? parentMenuEl : undefined,
-									modal: null,
+									clickOutside: null,
 									focusTrap: null,
 									escapeKeydown: null,
 								},
diff --git a/src/lib/builders/menu/types.ts b/src/lib/builders/menu/types.ts
index 16a0fbde..614551bf 100644
--- a/src/lib/builders/menu/types.ts
+++ b/src/lib/builders/menu/types.ts
@@ -1,4 +1,4 @@
-import type { FloatingConfig, InteractOutsideEvent } from '$lib/internal/actions/index.js';
+import type { FloatingConfig } from '$lib/internal/actions/index.js';
 import type { TextDirection } from '$lib/internal/types.js';
 import type { ChangeFn, FocusProp, IdObj } from '$lib/internal/helpers/index.js';
 import type { Writable } from 'svelte/store';
@@ -67,7 +67,7 @@ export type _CreateMenuProps = {
 	 * If `event.preventDefault()` is called within the function,
 	 * the dialog will not close when the user clicks outside of it.
 	 */
-	onOutsideClick?: (event: InteractOutsideEvent) => void;
+	onOutsideClick?: (event: PointerEvent) => void;
 
 	/**
 	 * Whether or not to loop the menu navigation.
@@ -190,7 +190,7 @@ export type _MenuBuilderOptions = {
 		closeFocus: WithGet<Writable<FocusProp | undefined>>;
 		disableFocusFirstItem: WithGet<Writable<boolean>>;
 		closeOnItemClick: WithGet<Writable<boolean>>;
-		onOutsideClick: WithGet<Writable<((event: InteractOutsideEvent) => void) | undefined>>;
+		onOutsideClick: WithGet<Writable<((event: PointerEvent) => void) | undefined>>;
 	};
 
 	nextFocusable: WithGet<Writable<HTMLElement | null>>;
diff --git a/src/lib/builders/menubar/create.ts b/src/lib/builders/menubar/create.ts
index 27904212..e3eafda3 100644
--- a/src/lib/builders/menubar/create.ts
+++ b/src/lib/builders/menubar/create.ts
@@ -25,7 +25,6 @@ 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';
@@ -149,7 +148,7 @@ export function createMenubar(props?: CreateMenubarProps) {
 					'aria-labelledby': $triggerId,
 					'data-state': $isVisible ? 'open' : 'closed',
 					'data-melt-scope': $menubarId,
-					'data-portal': portalAttr($portal),
+					'data-portal': $portal ? '' : undefined,
 					tabindex: -1,
 				} as const;
 			},
@@ -169,21 +168,21 @@ export function createMenubar(props?: CreateMenubarProps) {
 								options: {
 									floating: $positioning,
 									portal: getPortalDestination(node, $portal),
-									modal: {
-										closeOnInteractOutside: $closeOnOutsideClick,
-										shouldCloseOnInteractOutside: (e) => {
-											onOutsideClick.get()?.(e);
-											if (e.defaultPrevented) return false;
-											const target = e.target;
-											const menubarEl = document.getElementById(ids.menubar.get());
-											if (!menubarEl || !isElement(target)) return true;
-											return !menubarEl.contains(target);
-										},
-										onClose: () => {
-											activeMenu.set('');
-										},
-										open: $rootOpen,
-									},
+									clickOutside: $closeOnOutsideClick
+										? {
+												ignore: (e) => {
+													const target = e.target;
+													const menubarEl = document.getElementById(ids.menubar.get());
+													if (!menubarEl || !isElement(target)) return false;
+													return menubarEl.contains(target);
+												},
+												handler: (e) => {
+													onOutsideClick.get()?.(e);
+													if (e.defaultPrevented) return;
+													activeMenu.set('');
+												},
+										  }
+										: null,
 								},
 							});
 
diff --git a/src/lib/builders/popover/create.ts b/src/lib/builders/popover/create.ts
index 0a19d274..8fa0b2f5 100644
--- a/src/lib/builders/popover/create.ts
+++ b/src/lib/builders/popover/create.ts
@@ -17,10 +17,9 @@ import {
 	removeScroll,
 	styleToString,
 	toWritableStores,
-	portalAttr,
 } from '$lib/internal/helpers/index.js';
 
-import { usePopper, type InteractOutsideEvent } from '$lib/internal/actions/index.js';
+import { usePopper } from '$lib/internal/actions/index.js';
 import { safeOnMount } from '$lib/internal/helpers/lifecycle.js';
 import type { Defaults, MeltActionReturn } from '$lib/internal/types.js';
 import { tick } from 'svelte';
@@ -101,7 +100,7 @@ export function createPopover(args?: CreatePopoverProps) {
 				}),
 				id: $contentId,
 				'data-state': $isVisible ? 'open' : 'closed',
-				'data-portal': portalAttr($portal),
+				'data-portal': $portal ? '' : undefined,
 			};
 		},
 		action: (node: HTMLElement) => {
@@ -141,12 +140,11 @@ export function createPopover(args?: CreatePopoverProps) {
 										clickOutsideDeactivates: true,
 										escapeDeactivates: true,
 								  },
-							modal: {
-								shouldCloseOnInteractOutside: shouldCloseOnInteractOutside,
-								onClose: handleClose,
-								open: $isVisible,
-								closeOnInteractOutside: $closeOnOutsideClick,
-							},
+							clickOutside: $closeOnOutsideClick
+								? {
+										handler: handleClickOutside,
+								  }
+								: null,
 							escapeKeydown: $closeOnEscape
 								? {
 										handler: () => {
@@ -182,16 +180,16 @@ export function createPopover(args?: CreatePopoverProps) {
 		}
 	}
 
-	function shouldCloseOnInteractOutside(e: InteractOutsideEvent) {
+	function handleClickOutside(e: PointerEvent) {
 		onOutsideClick.get()?.(e);
-		if (e.defaultPrevented) return false;
+		if (e.defaultPrevented) return;
 		const target = e.target;
 		const triggerEl = document.getElementById(ids.trigger.get());
 
 		if (triggerEl && isElement(target)) {
-			if (target === triggerEl || triggerEl.contains(target)) return false;
+			if (target === triggerEl || triggerEl.contains(target)) return;
 		}
-		return true;
+		handleClose();
 	}
 
 	const trigger = makeElement(name('trigger'), {
diff --git a/src/lib/builders/popover/types.ts b/src/lib/builders/popover/types.ts
index db498c4d..48731e8e 100644
--- a/src/lib/builders/popover/types.ts
+++ b/src/lib/builders/popover/types.ts
@@ -1,4 +1,4 @@
-import type { FloatingConfig, InteractOutsideEvent } from '$lib/internal/actions/index.js';
+import type { FloatingConfig } from '$lib/internal/actions/index.js';
 import type { ChangeFn, FocusProp, IdObj } from '$lib/internal/helpers/index.js';
 import type { BuilderReturn } from '$lib/internal/types.js';
 import type { Writable } from 'svelte/store';
@@ -66,7 +66,7 @@ export type CreatePopoverProps = {
 	 * If `event.preventDefault()` is called within the function,
 	 * the dialog will not close when the user clicks outside of it.
 	 */
-	onOutsideClick?: (event: InteractOutsideEvent) => void;
+	onOutsideClick?: (event: PointerEvent) => void;
 
 	/**
 	 * Whether or not to prevent scrolling when the popover is open.
diff --git a/src/lib/builders/range-calendar/create.ts b/src/lib/builders/range-calendar/create.ts
index 8702c21d..85dc8036 100644
--- a/src/lib/builders/range-calendar/create.ts
+++ b/src/lib/builders/range-calendar/create.ts
@@ -334,6 +334,7 @@ export function createRangeCalendar<T extends DateValue = DateValue>(
 			const disabled = $isPrevButtonDisabled;
 			return {
 				role: 'button',
+				type: 'button' as const,
 				'aria-label': 'Previous',
 				'aria-disabled': disabled ? ('true' as const) : undefined,
 				disabled: disabled ? true : undefined,
@@ -358,6 +359,7 @@ export function createRangeCalendar<T extends DateValue = DateValue>(
 			const disabled = $isNextButtonDisabled;
 			return {
 				role: 'button',
+				type: 'button' as const,
 				'aria-label': 'Next',
 				'aria-disabled': disabled ? ('true' as const) : undefined,
 				disabled: disabled ? true : undefined,
diff --git a/src/lib/builders/toolbar/create.ts b/src/lib/builders/toolbar/create.ts
index 11641e0f..051ab1c4 100644
--- a/src/lib/builders/toolbar/create.ts
+++ b/src/lib/builders/toolbar/create.ts
@@ -48,9 +48,10 @@ export const createToolbar = (props?: CreateToolbarProps) => {
 			({
 				role: 'button',
 				type: 'button',
-				tabIndex: -1,
 			} as const),
 		action: (node: HTMLElement): MeltActionReturn<ToolbarEvents['button']> => {
+			setNodeTabIndex(node);
+
 			const unsub = addMeltEventListener(node, 'keydown', handleKeyDown);
 
 			return {
@@ -63,10 +64,10 @@ export const createToolbar = (props?: CreateToolbarProps) => {
 		returned: () =>
 			({
 				role: 'link',
-				'data-melt-toolbar-item': '',
-				tabIndex: -1,
 			} as const),
 		action: (node: HTMLElement): MeltActionReturn<ToolbarEvents['link']> => {
+			setNodeTabIndex(node);
+
 			const unsub = addMeltEventListener(node, 'keydown', handleKeyDown);
 
 			return {
@@ -149,6 +150,8 @@ export const createToolbar = (props?: CreateToolbarProps) => {
 				};
 			},
 			action: (node: HTMLElement): MeltActionReturn<ToolbarEvents['item']> => {
+				setNodeTabIndex(node);
+
 				function getNodeProps() {
 					const itemValue = node.dataset.value;
 					const disabled = node.dataset.disabled === 'true';
@@ -172,17 +175,6 @@ export const createToolbar = (props?: CreateToolbarProps) => {
 					});
 				}
 
-				const parentToolbar = node.closest('[data-melt-toolbar]');
-				if (!isHTMLElement(parentToolbar)) return {};
-
-				const items = getToolbarItems(parentToolbar);
-
-				if (items[0] === node) {
-					node.tabIndex = 0;
-				} else {
-					node.tabIndex = -1;
-				}
-
 				const unsub = executeCallbacks(
 					addMeltEventListener(node, 'click', () => {
 						handleValueUpdate();
@@ -224,12 +216,6 @@ export const createToolbar = (props?: CreateToolbarProps) => {
 		};
 	};
 
-	function getToolbarItems(element: HTMLElement) {
-		return Array.from(
-			element.querySelectorAll(`${selector('item')}, ${selector('button')}`)
-		).filter((el): el is HTMLElement => isHTMLElement(el));
-	}
-
 	function handleKeyDown(e: KeyboardEvent) {
 		const $orientation = orientation.get();
 		const $loop = loop.get();
@@ -250,9 +236,7 @@ export const createToolbar = (props?: CreateToolbarProps) => {
 		const root = el.closest('[data-melt-toolbar]');
 		if (!isHTMLElement(root)) return;
 
-		const items = Array.from(
-			root.querySelectorAll(`${selector('item')}, ${selector('button')}`)
-		).filter((el): el is HTMLElement => isHTMLElement(el));
+		const items = getToolbarItems(root);
 
 		const currentIndex = items.indexOf(el);
 
@@ -294,3 +278,30 @@ export const createToolbar = (props?: CreateToolbarProps) => {
 		options,
 	};
 };
+
+/**
+ * Sets the appropriate tabIndex for the node based on its position in the
+ * parent toolbar.
+ */
+function setNodeTabIndex(node: HTMLElement) {
+	const parentToolbar = node.closest('[data-melt-toolbar]');
+
+	if (!isHTMLElement(parentToolbar)) return;
+
+	const items = getToolbarItems(parentToolbar);
+
+	if (items[0] === node) {
+		node.tabIndex = 0;
+	} else {
+		node.tabIndex = -1;
+	}
+}
+
+/**
+ * Returns an array of all toolbar items within the given element.
+ */
+function getToolbarItems(element: HTMLElement) {
+	return Array.from(
+		element.querySelectorAll(`${selector('item')}, ${selector('button')}, ${selector('link')}`)
+	).filter((el): el is HTMLElement => isHTMLElement(el));
+}
diff --git a/src/lib/builders/tooltip/create.ts b/src/lib/builders/tooltip/create.ts
index 28a465c7..a4383ddb 100644
--- a/src/lib/builders/tooltip/create.ts
+++ b/src/lib/builders/tooltip/create.ts
@@ -18,7 +18,6 @@ import {
 	pointInPolygon,
 	styleToString,
 	toWritableStores,
-	portalAttr,
 } from '$lib/internal/helpers/index.js';
 
 import { useFloating, usePortal } from '$lib/internal/actions/index.js';
@@ -38,7 +37,7 @@ const defaults = {
 	openDelay: 1000,
 	closeDelay: 0,
 	forceVisible: false,
-	portal: undefined,
+	portal: 'body',
 	closeOnEscape: true,
 	disableHoverableContent: false,
 	group: undefined,
@@ -202,7 +201,7 @@ export function createTooltip(props?: CreateTooltipProps) {
 					display: $isVisible ? undefined : 'none',
 				}),
 				id: $contentId,
-				'data-portal': portalAttr($portal),
+				'data-portal': $portal ? '' : undefined,
 				'data-state': $open ? 'open' : 'closed',
 			};
 		},
@@ -222,7 +221,7 @@ export function createTooltip(props?: CreateTooltipProps) {
 
 					const floatingReturn = useFloating(triggerEl, node, $positioning);
 					unsubFloating = floatingReturn.destroy;
-					if ($portal === null) {
+					if (!$portal) {
 						unsubPortal();
 						return;
 					}
diff --git a/src/lib/internal/actions/modal/types.ts b/src/lib/internal/actions/modal/types.ts
index 625aa2b6..b28bbc95 100644
--- a/src/lib/internal/actions/modal/types.ts
+++ b/src/lib/internal/actions/modal/types.ts
@@ -4,7 +4,7 @@ export type ModalConfig = {
 	/**
 	 * Whether the modal is currently open.
 	 */
-	open?: boolean;
+	open: boolean;
 
 	/**
 	 * Handler called when the overlay closes.
diff --git a/src/lib/internal/actions/popper/action.ts b/src/lib/internal/actions/popper/action.ts
index f17366a3..a62b23e3 100644
--- a/src/lib/internal/actions/popper/action.ts
+++ b/src/lib/internal/actions/popper/action.ts
@@ -1,5 +1,6 @@
 import {
 	createFocusTrap,
+	useClickOutside,
 	useEscapeKeydown,
 	useFloating,
 	usePortal,
@@ -12,12 +13,11 @@ import {
 } from '$lib/internal/helpers/index.js';
 import type { Action } from 'svelte/action';
 import type { PopperArgs, PopperConfig } from './types.js';
-import { useModal } from '../modal/action.js';
 
 const defaultConfig = {
 	floating: {},
 	focusTrap: {},
-	modal: {},
+	clickOutside: {},
 	escapeKeydown: {},
 	portal: 'body',
 } satisfies PopperConfig;
@@ -60,25 +60,19 @@ export const usePopper: Action<HTMLElement, PopperArgs> = (popperElement, args)
 		}
 	}
 
-	if (opts.modal !== null) {
+	if (opts.clickOutside !== null) {
 		callbacks.push(
-			useModal(popperElement, {
-				onClose: () => {
-					if (isHTMLElement(anchorElement)) {
+			useClickOutside(popperElement, {
+				enabled: open,
+				handler: (e: PointerEvent) => {
+					if (e.defaultPrevented) return;
+
+					if (isHTMLElement(anchorElement) && !anchorElement.contains(e.target as Element)) {
 						open.set(false);
 						anchorElement.focus();
 					}
 				},
-				shouldCloseOnInteractOutside: (e) => {
-					if (e.defaultPrevented) return false;
-
-					if (isHTMLElement(anchorElement) && anchorElement.contains(e.target as Element)) {
-						return false;
-					}
-
-					return true;
-				},
-				...opts.modal,
+				...opts.clickOutside,
 			}).destroy
 		);
 	}
diff --git a/src/lib/internal/actions/popper/types.ts b/src/lib/internal/actions/popper/types.ts
index 6e25cc30..71ed3e62 100644
--- a/src/lib/internal/actions/popper/types.ts
+++ b/src/lib/internal/actions/popper/types.ts
@@ -1,4 +1,5 @@
 import type {
+	ClickOutsideConfig,
 	FloatingConfig,
 	FocusTrapConfig,
 	PortalConfig,
@@ -6,12 +7,11 @@ import type {
 } from '$lib/internal/actions/index.js';
 import type { VirtualElement } from '@floating-ui/core';
 import type { Writable } from 'svelte/store';
-import type { ModalConfig } from '../modal/types.js';
 
 export type PopperConfig = {
 	floating?: FloatingConfig;
 	focusTrap?: FocusTrapConfig | null;
-	modal?: ModalConfig | null;
+	clickOutside?: ClickOutsideConfig | null;
 	portal?: PortalConfig | null;
 	escapeKeydown?: EscapeKeydownConfig | null;
 };
diff --git a/src/lib/internal/helpers/attr.ts b/src/lib/internal/helpers/attr.ts
index 621863a9..fc71525e 100644
--- a/src/lib/internal/helpers/attr.ts
+++ b/src/lib/internal/helpers/attr.ts
@@ -17,14 +17,3 @@ 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 af6d254a..507fae26 100644
--- a/src/lib/internal/helpers/elements.ts
+++ b/src/lib/internal/helpers/elements.ts
@@ -16,7 +16,8 @@ export function getPortalDestination(
 	node: HTMLElement,
 	portalProp: string | HTMLElement | undefined | null
 ) {
+	const portalParent = getPortalParent(node);
 	if (portalProp !== undefined) return portalProp;
-	if (portalProp === null) return null;
-	return getPortalParent(node);
+	if (portalParent === 'body') return document.body;
+	return null;
 }
diff --git a/src/lib/internal/helpers/ignore.ts b/src/lib/internal/helpers/ignore.ts
index 6477beba..d59241c7 100644
--- a/src/lib/internal/helpers/ignore.ts
+++ b/src/lib/internal/helpers/ignore.ts
@@ -1,8 +1,7 @@
 import { getElementByMeltId, isElement, isHTMLLabelElement } from '$lib/internal/helpers/index.js';
-import type { InteractOutsideEvent } from '../actions/index.js';
 
 export function createClickOutsideIgnore(meltId: string) {
-	return (e: InteractOutsideEvent) => {
+	return (e: PointerEvent) => {
 		const target = e.target;
 		const triggerEl = getElementByMeltId(meltId);
 		if (!triggerEl || !isElement(target)) return false;
diff --git a/src/lib/shared/index.ts b/src/lib/shared/index.ts
index 2847a128..f9b788d1 100644
--- a/src/lib/shared/index.ts
+++ b/src/lib/shared/index.ts
@@ -7,7 +7,6 @@
 import type { SegmentPart, EditableSegmentPart } from '$lib/builders/date-field/_internal/types.js';
 import type { Granularity, Matcher, DateRange, Month } from '$lib/internal/helpers/date/index.js';
 import type { FocusProp, FocusTarget } from '$lib/internal/helpers/index.js';
-import type { InteractOutsideEvent } from '$lib/internal/actions/index.js';
 
 export type {
 	Granularity,
@@ -18,5 +17,4 @@ export type {
 	Month,
 	SegmentPart,
 	EditableSegmentPart,
-	InteractOutsideEvent,
 };
diff --git a/src/routes/(landing-ui)/search.svelte b/src/routes/(landing-ui)/search.svelte
index 6c8484a0..0614fac9 100644
--- a/src/routes/(landing-ui)/search.svelte
+++ b/src/routes/(landing-ui)/search.svelte
@@ -1,7 +1,7 @@
 <script lang="ts">
 	import { goto } from '$app/navigation';
 	import { Tooltip } from '$docs/components/index.js';
-	import { createCombobox, createDialog, melt } from '$lib/index.js';
+	import { createCombobox, createDialog, createSync, melt } from '$lib/index.js';
 	import { CornerDownRight, LoaderIcon, Search as SearchIcon } from '$icons/index.js';
 	import { onMount } from 'svelte';
 	import type { Pagefind, PagefindSearchFragment, PagefindSubResult } from '../../pagefind.js';
@@ -35,7 +35,6 @@
 			}
 			return undefined;
 		},
-		forceVisible: true,
 		preventScroll: false,
 		highlightOnHover: false,
 	});
diff --git a/src/routes/repro/+page.ts b/src/routes/repro/+page.ts
index b88b1711..28882678 100644
--- a/src/routes/repro/+page.ts
+++ b/src/routes/repro/+page.ts
@@ -2,5 +2,5 @@ import { redirect } from '@sveltejs/kit';
 import type { PageLoad } from './$types.js';
 
 export const load: PageLoad = async () => {
-	redirect(303, 'https://stackblitz.com/github/melt-ui/playground');
+	redirect(303, 'https://stackblitz.com/github/melt-ui/template');
 };
diff --git a/vite.config.ts b/vite.config.ts
index 5f4b7ad3..7df879ab 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -24,6 +24,6 @@ export default defineConfig({
 		deps: {
 			inline: ['clsx'],
 		},
-		retry: 3,
+		retry: 5,
 	},
 });


Test patch:
diff --git a/src/tests/context-menu/ContextMenu.spec.ts b/src/tests/context-menu/ContextMenu.spec.ts
index 7e00a02d..0de7ce19 100644
--- a/src/tests/context-menu/ContextMenu.spec.ts
+++ b/src/tests/context-menu/ContextMenu.spec.ts
@@ -5,7 +5,6 @@ import { userEvent } from '@testing-library/user-event';
 import { testKbd as kbd } from '../utils.js';
 import ContextMenuTest from './ContextMenuTest.svelte';
 import type { CreateContextMenuProps } from '$lib/index.js';
-import { sleep } from '$lib/internal/helpers/sleep.js';
 
 function setup(props: CreateContextMenuProps = {}) {
 	const user = userEvent.setup();
@@ -148,10 +147,7 @@ describe('Context Menu', () => {
 	test('Should close on outside click by default', async () => {
 		const { user, getByTestId, queryByTestId } = await open();
 		const outsideClick = getByTestId('outside-click');
-		await sleep(100);
-		expect(outsideClick).toBeVisible();
 		await user.click(outsideClick);
-		await sleep(100);
 		await waitFor(() => expect(queryByTestId('menu')).toBeNull());
 	});
 
diff --git a/src/tests/popover/Popover.spec.ts b/src/tests/popover/Popover.spec.ts
index 6c29d3a8..9ed142a2 100644
--- a/src/tests/popover/Popover.spec.ts
+++ b/src/tests/popover/Popover.spec.ts
@@ -53,10 +53,7 @@ describe('Popover (Default)', () => {
 		await user.click(content);
 		await waitFor(() => expect(content).toBeVisible());
 		const outside = getByTestId('outside');
-		await sleep(100);
-		expect(outside).toBeVisible();
 		await user.click(outside);
-		await sleep(100);
 		await waitFor(() => expect(content).not.toBeVisible());
 	});
 
@@ -73,10 +70,7 @@ describe('Popover (Default)', () => {
 		await user.click(content);
 		await waitFor(() => expect(content).toBeVisible());
 		const outside = getByTestId('outside');
-		await sleep(100);
-		expect(outside).toBeVisible();
 		await user.click(outside);
-		await sleep(100);
 		await waitFor(() => expect(content).toBeVisible());
 	});
 
diff --git a/src/tests/portal/PortalNested.spec.ts b/src/tests/portal/PortalNested.spec.ts
index 6e961296..ae35084f 100644
--- a/src/tests/portal/PortalNested.spec.ts
+++ b/src/tests/portal/PortalNested.spec.ts
@@ -63,7 +63,7 @@ const testComponent = async (component: Structure, level = 0) => {
 };
 
 // Execute the test
-test.skip('recursive component test', async () => {
+test('recursive component test', async () => {
 	await render(PortalNestedTest);
 	await testComponent(structure);
 });
diff --git a/src/tests/portal/PortalNested.svelte b/src/tests/portal/PortalNested.svelte
index 099c45ca..fdda1a15 100644
--- a/src/tests/portal/PortalNested.svelte
+++ b/src/tests/portal/PortalNested.svelte
@@ -16,14 +16,14 @@
 	export const structure: Structure = {
 		name: 'dialog',
 		children: [
-			{
-				name: 'dialog',
-				children: [
-					{
-						name: 'select',
-					},
-				],
-			},
+			// {
+			// 	name: 'dialog',
+			// 	children: [
+			// 		{
+			// 			name: 'select',
+			// 		},
+			// 	],
+			// },
 			{
 				name: 'popover',
 				children: [{ name: 'dialog' }, { name: 'popover' }],
diff --git a/src/tests/portal/index.ts b/src/tests/portal/index.ts
deleted file mode 100644
index e69de29b..00000000
diff --git a/src/tests/toolbar/Toolbar.spec.ts b/src/tests/toolbar/Toolbar.spec.ts
index 67bdb009..1f93f658 100644
--- a/src/tests/toolbar/Toolbar.spec.ts
+++ b/src/tests/toolbar/Toolbar.spec.ts
@@ -6,6 +6,7 @@ import { describe } from 'vitest';
 import ToolbarTest from './ToolbarTest.svelte';
 import { writable } from 'svelte/store';
 import { tick } from 'svelte';
+import { type CreateToolbarProps } from '$lib/index.js';
 
 const group1Items = ['item-1', 'item-2', 'item-3'];
 const group2Items = ['item-4', 'item-5', 'item-6'];
@@ -13,8 +14,15 @@ const tbItems = [...group1Items, ...group2Items];
 
 const tbLinks = ['link-1'];
 const tbButtons = ['button-1'];
+const tbLinksAndButtons = [...tbLinks, ...tbButtons];
+const tbButtonsAndLinks = [...tbButtons, ...tbLinks];
 
 const allToolbarItems = [...tbItems, ...tbLinks, ...tbButtons];
+const allToolbarItemsLinksFirst = [...tbLinks, ...tbItems, ...tbButtons];
+const allToolbarItemsButtonsFirst = [...tbButtons, ...tbItems, ...tbLinks];
+
+const horizontal: CreateToolbarProps['orientation'] = 'horizontal';
+const vertical: CreateToolbarProps['orientation'] = 'vertical';
 
 describe('Toolbar', () => {
 	test('has no accessibility violations', async () => {
@@ -22,62 +30,287 @@ describe('Toolbar', () => {
 		expect(await axe(container)).toHaveNoViolations();
 	});
 
-	test("tab enters toolbar but doesn't navigate between items", async () => {
+	test.each([
+		{ desc: '', props: {}, items: allToolbarItems },
+		{
+			desc: 'first item is a link',
+			props: { linksFirst: true },
+			items: allToolbarItemsLinksFirst,
+		},
+		{
+			desc: 'first item is a button',
+			props: { buttonsFirst: true },
+			items: allToolbarItemsButtonsFirst,
+		},
+		{
+			desc: 'no group items, first item is a link',
+			props: { linksButtonsOnly: true, linksFirst: true },
+			items: tbLinksAndButtons,
+		},
+		{
+			desc: 'no group items, first item is a button',
+			props: { linksButtonsOnly: true, buttonsFirst: true },
+			items: tbButtonsAndLinks,
+		},
+	])("tab enters toolbar but doesn't navigate between items - $desc", async ({ props, items }) => {
 		const user = userEvent.setup();
-		const { getByTestId } = render(ToolbarTest);
+		const { getByTestId } = render(ToolbarTest, props);
 
 		await user.tab();
-		expect(getByTestId('item-1')).toHaveFocus();
+		expect(getByTestId(items[0])).toHaveFocus();
 		await user.tab();
-		expect(getByTestId('item-2')).not.toHaveFocus();
+		expect(getByTestId(items[1])).not.toHaveFocus();
 		await user.tab({ shift: true });
-		expect(getByTestId('item-1')).toHaveFocus();
+		expect(getByTestId(items[0])).toHaveFocus();
 		await user.tab({ shift: true });
-		expect(getByTestId('item-1')).not.toHaveFocus();
+		expect(getByTestId(items[0])).not.toHaveFocus();
 	});
 
-	test('arrow keys navigate between items', async () => {
+	test.each([
+		{ desc: 'horizontal', props: { orientation: horizontal }, items: allToolbarItems },
+		{ desc: 'vertical', props: { orientation: vertical }, items: allToolbarItems },
+		{
+			desc: 'horizontal, first item is a link',
+			props: { orientation: horizontal, linksFirst: true },
+			items: allToolbarItemsLinksFirst,
+		},
+		{
+			desc: 'vertical, first item is a link',
+			props: { orientation: vertical, linksFirst: true },
+			items: allToolbarItemsLinksFirst,
+		},
+		{
+			desc: 'horizontal, first item is a button',
+			props: { orientation: horizontal, buttonsFirst: true },
+			items: allToolbarItemsButtonsFirst,
+		},
+		{
+			desc: 'vertical, first item is a button',
+			props: { orientation: vertical, buttonsFirst: true },
+			items: allToolbarItemsButtonsFirst,
+		},
+		{
+			desc: 'horizontal, no group items, first item is a link',
+			props: { orientation: horizontal, linksButtonsOnly: true, linksFirst: true },
+			items: tbLinksAndButtons,
+		},
+		{
+			desc: 'vertical, no group items, first item is a link',
+			props: { orientation: vertical, linksButtonsOnly: true, linksFirst: true },
+			items: tbLinksAndButtons,
+		},
+		{
+			desc: 'horizontal, no group items, first item is a button',
+			props: { orientation: horizontal, linksButtonsOnly: true, buttonsFirst: true },
+			items: tbButtonsAndLinks,
+		},
+		{
+			desc: 'vertical, no group items, first item is a button',
+			props: { orientation: vertical, linksButtonsOnly: true, buttonsFirst: true },
+			items: tbButtonsAndLinks,
+		},
+	])('arrow keys navigate between items - $desc', async ({ props, items }) => {
 		const user = userEvent.setup();
-		const { getByTestId } = render(ToolbarTest);
+		const { getByTestId } = render(ToolbarTest, props);
 
-		await user.tab();
+		const nextKey = `{${props.orientation === horizontal ? kbd.ARROW_RIGHT : kbd.ARROW_DOWN}}`;
+		const prevKey = `{${props.orientation === horizontal ? kbd.ARROW_LEFT : kbd.ARROW_UP}}`;
+
+		const reversedItems = [...items].reverse();
 
-		const reversedItems = [...allToolbarItems].reverse();
+		await user.tab();
 
-		for (const item of allToolbarItems) {
+		for (const item of items) {
 			expect(getByTestId(item)).toHaveFocus();
-			await user.keyboard(`{${kbd.ARROW_RIGHT}}`);
+			await user.keyboard(nextKey);
 		}
 
 		for (const item of reversedItems) {
 			expect(getByTestId(item)).toHaveFocus();
-			await user.keyboard(`{${kbd.ARROW_LEFT}}`);
+			await user.keyboard(prevKey);
 		}
 	});
 
-	test('home and end keys focus first and last items - horizontal', async () => {
-		const user = userEvent.setup();
-		const { getByTestId } = render(ToolbarTest);
-
-		await user.tab();
-		const firstItem = getByTestId('item-1');
-		const lastItem = getByTestId('button-1');
-		expect(firstItem).toHaveFocus();
-		await user.keyboard(`{${kbd.END}}`);
-		expect(lastItem).toHaveFocus();
-		await user.keyboard(`{${kbd.HOME}}`);
-		expect(firstItem).toHaveFocus();
-	});
-
-	test('home and end keys focus first and last items - vertical', async () => {
+	test.each([
+		{ desc: 'horizontal', props: { orientation: horizontal }, items: allToolbarItems },
+		{ desc: 'vertical', props: { orientation: vertical }, items: allToolbarItems },
+		{
+			desc: 'horizontal, first item is a link',
+			props: { orientation: horizontal, linksFirst: true },
+			items: allToolbarItemsLinksFirst,
+		},
+		{
+			desc: 'vertical, first item is a link',
+			props: { orientation: vertical, linksFirst: true },
+			items: allToolbarItemsLinksFirst,
+		},
+		{
+			desc: 'horizontal, first item is a button',
+			props: { orientation: horizontal, buttonsFirst: true },
+			items: allToolbarItemsButtonsFirst,
+		},
+		{
+			desc: 'vertical, first item is a button',
+			props: { orientation: vertical, buttonsFirst: true },
+			items: allToolbarItemsButtonsFirst,
+		},
+		{
+			desc: 'horizontal, no group items, first item is a link',
+			props: { orientation: horizontal, linksButtonsOnly: true, linksFirst: true },
+			items: tbLinksAndButtons,
+		},
+		{
+			desc: 'vertical, no group items, first item is a link',
+			props: { orientation: vertical, linksButtonsOnly: true, linksFirst: true },
+			items: tbLinksAndButtons,
+		},
+		{
+			desc: 'horizontal, no group items, first item is a button',
+			props: { orientation: horizontal, linksButtonsOnly: true, buttonsFirst: true },
+			items: tbButtonsAndLinks,
+		},
+		{
+			desc: 'vertical, no group items, first item is a button',
+			props: { orientation: vertical, linksButtonsOnly: true, buttonsFirst: true },
+			items: tbButtonsAndLinks,
+		},
+	])(
+		'arrow key navigation loops around when looping is enabled - $desc',
+		async ({ props, items }) => {
+			const user = userEvent.setup();
+			const { getByTestId } = render(ToolbarTest, { loop: true, ...props });
+
+			const nextKey = `{${props.orientation === horizontal ? kbd.ARROW_RIGHT : kbd.ARROW_DOWN}}`;
+			const prevKey = `{${props.orientation === horizontal ? kbd.ARROW_LEFT : kbd.ARROW_UP}}`;
+
+			await user.tab();
+			const firstItem = getByTestId(items[0]);
+			const lastItem = getByTestId(items[items.length - 1]);
+			expect(firstItem).toHaveFocus();
+			await user.keyboard(prevKey);
+			expect(lastItem).toHaveFocus();
+			await user.keyboard(nextKey);
+			expect(firstItem).toHaveFocus();
+		}
+	);
+
+	test.each([
+		{ desc: 'horizontal', props: { orientation: horizontal }, items: allToolbarItems },
+		{ desc: 'vertical', props: { orientation: vertical }, items: allToolbarItems },
+		{
+			desc: 'horizontal, first item is a link',
+			props: { orientation: horizontal, linksFirst: true },
+			items: allToolbarItemsLinksFirst,
+		},
+		{
+			desc: 'vertical, first item is a link',
+			props: { orientation: vertical, linksFirst: true },
+			items: allToolbarItemsLinksFirst,
+		},
+		{
+			desc: 'horizontal, first item is a button',
+			props: { orientation: horizontal, buttonsFirst: true },
+			items: allToolbarItemsButtonsFirst,
+		},
+		{
+			desc: 'vertical, first item is a button',
+			props: { orientation: vertical, buttonsFirst: true },
+			items: allToolbarItemsButtonsFirst,
+		},
+		{
+			desc: 'horizontal, no group items, first item is a link',
+			props: { orientation: horizontal, linksButtonsOnly: true, linksFirst: true },
+			items: tbLinksAndButtons,
+		},
+		{
+			desc: 'vertical, no group items, first item is a link',
+			props: { orientation: vertical, linksButtonsOnly: true, linksFirst: true },
+			items: tbLinksAndButtons,
+		},
+		{
+			desc: 'horizontal, no group items, first item is a button',
+			props: { orientation: horizontal, linksButtonsOnly: true, buttonsFirst: true },
+			items: tbButtonsAndLinks,
+		},
+		{
+			desc: 'vertical, no group items, first item is a button',
+			props: { orientation: vertical, linksButtonsOnly: true, buttonsFirst: true },
+			items: tbButtonsAndLinks,
+		},
+	])(
+		'arrow key navigation does not loop around when looping is disabled - $desc',
+		async ({ props, items }) => {
+			const user = userEvent.setup();
+			const { getByTestId } = render(ToolbarTest, { loop: false, ...props });
+
+			const nextKey = `{${props.orientation === horizontal ? kbd.ARROW_RIGHT : kbd.ARROW_DOWN}}`;
+			const prevKey = `{${props.orientation === horizontal ? kbd.ARROW_LEFT : kbd.ARROW_UP}}`;
+
+			const firstItem = getByTestId(items[0]);
+			const lastItem = getByTestId(items[items.length - 1]);
+
+			firstItem.focus();
+			expect(firstItem).toHaveFocus();
+			await user.keyboard(prevKey);
+			expect(firstItem).toHaveFocus();
+
+			lastItem.focus();
+			expect(lastItem).toHaveFocus();
+			await user.keyboard(nextKey);
+			expect(lastItem).toHaveFocus();
+		}
+	);
+
+	test.each([
+		{ desc: 'horizontal', props: { orientation: horizontal }, items: allToolbarItems },
+		{ desc: 'vertical', props: { orientation: vertical }, items: allToolbarItems },
+		{
+			desc: 'horizontal, first item is a link',
+			props: { orientation: horizontal, linksFirst: true },
+			items: allToolbarItemsLinksFirst,
+		},
+		{
+			desc: 'vertical, first item is a link',
+			props: { orientation: vertical, linksFirst: true },
+			items: allToolbarItemsLinksFirst,
+		},
+		{
+			desc: 'horizontal, first item is a button',
+			props: { orientation: horizontal, buttonsFirst: true },
+			items: allToolbarItemsButtonsFirst,
+		},
+		{
+			desc: 'vertical, first item is a button',
+			props: { orientation: vertical, buttonsFirst: true },
+			items: allToolbarItemsButtonsFirst,
+		},
+		{
+			desc: 'horizontal, no group items, first item is a link',
+			props: { orientation: horizontal, linksButtonsOnly: true, linksFirst: true },
+			items: tbLinksAndButtons,
+		},
+		{
+			desc: 'vertical, no group items, first item is a link',
+			props: { orientation: vertical, linksButtonsOnly: true, linksFirst: true },
+			items: tbLinksAndButtons,
+		},
+		{
+			desc: 'horizontal, no group items, first item is a button',
+			props: { orientation: horizontal, linksButtonsOnly: true, buttonsFirst: true },
+			items: tbButtonsAndLinks,
+		},
+		{
+			desc: 'vertical, no group items, first item is a button',
+			props: { orientation: vertical, linksButtonsOnly: true, buttonsFirst: true },
+			items: tbButtonsAndLinks,
+		},
+	])('home and end keys focus first and last items - $desc', async ({ props, items }) => {
 		const user = userEvent.setup();
-		const { getByTestId } = render(ToolbarTest, {
-			orientation: 'vertical',
-		});
+		const { getByTestId } = render(ToolbarTest, props);
 
 		await user.tab();
-		const firstItem = getByTestId('item-1');
-		const lastItem = getByTestId('button-1');
+		const firstItem = getByTestId(items[0]);
+		const lastItem = getByTestId(items[items.length - 1]);
 		expect(firstItem).toHaveFocus();
 		await user.keyboard(`{${kbd.END}}`);
 		expect(lastItem).toHaveFocus();
@@ -157,27 +390,6 @@ describe('Toolbar', () => {
 		expect(group2OnItems.length).toBe(1);
 	});
 
-	test('orientation changes keyboard nav', async () => {
-		const user = userEvent.setup();
-		const { getByTestId } = render(ToolbarTest, {
-			orientation: 'vertical',
-		});
-
-		await user.tab();
-
-		const reversedItems = [...allToolbarItems].reverse();
-
-		for (const item of allToolbarItems) {
-			expect(getByTestId(item)).toHaveFocus();
-			await user.keyboard(`{${kbd.ARROW_DOWN}}`);
-		}
-
-		for (const item of reversedItems) {
-			expect(getByTestId(item)).toHaveFocus();
-			await user.keyboard(`{${kbd.ARROW_UP}}`);
-		}
-	});
-
 	test('default value sets initial pressed/checked items', async () => {
 		const { getByTestId } = render(ToolbarTest, {
 			orientation: 'vertical',
diff --git a/src/tests/toolbar/ToolbarTest.svelte b/src/tests/toolbar/ToolbarTest.svelte
index 0af7dd03..840c50ab 100644
--- a/src/tests/toolbar/ToolbarTest.svelte
+++ b/src/tests/toolbar/ToolbarTest.svelte
@@ -25,6 +25,10 @@
 		value: undefined,
 	};
 
+	export let linksFirst = false;
+	export let buttonsFirst = false;
+	export let linksButtonsOnly = false;
+
 	const {
 		elements: { root, button, link, separator },
 		builders: { createToolbarGroup },
@@ -44,19 +48,31 @@
 
 <main>
 	<div use:melt={$root} data-testid="root">
-		<div use:melt={$fontGroup} data-testid="group-1">
-			<button use:melt={$fontItem('item-1')} data-testid="item-1"> item-1 </button>
-			<button use:melt={$fontItem('item-2')} data-testid="item-2"> item-2 </button>
-			<button use:melt={$fontItem('item-3')} data-testid="item-3"> item-3 </button>
-		</div>
-		<div use:melt={$separator} />
-		<div use:melt={$alignGroup} data-testid="group-2">
-			<button use:melt={$alignItem('item-4')} data-testid="item-4"> item-4 </button>
-			<button use:melt={$alignItem('item-5')} data-testid="item-5"> item-5 </button>
-			<button use:melt={$alignItem('item-6')} data-testid="item-6"> item-6</button>
-		</div>
-		<div use:melt={$separator} />
-		<a href="/" use:melt={$link} data-testid="link-1"> link-1 </a>
-		<button use:melt={$button} data-testid="button-1">button-1</button>
+		{#if linksFirst}
+			<a href="/" use:melt={$link} data-testid="link-1"> link-1 </a>
+		{/if}
+		{#if buttonsFirst}
+			<button use:melt={$button} data-testid="button-1"> button-1 </button>
+		{/if}
+		{#if !linksButtonsOnly}
+			<div use:melt={$fontGroup} data-testid="group-1">
+				<button use:melt={$fontItem('item-1')} data-testid="item-1"> item-1 </button>
+				<button use:melt={$fontItem('item-2')} data-testid="item-2"> item-2 </button>
+				<button use:melt={$fontItem('item-3')} data-testid="item-3"> item-3 </button>
+			</div>
+			<div use:melt={$separator} />
+			<div use:melt={$alignGroup} data-testid="group-2">
+				<button use:melt={$alignItem('item-4')} data-testid="item-4"> item-4 </button>
+				<button use:melt={$alignItem('item-5')} data-testid="item-5"> item-5 </button>
+				<button use:melt={$alignItem('item-6')} data-testid="item-6"> item-6</button>
+			</div>
+			<div use:melt={$separator} />
+		{/if}
+		{#if !linksFirst}
+			<a href="/" use:melt={$link} data-testid="link-1"> link-1 </a>
+		{/if}
+		{#if !buttonsFirst}
+			<button use:melt={$button} data-testid="button-1"> button-1 </button>
+		{/if}
 	</div>
 </main>