Repository: melt-ui/melt-ui
Pull request #994 (2024-02-17 01:34:13)

fix: interact outside pointerup/mouseup issue
We're now tracking the target of the `pointerdown`/`mousedown`/`touchstart` events to determine if we should close the modal or not.

Before:
https://github.com/melt-ui/melt-ui/assets/64506580/23f0c9e6-cebb-4112-bfbe-8d580dd76031

After:
https://github.com/melt-ui/melt-ui/assets/64506580/2f3587c4-88c8-46ae-bb9d-fcdd349f2757

Closes: #750

Todo:
- [x] add tests

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

Latest commit: 1da44f7f895294b1d513b9a5bda4cb339c46be9f

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/huntabyte/melt-ui/new/fix/750?filename=.changeset/forty-beers-reply.md&value=---%0A%22%40melt-ui%2Fsvelte%22%3A%20patch%0A---%0A%0Afix%3A%20interact%20outside%20pointerup%2Fmouseup%20issue%0A)


github-actions[bot]: <!-- refined-cf-pages-action:deployment-summary:melt-ui -->

###### built with [Refined Cloudflare Pages Action](https://github.com/AdrianGonz97/refined-cf-pages-action)
## ⚡ Cloudflare Pages Deployment
| Name | Status | Preview | Last Commit |
| :--- | :----- | :------ | :---------- |
| **melt-ui** | 🔨 Building ([View Log](https://github.com/melt-ui/melt-ui/actions/runs/7938388288)) |  | ea64f1e2027f6554c1e7fe07d3cd8b578a2039e5 |


Patch:
diff --git a/.changeset/forty-beers-reply.md b/.changeset/forty-beers-reply.md
new file mode 100644
index 00000000..0aa3e0de
--- /dev/null
+++ b/.changeset/forty-beers-reply.md
@@ -0,0 +1,5 @@
+---
+"@melt-ui/svelte": patch
+---
+
+Dialog: fixed interact outside pointerup/mouseup issue where the dialog would close if you clicked and dragged outside of the dialog before releasing the press (Closes: #750)
diff --git a/.changeset/spotty-ants-kiss.md b/.changeset/spotty-ants-kiss.md
deleted file mode 100644
index 2ce2f903..00000000
--- a/.changeset/spotty-ants-kiss.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@melt-ui/svelte": patch
----
-
-ToggleGroup: Fixes curr & next always bbeing the same value
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ca41b48d..0472ac1b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,15 @@
 # @melt-ui/svelte
 
+## 0.74.1
+
+### Patch Changes
+
+- Date Picker/Date Range Picker: disable trigger when picker is disabled (Closes #815) ([#989](https://github.com/melt-ui/melt-ui/pull/989))
+
+- Toast: return close element `type` attribute type as `"button"` instead of `string` ([#993](https://github.com/melt-ui/melt-ui/pull/993))
+
+- ToggleGroup: Fixes curr & next always bbeing the same value ([#983](https://github.com/melt-ui/melt-ui/pull/983))
+
 ## 0.74.0
 
 ### Minor Changes
diff --git a/package.json b/package.json
index 1e18afed..798f88d5 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
 	"name": "@melt-ui/svelte",
-	"version": "0.74.0",
+	"version": "0.74.1",
 	"license": "MIT",
 	"repository": "github:melt-ui/melt-ui",
 	"author": "Thomas G. Lopes",
diff --git a/src/lib/builders/date-picker/create.ts b/src/lib/builders/date-picker/create.ts
index 9e52e512..3adbfce7 100644
--- a/src/lib/builders/date-picker/create.ts
+++ b/src/lib/builders/date-picker/create.ts
@@ -76,12 +76,13 @@ export function createDatePicker(props?: CreateDatePickerProps) {
 	});
 
 	const trigger = makeElement('popover-trigger', {
-		stores: [popover.elements.trigger],
-		returned: ([$trigger]) => {
+		stores: [popover.elements.trigger, options.disabled],
+		returned: ([$trigger, $disabled]) => {
 			return {
 				...omit($trigger, 'action'),
 				'aria-label': 'Open date picker',
 				'data-segment': 'trigger',
+				disabled: $disabled ? true : undefined,
 			};
 		},
 		action: (node: HTMLElement): MeltActionReturn<DatePickerEvents['trigger']> => {
diff --git a/src/lib/builders/date-range-picker/create.ts b/src/lib/builders/date-range-picker/create.ts
index 6c89c640..5f046587 100644
--- a/src/lib/builders/date-range-picker/create.ts
+++ b/src/lib/builders/date-range-picker/create.ts
@@ -88,12 +88,13 @@ export function createDateRangePicker(props?: CreateDateRangePickerProps) {
 	const placeholder = dateStore(rfPlaceholder, withDefaults.defaultPlaceholder ?? defaultDate);
 
 	const trigger = makeElement('popover-trigger', {
-		stores: [popover.elements.trigger],
-		returned: ([$trigger]) => {
+		stores: [popover.elements.trigger, options.disabled],
+		returned: ([$trigger, $disabled]) => {
 			return {
 				...omit($trigger, 'action'),
 				'aria-label': 'Open date picker',
 				'data-segment': 'trigger',
+				disabled: $disabled ? true : undefined,
 			};
 		},
 		action: (node: HTMLElement): MeltActionReturn<DateRangePickerEvents['trigger']> => {
diff --git a/src/lib/builders/toast/create.ts b/src/lib/builders/toast/create.ts
index cf3b30a0..fd7d104a 100644
--- a/src/lib/builders/toast/create.ts
+++ b/src/lib/builders/toast/create.ts
@@ -192,7 +192,7 @@ export function createToaster<T = object>(props?: CreateToasterProps) {
 	const close = makeElement(name('close'), {
 		returned: () => {
 			return (id: string) => ({
-				type: 'button',
+				type: 'button' as const,
 				'data-id': id,
 			});
 		},
diff --git a/src/lib/internal/actions/interact-outside/action.ts b/src/lib/internal/actions/interact-outside/action.ts
index 9f6f6712..4fcdbb2d 100644
--- a/src/lib/internal/actions/interact-outside/action.ts
+++ b/src/lib/internal/actions/interact-outside/action.ts
@@ -9,19 +9,26 @@ import type { InteractOutsideConfig, InteractOutsideEvent } from './types.js';
 export function useInteractOutside(node: HTMLElement, config: InteractOutsideConfig) {
 	let unsub = noop;
 
+	let isPointerDown = false;
+	let isPointerDownInside = false;
+	let ignoreEmulatedMouseEvents = false;
+
 	function update(config: InteractOutsideConfig) {
 		unsub();
 		const { onInteractOutside, onInteractOutsideStart, enabled } = config;
 
 		if (!enabled) return;
 
-		let isPointerDown = false;
-		let ignoreEmulatedMouseEvents = false;
-
 		function onPointerDown(e: PointerEvent | MouseEvent | TouchEvent) {
 			if (onInteractOutside && isValidEvent(e, node)) {
 				onInteractOutsideStart?.(e);
 			}
+			const target = e.target;
+
+			if (isElement(target) && isOrContainsTarget(node, target)) {
+				isPointerDownInside = true;
+			}
+
 			isPointerDown = true;
 		}
 
@@ -34,10 +41,10 @@ export function useInteractOutside(node: HTMLElement, config: InteractOutsideCon
 		// Use pointer events if available, otherwise use mouse/touch events
 		if (typeof PointerEvent !== 'undefined') {
 			const onPointerUp = (e: PointerEvent) => {
-				if (isPointerDown && isValidEvent(e, node)) {
+				if (shouldTriggerInteractOutside(e)) {
 					triggerInteractOutside(e);
 				}
-				isPointerDown = false;
+				resetPointerState();
 			};
 
 			unsub = executeCallbacks(
@@ -48,18 +55,18 @@ export function useInteractOutside(node: HTMLElement, config: InteractOutsideCon
 			const onMouseUp = (e: MouseEvent) => {
 				if (ignoreEmulatedMouseEvents) {
 					ignoreEmulatedMouseEvents = false;
-				} else if (isPointerDown && isValidEvent(e, node)) {
+				} else if (shouldTriggerInteractOutside(e)) {
 					triggerInteractOutside(e);
 				}
-				isPointerDown = false;
+				resetPointerState();
 			};
 
 			const onTouchEnd = (e: TouchEvent) => {
 				ignoreEmulatedMouseEvents = true;
-				if (isPointerDown && isValidEvent(e, node)) {
+				if (shouldTriggerInteractOutside(e)) {
 					triggerInteractOutside(e);
 				}
-				isPointerDown = false;
+				resetPointerState();
 			};
 
 			unsub = executeCallbacks(
@@ -71,6 +78,18 @@ export function useInteractOutside(node: HTMLElement, config: InteractOutsideCon
 		}
 	}
 
+	function shouldTriggerInteractOutside(e: InteractOutsideEvent) {
+		if (isPointerDown && !isPointerDownInside && isValidEvent(e, node)) {
+			return true;
+		}
+		return false;
+	}
+
+	function resetPointerState() {
+		isPointerDown = false;
+		isPointerDownInside = false;
+	}
+
 	update(config);
 
 	return {
@@ -90,7 +109,11 @@ function isValidEvent(e: InteractOutsideEvent, node: HTMLElement): boolean {
 		return false;
 	}
 
-	return node && !node.contains(target);
+	return node && !isOrContainsTarget(node, target);
+}
+
+function isOrContainsTarget(node: HTMLElement, target: Element) {
+	return node === target || node.contains(target);
 }
 
 function getOwnerDocument(el: Element | null | undefined) {


Test patch:
diff --git a/src/tests/date-picker/DatePicker.spec.ts b/src/tests/date-picker/DatePicker.spec.ts
index 821fea42..2ff9089e 100644
--- a/src/tests/date-picker/DatePicker.spec.ts
+++ b/src/tests/date-picker/DatePicker.spec.ts
@@ -552,4 +552,12 @@ describe('DatePicker', () => {
 		await user.click(outside);
 		await waitFor(() => expect(content).toBeVisible());
 	});
+
+	test('trigger is disabled when `disabled` prop is true', async () => {
+		const { trigger } = setup({
+			disabled: true,
+		});
+
+		expect(trigger).toBeDisabled();
+	});
 });
diff --git a/src/tests/dialog/Dialog.spec.ts b/src/tests/dialog/Dialog.spec.ts
index 2fd27dc6..c9dedae1 100644
--- a/src/tests/dialog/Dialog.spec.ts
+++ b/src/tests/dialog/Dialog.spec.ts
@@ -247,4 +247,34 @@ describe('Dialog', () => {
 		expect(title.id).toBe(ids.title);
 		expect(description.id).toBe(ids.description);
 	});
+
+	it("Doesn't close on pointerup if the previous pointerdown didn't occur inside the dialog", async () => {
+		const { getByTestId, user, trigger } = setup();
+		const overlay = getByTestId('overlay');
+		const content = getByTestId('content');
+
+		expect(content).not.toBeVisible();
+		await user.click(trigger);
+		expect(content).toBeVisible();
+		await sleep(100);
+		expect(overlay).toBeVisible();
+		await user.pointer({ target: content, offset: 2, keys: '[MouseLeft>]' });
+		await user.pointer({ target: overlay, offset: 2, keys: '[/MouseLeft]' });
+		expect(content).toBeVisible();
+	});
+
+	it('Closes on pointerup if the previous pointerdown occurred outside the dialog', async () => {
+		const { getByTestId, user, trigger } = setup();
+		const overlay = getByTestId('overlay');
+		const content = getByTestId('content');
+
+		expect(content).not.toBeVisible();
+		await user.click(trigger);
+		expect(content).toBeVisible();
+		await sleep(100);
+		expect(overlay).toBeVisible();
+		await user.pointer({ target: overlay, offset: 2, keys: '[MouseLeft>]' });
+		await user.pointer({ target: overlay, offset: 2, keys: '[/MouseLeft]' });
+		expect(content).not.toBeVisible();
+	});
 });