Skip to content

Commit

Permalink
Adjust outside click handling
Browse files Browse the repository at this point in the history
- Don’t close dialog if opened during mouse up event
- Don’t close dialog if drag starts inside dialog and ends outside dialog
  • Loading branch information
thecrypticace committed Jul 12, 2022
1 parent a294fdb commit f097a8d
Show file tree
Hide file tree
Showing 6 changed files with 353 additions and 5 deletions.
77 changes: 76 additions & 1 deletion packages/@headlessui-react/src/components/dialog/dialog.test.tsx
Expand Up @@ -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'
Expand Down Expand Up @@ -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 (
<>
<button id="trigger" onMouseUpCapture={() => setIsOpen((v) => !v)}>
Trigger
</button>
<Dialog open={isOpen} onClose={setIsOpen}>
<Dialog.Backdrop />
<Dialog.Panel>
<button id="inside">Inside</button>
<TabSentinel />
</Dialog.Panel>
</Dialog>
</>
)
}

render(<Example />)

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 (
<>
<button id="trigger" onClick={() => setIsOpen((v) => !v)}>
Trigger
</button>
<div id="imoutside">this thing</div>
<Dialog open={isOpen} onClose={setIsOpen}>
<Dialog.Backdrop />
<Dialog.Panel>
<button id="inside">Inside</button>
<TabSentinel />
</Dialog.Panel>
</Dialog>
</>
)
}

render(<Example />)

// 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', () => {
Expand Down
24 changes: 23 additions & 1 deletion packages/@headlessui-react/src/hooks/use-outside-click.ts
Expand Up @@ -90,9 +90,31 @@ export function useOutsideClick(
return cb(event, target)
}

let initialClickTarget = useRef<EventTarget | null>(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`
Expand Down
68 changes: 68 additions & 0 deletions packages/@headlessui-react/src/test-utils/interactions.ts
Expand Up @@ -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<KeyboardEvent>) {
Expand Down
95 changes: 94 additions & 1 deletion packages/@headlessui-vue/src/components/dialog/dialog.test.ts
Expand Up @@ -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
Expand Down Expand Up @@ -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: `
<div>
<button id="trigger" @mouseup.capture="toggleOpen">
Trigger
</button>
<Dialog :open="isOpen" @close="setIsOpen">
<DialogBackdrop />
<DialogPanel>
<button id="inside">Inside</button>
<TabSentinel />
</DialogPanel>
</Dialog>
</div>
`,
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: `
<div>
<button id="trigger" @click="toggleOpen">
Trigger
</button>
<div id="imoutside">this thing</div>
<Dialog :open="isOpen" @close="setIsOpen">
<DialogBackdrop />
<DialogPanel>
<button id="inside">Inside</button>
<TabSentinel />
</DialogPanel>
</Dialog>
</div>
`,
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', () => {
Expand Down
26 changes: 24 additions & 2 deletions 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'

Expand Down Expand Up @@ -76,9 +76,31 @@ export function useOutsideClick(
return cb(event, target)
}

let initialClickTarget = ref<EventTarget | null>(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`
Expand Down

0 comments on commit f097a8d

Please sign in to comment.