Repository: melt-ui/melt-ui
Pull request #811 (2023-12-08 13:30:41)

fix: PIN Input - fix `delete` key event handling and add tests
I think this is what is causing my test to fail in:
https://github.com/huntabyte/bits-ui/pull/227

edit: yep, seems like this works!

also added tests to test this and test other PIN Inputs features

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

Latest commit: 66210c15690de399ddae23819860581c62c87eb5

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/ollema/melt-ui/new/fix-pin-input-bug?filename=.changeset/dirty-kiwis-greet.md&value=---%0A%22%40melt-ui%2Fsvelte%22%3A%20patch%0A---%0A%0Afix%3A%20update%20value%20when%20deleting%20inputs%20with%20the%20delete%20key%0A)



Patch:
diff --git a/.changeset/calm-zebras-hug.md b/.changeset/calm-zebras-hug.md
new file mode 100644
index 00000000..2614adbd
--- /dev/null
+++ b/.changeset/calm-zebras-hug.md
@@ -0,0 +1,5 @@
+---
+"@melt-ui/svelte": patch
+---
+
+Listbox: ignore certain keys in typeahead (fixes #803)
diff --git a/.changeset/cuddly-terms-love.md b/.changeset/cuddly-terms-love.md
new file mode 100644
index 00000000..ce5a9ae3
--- /dev/null
+++ b/.changeset/cuddly-terms-love.md
@@ -0,0 +1,5 @@
+---
+'@melt-ui/svelte': patch
+---
+
+PIN Input: fix `value` store not updating in delete keydown event
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8fb5cd52..ce75b85d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,11 @@
 # @melt-ui/svelte
 
+## 0.65.1
+
+### Patch Changes
+
+- Select & Combobox: Fix `removeScroll` bugs (closes #733 & #808) ([#809](https://github.com/melt-ui/melt-ui/pull/809))
+
 ## 0.65.0
 
 ### Minor Changes
diff --git a/package.json b/package.json
index 6c48ad7a..0d341758 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
 	"name": "@melt-ui/svelte",
-	"version": "0.65.0",
+	"version": "0.65.1",
 	"license": "MIT",
 	"repository": "github:melt-ui/melt-ui",
 	"author": "Thomas G. Lopes",
@@ -71,7 +71,6 @@
 		"nanoid": "^4.0.2"
 	},
 	"devDependencies": {
-		"mode-watcher": "^0.1.2",
 		"@changesets/cli": "^2.26.2",
 		"@fontsource/inter": "^5.0.8",
 		"@huntabyte/mdsvex": "0.16.5",
@@ -88,6 +87,7 @@
 		"@storybook/sveltekit": "^7.3.2",
 		"@storybook/testing-library": "^0.2.0",
 		"@sveltejs/adapter-auto": "^2.1.0",
+		"@sveltejs/adapter-cloudflare": "^2.3.4",
 		"@sveltejs/adapter-static": "^2.0.3",
 		"@sveltejs/adapter-vercel": "^3.0.2",
 		"@sveltejs/kit": "^1.22.3",
@@ -120,6 +120,7 @@
 		"jest-axe": "^8.0.0",
 		"jsdom": "^22.1.0",
 		"lucide-svelte": "^0.263.0",
+		"mode-watcher": "^0.1.2",
 		"pagefind": "^1.0.3",
 		"postcss": "^8.4.27",
 		"postcss-load-config": "^4.0.1",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a7805857..5cc741ee 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -73,6 +73,9 @@ devDependencies:
   '@sveltejs/adapter-auto':
     specifier: ^2.1.0
     version: 2.1.0(@sveltejs/kit@1.22.3)
+  '@sveltejs/adapter-cloudflare':
+    specifier: ^2.3.4
+    version: 2.3.4(@sveltejs/kit@1.22.3)
   '@sveltejs/adapter-static':
     specifier: ^2.0.3
     version: 2.0.3(@sveltejs/kit@1.22.3)
@@ -1821,6 +1824,10 @@ packages:
       prettier: 2.8.7
     dev: true
 
+  /@cloudflare/workers-types@4.20231121.0:
+    resolution: {integrity: sha512-+kWfpCkqiepwAKXyHoE0gnkPgkLhz0/9HOBIGhHRsUvUKvhUtm3mbqqoGRWgF1qcjzrDUBbrrOq4MYHfFtc2RA==}
+    dev: true
+
   /@colors/colors@1.5.0:
     resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==}
     engines: {node: '>=0.1.90'}
@@ -4391,6 +4398,17 @@ packages:
       import-meta-resolve: 3.0.0
     dev: true
 
+  /@sveltejs/adapter-cloudflare@2.3.4(@sveltejs/kit@1.22.3):
+    resolution: {integrity: sha512-+5Msbm7svrZjaBt0KapNh2zEaMRfrhbTRBWRXrFa+w+KFRQnHjFIPvWz/NQ11XKVu7dRe7mkzqvqieL2b0hpJw==}
+    peerDependencies:
+      '@sveltejs/kit': ^1.0.0
+    dependencies:
+      '@cloudflare/workers-types': 4.20231121.0
+      '@sveltejs/kit': 1.22.3(svelte@4.0.0)(vite@4.4.7)
+      esbuild: 0.18.17
+      worktop: 0.8.0-next.15
+    dev: true
+
   /@sveltejs/adapter-static@2.0.3(@sveltejs/kit@1.22.3):
     resolution: {integrity: sha512-VUqTfXsxYGugCpMqQv1U0LIdbR3S5nBkMMDmpjGVJyM6Q2jHVMFtdWJCkeHMySc6mZxJ+0eZK3T7IgmUCDrcUQ==}
     peerDependencies:
@@ -10985,6 +11003,11 @@ packages:
       functions-have-names: 1.2.3
     dev: true
 
+  /regexparam@2.0.2:
+    resolution: {integrity: sha512-A1PeDEYMrkLrfyOwv2jwihXbo9qxdGD3atBYQA9JJgreAx8/7rC6IUkWOw2NQlOxLp2wL0ifQbh1HuidDfYA6w==}
+    engines: {node: '>=8'}
+    dev: true
+
   /regexpp@3.2.0:
     resolution: {integrity: sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==}
     engines: {node: '>=8'}
@@ -13047,6 +13070,14 @@ packages:
     resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==}
     dev: true
 
+  /worktop@0.8.0-next.15:
+    resolution: {integrity: sha512-0ycNO52P6nVwsjr1y20zuf0nqJatAb8L7MODBfQIxbxndHV5O4s50oZZMHWhJG1RLpHwbK0Epq8aaQK4E2GlgQ==}
+    engines: {node: '>=12'}
+    dependencies:
+      mrmime: 1.0.1
+      regexparam: 2.0.2
+    dev: true
+
   /wrap-ansi@6.2.0:
     resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
     engines: {node: '>=8'}
diff --git a/src/lib/builders/listbox/create.ts b/src/lib/builders/listbox/create.ts
index 082ff338..07232695 100644
--- a/src/lib/builders/listbox/create.ts
+++ b/src/lib/builders/listbox/create.ts
@@ -456,22 +456,12 @@ export function createListbox<
 		},
 		action: (node: HTMLElement): MeltActionReturn<ListboxEvents['menu']> => {
 			let unsubPopper = noop;
-			let unsubScroll = noop;
 			const unsubscribe = executeCallbacks(
 				// Bind the popper portal to the input element.
 				effect(
-					[
-						isVisible,
-						preventScroll,
-						closeOnEscape,
-						portal,
-						closeOnOutsideClick,
-						positioning,
-						activeTrigger,
-					],
+					[isVisible, closeOnEscape, portal, closeOnOutsideClick, positioning, activeTrigger],
 					([
 						$isVisible,
-						$preventScroll,
 						$closeOnEscape,
 						$portal,
 						$closeOnOutsideClick,
@@ -479,12 +469,8 @@ export function createListbox<
 						$activeTrigger,
 					]) => {
 						unsubPopper();
-						unsubScroll();
 
 						if (!$isVisible || !$activeTrigger) return;
-						if ($preventScroll) {
-							unsubScroll = removeScroll();
-						}
 
 						const ignoreHandler = createClickOutsideIgnore(get(ids.trigger));
 
@@ -527,7 +513,6 @@ export function createListbox<
 				destroy: () => {
 					unsubscribe();
 					unsubPopper();
-					unsubScroll();
 				},
 			};
 		},
@@ -661,6 +646,20 @@ export function createListbox<
 		});
 	});
 
+	effect([open], ([$open]) => {
+		if (!isBrowser) return;
+
+		let unsubScroll = noop;
+
+		if (get(preventScroll) && $open) {
+			unsubScroll = removeScroll();
+		}
+
+		return () => {
+			unsubScroll();
+		};
+	});
+
 	return {
 		ids,
 		elements: {
diff --git a/src/lib/builders/pin-input/create.ts b/src/lib/builders/pin-input/create.ts
index 63a0ff0e..aa12cfda 100644
--- a/src/lib/builders/pin-input/create.ts
+++ b/src/lib/builders/pin-input/create.ts
@@ -131,6 +131,7 @@ export function createPinInput(props?: CreatePinInputProps) {
 						e.preventDefault();
 						node.value = '';
 						tick().then(() => (node.placeholder = ''));
+						value.set(inputs.map((input) => input.value.slice(-1) ?? undefined));
 					}
 
 					if (e.key === 'ArrowLeft') {
diff --git a/src/lib/internal/helpers/typeahead.ts b/src/lib/internal/helpers/typeahead.ts
index c1d88742..6d4783e9 100644
--- a/src/lib/internal/helpers/typeahead.ts
+++ b/src/lib/internal/helpers/typeahead.ts
@@ -26,6 +26,13 @@ export type HandleTypeaheadSearch = {
 	 */
 };
 
+/**
+ * Keys to ignore for typeahead so we aren't matching things
+ * like `Shift menu item` or `Control center` or `Alt menu` when
+ * a user presses those keys.
+ */
+const ignoredKeys = new Set(['Shift', 'Control', 'Alt', 'Meta', 'CapsLock', 'NumLock']);
+
 /**
  * Default options for the typeahead search.
  * We default to roving focus when a match is found, but
@@ -45,6 +52,7 @@ export function createTypeaheadSearch(args: TypeaheadArgs = {}) {
 	});
 
 	const handleTypeaheadSearch = (key: string, items: HTMLElement[]) => {
+		if (ignoredKeys.has(key)) return;
 		const currentItem = withDefaults.getCurrentItem();
 
 		const $typed = get(typed);
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
index 48765cf2..bffecc83 100644
--- a/src/routes/+layout.svelte
+++ b/src/routes/+layout.svelte
@@ -1,36 +1,48 @@
 <script lang="ts">
 	import '@fontsource/inter/100.css';
+	import INTER_100 from '@fontsource/inter/files/inter-latin-100-normal.woff2';
 	import '@fontsource/inter/200.css';
+	import INTER_200 from '@fontsource/inter/files/inter-latin-200-normal.woff2';
 	import '@fontsource/inter/300.css';
+	import INTER_300 from '@fontsource/inter/files/inter-latin-300-normal.woff2';
 	import '@fontsource/inter/400.css';
+	import INTER_400 from '@fontsource/inter/files/inter-latin-400-normal.woff2';
 	import '@fontsource/inter/500.css';
+	import INTER_500 from '@fontsource/inter/files/inter-latin-500-normal.woff2';
 	import '@fontsource/inter/600.css';
+	import INTER_600 from '@fontsource/inter/files/inter-latin-600-normal.woff2';
 	import '@fontsource/inter/700.css';
+	import INTER_700 from '@fontsource/inter/files/inter-latin-700-normal.woff2';
 	import '@fontsource/inter/800.css';
+	import INTER_800 from '@fontsource/inter/files/inter-latin-800-normal.woff2';
 	import '@fontsource/inter/900.css';
+	import INTER_900 from '@fontsource/inter/files/inter-latin-900-normal.woff2';
+
 	import '../app.postcss';
 	import '../fonts.css';
 
-	import { browser, dev } from '$app/environment';
+	import { dev } from '$app/environment';
 	import { page } from '$app/stores';
 	import { JsIndicator, SiteHeader, TailwindIndicator } from '$docs/components/index.js';
 	import { cn } from '$docs/utils';
-	import { env } from '$env/dynamic/public';
-	import * as Fathom from 'fathom-client';
-	import { ModeWatcher } from 'mode-watcher';
-	import { onMount } from 'svelte';
 
-	onMount(() => {
-		if (!env.PUBLIC_FATHOM_ID || !env.PUBLIC_FATHOM_URL || dev) return;
-		Fathom.load(env.PUBLIC_FATHOM_ID, {
-			url: env.PUBLIC_FATHOM_URL,
-		});
-	});
+	import { ModeWatcher } from 'mode-watcher';
 
-	$: $page.url.pathname, browser && Fathom.trackPageview();
 	$: isRoot = $page.url.pathname === '/';
 </script>
 
+<svelte:head>
+	<link rel="preload" as="font" href={INTER_100} type="font/woff2" crossorigin="anonymous" />
+	<link rel="preload" as="font" href={INTER_200} type="font/woff2" crossorigin="anonymous" />
+	<link rel="preload" as="font" href={INTER_300} type="font/woff2" crossorigin="anonymous" />
+	<link rel="preload" as="font" href={INTER_400} type="font/woff2" crossorigin="anonymous" />
+	<link rel="preload" as="font" href={INTER_500} type="font/woff2" crossorigin="anonymous" />
+	<link rel="preload" as="font" href={INTER_600} type="font/woff2" crossorigin="anonymous" />
+	<link rel="preload" as="font" href={INTER_700} type="font/woff2" crossorigin="anonymous" />
+	<link rel="preload" as="font" href={INTER_800} type="font/woff2" crossorigin="anonymous" />
+	<link rel="preload" as="font" href={INTER_900} type="font/woff2" crossorigin="anonymous" />
+</svelte:head>
+
 <ModeWatcher defaultMode="dark" />
 
 <div class="relative flex min-h-screen flex-col md:flex-col-reverse" id="page">
diff --git a/svelte.config.js b/svelte.config.js
index 56eba24a..58c849e1 100644
--- a/svelte.config.js
+++ b/svelte.config.js
@@ -1,15 +1,9 @@
-import adapterVercel from '@sveltejs/adapter-vercel';
+import { mdsvex } from '@huntabyte/mdsvex';
+import { preprocessMeltUI } from '@melt-ui/pp';
 import adapterStatic from '@sveltejs/adapter-static';
 import { vitePreprocess } from '@sveltejs/kit/vite';
-import { preprocessMeltUI } from '@melt-ui/pp';
-import { mdsvex } from '@huntabyte/mdsvex';
-import { mdsvexOptions } from './mdsvex.config.js';
 import sequence from 'svelte-sequential-preprocessor';
-
-const IS_PREDEPLOY = process.env.PREDEPLOY === '1';
-const IS_VERCEL = process.env.VERCEL === '1';
-
-const adapter = IS_PREDEPLOY || !IS_VERCEL ? adapterStatic() : adapterVercel({ runtime: 'edge' });
+import { mdsvexOptions } from './mdsvex.config.js';
 
 /** @type {import('@sveltejs/kit').Config} */
 const config = {
@@ -22,7 +16,7 @@ const config = {
 		// adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list.
 		// If your environment is not supported or you settled on a specific environment, switch out the adapter.
 		// See https://kit.svelte.dev/docs/adapters for more information about adapters.
-		adapter,
+		adapter: adapterStatic(),
 		prerender: {
 			handleMissingId: 'ignore',
 		},


Test patch:
diff --git a/src/tests/pin-input/PinInput.spec.ts b/src/tests/pin-input/PinInput.spec.ts
new file mode 100644
index 00000000..8b83e819
--- /dev/null
+++ b/src/tests/pin-input/PinInput.spec.ts
@@ -0,0 +1,199 @@
+import { render } from '@testing-library/svelte';
+import userEvent from '@testing-library/user-event';
+import { axe } from 'jest-axe';
+import { describe, it } from 'vitest';
+import { testKbd as kbd } from '../utils.js';
+import PinInputTest from './PinInputTest.svelte';
+import { writable } from 'svelte/store';
+
+describe('PIN Input', () => {
+	it('has no accessibility violations', async () => {
+		const { container } = render(PinInputTest);
+		expect(await axe(container)).toHaveNoViolations();
+	});
+
+	it("doesn't display the hidden input", async () => {
+		const { getByTestId } = render(PinInputTest);
+		const hiddenInput = getByTestId('hidden-input');
+		expect(hiddenInput).not.toBeVisible();
+	});
+
+	it('uses the `placeholder` prop as the placeholder', async () => {
+		const valueStore = writable([]);
+		const { getByTestId } = render(PinInputTest, { value: valueStore, placeholder: 'X' });
+		expect(getByTestId('input-1')).toHaveAttribute('placeholder', 'X');
+	});
+
+	it('uses the `type` prop to determine the input type', async () => {
+		const { getByTestId } = render(PinInputTest, { type: 'password' });
+		expect(getByTestId('input-1')).toHaveAttribute('type', 'password');
+	});
+
+	it('syncs the `name` prop to the hidden input', async () => {
+		const { getByTestId } = render(PinInputTest, { name: 'test' });
+		const hiddenInput = getByTestId('hidden-input');
+		expect(hiddenInput).toHaveAttribute('name', 'test');
+	});
+
+	it('inputs should be disabled when the `disabled` prop is set to true', async () => {
+		const { getByTestId } = render(PinInputTest, { disabled: true });
+		expect(getByTestId('input-1')).toBeDisabled();
+		expect(getByTestId('input-2')).toBeDisabled();
+		expect(getByTestId('input-3')).toBeDisabled();
+		expect(getByTestId('input-4')).toBeDisabled();
+		expect(getByTestId('input-5')).toBeDisabled();
+	});
+
+	it('syncs the `value` prop to the hidden input', async () => {
+		const value = ['0', '1', '1', '3', '4'];
+		const valueStore = writable(value);
+		const valueStr = value.join('');
+		const { getByTestId } = render(PinInputTest, { value: valueStore });
+		const hiddenInput = getByTestId('hidden-input');
+		expect(hiddenInput).toHaveValue(valueStr);
+	});
+
+	it('inputs should fire the `onChange` callback when changing', async () => {
+		let newValue = undefined;
+
+		const { getByTestId } = render(PinInputTest, {
+			onValueChange: ({ next }) => {
+				newValue = next;
+				return next;
+			},
+		});
+
+		const user = userEvent.setup();
+
+		const input3 = getByTestId('input-3');
+		input3.focus();
+		await user.type(input3, '6');
+		expect(newValue).toStrictEqual(['1', '2', '6', '4', '5']);
+	});
+
+	it('navigates between the inputs using the arrow keys', async () => {
+		const { getByTestId } = render(PinInputTest);
+		const user = userEvent.setup();
+
+		getByTestId('input-1').focus();
+		await user.keyboard(kbd.ARROW_RIGHT);
+		expect(getByTestId('input-2')).toHaveFocus();
+		await user.keyboard(kbd.ARROW_RIGHT);
+		expect(getByTestId('input-3')).toHaveFocus();
+		await user.keyboard(kbd.ARROW_RIGHT);
+		expect(getByTestId('input-4')).toHaveFocus();
+		await user.keyboard(kbd.ARROW_RIGHT);
+		expect(getByTestId('input-5')).toHaveFocus();
+		await user.keyboard(kbd.ARROW_RIGHT);
+		expect(getByTestId('input-5')).toHaveFocus();
+		await user.keyboard(kbd.ARROW_LEFT);
+		expect(getByTestId('input-4')).toHaveFocus();
+	});
+
+	it('navigates to the next input when typing', async () => {
+		const { getByTestId } = render(PinInputTest);
+		const user = userEvent.setup();
+
+		const input1 = getByTestId('input-1');
+		const input2 = getByTestId('input-2');
+		const input3 = getByTestId('input-3');
+		const input4 = getByTestId('input-4');
+		const input5 = getByTestId('input-5');
+		const hiddenInput = getByTestId('hidden-input');
+
+		input1.focus();
+		await user.type(input1, '1');
+		expect(input2).toHaveFocus();
+		await user.type(input2, '2');
+		expect(input3).toHaveFocus();
+		await user.type(input3, '3');
+		expect(input4).toHaveFocus();
+		await user.type(input4, '4');
+		expect(input5).toHaveFocus();
+		await user.type(input5, '5');
+		expect(input5).toHaveFocus();
+		await user.type(input5, '5');
+		expect(input5).toHaveFocus();
+		expect(hiddenInput).toHaveValue('12345');
+	});
+
+	it('deletes current input when pressing delete', async () => {
+		const user = userEvent.setup();
+		const { getByTestId } = render(PinInputTest);
+
+		const input1 = getByTestId('input-1');
+		const input2 = getByTestId('input-2');
+		const input3 = getByTestId('input-3');
+		const input4 = getByTestId('input-4');
+		const input5 = getByTestId('input-5');
+		const hiddenInput = getByTestId('hidden-input');
+
+		expect(hiddenInput).toHaveValue('12345');
+		input1.focus();
+		expect(input1).toHaveValue('1');
+		await user.keyboard(kbd.DELETE);
+		expect(input1).toHaveValue('');
+		expect(input1).toHaveFocus();
+		expect(hiddenInput).toHaveValue('2345');
+		input2.focus();
+		expect(input2).toHaveValue('2');
+		await user.keyboard(kbd.DELETE);
+		expect(input2).toHaveValue('');
+		expect(input2).toHaveFocus();
+		expect(hiddenInput).toHaveValue('345');
+		input3.focus();
+		expect(input3).toHaveValue('3');
+		await user.keyboard(kbd.DELETE);
+		expect(input3).toHaveValue('');
+		expect(input3).toHaveFocus();
+		expect(hiddenInput).toHaveValue('45');
+		input4.focus();
+		expect(input4).toHaveValue('4');
+		await user.keyboard(kbd.DELETE);
+		expect(input4).toHaveValue('');
+		expect(input4).toHaveFocus();
+		expect(hiddenInput).toHaveValue('5');
+		input5.focus();
+		expect(input5).toHaveValue('5');
+		await user.keyboard(kbd.DELETE);
+		expect(input5).toHaveValue('');
+		expect(input5).toHaveFocus();
+		expect(hiddenInput).toHaveValue('');
+	});
+
+	it('deletes current input when pressing backspace, if empty moves to previous input and deletes that value as well.', async () => {
+		const { getByTestId } = render(PinInputTest);
+		const user = userEvent.setup();
+
+		const input1 = getByTestId('input-1');
+		const input2 = getByTestId('input-2');
+		const input3 = getByTestId('input-3');
+		const input4 = getByTestId('input-4');
+		const input5 = getByTestId('input-5');
+		const hiddenInput = getByTestId('hidden-input');
+
+		expect(hiddenInput).toHaveValue('12345');
+		input5.focus();
+		expect(input5).toHaveValue('5');
+		await user.keyboard(kbd.BACKSPACE);
+		expect(input5).toHaveValue('');
+		expect(input5).toHaveFocus();
+		expect(input4).toHaveValue('4');
+		await user.keyboard(kbd.BACKSPACE);
+		expect(input4).toHaveValue('');
+		expect(input4).toHaveFocus();
+		expect(input3).toHaveValue('3');
+		await user.keyboard(kbd.BACKSPACE);
+		expect(input3).toHaveValue('');
+		expect(input3).toHaveFocus();
+		expect(input2).toHaveValue('2');
+		await user.keyboard(kbd.BACKSPACE);
+		expect(input2).toHaveValue('');
+		expect(input2).toHaveFocus();
+		expect(input1).toHaveValue('1');
+		await user.keyboard(kbd.BACKSPACE);
+		expect(input1).toHaveValue('');
+		expect(input1).toHaveFocus();
+		expect(hiddenInput).toHaveValue('');
+	});
+});
diff --git a/src/tests/pin-input/PinInputTest.svelte b/src/tests/pin-input/PinInputTest.svelte
new file mode 100644
index 00000000..02c03664
--- /dev/null
+++ b/src/tests/pin-input/PinInputTest.svelte
@@ -0,0 +1,32 @@
+<script lang="ts">
+	import { createPinInput, melt, type CreatePinInputProps } from '$lib/index.js';
+
+	export let placeholder: CreatePinInputProps['placeholder'] = '○';
+	export let name: CreatePinInputProps['name'] = undefined;
+	export let disabled: CreatePinInputProps['disabled'] = false;
+	export let type: CreatePinInputProps['type'] = 'text';
+	export let defaultValue: CreatePinInputProps['defaultValue'] = ['1', '2', '3', '4', '5'];
+	export let value: CreatePinInputProps['value'] = undefined;
+	export let onValueChange: CreatePinInputProps['onValueChange'] = undefined;
+
+	const {
+		elements: { root, input, hiddenInput },
+	} = createPinInput({
+		placeholder,
+		name,
+		disabled,
+		type,
+		defaultValue,
+		value,
+		onValueChange,
+	});
+</script>
+
+<main>
+	<div use:melt={$root} data-testid="root">
+		{#each Array(5) as _, i}
+			<input use:melt={$input()} data-testid="input-{i + 1}" />
+		{/each}
+		<input use:melt={$hiddenInput} data-testid="hidden-input" />
+	</div>
+</main>