Repository: melt-ui/melt-ui (ts)

Issue:
🐛Bug: Tag Input doesn't enforce uniqueness with custom add()
**Describe the bug**
Tag Input doesn't enforce uniqueness when using a custom `add()` function

**To Reproduce**
Steps to reproduce the behavior:
```
	const {
                 states: {tags},
		elements: { root, input, tag, deleteTrigger }
	} = createTagsInput({
		add: async (tag) => tag.trim(),
		unique: true
	})
```

**Expected behavior**
If I add the tag `hi` (no spaces) then try to add `hi  ` (with spaces) i'd expect it to reject.


Pull request description:
fix: tag being created/updated even if not valid
Closes #348 

I've also added some tests for the tags

Patch:
diff --git a/.changeset/tricky-boats-breathe.md b/.changeset/tricky-boats-breathe.md
new file mode 100644
index 00000000..4fd27cf0
--- /dev/null
+++ b/.changeset/tricky-boats-breathe.md
@@ -0,0 +1,5 @@
+---
+"@melt-ui/svelte": patch
+---
+
+fix: tag being created/updated even if not valid
diff --git a/src/docs/content/builders/tags-input.md b/src/docs/content/builders/tags-input.md
index ada3014f..d075727b 100644
--- a/src/docs/content/builders/tags-input.md
+++ b/src/docs/content/builders/tags-input.md
@@ -61,7 +61,7 @@ Use the return values to construct a tags-input.
 
 ### Adding a tag
 
-An asynchronous `add` function may be passed into the builder. It is called prior to adding the tag
+An `add` function may be passed into the builder. It is called and awaited prior to adding the tag
 to the `$tags` store.
 
 It provides you the ability to validate the input value, set a custom id, for example from a backend
@@ -70,7 +70,7 @@ or 3rd-party API, or update the value to always be uppercase, lowercase, etc.
 The function definition is:
 
 ```ts
-fn: (tag: string) => Promise<Tag | undefined>
+fn: (tag: string) => Tag | string | Promise<Tag | string>
 ```
 
 whereby `tag` is the input value.
@@ -110,7 +110,7 @@ uppercase.
 
 ### Updating a tag
 
-An asynchronous update function may be passed into the builder. It is called prior to updating a tag
+An update function may be passed into the builder. It is called and awaited prior to updating a tag
 in $tags store, following an edit.
 
 It provides the ability do something before a tag is updated, such as updating the value in a
@@ -119,7 +119,7 @@ backend database, setting a new id, or simply manipulating the value to be added
 The function definition is:
 
 ```ts
-fn: (tag: Tag) => Promise<Tag>.
+fn: (tag: Tag) => Tag | Promise<Tag>.
 ```
 
 <Callout type="info">
@@ -146,8 +146,8 @@ The following example uses the existing id and sets the value to uppercase
 
 ### Removing a tag
 
-An asynchronous remove function may be passed into the builder. It is called prior to removing the
-tag from the $tags store.
+An asynchronous remove function may be passed into the builder. It is called and awaited prior to
+removing the tag from the $tags store.
 
 It provides the ability do something before the tag is removed from $tags store, such as deleting
 the tag from a backend database.
@@ -155,7 +155,7 @@ the tag from a backend database.
 The function definition is:
 
 ```ts
-fn: (tag: Tag) => Promise<boolean>
+fn: (tag: Tag) => boolean | Promise<boolean>
 ```
 
 whereby tag is the tag to be removed from the $tags store.
diff --git a/src/lib/builders/tags-input/create.ts b/src/lib/builders/tags-input/create.ts
index 172cb45f..de779403 100644
--- a/src/lib/builders/tags-input/create.ts
+++ b/src/lib/builders/tags-input/create.ts
@@ -152,6 +152,9 @@ export function createTagsInput(props?: CreateTagsInputProps) {
 		// Trim the value, only after the user defined add function
 		if (get(trim)) workingTag.value = workingTag.value.trim();
 
+		// if it's not valid we don't add it to the tags list
+		if (!isInputValid(workingTag.value)) return false;
+
 		tags.update((current) => {
 			current.push(workingTag);
 			return current;
@@ -183,6 +186,9 @@ export function createTagsInput(props?: CreateTagsInputProps) {
 		// Trim the value, only after the user defined update function
 		if (get(trim)) workingTag.value = workingTag.value.trim();
 
+		// if it's not valid we don't add it to the tags list
+		if (!isInputValid(workingTag.value)) return false;
+
 		// Update the tag matching the old id
 		tags.update(($tags) => {
 			return $tags.map((t) => {
diff --git a/src/lib/builders/tags-input/types.ts b/src/lib/builders/tags-input/types.ts
index 25854f0c..c536a0ba 100644
--- a/src/lib/builders/tags-input/types.ts
+++ b/src/lib/builders/tags-input/types.ts
@@ -37,9 +37,9 @@ export type TagProps = {
 	editable?: boolean;
 };
 
-export type UpdateTag = (tag: Tag) => Promise<Tag>;
-export type RemoveTag = (tag: Tag) => Promise<boolean>;
-export type AddTag = (tag: string) => Promise<Tag | string>;
+export type UpdateTag = (tag: Tag) => Tag | Promise<Tag>;
+export type RemoveTag = (tag: Tag) => boolean | Promise<boolean>;
+export type AddTag = (tag: string) => (Tag | string) | Promise<Tag | string>;
 
 export type TagsInput = BuilderReturn<typeof createTagsInput>;
 export type TagsInputElements = TagsInput['elements'];
diff --git a/src/lib/index.ts b/src/lib/index.ts
index 7d04fd63..a9e0f373 100644
--- a/src/lib/index.ts
+++ b/src/lib/index.ts
@@ -1,3 +1,2 @@
 export * from './builders/index.js';
 export { melt } from './internal/actions/index.js';
-export type { StoreValue, StoreValueObj } from './internal/types.js';


Test patch:
diff --git a/src/tests/tags-input/TagsInput.spec.ts b/src/tests/tags-input/TagsInput.spec.ts
new file mode 100644
index 00000000..1d2fce90
--- /dev/null
+++ b/src/tests/tags-input/TagsInput.spec.ts
@@ -0,0 +1,167 @@
+import { kbd } from '$lib/internal/helpers';
+import { queryByText, render } from '@testing-library/svelte';
+import userEvent from '@testing-library/user-event';
+import { axe } from 'jest-axe';
+import { describe, test } from 'vitest';
+import TagsInput from './TagsInput.svelte';
+
+describe('TagsInput', () => {
+	test('No accessibility violations', async () => {
+		const { container } = render(TagsInput);
+
+		expect(await axe(container)).toHaveNoViolations();
+	});
+
+	test('Default tags are present', async () => {
+		const defaultTags = ['svelte', 'ts'];
+		const { findByTestId } = render(TagsInput, {
+			props: {
+				defaultTags,
+			},
+		});
+
+		for (let i = 0; i < defaultTags.length; i++) {
+			const tag = await findByTestId(`tag-${i}`);
+			expect(tag).toBeInTheDocument();
+			expect(queryByText(tag, defaultTags[i])).toBeInTheDocument();
+		}
+	});
+
+	test('New tag can be added', async () => {
+		const { findByRole, queryByText } = render(TagsInput);
+
+		const input = await findByRole('textbox');
+		const user = userEvent.setup();
+		const newTag = 'new tag';
+		await user.type(input, `${newTag}{${kbd.ENTER}}`);
+		expect(input).not.toHaveValue();
+		expect(queryByText(newTag)).toBeInTheDocument();
+	});
+
+	test('Respect uniqueness', async () => {
+		const newTag = 'new tag';
+		const { findByRole } = render(TagsInput, {
+			props: {
+				unique: true,
+				defaultTags: [newTag],
+			},
+		});
+
+		const input = await findByRole('textbox');
+		const user = userEvent.setup();
+		await user.type(input, `${newTag}{${kbd.ENTER}}`);
+		expect(input).toHaveValue();
+	});
+
+	test('Respect uniqueness with custom add function', async () => {
+		const newTag = 'new tag';
+		const { findByRole } = render(TagsInput, {
+			props: {
+				unique: true,
+				defaultTags: [newTag],
+				add(v) {
+					return v.trim();
+				},
+				trim: false,
+			},
+		});
+
+		const input = await findByRole('textbox');
+		const user = userEvent.setup();
+		await user.type(input, `${newTag}   {${kbd.ENTER}}`);
+		expect(input).toHaveValue();
+	});
+
+	test('Auto trim', async () => {
+		const newTag = 'new tag';
+		const { findByRole, queryByText } = render(TagsInput, {
+			props: {
+				trim: true,
+			},
+		});
+
+		const input = await findByRole('textbox');
+		const user = userEvent.setup();
+		await user.type(input, `${newTag}   {${kbd.ENTER}}`);
+		expect(input).not.toHaveValue();
+		expect(queryByText(newTag)).toBeInTheDocument();
+	});
+
+	test('Respect uniqueness with auto trim', async () => {
+		const newTag = 'new tag';
+		const { findByRole } = render(TagsInput, {
+			props: {
+				unique: true,
+				defaultTags: [newTag],
+				trim: true,
+			},
+		});
+
+		const input = await findByRole('textbox');
+		const user = userEvent.setup();
+		await user.type(input, `${newTag}   {${kbd.ENTER}}`);
+		expect(input).toHaveValue();
+	});
+
+	test('Respect allowed list', async () => {
+		const newTag = 'new tag';
+		const allowedTag = 'something else';
+		const { findByRole, queryByText } = render(TagsInput, {
+			props: {
+				allowed: [allowedTag],
+				trim: true,
+			},
+		});
+
+		const input = await findByRole('textbox');
+		const user = userEvent.setup();
+		await user.type(input, `${newTag}{${kbd.ENTER}}`);
+		expect(input).toHaveValue();
+		user.clear(input);
+		await user.type(input, `${allowedTag}{${kbd.ENTER}}`);
+		expect(input).not.toHaveValue();
+		expect(queryByText(allowedTag)).toBeInTheDocument();
+	});
+
+	test('Respect allowed list with auto trim', async () => {
+		const newTag = 'new tag';
+		const allowedTag = 'something else';
+		const { findByRole, queryByText } = render(TagsInput, {
+			props: {
+				allowed: [allowedTag],
+				trim: true,
+			},
+		});
+
+		const input = await findByRole('textbox');
+		const user = userEvent.setup();
+		await user.type(input, `${newTag}   {${kbd.ENTER}}`);
+		expect(input).toHaveValue();
+		user.clear(input);
+		await user.type(input, `${allowedTag}    {${kbd.ENTER}}`);
+		expect(input).not.toHaveValue();
+		expect(queryByText(allowedTag)).toBeInTheDocument();
+	});
+
+	test('Respect allowed list with custom add', async () => {
+		const newTag = 'new tag';
+		const allowedTag = 'something else';
+		const { findByRole, queryByText } = render(TagsInput, {
+			props: {
+				allowed: [allowedTag],
+				add(v) {
+					return v.trim();
+				},
+			},
+		});
+
+		const input = await findByRole('textbox');
+		const user = userEvent.setup();
+		await user.type(input, `${newTag}   {${kbd.ENTER}}`);
+		expect(input).toHaveValue();
+		user.clear(input);
+		await user.type(input, `${allowedTag}    {${kbd.ENTER}}`);
+		expect(input).not.toHaveValue();
+		expect(queryByText(allowedTag)).toBeInTheDocument();
+	});
+});
diff --git a/src/tests/tags-input/TagsInput.svelte b/src/tests/tags-input/TagsInput.svelte
new file mode 100644
index 00000000..9ee69bf3
--- /dev/null
+++ b/src/tests/tags-input/TagsInput.svelte
@@ -0,0 +1,61 @@
+<script lang="ts">
+	import { createTagsInput, melt, type AddTag } from '$lib/index.js';
+	import { X } from 'lucide-svelte';
+
+	export let defaultTags = ['Svelte', 'Typescript'];
+	export let unique = true;
+	export let trim = true;
+	export let allowed: string[] | undefined = undefined;
+	export let add: AddTag | undefined = undefined;
+	const {
+		elements: { root, input, tag, deleteTrigger, edit },
+		states: { tags },
+	} = createTagsInput({
+		defaultTags,
+		unique,
+		allowed,
+		add,
+		trim,
+	});
+</script>
+
+<main>
+	<div class="flex flex-col items-start justify-center gap-2">
+		<div
+			use:melt={$root}
+			class="flex min-w-[280px] flex-row flex-wrap gap-2.5 rounded-md bg-white px-3 py-2 text-magnum-700
+		focus-within:ring focus-within:ring-magnum-400"
+		>
+			{#each $tags as t, i}
+				<div
+					data-testid="tag-{i}"
+					use:melt={$tag(t)}
+					class="flex items-center overflow-hidden rounded-md bg-magnum-200 text-magnum-900 [word-break:break-word]
+		data-[disabled]:bg-magnum-300 data-[selected]:bg-magnum-400 data-[disabled]:hover:cursor-default
+		data-[disabled]:focus:!outline-none data-[disabled]:focus:!ring-0"
+				>
+					<span class="flex items-center border-r border-white/10 px-1.5">{t.value}</span>
+					<button
+						data-testid="delete-tag-{i}"
+						use:melt={$deleteTrigger(t)}
+						class="flex h-full items-center px-1 enabled:hover:bg-magnum-300"
+					>
+						<X class="square-3" />
+					</button>
+				</div>
+				<div
+					data-testid="edit-tag-{i}"
+					use:melt={$edit(t)}
+					class="flex items-center overflow-hidden rounded-md px-1.5 [word-break:break-word] data-[invalid-edit]:focus:!ring-red-500"
+				/>
+			{/each}
+
+			<input
+				use:melt={$input}
+				type="text"
+				placeholder="Enter tags..."
+				class="min-w-[4.5rem] shrink grow basis-0 border-0 text-black outline-none focus:!ring-0 data-[invalid]:text-red-500"
+			/>
+		</div>
+	</div>
+</main>