Repository: melt-ui/melt-ui
Pull request #391 (2023-08-15 20:38:06)

Refactor Combobox API to be more declarative


Patch:
diff --git a/.changeset/silent-lamps-appear.md b/.changeset/silent-lamps-appear.md
new file mode 100644
index 00000000..51c49519
--- /dev/null
+++ b/.changeset/silent-lamps-appear.md
@@ -0,0 +1,5 @@
+---
+'@melt-ui/svelte': patch
+---
+
+Select: improve valueLabel behaviour
diff --git a/.changeset/witty-hats-sparkle.md b/.changeset/witty-hats-sparkle.md
new file mode 100644
index 00000000..0c57f4ce
--- /dev/null
+++ b/.changeset/witty-hats-sparkle.md
@@ -0,0 +1,5 @@
+---
+'@melt-ui/svelte': minor
+---
+
+Combobox: refactor API to be more declarative
diff --git a/src/docs/data/builders/combobox.ts b/src/docs/data/builders/combobox.ts
index 4799e8c5..46ff867b 100644
--- a/src/docs/data/builders/combobox.ts
+++ b/src/docs/data/builders/combobox.ts
@@ -11,15 +11,10 @@ import { getMenuArrowSchema } from './menu.js';
 const OPTION_PROPS = [
 	{
 		name: 'filterFunction',
-		type: '(item: T, inputValue: string)',
+		type: '({ itemValue: T; input: string; })',
 		description:
 			'A function that returns `true` if the item should be included in the filtered list.',
 	},
-	{
-		name: 'itemToString',
-		type: '(item: T)',
-		description: 'A function that returns a string representation of the item.',
-	},
 	{
 		name: 'scrollAlignment',
 		type: ['"nearest"', '"center"'],
@@ -40,11 +35,6 @@ const BUILDER_NAME = 'combobox';
 const builder = builderSchema(BUILDER_NAME, {
 	title: 'createCombobox',
 	props: [
-		{
-			name: 'items',
-			type: 'T[]',
-			description: 'The list of items to display in the combobox list.',
-		},
 		{
 			name: 'defaultValue',
 			type: 'T',
@@ -65,6 +55,18 @@ const builder = builderSchema(BUILDER_NAME, {
 		PROPS.DEFAULT_OPEN,
 		PROPS.OPEN,
 		PROPS.ON_OPEN_CHANGE,
+		{
+			name: 'defaultInputValue',
+			type: 'string',
+			default: '""',
+			description: 'The initial value of the input.',
+		},
+		{
+			name: 'debounce',
+			type: 'number',
+			default: '0',
+			description: 'The debounce time for the inputValue.',
+		},
 		...OPTION_PROPS,
 	],
 	elements: [
@@ -97,9 +99,9 @@ const builder = builderSchema(BUILDER_NAME, {
 			description: 'A readable store with the value of the input.',
 		},
 		{
-			name: 'filteredItems',
-			type: 'Readable<T[]>',
-			description: 'A readable store whose value is the filtered list of items.',
+			name: 'isEmpty',
+			type: 'Readable<boolean>',
+			description: 'A readable store that returns true when no visible items are present.',
 		},
 		{
 			name: 'value',
@@ -114,11 +116,6 @@ const builder = builderSchema(BUILDER_NAME, {
 			description:
 				'A derived store that returns a function that returns whether or not the item is selected.',
 		},
-		{
-			name: 'updateItems',
-			type: 'UpdaterFunction: (items: T[]) => T[]',
-			description: 'A function that updates the list of items.',
-		},
 	],
 	options: OPTION_PROPS,
 });
@@ -158,14 +155,15 @@ const item = elementSchema('item', {
 	description: 'The menu item element',
 	props: [
 		{
-			name: 'item',
+			name: 'value',
 			type: 'T',
-			description: 'The item that the option represents.',
+			description: 'The value of the item.',
+			required: true,
 		},
 		{
-			name: 'index',
-			type: 'number',
-			description: 'The array index of the item.',
+			name: 'label',
+			type: 'string',
+			description: 'The label of the item. When not present, the text content will be used.',
 		},
 		{
 			name: 'disabled',
@@ -175,14 +173,14 @@ const item = elementSchema('item', {
 		},
 	],
 	dataAttributes: [
-		{
-			name: 'data-index',
-			value: 'The index of the item in the list.',
-		},
 		{
 			name: 'data-disabled',
 			value: ATTRS.DISABLED('`item`'),
 		},
+		{
+			name: 'data-selected',
+			value: ATTRS.SELECTED('`item`'),
+		},
 		{
 			name: 'data-highlighted',
 			value: ATTRS.HIGHLIGHTED(),
@@ -247,7 +245,7 @@ const schemas = [builder, menu, input, item, label, arrow];
 
 const features = [
 	'Full keyboard navigation',
-	'Updatable data source',
+	'Declarative API',
 	'Can be controlled or uncontrolled',
 	'Custom positioning',
 ];
diff --git a/src/docs/previews/combobox/controlled/css/index.svelte b/src/docs/previews/combobox/controlled/css/index.svelte
deleted file mode 100644
index 7bc7f1c6..00000000
--- a/src/docs/previews/combobox/controlled/css/index.svelte
+++ /dev/null
@@ -1,239 +0,0 @@
-<script lang="ts">
-	import {
-		createCombobox,
-		type ComboboxFilterFunction,
-		melt,
-	} from '$lib/index.js';
-	import { Check, ChevronDown, ChevronUp } from 'lucide-svelte';
-	import { writable } from 'svelte/store';
-
-	interface Book {
-		author: string;
-		title: string;
-		disabled: boolean;
-	}
-
-	let books: Book[] = [
-		{
-			author: 'Harper Lee',
-			title: 'To Kill a Mockingbird',
-			disabled: false,
-		},
-		{
-			author: 'Lev Tolstoy',
-			title: 'War and Peace',
-			disabled: false,
-		},
-		{
-			author: 'Fyodor Dostoyevsy',
-			title: 'The Idiot',
-			disabled: true,
-		},
-		{
-			author: 'Oscar Wilde',
-			title: 'A Picture of Dorian Gray',
-			disabled: false,
-		},
-		{
-			author: 'George Orwell',
-			title: '1984',
-			disabled: false,
-		},
-		{
-			author: 'Jane Austen',
-			title: 'Pride and Prejudice',
-			disabled: false,
-		},
-		{
-			author: 'Marcus Aurelius',
-			title: 'Meditations',
-			disabled: false,
-		},
-		{
-			author: 'Fyodor Dostoevsky',
-			title: 'The Brothers Karamazov',
-			disabled: false,
-		},
-		{
-			author: 'Lev Tolstoy',
-			title: 'Anna Karenina',
-			disabled: false,
-		},
-		{
-			author: 'Fyodor Dostoevsky',
-			title: 'Crime and Punishment',
-			disabled: false,
-		},
-	];
-
-	const filterFunction: ComboboxFilterFunction<Book> = (item, inputValue) => {
-		// Example string normalization function. Replace as needed.
-		const normalize = (str: string) => str.normalize().toLowerCase();
-		const normalizedInput = normalize(inputValue);
-		return (
-			normalizedInput === '' ||
-			normalize(item.title).includes(normalizedInput) ||
-			normalize(item.author).includes(normalizedInput)
-		);
-	};
-
-	const customOpen = writable(true);
-
-	const {
-		elements: { menu, input, item },
-		states: { open, inputValue, filteredItems },
-		helpers: { isSelected },
-	} = createCombobox({
-		filterFunction,
-		items: books,
-		itemToString: (item) => item.title,
-		open: customOpen,
-		closeOnOutsideClick: false,
-	});
-</script>
-
-<label>
-	<span>Choose your favorite book:</span>
-	<div>
-		<input use:melt={$input} placeholder="Best book ever" value={$inputValue} />
-		<div class="chevron-wrapper">
-			{#if $open}
-				<ChevronUp class="square-4" />
-			{:else}
-				<ChevronDown class="square-4" />
-			{/if}
-		</div>
-	</div>
-</label>
-
-<div class="menu-container" use:melt={$menu}>
-	<ul class="menu">
-		{#if $open}
-			{#if $filteredItems.length !== 0}
-				{#each $filteredItems as book, index (index)}
-					<li
-						use:melt={$item({
-							index,
-							item: book,
-							disabled: book.disabled,
-						})}
-						class="item"
-					>
-						{#if $isSelected(book)}
-							<div class="check">
-								<Check class="square-4" />
-							</div>
-						{/if}
-						<div>
-							<span>{book.title}</span>
-							<span class="author">{book.author}</span>
-						</div>
-					</li>
-				{/each}
-			{:else}
-				<li class="item">No results found</li>
-			{/if}
-		{/if}
-	</ul>
-</div>
-
-<style>
-	label {
-		cursor: pointer;
-	}
-
-	label > span {
-		display: block;
-		padding-bottom: 0.25rem;
-		text-transform: capitalize;
-	}
-
-	label > div {
-		position: relative;
-	}
-
-	label input {
-		display: flex;
-		height: 2.5rem;
-		align-items: center;
-		justify-content: space-between;
-		border-radius: 0.375rem;
-
-		--tw-bg-opacity: 1;
-		background-color: rgb(255 255 255 / var(--tw-bg-opacity));
-
-		padding-left: 0.75rem;
-		padding-right: 3rem;
-
-		--tw-text-opacity: 1;
-		color: rgb(189 87 17 / var(--tw-text-opacity));
-	}
-
-	.chevron-wrapper {
-		position: absolute;
-		right: 0.25rem;
-		top: 50%;
-		z-index: 10;
-
-		--tw-translate-y: -50%;
-		transform: translate(var(--tw-translate-y));
-
-		--tw-text-opacity: 1;
-		color: rgb(189 87 17 / var(--tw-text-opacity));
-	}
-
-	.menu-container {
-		display: flex;
-		flex-direction: column;
-
-		z-index: 10;
-		max-height: 300px;
-
-		overflow: hidden;
-
-		border-radius: 0.375rem;
-	}
-
-	.menu {
-		display: flex;
-		flex-direction: column;
-		gap: 0.5rem;
-
-		max-height: 100%;
-
-		overflow-y: auto;
-
-		--tw-bg-opacity: 1;
-		background-color: rgb(255 255 255 / var(--tw-bg-opacity));
-
-		padding: 0.5rem;
-	}
-
-	.item {
-		position: relative;
-		cursor: pointer;
-		border-radius: 0.375rem;
-
-		padding: 0.25rem 2rem 0.25rem 1rem;
-
-		--tw-text-opacity: 1;
-		color: rgb(38 38 38 / var(--tw-text-opacity));
-	}
-
-	.item .author {
-		display: block;
-
-		font-size: 0.875rem;
-		line-height: 1.25rem;
-
-		opacity: 0.7;
-	}
-
-	.check {
-		position: absolute;
-		left: 0.5rem;
-		top: 50%;
-		color: #bd5711;
-		translate: 0 calc(-50% + 1px);
-	}
-</style>
diff --git a/src/docs/previews/combobox/controlled/tailwind/index.svelte b/src/docs/previews/combobox/controlled/tailwind/index.svelte
deleted file mode 100644
index be2f72c8..00000000
--- a/src/docs/previews/combobox/controlled/tailwind/index.svelte
+++ /dev/null
@@ -1,180 +0,0 @@
-<script lang="ts">
-	import {
-		createCombobox,
-		type ComboboxFilterFunction,
-		melt,
-	} from '$lib/index.js';
-	import { Check, ChevronDown, ChevronUp } from 'lucide-svelte';
-	import { writable } from 'svelte/store';
-
-	interface Book {
-		author: string;
-		title: string;
-		disabled: boolean;
-	}
-
-	let books: Book[] = [
-		{
-			author: 'Harper Lee',
-			title: 'To Kill a Mockingbird',
-			disabled: false,
-		},
-		{
-			author: 'Lev Tolstoy',
-			title: 'War and Peace',
-			disabled: false,
-		},
-		{
-			author: 'Fyodor Dostoyevsy',
-			title: 'The Idiot',
-			disabled: true,
-		},
-		{
-			author: 'Oscar Wilde',
-			title: 'A Picture of Dorian Gray',
-			disabled: false,
-		},
-		{
-			author: 'George Orwell',
-			title: '1984',
-			disabled: false,
-		},
-		{
-			author: 'Jane Austen',
-			title: 'Pride and Prejudice',
-			disabled: false,
-		},
-		{
-			author: 'Marcus Aurelius',
-			title: 'Meditations',
-			disabled: false,
-		},
-		{
-			author: 'Fyodor Dostoevsky',
-			title: 'The Brothers Karamazov',
-			disabled: false,
-		},
-		{
-			author: 'Lev Tolstoy',
-			title: 'Anna Karenina',
-			disabled: false,
-		},
-		{
-			author: 'Fyodor Dostoevsky',
-			title: 'Crime and Punishment',
-			disabled: false,
-		},
-	];
-
-	const filterFunction: ComboboxFilterFunction<Book> = (item, inputValue) => {
-		// Example string normalization function. Replace as needed.
-		const normalize = (str: string) => str.normalize().toLowerCase();
-		const normalizedInput = normalize(inputValue);
-		return (
-			normalizedInput === '' ||
-			normalize(item.title).includes(normalizedInput) ||
-			normalize(item.author).includes(normalizedInput)
-		);
-	};
-
-	const customOpen = writable(false);
-
-	const {
-		elements: { menu, input, item },
-		states: { open, inputValue, filteredItems },
-		helpers: { isSelected },
-	} = createCombobox({
-		filterFunction,
-		items: books,
-		itemToString: (item) => item.title,
-		open: customOpen,
-		closeOnOutsideClick: false,
-		preventScroll: false,
-	});
-</script>
-
-<!-- This is a temporary controlled example while building -->
-<!-- and will be replaced with a better one before merge. -->
-
-{#if $customOpen}
-	<button class="mr-2 px-3.5 py-2.5" on:click={() => customOpen.set(false)}>
-		Close combobox
-	</button>
-{:else}
-	<button class="mr-2 px-3.5 py-2.5" on:click={() => customOpen.set(true)}>
-		Open combobox
-	</button>
-{/if}
-
-<label class="cursor-pointer">
-	<span class="block pb-1 capitalize">Choose your favorite book:</span>
-	<div class="relative">
-		<input
-			use:melt={$input}
-			class="flex h-10 items-center justify-between rounded-md bg-white
-            px-3 pr-12 text-magnum-700"
-			placeholder="Best book ever"
-			value={$inputValue}
-		/>
-		<div class="absolute right-1 top-1/2 z-10 -translate-y-1/2 text-magnum-700">
-			{#if $open}
-				<ChevronUp class="square-4" />
-			{:else}
-				<ChevronDown class="square-4" />
-			{/if}
-		</div>
-	</div>
-</label>
-
-<ul
-	class="z-10 flex max-h-[300px] flex-col overflow-hidden rounded-md"
-	use:melt={$menu}
->
-	<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
-	<div
-		class="flex max-h-full flex-col gap-2 overflow-y-auto bg-white px-2 py-2"
-		tabindex="0"
-	>
-		{#if $open}
-			{#if $filteredItems.length !== 0}
-				{#each $filteredItems as book, index (index)}
-					<li
-						use:melt={$item({
-							index,
-							item: book,
-							disabled: book.disabled,
-						})}
-						class="relative cursor-pointer rounded-md py-1 pl-8 pr-4 text-neutral-800
-                        data-[highlighted]:bg-magnum-100 data-[highlighted]:text-magnum-700
-                        data-[disabled]:opacity-50"
-					>
-						{#if $isSelected(book)}
-							<div class="check">
-								<Check class="square-4" />
-							</div>
-						{/if}
-						<div>
-							<span>{book.title}</span>
-							<span class="block text-sm opacity-70">{book.author}</span>
-						</div>
-					</li>
-				{/each}
-			{:else}
-				<li
-					class="relative cursor-pointer rounded-md py-1 pl-8 pr-4
-                    text-neutral-800 data-[highlighted]:bg-magnum-100
-                    data-[highlighted]:text-magnum-700"
-				>
-					No results found
-				</li>
-			{/if}
-		{/if}
-	</div>
-</ul>
-
-<style lang="postcss">
-	.check {
-		@apply absolute left-2 top-1/2 text-magnum-500;
-		translate: 0 calc(-50% + 1px);
-	}
-</style>
diff --git a/src/docs/previews/combobox/main/css/index.svelte b/src/docs/previews/combobox/main/css/index.svelte
index 8677a4d9..6ecc3149 100644
--- a/src/docs/previews/combobox/main/css/index.svelte
+++ b/src/docs/previews/combobox/main/css/index.svelte
@@ -1,5 +1,9 @@
 <script lang="ts">
-	import { createCombobox, melt } from '$lib/index.js';
+	import {
+		createCombobox,
+		melt,
+		type ComboboxFilterFunction,
+	} from '$lib/index.js';
 	import { Check, ChevronDown, ChevronUp } from 'lucide-svelte';
 
 	interface Book {
@@ -61,30 +65,34 @@
 		},
 	];
 
+	const filterFunction: ComboboxFilterFunction<Book> = ({
+		itemValue,
+		input,
+	}) => {
+		// Example string normalization function. Replace as needed.
+		const normalize = (str: string) => str.normalize().toLowerCase();
+		const normalizedInput = normalize(input);
+		return (
+			normalizedInput === '' ||
+			normalize(itemValue.title).includes(normalizedInput) ||
+			normalize(itemValue.author).includes(normalizedInput)
+		);
+	};
+
 	const {
-		elements: { input, menu, item, label },
-		states: { open, inputValue, filteredItems },
+		elements: { menu, input, item, label },
+		states: { open, isEmpty },
 		helpers: { isSelected },
 	} = createCombobox({
-		filterFunction: (item, inputValue) => {
-			// Example string normalization function. Replace as needed.
-			const normalize = (str: string) => str.normalize().toLowerCase();
-			const normalizedInput = normalize(inputValue);
-			return (
-				normalizedInput === '' ||
-				normalize(item.title).includes(normalizedInput) ||
-				normalize(item.author).includes(normalizedInput)
-			);
-		},
-		items: books,
-		itemToString: (item) => item.title,
+		filterFunction,
+		forceVisible: true,
 	});
 </script>
 
 <label use:melt={$label}>
 	<span>Choose your favorite book:</span>
 	<div>
-		<input use:melt={$input} placeholder="Best book ever" value={$inputValue} />
+		<input use:melt={$input} placeholder="Best book ever" />
 		<div class="chevron-wrapper">
 			{#if $open}
 				<ChevronUp class="square-4" />
@@ -96,34 +104,33 @@
 </label>
 
 <div class="menu-container" use:melt={$menu}>
-	<ul class="menu">
-		{#if $open}
-			{#if $filteredItems.length !== 0}
-				{#each $filteredItems as book, index (index)}
-					<li
-						use:melt={$item({
-							index,
-							item: book,
-							disabled: book.disabled,
-						})}
-						class="item"
-					>
-						{#if $isSelected(book)}
-							<div class="check">
-								<Check class="square-4" />
-							</div>
-						{/if}
-						<div>
-							<span>{book.title}</span>
-							<span class="author">{book.author}</span>
+	{#if $open}
+		<ul class="menu">
+			{#each books as book, index (index)}
+				<li
+					use:melt={$item({
+						value: book,
+						label: book.title,
+						disabled: book.disabled,
+					})}
+					class="item"
+				>
+					{#if $isSelected(book)}
+						<div class="check">
+							<Check class="square-4" />
 						</div>
-					</li>
-				{/each}
-			{:else}
+					{/if}
+					<div>
+						<span>{book.title}</span>
+						<span class="author">{book.author}</span>
+					</div>
+				</li>
+			{/each}
+			{#if $isEmpty}
 				<li class="item">No results found</li>
 			{/if}
-		{/if}
-	</ul>
+		</ul>
+	{/if}
 </div>
 
 <style>
diff --git a/src/docs/previews/combobox/main/tailwind/index.svelte b/src/docs/previews/combobox/main/tailwind/index.svelte
index a81df7d9..64192a23 100644
--- a/src/docs/previews/combobox/main/tailwind/index.svelte
+++ b/src/docs/previews/combobox/main/tailwind/index.svelte
@@ -66,25 +66,26 @@
 		},
 	];
 
-	const filterFunction: ComboboxFilterFunction<Book> = (item, inputValue) => {
+	const filterFunction: ComboboxFilterFunction<Book> = ({
+		itemValue,
+		input,
+	}) => {
 		// Example string normalization function. Replace as needed.
 		const normalize = (str: string) => str.normalize().toLowerCase();
-		const normalizedInput = normalize(inputValue);
+		const normalizedInput = normalize(input);
 		return (
 			normalizedInput === '' ||
-			normalize(item.title).includes(normalizedInput) ||
-			normalize(item.author).includes(normalizedInput)
+			normalize(itemValue.title).includes(normalizedInput) ||
+			normalize(itemValue.author).includes(normalizedInput)
 		);
 	};
 
 	const {
 		elements: { menu, input, item, label },
-		states: { open, filteredItems },
+		states: { open, isEmpty },
 		helpers: { isSelected },
 	} = createCombobox({
 		filterFunction,
-		items: books,
-		itemToString: (item) => item.title,
 		forceVisible: true,
 	});
 </script>
@@ -122,30 +123,29 @@
 			class="flex max-h-full flex-col gap-2 overflow-y-auto bg-white px-2 py-2"
 			tabindex="0"
 		>
-			{#if $filteredItems.length !== 0}
-				{#each $filteredItems as book, index (index)}
-					<li
-						use:melt={$item({
-							index,
-							item: book,
-							disabled: book.disabled,
-						})}
-						class="relative cursor-pointer rounded-md py-1 pl-8 pr-4 text-neutral-800
+			{#each books as book, index (index)}
+				<li
+					use:melt={$item({
+						value: book,
+						label: book.title,
+						disabled: book.disabled,
+					})}
+					class="relative cursor-pointer rounded-md py-1 pl-8 pr-4 text-neutral-800
                         data-[highlighted]:bg-magnum-100 data-[highlighted]:text-magnum-700
                         data-[disabled]:opacity-50"
-					>
-						{#if $isSelected(book)}
-							<div class="check">
-								<Check class="square-4" />
-							</div>
-						{/if}
-						<div>
-							<span>{book.title}</span>
-							<span class="block text-sm opacity-70">{book.author}</span>
+				>
+					{#if $isSelected(book)}
+						<div class="check">
+							<Check class="square-4" />
 						</div>
-					</li>
-				{/each}
-			{:else}
+					{/if}
+					<div>
+						<span>{book.title}</span>
+						<span class="block text-sm opacity-70">{book.author}</span>
+					</div>
+				</li>
+			{/each}
+			{#if $isEmpty}
 				<li
 					class="relative cursor-pointer rounded-md py-1 pl-8 pr-4
                     text-neutral-800 data-[highlighted]:bg-magnum-100
diff --git a/src/docs/previews/context-menu/main/css/index.svelte b/src/docs/previews/context-menu/main/css/index.svelte
index 9e751d5a..38e03717 100644
--- a/src/docs/previews/context-menu/main/css/index.svelte
+++ b/src/docs/previews/context-menu/main/css/index.svelte
@@ -206,11 +206,6 @@
 		background-color: rgb(var(--color-magnum-200) / 1);
 	}
 
-	.icon {
-		height: 16px;
-		width: 16px;
-	}
-
 	.rightSlot {
 		margin-left: auto;
 		padding-left: 1.25rem;
diff --git a/src/lib/builders/combobox/create.ts b/src/lib/builders/combobox/create.ts
index e615bf77..30043246 100644
--- a/src/lib/builders/combobox/create.ts
+++ b/src/lib/builders/combobox/create.ts
@@ -29,6 +29,7 @@ import {
 	derivedVisible,
 	addMeltEventListener,
 	getPortalDestination,
+	deepEqual,
 } from '$lib/internal/helpers/index.js';
 import { onMount, tick } from 'svelte';
 import { derived, get, readonly, writable, type Writable } from 'svelte/store';
@@ -36,6 +37,7 @@ import type { ComboboxItemProps, CreateComboboxProps } from './types.js';
 import type { Defaults, MeltActionReturn } from '$lib/internal/types.js';
 import { createLabel } from '../label/create.js';
 import type { ComboboxEvents } from './events.js';
+import { debounceable } from '$lib/internal/helpers/store/debounceable.js';
 
 // prettier-ignore
 export const INTERACTION_KEYS = [kbd.ARROW_LEFT, kbd.ESCAPE, kbd.ARROW_RIGHT, kbd.SHIFT, kbd.CAPS_LOCK, kbd.CONTROL, kbd.ALT, kbd.META, kbd.ENTER, kbd.F1, kbd.F2, kbd.F3, kbd.F4, kbd.F5, kbd.F6, kbd.F7, kbd.F8, kbd.F9, kbd.F10, kbd.F11, kbd.F12];
@@ -53,7 +55,8 @@ const defaults = {
 	closeOnEscape: true,
 	forceVisible: false,
 	portal: undefined,
-	itemToString: (item: unknown) => `${item}`,
+	filterFunction: () => true,
+	debounce: 0,
 } satisfies Defaults<CreateComboboxProps<unknown>>;
 
 const { name, selector } = createElHelpers('combobox');
@@ -65,35 +68,36 @@ const { name, selector } = createElHelpers('combobox');
  * @TODO would it be useful to have a callback for when an item is selected?
  * @TODO multi-select using `tags-input` builder?
  */
-export function createCombobox<Item>(props: CreateComboboxProps<Item>) {
-	const withDefaults = { ...defaults, ...props } satisfies CreateComboboxProps<Item>;
-	// Either the provided open store or a store with the default open value
-	const openWritable = withDefaults.open ?? writable(withDefaults.defaultOpen);
-	// The overridable open store which is the source of truth for the open state.
-	const open = overridable(openWritable, withDefaults?.onOpenChange);
+export function createCombobox<ItemValue>(props?: CreateComboboxProps<ItemValue>) {
+	const withDefaults = { ...defaults, ...props } satisfies CreateComboboxProps<ItemValue>;
+
 	// Trigger element for the popper portal. This will be our input element.
 	const activeTrigger = writable<HTMLElement | null>(null);
 	// The currently highlighted menu item.
 	const highlightedItem = writable<HTMLElement | null>(null);
-	// All items in the menu.
-	const items = writable(withDefaults.items);
-	// A subset of items that match the filterFunction predicate.
-	const filteredItems = writable(withDefaults.items);
 
 	const valueWritable =
-		withDefaults.value ?? (writable(withDefaults.defaultValue) as Writable<Item | undefined>);
+		withDefaults.value ?? (writable(withDefaults.defaultValue) as Writable<ItemValue | undefined>);
 	const value = overridable(valueWritable, withDefaults?.onValueChange);
 
 	// The current value of the input element.
-	const inputValue = writable('');
-	// options
-	const options = toWritableStores(omit(withDefaults, 'items', 'open', 'defaultOpen'));
+	const inputValue = debounceable(withDefaults.defaultInputValue ?? '', withDefaults.debounce);
+
+	// Either the provided open store or a store with the default open value
+	const openWritable = withDefaults.open ?? writable(true);
+	// The overridable open store which is the source of truth for the open state.
+	const open = overridable(openWritable, withDefaults?.onOpenChange);
+	// Open so we can register the optionsList items before mounted = true
+	open.set(true);
+
+	const isEmpty = writable(false);
+
+	const options = toWritableStores(omit(withDefaults, 'open', 'defaultOpen', 'debounce'));
 
 	const {
 		scrollAlignment,
 		loop,
 		filterFunction,
-		itemToString,
 		closeOnOutsideClick,
 		closeOnEscape,
 		preventScroll,
@@ -102,49 +106,92 @@ export function createCombobox<Item>(props: CreateComboboxProps<Item>) {
 		positioning,
 	} = options;
 
+	const touchedInput = debounceable(false, withDefaults.debounce);
+
 	const ids = {
 		input: generateId(),
 		menu: generateId(),
 		label: generateId(),
 	};
 
+	/** ------- */
+	/** HELPERS */
+	/** ------- */
+	function getItemProps(el: HTMLElement): ComboboxItemProps<ItemValue> {
+		const value = el.getAttribute('data-value');
+		const label = el.getAttribute('data-label');
+		const disabled = el.hasAttribute('data-disabled');
+
+		return {
+			value: value ? JSON.parse(value) : value,
+			label: label ?? el.textContent ?? undefined,
+			disabled: disabled ? true : false,
+		};
+	}
+
+	const cachedItemPropsArr: ComboboxItemProps<ItemValue>[] = [];
+	function getItemPropsArr(): ComboboxItemProps<ItemValue>[] {
+		if (!isBrowser) return cachedItemPropsArr;
+		const menuElement = document.getElementById(ids.menu);
+		if (!isHTMLElement(menuElement)) return cachedItemPropsArr;
+
+		const items = getOptions(menuElement);
+		return items.map(getItemProps);
+	}
+
+	function getSelectedLabel(): string | undefined {
+		const $value = get(value);
+		const itemPropsArr = getItemPropsArr();
+		const selectedItem = itemPropsArr.find((item) => deepEqual(item.value, $value));
+		return selectedItem?.label;
+	}
+
 	/** Resets the combobox inputValue and filteredItems back to the selectedItem */
 	function reset() {
-		const $itemToString = get(itemToString);
 		const $selectedItem = get(value);
 
 		// If no item is selected the input should be cleared and the filter reset.
 		if (!$selectedItem) {
-			inputValue.set('');
+			inputValue.forceSet('');
 		} else {
-			inputValue.set($itemToString($selectedItem));
+			inputValue.forceSet(getSelectedLabel() ?? '');
 		}
-		// Reset the filtered items to the full list.
-		filteredItems.set(get(items));
-	}
 
-	effect(value, ($value) => {
-		if ($value) {
-			inputValue.set(get(itemToString)($value));
-		}
-	});
+		touchedInput.forceSet(false);
+	}
 
 	/**
 	 * Selects an item from the menu and updates the input value.
 	 * @param index array index of the item to select.
 	 */
 	function selectItem(item: HTMLElement) {
-		if (item.dataset.index) {
-			const index = parseInt(item.dataset.index, 10);
-			const $item = get(filteredItems)[index];
-
-			value.set($item);
-			// Reset the filtered items to the full list.
-			filteredItems.set(get(items));
-			const activeTrigger = document.getElementById(ids.input);
-			if (activeTrigger) {
-				activeTrigger.focus();
-			}
+		if (!item.dataset.value) return;
+
+		value.set(JSON.parse(item.dataset.value));
+
+		const activeTrigger = document.getElementById(ids.input);
+		if (activeTrigger) {
+			activeTrigger.focus();
+		}
+	}
+
+	async function handleIsEmpty() {
+		if (!isBrowser) return;
+		await tick();
+
+		const menuElement = document.getElementById(ids.menu);
+
+		if (!isHTMLElement(menuElement)) return;
+
+		const options = getOptions(menuElement);
+		const visibleOptions = options.filter((opt) => {
+			const isHidden = opt.dataset.hidden !== undefined;
+			return !isHidden;
+		});
+		if (!visibleOptions.length) {
+			isEmpty.set(true);
+		} else {
+			isEmpty.set(false);
 		}
 	}
 
@@ -153,7 +200,7 @@ export function createCombobox<Item>(props: CreateComboboxProps<Item>) {
 	 * the selected item (if one exists). It also optionally accepts the current
 	 * open state to prevent unnecessary updates if we know the menu is already open.
 	 */
-	function openMenu(currentOpenState = false) {
+	async function openMenu(currentOpenState = false) {
 		/**
 		 * We're checking the open state here because the menu may have
 		 * been programatically opened by the user using a controlled store.
@@ -171,59 +218,45 @@ export function createCombobox<Item>(props: CreateComboboxProps<Item>) {
 		activeTrigger.set(triggerEl);
 
 		// Wait a tick for the menu to open then highlight the selected item.
-		tick().then(() => {
-			const menuElement = document.getElementById(ids.menu);
-			if (!isHTMLElement(menuElement)) return;
-			const selectedItem = menuElement.querySelector('[aria-selected=true]');
-			if (!isHTMLElement(selectedItem)) return;
-			highlightedItem.set(selectedItem);
-		});
+		await tick();
+
+		const menuElement = document.getElementById(ids.menu);
+		if (!isHTMLElement(menuElement)) return;
+
+		const selectedItem = menuElement.querySelector('[aria-selected=true]');
+		if (!isHTMLElement(selectedItem)) return;
+		highlightedItem.set(selectedItem);
 	}
 
 	/** Closes the menu & clears the active trigger */
 	function closeMenu() {
 		open.set(false);
+		touchedInput.forceSet(false);
 	}
 
+	/**
+	 * To properly anchor the popper to the input/trigger, we need to ensure both
+	 * the open state is true and the activeTrigger is not null. This helper store's
+	 * value is true when both of these conditions are met and keeps the code tidy.
+	 */
+	const isVisible = derivedVisible({ open, forceVisible, activeTrigger });
+
 	/**
 	 * Determines if a given item is selected.
 	 * This is useful for displaying additional markup on the selected item.
 	 */
-	const isSelected = derived([value], ([$selectedItem]) => {
-		return (item: Item) => $selectedItem === item;
+	const isSelected = derived([value], ([$value]) => {
+		return (item: ItemValue) => $value === item;
 	});
 
-	/**
-	 * Function to update the items in the combobox. It provides the current
-	 * items as an argument and expects an updated list in return.
-	 *
-	 * The updated list is set in both `items` and `filteredItems` stores so
-	 * that the filterFunction predicate is applied to any added items. Eg:
-	 * ```ts
-	 * function addNewBook(book: Book) {
-	 *   updateItems((books) => {
-	 *     books.push(book);
-	 *     return books
-	 * });
-	 * };
-	 * ```
-	 */
-	function updateItems(updaterFunction: (currentItems: Item[]) => Item[]): void {
-		const $currentItems = get(items);
-		const $inputValue = get(inputValue);
-		const $filterFunction = get(filterFunction);
-		// Retrieve the updated list of items from the user-provided function.
-		const updatedItems = updaterFunction($currentItems);
-		// Update the store containing all items.
-		items.set(updatedItems);
-		// Run the filter function on the updated list and store the result.
-		filteredItems.set(updatedItems.filter((item) => $filterFunction(item, $inputValue)));
-	}
+	/** -------- */
+	/** ELEMENTS */
+	/** -------- */
 
 	/** Action and attributes for the text input. */
 	const input = builder(name('input'), {
 		stores: [open, highlightedItem, inputValue],
-		returned: ([$open, $highlightedItem, inputValue]) => {
+		returned: ([$open, $highlightedItem, $inputValue]) => {
 			return {
 				'aria-activedescendant': $highlightedItem?.id,
 				'aria-autocomplete': 'list',
@@ -233,7 +266,7 @@ export function createCombobox<Item>(props: CreateComboboxProps<Item>) {
 				autocomplete: 'off',
 				id: ids.input,
 				role: 'combobox',
-				value: inputValue,
+				value: $inputValue.value,
 			} as const;
 		},
 		action: (node: HTMLInputElement): MeltActionReturn<ComboboxEvents['input']> => {
@@ -278,8 +311,11 @@ export function createCombobox<Item>(props: CreateComboboxProps<Item>) {
 							if (!isHTMLElement(menuEl)) return;
 
 							const enabledItems = Array.from(
-								menuEl.querySelectorAll(`${selector('item')}:not([data-disabled])`)
+								menuEl.querySelectorAll(
+									`${selector('item')}:not([data-disabled]):not([data-hidden])`
+								)
 							).filter((item): item is HTMLElement => isHTMLElement(item));
+
 							if (!enabledItems.length) return;
 
 							if (e.key === kbd.ARROW_DOWN) {
@@ -309,6 +345,7 @@ export function createCombobox<Item>(props: CreateComboboxProps<Item>) {
 					// Pressing Alt + Up should close the menu.
 					if (e.key === kbd.ARROW_UP && e.altKey) {
 						closeMenu();
+						reset();
 					}
 					// Navigation (up, down, etc.) should change the highlighted item.
 					if (FIRST_LAST_KEYS.includes(e.key)) {
@@ -319,7 +356,9 @@ export function createCombobox<Item>(props: CreateComboboxProps<Item>) {
 						const itemElements = getOptions(menuElement);
 						if (!itemElements.length) return;
 						// Disabled items can't be highlighted. Skip them.
-						const candidateNodes = itemElements.filter((opt) => !isElementDisabled(opt));
+						const candidateNodes = itemElements.filter(
+							(opt) => !isElementDisabled(opt) && !opt.dataset.hidden
+						);
 						// Get the index of the currently highlighted item.
 						const $currentItem = get(highlightedItem);
 						const currentIndex = $currentItem ? candidateNodes.indexOf($currentItem) : -1;
@@ -357,11 +396,24 @@ export function createCombobox<Item>(props: CreateComboboxProps<Item>) {
 				// Listens to the input value and filters the items accordingly.
 				addMeltEventListener(node, 'input', (e) => {
 					if (!isHTMLInputElement(e.target)) return;
-					const $filterFunction = get(filterFunction);
-					const $items = get(items);
 					const value = e.target.value;
 					inputValue.set(value);
-					filteredItems.set($items.filter((item) => $filterFunction(item, value)));
+					touchedInput.set(true);
+
+					tick().then(() => {
+						const $highlightedItem = get(highlightedItem);
+						if ($highlightedItem?.dataset.hidden) {
+							// Find next visible item
+							const menuElement = document.getElementById(ids.menu);
+							if (!isHTMLElement(menuElement)) return;
+							const itemElements = getOptions(menuElement);
+							const candidateNodes = itemElements.filter(
+								(opt) => !isElementDisabled(opt) && !opt.dataset.hidden
+							);
+
+							highlightedItem.set(candidateNodes[0] ?? null);
+						}
+					});
 				})
 			);
 
@@ -373,6 +425,7 @@ export function createCombobox<Item>(props: CreateComboboxProps<Item>) {
 						const escape = useEscapeKeydown(node, {
 							handler: () => {
 								closeMenu();
+								reset();
 							},
 						});
 						if (escape && escape.destroy) {
@@ -393,17 +446,6 @@ export function createCombobox<Item>(props: CreateComboboxProps<Item>) {
 		},
 	});
 
-	onMount(() => {
-		activeTrigger.set(document.getElementById(ids.input));
-	});
-
-	/**
-	 * To properly anchor the popper to the input/trigger, we need to ensure both
-	 * the open state is true and the activeTrigger is not null. This helper store's
-	 * value is true when both of these conditions are met and keeps the code tidy.
-	 */
-	const isVisible = derivedVisible({ open, forceVisible, activeTrigger });
-
 	/**
 	 * Action and attributes for the menu element.
 	 */
@@ -461,6 +503,7 @@ export function createCombobox<Item>(props: CreateComboboxProps<Item>) {
 										? {
 												handler: () => {
 													closeMenu();
+													reset();
 												},
 										  }
 										: null,
@@ -505,25 +548,38 @@ export function createCombobox<Item>(props: CreateComboboxProps<Item>) {
 	});
 
 	const item = builder(name('item'), {
-		stores: [value],
+		stores: [value, filterFunction, inputValue, touchedInput],
 		returned:
-			([$selectedItem]) =>
-			(props: ComboboxItemProps<Item>) =>
-				({
+			([$value, $filterFunction, $inputValue, $touchedInput]) =>
+			(props: ComboboxItemProps<ItemValue>) => {
+				cachedItemPropsArr.push(props);
+				let hidden = false;
+				if (
+					$touchedInput.debounced &&
+					$filterFunction?.({ input: $inputValue.debounced, itemValue: props.value }) === false
+				) {
+					hidden = true;
+				}
+				const selected = deepEqual(props.value, $value);
+
+				return {
+					'data-value': JSON.stringify(props.value),
+					'data-label': props.label,
 					'data-disabled': props.disabled ? '' : undefined,
 					'aria-disabled': props.disabled ? true : undefined,
-					'aria-selected': props.item === $selectedItem,
-					'data-index': props.index,
-					id: `${ids.input}-descendent-${props.index}`,
+					'aria-selected': selected,
+					'data-selected': selected ? '' : undefined,
+					hidden: hidden ? true : undefined,
+					'data-hidden': hidden ? '' : undefined,
+					id: generateId(),
 					role: 'option',
 					style: styleToString({ cursor: props.disabled ? 'default' : 'pointer' }),
-				} as const),
+				} as const;
+			},
 		action: (node: HTMLElement): MeltActionReturn<ComboboxEvents['item']> => {
 			const unsubscribe = executeCallbacks(
 				// Handle highlighting items when the pointer moves over them.
 				addMeltEventListener(node, 'pointermove', () => {
-					// Skip highlighting if the item is already highlighted.
-					if (node === get(highlightedItem)) return;
 					// If the item is disabled, clear the highlight.
 					if (isElementDisabled(node)) {
 						highlightedItem.set(null);
@@ -547,6 +603,36 @@ export function createCombobox<Item>(props: CreateComboboxProps<Item>) {
 		},
 	});
 
+	/** ------------------- */
+	/** LIFECYCLE & EFFECTS */
+	/** ------------------- */
+
+	onMount(() => {
+		open.set(withDefaults.defaultOpen);
+
+		if (!isBrowser) return;
+		const menuEl = document.getElementById(ids.menu);
+		if (!menuEl) return;
+
+		const triggerEl = document.getElementById(ids.input);
+		if (triggerEl) {
+			activeTrigger.set(triggerEl);
+		}
+
+		const selectedEl = menuEl.querySelector('[data-selected]');
+		if (!isHTMLElement(selectedEl)) return;
+
+		const dataLabel = selectedEl.getAttribute('data-label');
+		inputValue.set(dataLabel ?? selectedEl.textContent ?? '');
+	});
+
+	effect(value, function setInputValue($value) {
+		if (!$value) return;
+		tick().then(() => {
+			inputValue.set(getSelectedLabel() ?? '');
+		});
+	});
+
 	/**
 	 * Handles moving the `data-highlighted` attribute between items when
 	 * the user moves their pointer or navigates with their keyboard.
@@ -567,6 +653,10 @@ export function createCombobox<Item>(props: CreateComboboxProps<Item>) {
 		}
 	});
 
+	effect([inputValue, touchedInput], () => {
+		handleIsEmpty();
+	});
+
 	return {
 		elements: {
 			input,
@@ -576,12 +666,11 @@ export function createCombobox<Item>(props: CreateComboboxProps<Item>) {
 		},
 		states: {
 			open,
-			inputValue: readonly(inputValue),
-			filteredItems: readonly(filteredItems),
 			value,
+			inputValue: readonly(inputValue),
+			isEmpty: readonly(isEmpty),
 		},
 		helpers: {
-			updateItems,
 			isSelected,
 		},
 		options,
diff --git a/src/lib/builders/combobox/types.ts b/src/lib/builders/combobox/types.ts
index 925e299a..712aff11 100644
--- a/src/lib/builders/combobox/types.ts
+++ b/src/lib/builders/combobox/types.ts
@@ -5,7 +5,7 @@ import type { createCombobox } from './create.js';
 import type { FloatingConfig } from '$lib/internal/actions/index.js';
 export type { ComboboxComponentEvents } from './events.js';
 
-export type CreateComboboxProps<Item> = {
+export type CreateComboboxProps<ItemValue> = {
 	/**
 	 * Options for positioning the popover menu.
 	 *
@@ -13,11 +13,6 @@ export type CreateComboboxProps<Item> = {
 	 */
 	positioning?: FloatingConfig;
 
-	/**
-	 * The list of items to display in the combobox.
-	 */
-	items: Item[];
-
 	/**
 	 * Determines behavior when scrolling items into view.
 	 * @see https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoView#block
@@ -28,12 +23,12 @@ export type CreateComboboxProps<Item> = {
 	 * Predicate function to filter the visible items. When the user types,
 	 * the filterFunction will be run on each item along with the current
 	 * input value. If the predicate returns true, the item will be displayed.
+	 * By default, the item's label will be compared to the current input value.
 	 * @param item the current item being filtered.
 	 * @param value the current input value.
 	 * @returns whether the item should be visible.
 	 */
-	filterFunction: ComboboxFilterFunction<Item>;
-	itemToString?: ComboboxItemToString<Item>;
+	filterFunction?: ComboboxFilterFunction<ItemValue>;
 
 	/**
 	 * Whether or not the combobox should loop through the list when
@@ -72,19 +67,26 @@ export type CreateComboboxProps<Item> = {
 	 *
 	 * @default undefined
 	 */
-	defaultValue?: Item;
+	defaultValue?: ItemValue;
 
 	/**
 	 * An optional controlled store that manages the value state of the combobox.
 	 */
-	value?: Writable<Item | undefined>;
+	value?: Writable<ItemValue | undefined>;
 
 	/**
 	 * A change handler for the value store called when the value would normally change.
 	 *
 	 * @see https://melt-ui.com/docs/controlled#change-functions
 	 */
-	onValueChange?: ChangeFn<Item | undefined>;
+	onValueChange?: ChangeFn<ItemValue | undefined>;
+
+	/**
+	 * The default value for inputValue.
+	 *
+	 * @default undefined
+	 */
+	defaultInputValue?: string;
 
 	/**
 	 * Whether or not to close the combobox menu when the user clicks
@@ -126,18 +128,26 @@ export type CreateComboboxProps<Item> = {
 	 * @default false
 	 */
 	forceVisible?: boolean;
-};
 
-export type ComboboxFilterFunction<T> = (item: T, value: string) => boolean;
+	/**
+	 * The delay in milliseconds for the filterFunction to be called after the input value changes.
+	 */
+	debounce?: number;
+};
 
-export type ComboboxItemToString<T> = (item: T) => string;
+type ComboboxFilterFunctionArgs<T> = {
+	itemValue: T;
+	input: string;
+};
+export type ComboboxFilterFunction<T> = (args: ComboboxFilterFunctionArgs<T>) => boolean;
 
 export type ComboboxItemProps<T> = {
-	item: T;
+	value: T;
 	/**
-	 * Array index of the item.
+	 * By default, the textContent of the item will be used as the label.
+	 * Use the `label` prop to override this behavior.
 	 */
-	index: number;
+	label?: string;
 	/**
 	 *  Is the item disabled?
 	 */
diff --git a/src/lib/builders/select/create.ts b/src/lib/builders/select/create.ts
index 84b0ddc3..77bda369 100644
--- a/src/lib/builders/select/create.ts
+++ b/src/lib/builders/select/create.ts
@@ -76,10 +76,10 @@ const { name } = createElHelpers<SelectParts>('select');
 
 export function createSelect<
 	// eslint-disable-next-line @typescript-eslint/no-explicit-any
-	Item extends Multiple extends true ? Array<unknown> : unknown = any,
+	Value extends Multiple extends true ? Array<unknown> : unknown = any,
 	Multiple extends boolean = false
->(props?: CreateSelectProps<Item, Multiple>) {
-	const withDefaults = { ...defaults, ...props } satisfies CreateSelectProps<Item, Multiple>;
+>(props?: CreateSelectProps<Value, Multiple>) {
+	const withDefaults = { ...defaults, ...props } satisfies CreateSelectProps<Value, Multiple>;
 
 	const options = toWritableStores({
 		...omit(
@@ -116,10 +116,10 @@ export function createSelect<
 	// Open so we can register the optionsList items before mounted = true
 	open.set(true);
 
-	const valueWritable = withDefaults.value ?? writable<Item>(withDefaults.defaultValue);
+	const valueWritable = withDefaults.value ?? writable<Value>(withDefaults.defaultValue);
 	const value = overridable(valueWritable, withDefaults?.onValueChange);
 
-	const valueLabel = writable<string | number | null>(withDefaults.defaultValueLabel ?? null);
+	const valueLabel = writable<string | null>(withDefaults.defaultValueLabel ?? null);
 	const activeTrigger = writable<HTMLElement | null>(null);
 
 	/**
@@ -138,7 +138,7 @@ export function createSelect<
 	 * This is used to determine how we handle focus on open behavior differently
 	 * than when the user is using the mouse.
 	 */
-	const isUsingKeyboard = writable(false);
+	let isUsingKeyboard = false;
 
 	const ids = {
 		menu: generateId(),
@@ -146,6 +146,8 @@ export function createSelect<
 		label: generateId(),
 	};
 
+	const { typed, handleTypeaheadSearch } = createTypeaheadSearch();
+
 	onMount(() => {
 		// Run after all initial effects
 		tick().then(() => {
@@ -160,9 +162,9 @@ export function createSelect<
 		if (!menuEl) return;
 
 		const triggerEl = document.getElementById(ids.trigger);
-
-		if (!triggerEl) return;
-		activeTrigger.set(triggerEl);
+		if (triggerEl) {
+			activeTrigger.set(triggerEl);
+		}
 
 		const selectedEl = menuEl.querySelector('[data-selected]');
 		if (!isHTMLElement(selectedEl)) return;
@@ -442,46 +444,46 @@ export function createSelect<
 		}),
 	});
 
-	type OptionProps = {
-		value: unknown;
-		label: string | null;
-		disabled: boolean;
-	};
-
-	const getOptionProps = (el: HTMLElement) => {
+	const getOptionProps = (el: HTMLElement): SelectOptionProps<Value> => {
 		const value = el.getAttribute('data-value');
 		const label = el.getAttribute('data-label');
 		const disabled = el.hasAttribute('data-disabled');
 
 		return {
 			value: value ? JSON.parse(value) : value,
-			label: label ?? el.textContent ?? null,
+			label: label ?? el.textContent ?? undefined,
 			disabled: disabled ? true : false,
 		};
 	};
 
-	const setValue = (newValue: Item) => {
+	const setValue = (newValue: Value) => {
 		value.update(($value) => {
 			const $multiple = get(multiple);
 			if (Array.isArray($value) || ($value === undefined && $multiple)) {
-				return toggle(newValue, ($value ?? []) as unknown[]) as Item;
+				return toggle(newValue, ($value ?? []) as unknown[]) as Value;
 			}
 			return newValue;
 		});
 	};
 
-	const optionsList: OptionProps[] = [];
+	/**
+	 * List of options fetched from SSR.
+	 */
+	const cachedOptionPropsArr: SelectOptionProps<Value>[] = [];
+	const getOptionPropsArr = () => {
+		if (!isBrowser) return cachedOptionPropsArr;
+
+		const menuEl = document.getElementById(ids.menu);
+		if (!menuEl) return cachedOptionPropsArr;
+		const options = getOptions(menuEl);
+		return options.map(getOptionProps);
+	};
 
 	const option = builder(name('option'), {
 		stores: value,
 		returned: ($value) => {
-			return (props: SelectOptionProps<Item>) => {
-				const optProps: OptionProps = {
-					value: props.value,
-					label: props.label ?? null,
-					disabled: props.disabled ?? false,
-				};
-				optionsList.push(optProps);
+			return (props: SelectOptionProps<Value>) => {
+				cachedOptionPropsArr.push(props);
 
 				const isSelected = Array.isArray($value)
 					? $value.includes(props?.value)
@@ -571,11 +573,11 @@ export function createSelect<
 		},
 	});
 
-	effect(value, ($value) => {
-		if (!isBrowser) return;
+	effect(value, function updateValueLabel($value) {
+		const optionPropsArr = getOptionPropsArr();
 
 		if (Array.isArray($value)) {
-			const labels = optionsList.reduce((result, current) => {
+			const labels = optionPropsArr.reduce((result, current) => {
 				if ($value.includes(current.value) && current.label) {
 					result.add(current.label);
 				}
@@ -584,14 +586,12 @@ export function createSelect<
 
 			valueLabel.set(Array.from(labels).join(', '));
 		} else {
-			const newLabel = optionsList.find((opt) => opt.value === $value)?.label;
+			const newLabel = optionPropsArr.find((opt) => opt.value === $value)?.label;
 			valueLabel.set(newLabel ?? null);
 		}
 	});
 
-	const { typed, handleTypeaheadSearch } = createTypeaheadSearch();
-
-	effect([open, activeTrigger], ([$open, $activeTrigger]) => {
+	effect([open, activeTrigger], function handleFocus([$open, $activeTrigger]) {
 		const unsubs: Array<() => void> = [];
 
 		if (!isBrowser) return;
@@ -602,7 +602,7 @@ export function createSelect<
 		const constantMounted = mounted;
 		sleep(1).then(() => {
 			const menuEl = document.getElementById(ids.menu);
-			if (menuEl && $open && get(isUsingKeyboard)) {
+			if (menuEl && $open && isUsingKeyboard) {
 				// Focus on selected option or first option
 				const selectedOption = getSelectedOption(menuEl);
 
@@ -616,7 +616,7 @@ export function createSelect<
 			} else if (menuEl && $open) {
 				// focus on the menu element
 				handleRovingFocus(menuEl);
-			} else if ($activeTrigger && constantMounted && get(isUsingKeyboard)) {
+			} else if ($activeTrigger && constantMounted && isUsingKeyboard) {
 				// Hacky way to prevent the keydown event from triggering on the trigger
 				handleRovingFocus($activeTrigger);
 			}
@@ -639,9 +639,9 @@ export function createSelect<
 	effect([open, activeTrigger], ([$open, $activeTrigger]) => {
 		if (!isBrowser) return;
 
-		const handlePointer = () => isUsingKeyboard.set(false);
+		const handlePointer = () => (isUsingKeyboard = false);
 		const handleKeyDown = (e: KeyboardEvent) => {
-			isUsingKeyboard.set(true);
+			isUsingKeyboard = true;
 			if (e.key === kbd.ESCAPE && $open) {
 				open.set(false);
 				if (!$activeTrigger) return;
diff --git a/src/lib/internal/helpers/object.ts b/src/lib/internal/helpers/object.ts
index f1828ced..292a9a52 100644
--- a/src/lib/internal/helpers/object.ts
+++ b/src/lib/internal/helpers/object.ts
@@ -12,3 +12,53 @@ export function omit<T extends Record<string, unknown>, K extends keyof T>(
 	}
 	return result;
 }
+
+function isRegExp(value: unknown): value is RegExp {
+	return value instanceof RegExp;
+}
+
+/**
+ * The fastest deep equal with ES6 Map, Set and Typed arrays support.
+ * Adapted from the `fast-deep-equal` npm package.
+ *
+ * @see https://github.com/epoberezkin/fast-deep-equal#readme
+ */
+export function deepEqual(a: unknown, b: unknown) {
+	if (a === b) return true;
+
+	if (a && b && typeof a == 'object' && typeof b == 'object') {
+		if (a.constructor !== b.constructor) return false;
+
+		let length, i;
+
+		if (Array.isArray(a)) {
+			length = a.length;
+			if (!Array.isArray(b) || length != b.length) return false;
+			for (i = length; i-- !== 0; ) if (!deepEqual(a[i], b[i])) return false;
+			return true;
+		}
+
+		if (isRegExp(a)) return isRegExp(b) && a.source === b.source && a.flags === b.flags;
+		if (a.valueOf !== Object.prototype.valueOf) return a.valueOf() === b.valueOf();
+		if (a.toString !== Object.prototype.toString) return a.toString() === b.toString();
+
+		const keys = Object.keys(a);
+		length = keys.length;
+		if (length !== Object.keys(b).length) return false;
+
+		for (i = length; i-- !== 0; )
+			if (!Object.prototype.hasOwnProperty.call(b, keys[i])) return false;
+
+		for (i = length; i-- !== 0; ) {
+			const key = keys[i];
+
+			// eslint-disable-next-line @typescript-eslint/no-explicit-any
+			if (!deepEqual((a as any)[key], (b as any)[key])) return false;
+		}
+
+		return true;
+	}
+
+	// true if both NaN, false otherwise
+	return a !== a && b !== b;
+}
diff --git a/src/lib/internal/helpers/store/debounceable.ts b/src/lib/internal/helpers/store/debounceable.ts
new file mode 100644
index 00000000..37b6b226
--- /dev/null
+++ b/src/lib/internal/helpers/store/debounceable.ts
@@ -0,0 +1,66 @@
+import { writable } from 'svelte/store';
+
+export function debounceable<T>(initialValue: T, wait = 0) {
+	const store = writable({ value: initialValue, debounced: initialValue });
+	let timeout: NodeJS.Timeout | undefined;
+
+	function set(value: T) {
+		store.update((state) => {
+			state.value = value;
+			return state;
+		});
+
+		if (timeout) {
+			clearTimeout(timeout);
+		}
+
+		timeout = setTimeout(() => {
+			store.update((state) => {
+				state.debounced = state.value;
+				return state;
+			});
+		}, wait);
+	}
+
+	function update(fn: (value: T) => T) {
+		store.update((state) => {
+			state.value = fn(state.value);
+			return state;
+		});
+
+		if (timeout) {
+			clearTimeout(timeout);
+		}
+
+		timeout = setTimeout(() => {
+			store.update((state) => {
+				state.debounced = state.value;
+				return state;
+			});
+		}, wait);
+	}
+
+	function forceSet(value: T) {
+		store.update((state) => {
+			state.value = value;
+			state.debounced = value;
+			return state;
+		});
+	}
+
+	function forceUpdate(fn: (value: T) => T) {
+		store.update((state) => {
+			state.value = fn(state.value);
+			state.debounced = state.value;
+			return state;
+		});
+	}
+
+	return {
+		...store,
+		set,
+		update,
+		forceSet,
+		forceUpdate,
+	};
+}


Test patch:
diff --git a/src/lib/internal/helpers/tests/object.spec.ts b/src/lib/internal/helpers/tests/object.spec.ts
index a6c9de33..7d1db201 100644
--- a/src/lib/internal/helpers/tests/object.spec.ts
+++ b/src/lib/internal/helpers/tests/object.spec.ts
@@ -1,5 +1,5 @@
 import { describe, expect, it } from 'vitest';
-import { omit } from '../object.js';
+import { omit, deepEqual } from '../object.js';
 
 describe('omit', () => {
 	it('omits keys from a given object', () => {
@@ -8,3 +8,47 @@ describe('omit', () => {
 		).toStrictEqual({ savory: ['Basil', 'Bacon'] });
 	});
 });
+
+const deepEqualCases: Array<[unknown, unknown, boolean]> = [
+	[undefined, undefined, true],
+	[null, null, true],
+	[false, false, true],
+	[true, true, true],
+	[0, 0, true],
+	[0, 1, false],
+	[1, 1, true],
+	[1, 2, false],
+	['', '', true],
+	['', 'a', false],
+	['a', 'a', true],
+	['a', 'b', false],
+	[[], [], true],
+	[[], [1], false],
+	[[1], [1], true],
+	[[1], [2], false],
+	[{}, {}, true],
+	[{}, { a: 1 }, false],
+	[{ a: 1 }, { a: 1 }, true],
+	[{ a: 1 }, { a: 2 }, false],
+	[{ a: 1 }, { b: 1 }, false],
+	[{ a: 1 }, { a: 1, b: 1 }, false],
+	[{ a: 1, b: 1 }, { a: 1 }, false],
+	[{ a: 1, b: 1 }, { a: 1, b: 1 }, true],
+	[{ a: 1, b: 1 }, { a: 1, b: 2 }, false],
+	[{ a: 1, b: 1 }, { a: 1, c: 1 }, false],
+	[{ a: 1, b: 1 }, { a: 1, b: 1, c: 1 }, false],
+	[{ a: 1, b: 1, c: 1 }, { a: 1, b: 1 }, false],
+	[{ a: 1, b: 1, c: 1 }, { a: 1, b: 1, c: 1 }, true],
+	[{ a: 1, b: 1, c: 1 }, { a: 1, b: 1, c: 2 }, false],
+	[{ a: 1, b: 1, c: 1 }, { a: 1, b: 1, d: 1 }, false],
+	[{ a: 1, b: 1, c: { d: 1 } }, { a: 1, b: 1, c: { d: 1 } }, true],
+	[{ a: 1, b: 1, c: { d: 1 } }, { a: 1, b: 1, c: { d: 2 } }, false],
+];
+
+describe('deepEqual', () => {
+	for (const [a, b, expected] of deepEqualCases) {
+		it(`deepEqual(${JSON.stringify(a)}, ${JSON.stringify(b)}) === ${expected}`, () => {
+			expect(deepEqual(a, b)).toBe(expected);
+		});
+	}
+});
diff --git a/src/tests/portal/Combobox.svelte b/src/tests/portal/Combobox.svelte
index a927d171..51729077 100644
--- a/src/tests/portal/Combobox.svelte
+++ b/src/tests/portal/Combobox.svelte
@@ -8,11 +8,7 @@
 
 	const {
 		elements: { menu, input, item, label },
-		states: { filteredItems },
-	} = createCombobox({
-		items,
-		filterFunction: (item, value) => item.includes(value),
-	});
+	} = createCombobox();
 
 	const level = initLevel();
 </script>
@@ -26,20 +22,15 @@
 	<slot />
 	<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
 	<div tabindex="0">
-		{#if $filteredItems.length !== 0}
-			{#each $filteredItems as it, index (index)}
-				<li
-					use:melt={$item({
-						index,
-						item: it,
-					})}
-				>
-					{it}
-				</li>
-			{/each}
-		{:else}
-			<li>No results found</li>
-		{/if}
+		{#each items as it, index (index)}
+			<li
+				use:melt={$item({
+					value: it,
+				})}
+			>
+				{it}
+			</li>
+		{/each}
 	</div>
 </ul>
 
diff --git a/src/tests/select/Select.spec.ts b/src/tests/select/Select.spec.ts
index e528d8dd..b5f26013 100644
--- a/src/tests/select/Select.spec.ts
+++ b/src/tests/select/Select.spec.ts
@@ -1,5 +1,5 @@
 import '@testing-library/jest-dom';
-import { render, act, fireEvent } from '@testing-library/svelte';
+import { render, act, fireEvent, waitFor } from '@testing-library/svelte';
 import { axe } from 'jest-axe';
 import { describe } from 'vitest';
 import userEvent from '@testing-library/user-event';
@@ -143,6 +143,31 @@ describe('Select (Default)', () => {
 		await expect(trigger).toHaveTextContent('Caramel, Chocolate');
 	});
 
+	test('Shows correct label when defaultValue is provided', async () => {
+		const { getByTestId } = await render(SelectTest, { defaultValue: 'Chocolate' });
+		const trigger = getByTestId('trigger');
+
+		await expect(trigger).toHaveTextContent('Chocolate');
+	});
+
+	test('Manually setting the value updates the label', async () => {
+		const { getByTestId } = await render(SelectTest);
+		const manualBtn = getByTestId('manual-btn');
+		const trigger = getByTestId('trigger');
+
+		manualBtn.click();
+		await waitFor(() => expect(trigger).toHaveTextContent('Chocolate'), { timeout: 50 });
+	});
+
+	test('Updating options and setting the value updates the label', async () => {
+		const { getByTestId } = await render(SelectTest);
+		const updateBtn = getByTestId('update-btn');
+		const trigger = getByTestId('trigger');
+
+		updateBtn.click();
+		await waitFor(() => expect(trigger).toHaveTextContent('Vanilla'), { timeout: 500 });
+	});
+
 	test.todo('Disabled select cannot be opened');
 	test.todo('Options loop when loop prop is set');
 });
diff --git a/src/tests/select/SelectTest.svelte b/src/tests/select/SelectTest.svelte
index 23f148b5..71dc59e3 100644
--- a/src/tests/select/SelectTest.svelte
+++ b/src/tests/select/SelectTest.svelte
@@ -1,39 +1,63 @@
 <script lang="ts">
 	import { createSelect, melt } from '$lib/index.js';
 	import { Check, ChevronDown } from 'lucide-svelte';
+	import { tick } from 'svelte';
 
 	export let multiple = false;
+	export let defaultValue: string | undefined = undefined;
 
 	const {
 		elements: { trigger, menu, option, group, groupLabel },
-		states: { valueLabel },
+		states: { valueLabel, value },
 		helpers: { isSelected },
-	} = createSelect({ multiple });
+	} = createSelect({ multiple, defaultValue });
 
-	const options = {
+	let options = {
 		sweet: ['Caramel', 'Chocolate', 'Strawberry', 'Cookies & Cream'],
 		savory: ['Basil', 'Bacon', 'Rosemary', 'Balsamic Fig'],
 	};
 </script>
 
 <main>
-	<button class="trigger" use:melt={$trigger} aria-label="Food" data-testid="trigger">
+	<button
+		on:click={() => {
+			value.set('Chocolate');
+		}}
+		data-testid="manual-btn"
+	>
+		Manual set
+	</button>
+
+	<button
+		on:click={async () => {
+			options.sweet.push('Vanilla');
+			// trigger the update
+			options = options;
+			// Let the DOM update
+			await tick();
+			value.set('Vanilla');
+		}}
+		data-testid="update-btn"
+	>
+		Update options and set
+	</button>
+
+	<button use:melt={$trigger} aria-label="Food" data-testid="trigger">
 		{$valueLabel || 'Select an option'}
 		<ChevronDown />
 	</button>
 
-	<div class="menu" use:melt={$menu} data-testid="menu">
+	<div use:melt={$menu} data-testid="menu">
 		{#each Object.entries(options) as [key, arr]}
 			<div use:melt={$group(key)} data-testid="group-{key}">
-				<div class="label" use:melt={$groupLabel(key)} data-testid="label-{key}">{key}</div>
+				<div use:melt={$groupLabel(key)} data-testid="label-{key}">{key}</div>
 				{#each arr as item, i}
 					<div
-						class="option"
 						use:melt={$option({ value: item, label: item, disabled: i === 2 })}
 						data-testid="{key}-option-{i}"
 					>
 						{#if $isSelected(item)}
-							<div class="check" data-testid="check-{i}">
+							<div data-testid="check-{i}">
 								<Check />
 							</div>
 						{/if}
@@ -44,29 +68,3 @@
 		{/each}
 	</div>
 </main>
-
-<style lang="postcss">
-	.label {
-		@apply py-1 pl-4 pr-4  font-semibold capitalize text-neutral-800;
-	}
-
-	.menu {
-		@apply z-10 flex max-h-[360px]  flex-col overflow-y-auto;
-		@apply rounded-md bg-white p-1;
-		@apply ring-0 !important;
-	}
-	.option {
-		@apply relative cursor-pointer rounded-md py-1 pl-8 pr-4 text-neutral-800;
-		@apply focus:z-10 focus:text-magnum-700;
-		@apply data-[highlighted]:bg-magnum-50 data-[highlighted]:text-magnum-900;
-		@apply data-[selected]:bg-magnum-100 data-[selected]:text-magnum-900;
-	}
-	.trigger {
-		@apply flex h-10 min-w-[220px] items-center justify-between rounded-md bg-white px-3;
-		@apply py-2 text-magnum-700 transition-opacity hover:opacity-90;
-	}
-	.check {
-		@apply absolute left-2 top-1/2 z-20 text-magnum-500;
-		translate: 0 calc(-50% + 1px);
-	}
-</style>