Repository: melt-ui/melt-ui
Pull request #1229 (2024-06-13 21:19:00)

feat(tags-input): split on paste
related to #1227

![tags-input-split-on-paste](https://github.com/melt-ui/melt-ui/assets/969516/e3e489f9-5dd2-4076-b1d0-f4e16ed263a8)



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

Latest commit: 0ec8158a4e12c16aab0c8f4f56cb33c333f1cb92

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/fernandolguevara/melt-ui/new/feat-tags-input-split-on-paste?filename=.changeset/ten-clocks-repair.md&value=---%0A%22%40melt-ui%2Fsvelte%22%3A%20patch%0A---%0A%0Afeat(tags-input)%3A%20split%20on%20paste%0A)


Wizzel1: @fernandolguevara can this please be merged?
fernandolguevara: @TGlide, what do you think about this?
dfralan: BIS

Patch:
diff --git a/.changeset/clean-tomatoes-lie.md b/.changeset/clean-tomatoes-lie.md
new file mode 100644
index 00000000..79b5031a
--- /dev/null
+++ b/.changeset/clean-tomatoes-lie.md
@@ -0,0 +1,5 @@
+---
+"@melt-ui/svelte": minor
+---
+
+Added prop to createToaster() to pause all timers on hover
diff --git a/.changeset/fifty-suits-reflect.md b/.changeset/fifty-suits-reflect.md
new file mode 100644
index 00000000..4ba93a28
--- /dev/null
+++ b/.changeset/fifty-suits-reflect.md
@@ -0,0 +1,5 @@
+---
+'@melt-ui/svelte': patch
+---
+
+fix(slider): `onValueCommitted` called if the slider had an active state
diff --git a/.changeset/khaki-maps-relate.md b/.changeset/khaki-maps-relate.md
new file mode 100644
index 00000000..2a52dd91
--- /dev/null
+++ b/.changeset/khaki-maps-relate.md
@@ -0,0 +1,5 @@
+---
+"@melt-ui/svelte": minor
+---
+
+feat(tags-input): split on paste
diff --git a/.changeset/tasty-tools-accept.md b/.changeset/tasty-tools-accept.md
new file mode 100644
index 00000000..bc2d23fa
--- /dev/null
+++ b/.changeset/tasty-tools-accept.md
@@ -0,0 +1,5 @@
+---
+'@melt-ui/svelte': minor
+---
+
+add `name` prop to radio group builder hidden input element
diff --git a/src/docs/data/builders/combobox.ts b/src/docs/data/builders/combobox.ts
index 7e057994..39123596 100644
--- a/src/docs/data/builders/combobox.ts
+++ b/src/docs/data/builders/combobox.ts
@@ -31,6 +31,11 @@ const OPTION_PROPS = [
 		description:
 			'When true, hovering an option will update the `highlightedItem` store, and when the cursor leaves an option the store will be set to `null`',
 	},
+	{
+		name: 'name',
+		type: 'string',
+		description: 'The name to be used for the hidden input.',
+	},
 ];
 
 const BUILDER_NAME = 'combobox';
diff --git a/src/docs/data/builders/radio-group.ts b/src/docs/data/builders/radio-group.ts
index 66262751..9b9165cf 100644
--- a/src/docs/data/builders/radio-group.ts
+++ b/src/docs/data/builders/radio-group.ts
@@ -17,6 +17,11 @@ const OPTION_PROPS = [
 		default: '"vertical"',
 		description: 'The orientation of the radio group.',
 	},
+	{
+		name: 'name',
+		type: 'string',
+		description: 'The name to be used for the hidden input.',
+	},
 ];
 const BUILDER_NAME = 'radio group';
 
diff --git a/src/docs/data/builders/toast.ts b/src/docs/data/builders/toast.ts
index 04f445dd..0653a39a 100644
--- a/src/docs/data/builders/toast.ts
+++ b/src/docs/data/builders/toast.ts
@@ -20,6 +20,12 @@ const OPTION_PROPS = [
 		default: "'foreground'",
 		description: 'The sensitivity of the toast for accessibility purposes.',
 	},
+	{
+		name: 'hover',
+		type: ["'pause'", "'pause-all'", 'null'],
+		default: "'pause'",
+		description: 'The behavior when a toast is hovered.',
+	},
 ];
 
 const BUILDER_NAME = 'toast';
@@ -129,7 +135,7 @@ const keyboard: KeyboardSchema = [
 ];
 
 const schemas = [builder, content, title, description, close];
-const features = ['Automatically closes', 'Resets closing on hover'];
+const features = ['Automatically closes', 'Pause closing on hover (single, all, none)'];
 
 export const toastData: BuilderData = {
 	schemas,
diff --git a/src/docs/previews/toast/progress/tailwind/index.svelte b/src/docs/previews/toast/progress/tailwind/index.svelte
index e659ddda..58473fa8 100644
--- a/src/docs/previews/toast/progress/tailwind/index.svelte
+++ b/src/docs/previews/toast/progress/tailwind/index.svelte
@@ -7,16 +7,20 @@
 </script>
 
 <script lang="ts">
-	import { createToaster } from '$lib/index.js';
+	import { writable, type Writable } from 'svelte/store';
+	import { createToaster, type CreateToasterProps } from '$lib/index.js';
 	import { flip } from 'svelte/animate';
 	import Toast from './toast.svelte';
+	import SelectHover from './select-hover.svelte';
 
-	const {
+	const hover: Writable<CreateToasterProps['hover']> = writable('pause');
+
+	$: ({
 		elements,
 		helpers: { addToast },
 		states: { toasts },
 		actions: { portal },
-	} = createToaster<ToastData>();
+	} = createToaster<ToastData>({ hover: $hover }));
 
 	const toastData: ToastData[] = [
 		{
@@ -41,6 +45,8 @@
 	}
 </script>
 
+<SelectHover bind:value={$hover} />
+
 <button
 	class="inline-flex items-center justify-center rounded-xl bg-white px-4 py-3
 	font-medium leading-none text-magnum-700 shadow hover:opacity-75"
diff --git a/src/docs/previews/toast/progress/tailwind/select-hover.svelte b/src/docs/previews/toast/progress/tailwind/select-hover.svelte
new file mode 100644
index 00000000..104da266
--- /dev/null
+++ b/src/docs/previews/toast/progress/tailwind/select-hover.svelte
@@ -0,0 +1,82 @@
+<script lang="ts">
+	import { Check, ChevronDown } from '$icons/index.js';
+	import { createSelect, melt, type CreateToasterProps } from '$lib/index.js';
+	import { fade } from 'svelte/transition';
+
+	const options: { value: CreateToasterProps['hover']; label: string }[] = [
+		{ value: 'pause', label: 'pause' },
+		{ value: 'pause-all', label: 'pause-all' },
+		{ value: null, label: 'null' },
+	];
+
+	const {
+		elements: { trigger, menu, option, label },
+		states: { selected, selectedLabel, open },
+		helpers: { isSelected },
+	} = createSelect<CreateToasterProps['hover']>({
+		forceVisible: true,
+		positioning: {
+			placement: 'bottom',
+			fitViewport: true,
+			sameWidth: true,
+		},
+		defaultSelected: { value: 'pause', label: 'pause' },
+	});
+
+	export let value: CreateToasterProps['hover'];
+
+	$: value = $selected?.value;
+</script>
+
+<div class="absolute left-4 top-4">
+	<div class="flex flex-col gap-1">
+		<!-- svelte-ignore a11y-label-has-associated-control - $label contains the 'for' attribute -->
+		<label class="block text-white" use:melt={$label}> Hover behavior : </label>
+		<button
+			class="flex h-10 min-w-[220px] items-center justify-between rounded-lg bg-white px-3 py-2
+	text-magnum-700 shadow transition-opacity hover:opacity-90"
+			use:melt={$trigger}
+			aria-label="Hover behavior"
+		>
+			{$selectedLabel || 'Select hover behavior'}
+			<ChevronDown class="size-5" />
+		</button>
+		{#if $open}
+			<div
+				class="force-dark z-10 flex max-h-[300px] flex-col
+		overflow-y-auto rounded-lg bg-white p-1
+		shadow focus:!ring-0"
+				use:melt={$menu}
+				transition:fade={{ duration: 150 }}
+			>
+				{#each options as { value, label }}
+					<div
+						class="relative cursor-pointer rounded-lg py-1 pl-8 pr-4 text-neutral-800
+							hover:bg-magnum-100 focus:z-10
+							focus:text-magnum-700
+							data-[highlighted]:bg-magnum-200 data-[highlighted]:text-magnum-900
+							data-[disabled]:opacity-50"
+						use:melt={$option({ value, label })}
+					>
+						<div class="check {$isSelected(value) ? 'block' : 'hidden'}">
+							<Check class="size-4" />
+						</div>
+
+						{label}
+					</div>
+				{/each}
+			</div>
+		{/if}
+	</div>
+</div>
+
+<style lang="postcss">
+	.check {
+		position: absolute;
+		left: theme(spacing.2);
+		top: 50%;
+		z-index: theme(zIndex.20);
+		translate: 0 calc(-50% + 1px);
+		color: theme(colors.magnum.500);
+	}
+</style>
diff --git a/src/lib/builders/radio-group/create.ts b/src/lib/builders/radio-group/create.ts
index 331a5fdf..0b19c758 100644
--- a/src/lib/builders/radio-group/create.ts
+++ b/src/lib/builders/radio-group/create.ts
@@ -16,7 +16,7 @@ import {
 } from '$lib/internal/helpers/index.js';
 import { safeOnMount } from '$lib/internal/helpers/lifecycle.js';
 import type { Defaults, MeltActionReturn } from '$lib/internal/types.js';
-import { derived, writable } from 'svelte/store';
+import { derived, readonly, writable } from 'svelte/store';
 import { createHiddenInput } from '../hidden-input/create.js';
 import type { RadioGroupEvents } from './events.js';
 import type { CreateRadioGroupProps, RadioGroupItemProps } from './types.js';
@@ -27,6 +27,7 @@ const defaults = {
 	disabled: false,
 	required: false,
 	defaultValue: undefined,
+	name: undefined,
 } satisfies Defaults<CreateRadioGroupProps>;
 
 type RadioGroupParts = 'item' | 'hidden-input';
@@ -38,7 +39,7 @@ export function createRadioGroup(props?: CreateRadioGroupProps) {
 
 	// options
 	const options = toWritableStores(omit(withDefaults, 'value'));
-	const { disabled, required, loop, orientation } = options;
+	const { disabled, required, loop, orientation, name: nameProp } = options;
 
 	const valueWritable = withDefaults.value ?? writable(withDefaults.defaultValue);
 	const value = overridable(valueWritable, withDefaults?.onValueChange);
@@ -177,6 +178,7 @@ export function createRadioGroup(props?: CreateRadioGroupProps) {
 
 	const hiddenInput = createHiddenInput({
 		value,
+		name: readonly(nameProp),
 		disabled,
 		required,
 	});
diff --git a/src/lib/builders/radio-group/types.ts b/src/lib/builders/radio-group/types.ts
index f41ae499..6a743655 100644
--- a/src/lib/builders/radio-group/types.ts
+++ b/src/lib/builders/radio-group/types.ts
@@ -20,6 +20,8 @@ export type CreateRadioGroupProps = {
 	 */
 	required?: boolean;
 
+	name?: string;
+
 	/**
 	 * Whether or not the radio group should loop around when the end
 	 * is reached.
diff --git a/src/lib/builders/slider/create.ts b/src/lib/builders/slider/create.ts
index 459a9407..1046870f 100644
--- a/src/lib/builders/slider/create.ts
+++ b/src/lib/builders/slider/create.ts
@@ -521,11 +521,11 @@ export const createSlider = (props?: CreateSliderProps) => {
 			};
 
 			const pointerUp = () => {
-				isActive.set(false);
-
-				if (withDefaults?.onValueCommitted) {
+				if (withDefaults?.onValueCommitted && isActive.get()) {
 					withDefaults.onValueCommitted(value.get());
 				}
+
+				isActive.set(false);
 			};
 
 			const unsub = executeCallbacks(
diff --git a/src/lib/builders/tags-input/create.ts b/src/lib/builders/tags-input/create.ts
index ec7b0ba4..35741a91 100644
--- a/src/lib/builders/tags-input/create.ts
+++ b/src/lib/builders/tags-input/create.ts
@@ -339,11 +339,17 @@ export function createTagsInput(props?: CreateTagsInputProps) {
 					if (!addOnPaste.get()) return;
 					e.preventDefault();
 
-					// Update value with the pasted text or set invalid
-					if (isInputValid(pastedText) && (await addTag(pastedText))) {
-						node.value = '';
-					} else {
-						inputInvalid.set(true);
+					const newTags = pastedText.split(',')
+					addTag: for (let i = 0; i < newTags.length; i++) {
+						const newTag = newTags[i];
+						// Update value with the pasted tag or set invalid
+						if (isInputValid(newTag) && (await addTag(newTag))) {
+							continue addTag;
+						} else {
+							node.value = newTags.slice(i).join(',');
+							inputInvalid.set(true);
+							return;
+						}
 					}
 				}),
 				addMeltEventListener(node, 'keydown', async (e) => {
@@ -498,11 +504,11 @@ export function createTagsInput(props?: CreateTagsInputProps) {
 					tabindex: -1,
 					style: editing
 						? styleToString({
-								position: 'absolute',
-								opacity: 0,
-								'pointer-events': 'none',
-								margin: 0,
-						  })
+							position: 'absolute',
+							opacity: 0,
+							'pointer-events': 'none',
+							margin: 0,
+						})
 						: undefined,
 				} as const;
 			};
@@ -638,11 +644,11 @@ export function createTagsInput(props?: CreateTagsInputProps) {
 					tabindex: -1,
 					style: !editing
 						? styleToString({
-								position: 'absolute',
-								opacity: 0,
-								'pointer-events': 'none',
-								margin: 0,
-						  })
+							position: 'absolute',
+							opacity: 0,
+							'pointer-events': 'none',
+							margin: 0,
+						})
 						: undefined,
 				} as const;
 			};
diff --git a/src/lib/builders/toast/create.ts b/src/lib/builders/toast/create.ts
index 2576e73a..640ce466 100644
--- a/src/lib/builders/toast/create.ts
+++ b/src/lib/builders/toast/create.ts
@@ -21,13 +21,14 @@ const { name } = createElHelpers<ToastParts>('toast');
 const defaults = {
 	closeDelay: 5000,
 	type: 'foreground',
+	hover: 'pause',
 } satisfies CreateToasterProps;
 
 export function createToaster<T = object>(props?: CreateToasterProps) {
 	const withDefaults = { ...defaults, ...props } satisfies CreateToasterProps;
 
 	const options = toWritableStores(withDefaults);
-	const { closeDelay, type } = options;
+	const { closeDelay, type, hover } = options;
 
 	const toastsMap = writable(new Map<string, Toast<T>>());
 
@@ -98,6 +99,23 @@ export function createToaster<T = object>(props?: CreateToasterProps) {
 		});
 	};
 
+	const pauseToastTimer = (currentToast: Toast<T>) => {
+		if (currentToast.timeout !== null) {
+			window.clearTimeout(currentToast.timeout);
+		}
+		currentToast.pausedAt = performance.now();
+	};
+	const restartToastTimer = (currentToast: Toast<T>) => {
+		const pausedAt = currentToast.pausedAt ?? currentToast.createdAt;
+		const elapsed = pausedAt - currentToast.createdAt - currentToast.pauseDuration;
+		const remaining = currentToast.closeDelay - elapsed;
+		currentToast.timeout = window.setTimeout(() => {
+			removeToast(currentToast.id);
+		}, remaining);
+		currentToast.pauseDuration += performance.now() - pausedAt;
+		currentToast.pausedAt = undefined;
+	};
+
 	const content = makeElement(name('content'), {
 		stores: toastsMap,
 		returned: ($toasts) => {
@@ -123,31 +141,42 @@ export function createToaster<T = object>(props?: CreateToasterProps) {
 				addMeltEventListener(node, 'pointerenter', (e) => {
 					if (isTouch(e)) return;
 					toastsMap.update((currentMap) => {
-						const currentToast = currentMap.get(node.id);
-						if (!currentToast || currentToast.closeDelay === 0) return currentMap;
-
-						if (currentToast.timeout !== null) {
-							window.clearTimeout(currentToast.timeout);
+						switch (hover.get()) {
+							case 'pause': {
+								const currentToast = currentMap.get(node.id);
+								if (!currentToast || currentToast.closeDelay === 0) return currentMap;
+								pauseToastTimer(currentToast);
+								break;
+							}
+							case 'pause-all':
+								for (const [, currentToast] of currentMap) {
+									if (!currentToast || currentToast.closeDelay === 0) continue;
+									pauseToastTimer(currentToast);
+								}
+								break;
 						}
-						currentToast.pausedAt = performance.now();
 						return new Map(currentMap);
 					});
 				}),
 				addMeltEventListener(node, 'pointerleave', (e) => {
 					if (isTouch(e)) return;
 					toastsMap.update((currentMap) => {
-						const currentToast = currentMap.get(node.id);
-						if (!currentToast || currentToast.closeDelay === 0) return currentMap;
-
-						const pausedAt = currentToast.pausedAt ?? currentToast.createdAt;
-						const elapsed = pausedAt - currentToast.createdAt - currentToast.pauseDuration;
-						const remaining = currentToast.closeDelay - elapsed;
-						currentToast.timeout = window.setTimeout(() => {
-							removeToast(node.id);
-						}, remaining);
-
-						currentToast.pauseDuration += performance.now() - pausedAt;
-						currentToast.pausedAt = undefined;
+						switch (hover.get()) {
+							case 'pause': {
+								const currentToast = currentMap.get(node.id);
+								if (!currentToast || currentToast.closeDelay === 0) return currentMap;
+								restartToastTimer(currentToast);
+								break;
+							}
+
+							case 'pause-all':
+								for (const [, currentToast] of currentMap) {
+									if (!currentToast || currentToast.closeDelay === 0) continue;
+									restartToastTimer(currentToast);
+								}
+								break;
+						}
+
 						return new Map(currentMap);
 					});
 				}),
diff --git a/src/lib/builders/toast/types.ts b/src/lib/builders/toast/types.ts
index 82af1ec8..fd56622b 100644
--- a/src/lib/builders/toast/types.ts
+++ b/src/lib/builders/toast/types.ts
@@ -8,9 +8,10 @@ export type CreateToasterProps = {
 	// If set to 0, the toast will not be automatically closed.
 	closeDelay?: number;
 	type?: 'foreground' | 'background';
+	hover?: 'pause' | 'pause-all' | null;
 };
 
-export type AddToastProps<T = object> = CreateToasterProps & {
+export type AddToastProps<T = object> = Omit<CreateToasterProps, 'hover'> & {
 	data: T;
 };
 


Test patch:
diff --git a/src/tests/radio-group/RadioGroup.spec.ts b/src/tests/radio-group/RadioGroup.spec.ts
index deab96dd..29018ee2 100644
--- a/src/tests/radio-group/RadioGroup.spec.ts
+++ b/src/tests/radio-group/RadioGroup.spec.ts
@@ -84,4 +84,9 @@ describe('Radio Group', () => {
 		expect(els[2]).toHaveFocus();
 		expect(els[1]).not.toHaveFocus();
 	});
+
+	it('has specified name on hidden input', async () => {
+		const { getByTestId } = setup({ name: 'name' });
+		expect(getByTestId('input')).toHaveAttribute('name', 'name');
+	});
 });
diff --git a/src/tests/radio-group/RadioGroupTest.svelte b/src/tests/radio-group/RadioGroupTest.svelte
index 111c1785..b1acb7ab 100644
--- a/src/tests/radio-group/RadioGroupTest.svelte
+++ b/src/tests/radio-group/RadioGroupTest.svelte
@@ -16,6 +16,7 @@
 	export let disabled: CreateRadioGroupProps['disabled'] = undefined;
 	export let onValueChange: CreateRadioGroupProps['onValueChange'] = undefined;
 	export let required: CreateRadioGroupProps['required'] = undefined;
+	export let name: CreateRadioGroupProps['name'] = undefined;
 	export let items: Item[] = [
 		{ value: 'a', disabled: false },
 		{ value: 'b', disabled: false },
@@ -24,7 +25,7 @@
 	];
 
 	const {
-		elements: { root, item },
+		elements: { root, item, hiddenInput },
 		states: { value: localValue },
 	} = createRadioGroup({
 		value,
@@ -32,6 +33,7 @@
 		onValueChange,
 		disabled,
 		required,
+		name,
 		...removeUndefined($$restProps),
 	});
 </script>
@@ -39,6 +41,7 @@
 <main>
 	<div data-testid="value">{$localValue}</div>
 	<form>
+		<input use:melt={$hiddenInput} data-testid="input" />
 		<label id="radio-group-label" for="radio-group" data-testid="label"> Airplane mode </label>
 		<div use:melt={$root} data-testid="root">
 			{#each items as radioItem}