Repository: melt-ui/melt-ui
Pull request #658 (2023-10-22 02:01:05)

feat(devcontainers): add the ability to use GitHub Codespaces and VS Code remote containers
For `Hacktoberfest`, I have been adding `GitHub Codespaces` / `VS Code Remote Containers` configuration for OSS projects to make it easier for contributors to the project to get up and running with the correct developer setup. Including the appropriate configuration for a project allows contributors to work entirely within a web browser via `GH Codespaces`, or inside of fully, and correctly configured containers on their own computer with `VS Code Remote Containers` (both of these use the same configuration, as `GH Codespaces` leverages the `Remote Containers` features under the hood).

For the `melt-ui` project, this configuration includes the following:
- Preinstalled `Node LTS`
- Preinstalled `ESLint`,  `Prettier`, `Tailwind CSS`, and `Svelte` extensions
- Preinstalled `playwright` dependencies
- Automation of the `pnpm install` when the devcontainer is first created

Basically everything needed for a contributor to open the project after a fresh clone and be immediately productive, either locally with `VS Code` or remotely with `GitHub Codespaces`!

> [!NOTE]
> This `devcontainer` is based off of the Microsoft-provided "node" Docker image, which is based off the Debian12 Docker image. Playwright only started to support Firefox for Debian12 in `playwright@1.37.0` ([ref](https://github.com/microsoft/playwright/issues/23532)) which is _one minor version_ greater than the version of playwright in the _pnpm-lock.yml_ for `melt-ui`. Due to the fact that the _package.json_ specified `^1.36.0` for `playwright` and version `1.37.0` is semver-compatible with the specified version, I have updated to the latest semver-compatible version in my changes here, specifically `1.39.0`.
>
> I have run all `playwright` tests inside the `devcontainer` both headlessly and non-headlessly and have confirmed that everything still works correctly. I would have preferred to make no changes besides adding this configuration, but since the version bump was so small and only to a semver-compatible version, I decided it was worth it so that _everything_ works inside the `devcontainer`, including `playwright` tests.

For reference, here are some other repositories for whom I've opened PRs to do the same:

- https://github.com/AnilSeervi/inspirational-quotes/pull/19
- https://github.com/Icon-Shelf/icon-shelf/pull/135 (note that this one is a GUI electron app, but it's still possible to containerize it and do development in the cloud on GH Codespaces!)
- https://github.com/developertools-tech/developertools.tech/issues/36
- https://github.com/date-fns/date-fns/pull/3551
- https://github.com/floating-ui/floating-ui/pull/2606
- https://github.com/EveryBoard/EveryBoard/pull/155
- https://github.com/compiler-explorer/compiler-explorer/pull/5631
- https://github.com/JamesLMilner/terra-draw/pull/108



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

Latest commit: a78e36516f74552b348a2ad331c56f006633413c

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/andrewbrey/melt-ui/new/feat/andrewbrey/devcontainer?filename=.changeset/loud-zebras-pump.md&value=---%0A%22%40melt-ui%2Fsvelte%22%3A%20patch%0A---%0A%0Afeat(devcontainers)%3A%20add%20the%20ability%20to%20use%20GitHub%20Codespaces%20and%20VS%20Code%20remote%20containers%0A)



Patch:
diff --git a/.changeset/empty-timers-enjoy.md b/.changeset/empty-timers-enjoy.md
new file mode 100644
index 00000000..a966d089
--- /dev/null
+++ b/.changeset/empty-timers-enjoy.md
@@ -0,0 +1,6 @@
+---
+"@melt-ui/svelte": patch
+---
+
+DropdownMenu: Implement `disableTriggerRefocus` prop
+
diff --git a/.changeset/rotten-worms-warn.md b/.changeset/rotten-worms-warn.md
new file mode 100644
index 00000000..770aa61b
--- /dev/null
+++ b/.changeset/rotten-worms-warn.md
@@ -0,0 +1,8 @@
+---
+"@melt-ui/svelte": patch
+---
+
+Tooltip: Avoid calling openTooltip on every mousemove, fixing a couple of bugs:
+- onOpenChange was being called for every mouse move within the tooltip.
+- Overlapping grace areas were fighting over the group.
+
diff --git a/.changeset/serious-cows-lick.md b/.changeset/serious-cows-lick.md
new file mode 100644
index 00000000..9f91edb7
--- /dev/null
+++ b/.changeset/serious-cows-lick.md
@@ -0,0 +1,5 @@
+---
+"@melt-ui/svelte": patch
+---
+
+- Apply correct aria attributes depending on `type` of toolbar group
diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile
new file mode 100644
index 00000000..0017d8c5
--- /dev/null
+++ b/.devcontainer/Dockerfile
@@ -0,0 +1,12 @@
+ARG NODE_MAJOR_VERSION
+ARG PNPM_VERSION
+
+FROM mcr.microsoft.com/devcontainers/javascript-node:${NODE_MAJOR_VERSION}
+
+ENV EDITOR="code -w" VISUAL="code -w"
+
+# install system dependencies for playwright
+RUN npx --yes playwright install-deps
+
+# install additional npm packages
+RUN su node -c 'npm i -g pnpm@${PNPM_VERSION}'
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
new file mode 100644
index 00000000..f4afe0ab
--- /dev/null
+++ b/.devcontainer/devcontainer.json
@@ -0,0 +1,23 @@
+{
+	"name": "melt-ui",
+	"dockerFile": "Dockerfile",
+	"build": {
+		"args": { "NODE_MAJOR_VERSION": "18", "PNPM_VERSION": "8.6.3" }
+	},
+	"postCreateCommand": [".devcontainer/post-create.sh"],
+	"portsAttributes": {
+		"5173": { "label": "Melt UI" },
+		"6006": { "label": "Storybook" },
+		"9323": { "label": "Playwright Report" }
+	},
+	"customizations": {
+		"vscode": {
+			"extensions": [
+				"bradlc.vscode-tailwindcss",
+				"dbaeumer.vscode-eslint",
+				"esbenp.prettier-vscode",
+				"svelte.svelte-vscode"
+			]
+		}
+	}
+}
diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh
new file mode 100755
index 00000000..cc5270cc
--- /dev/null
+++ b/.devcontainer/post-create.sh
@@ -0,0 +1,15 @@
+#!/usr/bin/env bash
+
+set -eo pipefail
+
+# when in a VS Code or GitHub Codespaces devcontainer
+if [ -n "${REMOTE_CONTAINERS}" ] || [ -n "${CODESPACES}" ]; then
+	this_dir=$(cd -P -- "$(dirname -- "$(command -v -- "$0")")" && pwd -P)
+	workspace_root=$(realpath ${this_dir}/..)
+
+	# perform additional one-time setup just after
+	# the devcontainer is created
+	pnpm install --dir "${workspace_root}" # install workspace node dependencies
+	npx --yes playwright install           # install playwright browsers
+
+fi
diff --git a/.eslintrc.cjs b/.eslintrc.cjs
index 27089285..7f3e30b3 100644
--- a/.eslintrc.cjs
+++ b/.eslintrc.cjs
@@ -57,6 +57,6 @@ module.exports = {
 	},
 	globals: {
 		NodeJS: true,
-		$$Generic: 'readable',
+		$$Generic: 'writable',
 	},
 };
diff --git a/.gitignore b/.gitignore
index 6d9194e2..624f5a01 100644
--- a/.gitignore
+++ b/.gitignore
@@ -16,3 +16,4 @@ storybook-static
 /test-results/
 /playwright-report/
 /playwright/.cache/
+/.pnpm-store/
\ No newline at end of file
diff --git a/package.json b/package.json
index 8412ba52..2c0b7e5d 100644
--- a/package.json
+++ b/package.json
@@ -74,7 +74,7 @@
 		"@iconify-json/lucide": "^1.1.104",
 		"@iconify-json/simple-icons": "^1.1.57",
 		"@melt-ui/pp": "0.1.0",
-		"@playwright/test": "^1.36.0",
+		"@playwright/test": "^1.39.0",
 		"@storybook/addon-essentials": "^7.3.2",
 		"@storybook/addon-interactions": "^7.3.2",
 		"@storybook/addon-links": "^7.3.2",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 052f6771..028dbaa4 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -41,8 +41,8 @@ devDependencies:
     specifier: 0.1.0
     version: 0.1.0(@melt-ui/svelte@0.30.1)(svelte@4.0.0)
   '@playwright/test':
-    specifier: ^1.36.0
-    version: 1.36.0
+    specifier: ^1.39.0
+    version: 1.39.0
   '@storybook/addon-essentials':
     specifier: ^7.3.2
     version: 7.3.2(react-dom@18.2.0)(react@18.2.0)
@@ -2459,15 +2459,12 @@ packages:
     dev: true
     optional: true
 
-  /@playwright/test@1.36.0:
-    resolution: {integrity: sha512-yN+fvMYtiyLFDCQos+lWzoX4XW3DNuaxjBu68G0lkgLgC6BP+m/iTxJQoSicz/x2G5EsrqlZTqTIP9sTgLQerg==}
+  /@playwright/test@1.39.0:
+    resolution: {integrity: sha512-3u1iFqgzl7zr004bGPYiN/5EZpRUSFddQBra8Rqll5N0/vfpqlP9I9EXqAoGacuAbX6c9Ulg/Cjqglp5VkK6UQ==}
     engines: {node: '>=16'}
     hasBin: true
     dependencies:
-      '@types/node': 20.3.1
-      playwright-core: 1.36.0
-    optionalDependencies:
-      fsevents: 2.3.2
+      playwright: 1.39.0
     dev: true
 
   /@polka/url@1.0.0-next.21:
@@ -10065,10 +10062,20 @@ packages:
       pathe: 1.1.1
     dev: true
 
-  /playwright-core@1.36.0:
-    resolution: {integrity: sha512-7RTr8P6YJPAqB+8j5ATGHqD6LvLLM39sYVNsslh78g8QeLcBs5750c6+msjrHUwwGt+kEbczBj1XB22WMwn+WA==}
+  /playwright-core@1.39.0:
+    resolution: {integrity: sha512-+k4pdZgs1qiM+OUkSjx96YiKsXsmb59evFoqv8SKO067qBA+Z2s/dCzJij/ZhdQcs2zlTAgRKfeiiLm8PQ2qvw==}
+    engines: {node: '>=16'}
+    hasBin: true
+    dev: true
+
+  /playwright@1.39.0:
+    resolution: {integrity: sha512-naE5QT11uC/Oiq0BwZ50gDmy8c8WLPRTEWuSSFVG2egBka/1qMoSqYQcROMT9zLwJ86oPofcTH2jBY/5wWOgIw==}
     engines: {node: '>=16'}
     hasBin: true
+    dependencies:
+      playwright-core: 1.39.0
+    optionalDependencies:
+      fsevents: 2.3.2
     dev: true
 
   /polished@4.2.2:
diff --git a/src/docs/constants.ts b/src/docs/constants.ts
index aad2393a..12056c34 100644
--- a/src/docs/constants.ts
+++ b/src/docs/constants.ts
@@ -46,6 +46,12 @@ export const PROPS = {
 		description: DESCRIPTIONS.FORCE_VISIBLE(args.name ?? 'element'),
 		default: args.default ?? 'false',
 	}),
+	DISABLE_TRIGGER_REFOCUS: (args: PropArgs = {}): Prop => ({
+		name: 'disableTriggerRefocus',
+		type: 'boolean',
+		description: `Whether or not to refocus the trigger when the ${args.name ?? 'element'} closes`,
+		default: args.default ?? 'false',
+	}),
 	DISABLED: (args: PropArgs = {}): Prop => ({
 		name: 'disabled',
 		type: 'boolean',
diff --git a/src/docs/data/builders/menu.ts b/src/docs/data/builders/menu.ts
index e4662aee..25029686 100644
--- a/src/docs/data/builders/menu.ts
+++ b/src/docs/data/builders/menu.ts
@@ -23,6 +23,7 @@ export const menuBuilderProps = [
 	PROPS.DEFAULT_OPEN,
 	PROPS.OPEN,
 	PROPS.ON_OPEN_CHANGE,
+	PROPS.DISABLE_TRIGGER_REFOCUS
 ];
 
 export const menuBuilderOptions = [
diff --git a/src/docs/previews/toolbar/main/tailwind/index.svelte b/src/docs/previews/toolbar/main/tailwind/index.svelte
index aa5fee7d..03dc7895 100644
--- a/src/docs/previews/toolbar/main/tailwind/index.svelte
+++ b/src/docs/previews/toolbar/main/tailwind/index.svelte
@@ -30,25 +30,37 @@
 	class="flex min-w-max items-center gap-4 rounded-md bg-white px-3 py-3 text-neutral-700 shadow-sm lg:w-[35rem]"
 >
 	<div class="flex items-center gap-1" use:melt={$fontGroup}>
-		<button class="item" use:melt={$fontItem('bold')}>
+		<button class="item" aria-label="bold" use:melt={$fontItem('bold')}>
 			<Bold class="square-5" />
 		</button>
-		<button class="item" use:melt={$fontItem('italic')}>
+		<button class="item" aria-label="italic" use:melt={$fontItem('italic')}>
 			<Italic class="square-5" />
 		</button>
-		<button class="item" use:melt={$fontItem('strikethrough')}>
+		<button
+			class="item"
+			aria-label="strikethrough"
+			use:melt={$fontItem('strikethrough')}
+		>
 			<Strikethrough class="square-5" />
 		</button>
 	</div>
 	<div class="separator" use:melt={$separator} />
 	<div class="flex items-center gap-1" use:melt={$alignGroup}>
-		<button class="item" use:melt={$alignItem('left')}>
+		<button class="item" aria-label="align left" use:melt={$alignItem('left')}>
 			<AlignLeft class="square-5" />
 		</button>
-		<button class="item" use:melt={$alignItem('center')}>
+		<button
+			class="item"
+			aria-label="align center"
+			use:melt={$alignItem('center')}
+		>
 			<AlignCenter class="square-5" />
 		</button>
-		<button class="item" use:melt={$alignItem('right')}>
+		<button
+			class="item"
+			aria-label="align-right"
+			use:melt={$alignItem('right')}
+		>
 			<AlignRight class="square-5" />
 		</button>
 	</div>
diff --git a/src/lib/builders/dropdown-menu/create.ts b/src/lib/builders/dropdown-menu/create.ts
index 800f7b05..aa4f12f2 100644
--- a/src/lib/builders/dropdown-menu/create.ts
+++ b/src/lib/builders/dropdown-menu/create.ts
@@ -48,7 +48,7 @@ export function createDropdownMenu(props?: CreateDropdownMenuProps) {
 		rootActiveTrigger,
 		nextFocusable,
 		prevFocusable,
-		disableTriggerRefocus: true,
+		disableTriggerRefocus: props?.disableTriggerRefocus ?? false,
 		selector: 'dropdown-menu',
 		removeScroll: true,
 	});
diff --git a/src/lib/builders/menu/create.ts b/src/lib/builders/menu/create.ts
index 74370b4a..904e43cc 100644
--- a/src/lib/builders/menu/create.ts
+++ b/src/lib/builders/menu/create.ts
@@ -547,7 +547,6 @@ export function createMenuBuilder(opts: _MenuBuilderOptions) {
 
 						if (e.defaultPrevented) {
 							if (!isHTMLElement(itemEl)) return;
-
 							handleRovingFocus(itemEl);
 							return;
 						}
@@ -871,7 +870,6 @@ export function createMenuBuilder(opts: _MenuBuilderOptions) {
 							if (!isHTMLElement(menuEl)) return;
 
 							const firstItem = getMenuItems(menuEl)[0];
-
 							handleRovingFocus(firstItem);
 						}
 					}),
@@ -883,7 +881,6 @@ export function createMenuBuilder(opts: _MenuBuilderOptions) {
 
 						const triggerEl = e.currentTarget;
 						if (!isHTMLElement(triggerEl)) return;
-
 						handleRovingFocus(triggerEl);
 
 						const openTimer = get(subOpenTimer);
@@ -1102,7 +1099,7 @@ export function createMenuBuilder(opts: _MenuBuilderOptions) {
 				unsubs.push(removeScroll());
 			}
 
-			if (!$rootOpen && $rootActiveTrigger) {
+			if (!$rootOpen && $rootActiveTrigger && opts.disableTriggerRefocus === false) {
 				handleRovingFocus($rootActiveTrigger);
 			}
 
@@ -1121,6 +1118,9 @@ export function createMenuBuilder(opts: _MenuBuilderOptions) {
 					handleRovingFocus(menuItems[0]);
 				} else if ($rootActiveTrigger) {
 					// Focus on active trigger trigger
+					if (opts.disableTriggerRefocus) {
+						return;
+					}
 					handleRovingFocus($rootActiveTrigger);
 				} else {
 					if (opts.disableTriggerRefocus) {
@@ -1217,7 +1217,6 @@ export function createMenuBuilder(opts: _MenuBuilderOptions) {
 
 		const parentMenuEl = getParentMenu(target);
 		if (!parentMenuEl) return;
-
 		handleRovingFocus(parentMenuEl);
 	}
 
@@ -1465,7 +1464,6 @@ export function handleMenuNavigation(e: KeyboardEvent, loop?: boolean) {
 		default:
 			return;
 	}
-
 	handleRovingFocus(candidateNodes[nextIndex]);
 }
 
diff --git a/src/lib/builders/menu/types.ts b/src/lib/builders/menu/types.ts
index 07cf35c9..e4bdbeed 100644
--- a/src/lib/builders/menu/types.ts
+++ b/src/lib/builders/menu/types.ts
@@ -99,6 +99,13 @@ export type _CreateMenuProps = {
 	 * @default true
 	 */
 	typeahead?: boolean;
+
+	/**
+	 * Whether to disable the functionality that the trigger element is refocused when the menu is closed.
+	 *
+	 * @type {boolean}
+	 */
+	disableTriggerRefocus?: boolean;
 };
 
 export type _CreateSubmenuProps = Pick<_CreateMenuProps, 'arrowSize' | 'positioning'> & {
diff --git a/src/lib/builders/toolbar/create.ts b/src/lib/builders/toolbar/create.ts
index 2a26db3a..eae0ca67 100644
--- a/src/lib/builders/toolbar/create.ts
+++ b/src/lib/builders/toolbar/create.ts
@@ -130,6 +130,9 @@ export const createToolbar = (props?: CreateToolbarProps) => {
 					const disabled = $disabled || argDisabled;
 
 					const pressed = Array.isArray($value) ? $value.includes(itemValue) : $value === itemValue;
+
+					const isSingle = $type === 'single';
+					const isMultiple = $type === 'multiple';
 					return {
 						disabled: disabledAttr(disabled),
 						pressed,
@@ -137,9 +140,10 @@ export const createToolbar = (props?: CreateToolbarProps) => {
 						'data-disabled': disabledAttr(disabled),
 						'data-value': itemValue,
 						'data-state': pressed ? 'on' : 'off',
-						'aria-pressed': pressed,
+						'aria-checked': isSingle ? pressed : undefined,
+						'aria-pressed': isMultiple ? pressed : undefined,
 						type: 'button',
-						role: $type === 'single' ? 'radio' : undefined,
+						role: isSingle ? 'radio' : undefined,
 						'data-melt-toolbar-item': '',
 					} as const;
 				};
diff --git a/src/lib/builders/tooltip/create.ts b/src/lib/builders/tooltip/create.ts
index 52823f8b..ecff376c 100644
--- a/src/lib/builders/tooltip/create.ts
+++ b/src/lib/builders/tooltip/create.ts
@@ -302,9 +302,7 @@ export function createTooltip(props?: CreateTooltipProps) {
 
 				if ($openReason !== 'pointer') return;
 
-				if (isMouseInTooltipArea) {
-					openTooltip('pointer');
-				} else {
+				if (!isMouseInTooltipArea) {
 					closeTooltip();
 				}
 			})


Test patch:
diff --git a/src/tests/toolbar/Toolbar.spec.ts b/src/tests/toolbar/Toolbar.spec.ts
new file mode 100644
index 00000000..9a00ef09
--- /dev/null
+++ b/src/tests/toolbar/Toolbar.spec.ts
@@ -0,0 +1,294 @@
+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';
+import { describe } from 'vitest';
+import ToolbarTest from './ToolbarTest.svelte';
+import { writable } from 'svelte/store';
+import { tick } from 'svelte';
+
+const group1Items = ['item-1', 'item-2', 'item-3'];
+const group2Items = ['item-4', 'item-5', 'item-6'];
+const tbItems = [...group1Items, ...group2Items];
+
+const tbLinks = ['link-1'];
+const tbButtons = ['button-1'];
+
+const allToolbarItems = [...tbItems, ...tbLinks, ...tbButtons];
+
+describe('Toolbar', () => {
+	test('has no accessibility violations', async () => {
+		const { container } = render(ToolbarTest);
+		expect(await axe(container)).toHaveNoViolations();
+	});
+
+	test("tab enters toolbar but doesn't navigate between items", async () => {
+		const user = userEvent.setup();
+		const { getByTestId } = render(ToolbarTest);
+
+		await user.tab();
+		expect(getByTestId('item-1')).toHaveFocus();
+		await user.tab();
+		expect(getByTestId('item-2')).not.toHaveFocus();
+		await user.tab({ shift: true });
+		expect(getByTestId('item-1')).toHaveFocus();
+		await user.tab({ shift: true });
+		expect(getByTestId('item-1')).not.toHaveFocus();
+	});
+
+	test('arrow keys navigate between items', async () => {
+		const user = userEvent.setup();
+		const { getByTestId } = render(ToolbarTest);
+
+		await user.tab();
+
+		const reversedItems = [...allToolbarItems].reverse();
+
+		for (const item of allToolbarItems) {
+			expect(getByTestId(item)).toHaveFocus();
+			await user.keyboard(`{${kbd.ARROW_RIGHT}}`);
+		}
+
+		for (const item of reversedItems) {
+			expect(getByTestId(item)).toHaveFocus();
+			await user.keyboard(`{${kbd.ARROW_LEFT}}`);
+		}
+	});
+
+	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 () => {
+		const user = userEvent.setup();
+		const { getByTestId } = render(ToolbarTest, {
+			orientation: 'vertical',
+		});
+
+		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('items can be selected with keyboard', async () => {
+		const user = userEvent.setup();
+		const { getByTestId } = render(ToolbarTest);
+
+		for (const item of group1Items) {
+			const curr = getByTestId(item);
+			curr.focus();
+			expect(curr).toHaveFocus();
+			await user.keyboard(`{${kbd.SPACE}}`);
+			expect(curr).toHaveAttribute('aria-pressed', 'true');
+			expect(curr).toHaveAttribute('data-state', 'on');
+		}
+
+		for (const item of group2Items) {
+			const curr = getByTestId(item);
+			curr.focus();
+			expect(curr).toHaveFocus();
+			await user.keyboard(`{${kbd.SPACE}}`);
+			expect(curr).toHaveAttribute('aria-checked', 'true');
+			expect(curr).toHaveAttribute('data-state', 'on');
+		}
+	});
+
+	test('items can be selected with mouse', async () => {
+		const user = userEvent.setup();
+		const { getByTestId } = render(ToolbarTest);
+
+		for (const item of group1Items) {
+			const curr = getByTestId(item);
+			await user.click(curr);
+			expect(curr).toHaveAttribute('aria-pressed', 'true');
+			expect(curr).toHaveAttribute('data-state', 'on');
+		}
+
+		for (const item of group2Items) {
+			const curr = getByTestId(item);
+			await user.click(curr);
+			expect(curr).toHaveAttribute('aria-checked', 'true');
+			expect(curr).toHaveAttribute('data-state', 'on');
+		}
+	});
+
+	test('multiple items can be selected in `multiple` groups', async () => {
+		const user = userEvent.setup();
+		const { getByTestId } = render(ToolbarTest);
+
+		for (const item of group1Items) {
+			const curr = getByTestId(item);
+			await user.click(curr);
+		}
+
+		const group1 = getByTestId('group-1');
+		const group1OnItems = group1.querySelectorAll<HTMLElement>('[data-state="on"]');
+		expect(group1OnItems.length).toBe(group1Items.length);
+	});
+
+	test('multiple items cannot be selected in `single` groups', async () => {
+		const user = userEvent.setup();
+		const { getByTestId } = render(ToolbarTest);
+
+		const group2Items = ['item-4', 'item-5', 'item-6'];
+
+		for (const item of group2Items) {
+			const curr = getByTestId(item);
+			await user.click(curr);
+		}
+
+		const group2 = getByTestId('group-2');
+		const group2OnItems = group2.querySelectorAll<HTMLElement>('[data-state="on"]');
+		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',
+			toolbarGroup1Props: {
+				defaultValue: ['item-1', 'item-2'],
+			},
+			toolbarGroup2Props: {
+				defaultValue: 'item-5',
+			},
+		});
+
+		const group1 = getByTestId('group-1');
+		const group1OnItems = group1.querySelectorAll<HTMLElement>('[data-state="on"]');
+		expect(group1OnItems.length).toBe(2);
+		expect(group1OnItems[0]).toHaveAttribute('data-testid', 'item-1');
+		expect(group1OnItems[1]).toHaveAttribute('data-testid', 'item-2');
+
+		const group2 = getByTestId('group-2');
+		const group2OnItems = group2.querySelectorAll<HTMLElement>('[data-state="on"]');
+		expect(group2OnItems.length).toBe(1);
+		expect(group2OnItems[0]).toHaveAttribute('data-testid', 'item-5');
+	});
+
+	test('onValueChange function can override value', async () => {
+		const user = userEvent.setup();
+		const { getByTestId } = render(ToolbarTest, {
+			orientation: 'vertical',
+			toolbarGroup1Props: {
+				onValueChange: () => {
+					return ['item-1'];
+				},
+			},
+			toolbarGroup2Props: {
+				onValueChange: () => {
+					return 'item-5';
+				},
+			},
+		});
+
+		const item1 = getByTestId('item-1');
+		const item2 = getByTestId('item-2');
+		await user.click(item2);
+		expect(item2).not.toHaveAttribute('data-state', 'on');
+		expect(item1).toHaveAttribute('data-state', 'on');
+
+		const item5 = getByTestId('item-5');
+		const item6 = getByTestId('item-6');
+		await user.click(item6);
+		expect(item6).not.toHaveAttribute('data-state', 'on');
+		expect(item5).toHaveAttribute('data-state', 'on');
+	});
+
+	test('value prop overrides default value', async () => {
+		const valueStore1 = writable(['item-1', 'item-2']);
+		const valueStore2 = writable('item-5');
+		const { getByTestId } = render(ToolbarTest, {
+			orientation: 'vertical',
+			toolbarGroup1Props: {
+				defaultValue: ['item-3'],
+				value: valueStore1,
+			},
+			toolbarGroup2Props: {
+				defaultValue: 'item-6',
+				value: valueStore2,
+			},
+		});
+
+		const group1 = getByTestId('group-1');
+		const group1OnItems = group1.querySelectorAll<HTMLElement>('[data-state="on"]');
+		expect(group1OnItems.length).toBe(2);
+		expect(group1OnItems[0]).toHaveAttribute('data-testid', 'item-1');
+		expect(group1OnItems[1]).toHaveAttribute('data-testid', 'item-2');
+
+		const group2 = getByTestId('group-2');
+		const group2OnItems = group2.querySelectorAll<HTMLElement>('[data-state="on"]');
+		expect(group2OnItems.length).toBe(1);
+		expect(group2OnItems[0]).toHaveAttribute('data-testid', 'item-5');
+	});
+
+	test('can be programmatically updated using value store', async () => {
+		const valueStore1 = writable<string[]>([]);
+		const valueStore2 = writable<string>(undefined);
+		const { getByTestId } = render(ToolbarTest, {
+			orientation: 'vertical',
+			toolbarGroup1Props: {
+				value: valueStore1,
+			},
+			toolbarGroup2Props: {
+				value: valueStore2,
+			},
+		});
+
+		const group1 = getByTestId('group-1');
+		const group1OnItems = group1.querySelectorAll<HTMLElement>('[data-state="on"]');
+		expect(group1OnItems.length).toBe(0);
+
+		const group2 = getByTestId('group-2');
+		const group2OnItems = group2.querySelectorAll<HTMLElement>('[data-state="on"]');
+		expect(group2OnItems.length).toBe(0);
+
+		valueStore1.update(() => ['item-1', 'item-2']);
+		await tick();
+		const group1OnItems2 = group1.querySelectorAll<HTMLElement>('[data-state="on"]');
+		expect(group1OnItems2.length).toBe(2);
+		expect(group1OnItems2[0]).toHaveAttribute('data-testid', 'item-1');
+		expect(group1OnItems2[1]).toHaveAttribute('data-testid', 'item-2');
+
+		valueStore2.update(() => 'item-5');
+		await tick();
+		const group2OnItems2 = group2.querySelectorAll<HTMLElement>('[data-state="on"]');
+		expect(group2OnItems2.length).toBe(1);
+		expect(group2OnItems2[0]).toHaveAttribute('data-testid', 'item-5');
+	});
+});
diff --git a/src/tests/toolbar/ToolbarTest.svelte b/src/tests/toolbar/ToolbarTest.svelte
new file mode 100644
index 00000000..0af7dd03
--- /dev/null
+++ b/src/tests/toolbar/ToolbarTest.svelte
@@ -0,0 +1,62 @@
+<script lang="ts">
+	import {
+		createToolbar,
+		melt,
+		type CreateToolbarProps,
+		type CreateToolbarGroupProps,
+	} from '$lib/index.js';
+
+	export let loop: CreateToolbarProps['loop'] = undefined;
+	export let orientation: CreateToolbarProps['orientation'] = undefined;
+
+	export let toolbarGroup1Props: CreateToolbarGroupProps<'multiple'> = {
+		type: 'multiple',
+		defaultValue: undefined,
+		disabled: undefined,
+		onValueChange: undefined,
+		value: undefined,
+	};
+
+	export let toolbarGroup2Props: CreateToolbarGroupProps = {
+		type: 'single',
+		defaultValue: undefined,
+		disabled: undefined,
+		onValueChange: undefined,
+		value: undefined,
+	};
+
+	const {
+		elements: { root, button, link, separator },
+		builders: { createToolbarGroup },
+	} = createToolbar({
+		loop,
+		orientation,
+	});
+
+	const {
+		elements: { group: fontGroup, item: fontItem },
+	} = createToolbarGroup(toolbarGroup1Props);
+
+	const {
+		elements: { group: alignGroup, item: alignItem },
+	} = createToolbarGroup(toolbarGroup2Props);
+</script>
+
+<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>
+	</div>
+</main>