diff --git a/packages/@headlessui-react/src/components/dialog/dialog.test.tsx b/packages/@headlessui-react/src/components/dialog/dialog.test.tsx
index 21971b8409..f660254c31 100644
--- a/packages/@headlessui-react/src/components/dialog/dialog.test.tsx
+++ b/packages/@headlessui-react/src/components/dialog/dialog.test.tsx
@@ -17,7 +17,7 @@ import {
getDialogs,
getDialogOverlays,
} from '../../test-utils/accessibility-assertions'
-import { click, press, Keys } from '../../test-utils/interactions'
+import { click, mouseDrag, press, Keys } from '../../test-utils/interactions'
import { PropsOf } from '../../types'
import { Transition } from '../transitions/transition'
import { createPortal } from 'react-dom'
@@ -1066,6 +1066,81 @@ describe('Mouse interactions', () => {
assertDialog({ state: DialogState.Visible })
})
)
+
+ it(
+ 'should not close the dialog if opened during mouse up',
+ suppressConsoleLogs(async () => {
+ function Example() {
+ let [isOpen, setIsOpen] = useState(false)
+ return (
+ <>
+
+
+ >
+ )
+ }
+
+ render()
+
+ await click(document.getElementById('trigger'))
+
+ assertDialog({ state: DialogState.Visible })
+
+ await click(document.getElementById('inside'))
+
+ assertDialog({ state: DialogState.Visible })
+ })
+ )
+
+ it(
+ 'should not close the dialog if click starts inside the dialog but ends outside',
+ suppressConsoleLogs(async () => {
+ function Example() {
+ let [isOpen, setIsOpen] = useState(false)
+ return (
+ <>
+
+
this thing
+
+ >
+ )
+ }
+
+ render()
+
+ // Open the dialog
+ await click(document.getElementById('trigger'))
+
+ assertDialog({ state: DialogState.Visible })
+
+ // Start a click inside the dialog and end it outside
+ await mouseDrag(document.getElementById('inside'), document.getElementById('imoutside'))
+
+ // It should not have hidden
+ assertDialog({ state: DialogState.Visible })
+
+ await click(document.getElementById('imoutside'))
+
+ // It's gone
+ assertDialog({ state: DialogState.InvisibleUnmounted })
+ })
+ )
})
describe('Nesting', () => {
diff --git a/packages/@headlessui-react/src/hooks/use-outside-click.ts b/packages/@headlessui-react/src/hooks/use-outside-click.ts
index d358023afb..30c5e8d9f1 100644
--- a/packages/@headlessui-react/src/hooks/use-outside-click.ts
+++ b/packages/@headlessui-react/src/hooks/use-outside-click.ts
@@ -90,9 +90,31 @@ export function useOutsideClick(
return cb(event, target)
}
+ let initialClickTarget = useRef(null)
+
+ useWindowEvent(
+ 'mousedown',
+ (event) => {
+ if (enabledRef.current) {
+ initialClickTarget.current = event.target
+ }
+ },
+ true
+ )
+
useWindowEvent(
'click',
- (event) => handleOutsideClick(event, (event) => event.target as HTMLElement),
+ (event) => {
+ if (!initialClickTarget.current) {
+ return
+ }
+
+ handleOutsideClick(event, () => {
+ return initialClickTarget.current as HTMLElement
+ })
+
+ initialClickTarget.current = null
+ },
// We will use the `capture` phase so that layers in between with `event.stopPropagation()`
// don't "cancel" this outside click check. E.g.: A `Menu` inside a `DialogPanel` if the `Menu`
diff --git a/packages/@headlessui-react/src/test-utils/interactions.ts b/packages/@headlessui-react/src/test-utils/interactions.ts
index 2b7976dae7..2a71b96560 100644
--- a/packages/@headlessui-react/src/test-utils/interactions.ts
+++ b/packages/@headlessui-react/src/test-utils/interactions.ts
@@ -344,6 +344,74 @@ export async function mouseLeave(element: Document | Element | Window | null) {
}
}
+export async function mouseDrag(
+ startingElement: Document | Element | Window | Node | null,
+ endingElement: Document | Element | Window | Node | null
+) {
+ let button = MouseButton.Left
+
+ try {
+ if (startingElement === null) return expect(startingElement).not.toBe(null)
+ if (endingElement === null) return expect(endingElement).not.toBe(null)
+ if (startingElement instanceof HTMLButtonElement && startingElement.disabled) return
+
+ let options = { button }
+
+ // Cancel in pointerDown cancels mouseDown, mouseUp
+ let cancelled = !fireEvent.pointerDown(startingElement, options)
+
+ if (!cancelled) {
+ cancelled = !fireEvent.mouseDown(startingElement, options)
+ }
+
+ // Ensure to trigger a `focus` event if the element is focusable, or within a focusable element
+ if (!cancelled) {
+ let next: HTMLElement | null = startingElement as HTMLElement | null
+ while (next !== null) {
+ if (next.matches(focusableSelector)) {
+ next.focus()
+ break
+ }
+ next = next.parentElement
+ }
+ }
+
+ fireEvent.pointerMove(startingElement, options)
+ if (!cancelled) {
+ fireEvent.mouseMove(startingElement, options)
+ }
+
+ fireEvent.pointerOut(startingElement, options)
+ if (!cancelled) {
+ fireEvent.mouseOut(startingElement, options)
+ }
+
+ // crosses over to the ending element
+
+ fireEvent.pointerOver(endingElement, options)
+ if (!cancelled) {
+ fireEvent.mouseOver(endingElement, options)
+ }
+
+ fireEvent.pointerMove(endingElement, options)
+ if (!cancelled) {
+ fireEvent.mouseMove(endingElement, options)
+ }
+
+ fireEvent.pointerUp(endingElement, options)
+ if (!cancelled) {
+ fireEvent.mouseUp(endingElement, options)
+ }
+
+ fireEvent.click(endingElement, options)
+
+ await new Promise(nextFrame)
+ } catch (err) {
+ if (err instanceof Error) Error.captureStackTrace(err, click)
+ throw err
+ }
+}
+
// ---
function focusNext(event: Partial) {
diff --git a/packages/@headlessui-vue/src/components/dialog/dialog.test.ts b/packages/@headlessui-vue/src/components/dialog/dialog.test.ts
index c5d9b3776a..1d5266626c 100644
--- a/packages/@headlessui-vue/src/components/dialog/dialog.test.ts
+++ b/packages/@headlessui-vue/src/components/dialog/dialog.test.ts
@@ -25,7 +25,7 @@ import {
getDialogs,
getDialogOverlays,
} from '../../test-utils/accessibility-assertions'
-import { click, press, Keys } from '../../test-utils/interactions'
+import { click, mouseDrag, press, Keys } from '../../test-utils/interactions'
import { html } from '../../test-utils/html'
// @ts-expect-error
@@ -1444,6 +1444,99 @@ describe('Mouse interactions', () => {
assertDialog({ state: DialogState.Visible })
})
)
+
+ fit(
+ 'should not close the dialog if opened during mouse up',
+ suppressConsoleLogs(async () => {
+ renderTemplate({
+ template: `
+
+
+
+
+ `,
+ setup() {
+ let isOpen = ref(false)
+ return {
+ isOpen,
+ setIsOpen(value: boolean) {
+ isOpen.value = value
+ },
+ toggleOpen() {
+ isOpen.value = !isOpen.value
+ },
+ }
+ },
+ })
+
+ await click(document.getElementById('trigger'))
+
+ assertDialog({ state: DialogState.Visible })
+
+ await click(document.getElementById('inside'))
+
+ assertDialog({ state: DialogState.Visible })
+ })
+ )
+
+ it(
+ 'should not close the dialog if click starts inside the dialog but ends outside',
+ suppressConsoleLogs(async () => {
+ renderTemplate({
+ template: `
+
+
+
this thing
+
+
+ `,
+ setup() {
+ let isOpen = ref(false)
+ return {
+ isOpen,
+ setIsOpen(value: boolean) {
+ isOpen.value = value
+ },
+ toggleOpen() {
+ isOpen.value = !isOpen.value
+ },
+ }
+ },
+ })
+
+ // Open the dialog
+ await click(document.getElementById('trigger'))
+
+ assertDialog({ state: DialogState.Visible })
+
+ // Start a click inside the dialog and end it outside
+ await mouseDrag(document.getElementById('inside'), document.getElementById('imoutside'))
+
+ // It should not have hidden
+ assertDialog({ state: DialogState.Visible })
+
+ await click(document.getElementById('imoutside'))
+
+ // It's gone
+ assertDialog({ state: DialogState.InvisibleUnmounted })
+ })
+ )
})
describe('Nesting', () => {
diff --git a/packages/@headlessui-vue/src/hooks/use-outside-click.ts b/packages/@headlessui-vue/src/hooks/use-outside-click.ts
index 83d8e652b5..a0c412673c 100644
--- a/packages/@headlessui-vue/src/hooks/use-outside-click.ts
+++ b/packages/@headlessui-vue/src/hooks/use-outside-click.ts
@@ -1,5 +1,5 @@
import { useWindowEvent } from './use-window-event'
-import { computed, Ref, ComputedRef } from 'vue'
+import { computed, Ref, ComputedRef, ref } from 'vue'
import { FocusableMode, isFocusableElement } from '../utils/focus-management'
import { dom } from '../utils/dom'
@@ -76,9 +76,31 @@ export function useOutsideClick(
return cb(event, target)
}
+ let initialClickTarget = ref(null)
+
+ useWindowEvent(
+ 'mousedown',
+ (event) => {
+ if (enabled.value) {
+ initialClickTarget.value = event.target
+ }
+ },
+ true
+ )
+
useWindowEvent(
'click',
- (event) => handleOutsideClick(event, (event) => event.target as HTMLElement),
+ (event) => {
+ if (!initialClickTarget.value) {
+ return
+ }
+
+ handleOutsideClick(event, (event) => {
+ return event.target as HTMLElement
+ })
+
+ initialClickTarget.value = null
+ },
// We will use the `capture` phase so that layers in between with `event.stopPropagation()`
// don't "cancel" this outside click check. E.g.: A `Menu` inside a `DialogPanel` if the `Menu`
diff --git a/packages/@headlessui-vue/src/test-utils/interactions.ts b/packages/@headlessui-vue/src/test-utils/interactions.ts
index 4f99cb731d..01c7a3d7a1 100644
--- a/packages/@headlessui-vue/src/test-utils/interactions.ts
+++ b/packages/@headlessui-vue/src/test-utils/interactions.ts
@@ -338,6 +338,74 @@ export async function mouseLeave(element: Document | Element | Window | null) {
}
}
+export async function mouseDrag(
+ startingElement: Document | Element | Window | Node | null,
+ endingElement: Document | Element | Window | Node | null
+) {
+ let button = MouseButton.Left
+
+ try {
+ if (startingElement === null) return expect(startingElement).not.toBe(null)
+ if (endingElement === null) return expect(endingElement).not.toBe(null)
+ if (startingElement instanceof HTMLButtonElement && startingElement.disabled) return
+
+ let options = { button }
+
+ // Cancel in pointerDown cancels mouseDown, mouseUp
+ let cancelled = !fireEvent.pointerDown(startingElement, options)
+
+ if (!cancelled) {
+ cancelled = !fireEvent.mouseDown(startingElement, options)
+ }
+
+ // Ensure to trigger a `focus` event if the element is focusable, or within a focusable element
+ if (!cancelled) {
+ let next: HTMLElement | null = startingElement as HTMLElement | null
+ while (next !== null) {
+ if (next.matches(focusableSelector)) {
+ next.focus()
+ break
+ }
+ next = next.parentElement
+ }
+ }
+
+ fireEvent.pointerMove(startingElement, options)
+ if (!cancelled) {
+ fireEvent.mouseMove(startingElement, options)
+ }
+
+ fireEvent.pointerOut(startingElement, options)
+ if (!cancelled) {
+ fireEvent.mouseOut(startingElement, options)
+ }
+
+ // crosses over to the ending element
+
+ fireEvent.pointerOver(endingElement, options)
+ if (!cancelled) {
+ fireEvent.mouseOver(endingElement, options)
+ }
+
+ fireEvent.pointerMove(endingElement, options)
+ if (!cancelled) {
+ fireEvent.mouseMove(endingElement, options)
+ }
+
+ fireEvent.pointerUp(endingElement, options)
+ if (!cancelled) {
+ fireEvent.mouseUp(endingElement, options)
+ }
+
+ fireEvent.click(endingElement, options)
+
+ await new Promise(nextFrame)
+ } catch (err) {
+ if (err instanceof Error) Error.captureStackTrace(err, click)
+ throw err
+ }
+}
+
// ---
function focusNext(event: Partial) {