Skip to content

Commit f4f8c7a

Browse files
authored
fix(click-outside): don't trigger if mousedown inside target (#13244)
fixes #5886 fixes #9283
1 parent 2e54735 commit f4f8c7a

File tree

4 files changed

+75
-39
lines changed

4 files changed

+75
-39
lines changed

packages/vuetify/src/directives/click-outside/__tests__/click-outside.spec.ts

Lines changed: 34 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import ClickOutside from '../'
33
import { wait } from '../../../../test'
44

55
function bootstrap (args?: object) {
6-
let registeredHandler
76
const el = document.createElement('div')
87

98
const binding = {
@@ -13,8 +12,11 @@ function bootstrap (args?: object) {
1312
},
1413
}
1514

15+
let clickHandler
16+
let mousedownHandler
1617
jest.spyOn(window.document.body, 'addEventListener').mockImplementation((eventName, eventHandler, options) => {
17-
registeredHandler = eventHandler
18+
if (eventName === 'click') clickHandler = eventHandler
19+
if (eventName === 'mousedown') mousedownHandler = eventHandler
1820
})
1921
jest.spyOn(window.document.body, 'removeEventListener')
2022

@@ -23,72 +25,84 @@ function bootstrap (args?: object) {
2325
return {
2426
callback: binding.value.handler,
2527
el: el as HTMLElement,
26-
registeredHandler,
28+
clickHandler,
29+
mousedownHandler,
2730
}
2831
}
2932

30-
describe('click-outside.js', () => {
33+
describe('click-outside', () => {
3134
it('should register and unregister handler', () => {
32-
const { registeredHandler, el } = bootstrap()
33-
expect(window.document.body.addEventListener).toHaveBeenCalledWith('click', registeredHandler, true)
35+
const { clickHandler, el } = bootstrap()
36+
expect(window.document.body.addEventListener).toHaveBeenCalledWith('click', clickHandler, true)
3437

3538
ClickOutside.unbind(el)
36-
expect(window.document.body.removeEventListener).toHaveBeenCalledWith('click', registeredHandler, true)
39+
expect(window.document.body.removeEventListener).toHaveBeenCalledWith('click', clickHandler, true)
3740
})
3841

3942
it('should call the callback when closeConditional returns true', async () => {
40-
const { registeredHandler, callback } = bootstrap({ closeConditional: () => true })
43+
const { clickHandler, callback } = bootstrap({ closeConditional: () => true })
4144
const event = { target: document.createElement('div') }
4245

43-
registeredHandler(event)
46+
clickHandler(event)
4447
await wait()
4548
expect(callback).toHaveBeenCalledWith(event)
4649
})
4750

4851
it('should not call the callback when closeConditional returns false', async () => {
49-
const { registeredHandler, callback, el } = bootstrap({ closeConditional: () => false })
52+
const { clickHandler, callback, el } = bootstrap({ closeConditional: () => false })
5053

51-
registeredHandler({ target: el })
54+
clickHandler({ target: el })
5255
await wait()
5356
expect(callback).not.toHaveBeenCalled()
5457
})
5558

5659
it('should not call the callback when closeConditional is not provided', async () => {
57-
const { registeredHandler, callback, el } = bootstrap()
60+
const { clickHandler, callback, el } = bootstrap()
5861

59-
registeredHandler({ target: el })
62+
clickHandler({ target: el })
6063
await wait()
6164
expect(callback).not.toHaveBeenCalled()
6265
})
6366

6467
it('should not call the callback when clicked in element', async () => {
65-
const { registeredHandler, callback, el } = bootstrap({ closeConditional: () => true })
68+
const { clickHandler, callback, el } = bootstrap({ closeConditional: () => true })
6669

67-
registeredHandler({ target: el })
70+
clickHandler({ target: el })
6871
await wait()
6972
expect(callback).not.toHaveBeenCalledWith()
7073
})
7174

7275
it('should not call the callback when clicked in elements', async () => {
73-
const { registeredHandler, callback, el } = bootstrap({
76+
const { clickHandler, callback, el } = bootstrap({
7477
closeConditional: () => true,
7578
include: () => [el],
7679
})
7780

78-
registeredHandler({ target: document.createElement('div') })
81+
clickHandler({ target: document.createElement('div') })
7982
await wait()
8083
expect(callback).not.toHaveBeenCalledWith()
8184
})
8285

8386
it('should not call the callback when event is not fired by user action', async () => {
84-
const { registeredHandler, callback } = bootstrap({ closeConditional: () => true })
87+
const { clickHandler, callback } = bootstrap({ closeConditional: () => true })
8588

86-
registeredHandler({ isTrusted: false })
89+
clickHandler({ isTrusted: false })
8790
await wait()
8891
expect(callback).not.toHaveBeenCalledWith()
8992

90-
registeredHandler({ pointerType: false })
93+
clickHandler({ pointerType: false })
9194
await wait()
9295
expect(callback).not.toHaveBeenCalledWith()
9396
})
97+
98+
it('should not call the callback when mousedown was on the element', async () => {
99+
const { clickHandler, mousedownHandler, callback, el } = bootstrap({ closeConditional: () => true })
100+
const mousedownEvent = { target: el }
101+
const clickEvent = { target: document.createElement('div') }
102+
103+
mousedownHandler(mousedownEvent)
104+
clickHandler(clickEvent)
105+
await wait()
106+
expect(callback).not.toHaveBeenCalledWith(clickEvent)
107+
})
94108
})

packages/vuetify/src/directives/click-outside/index.ts

Lines changed: 31 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,12 @@ function defaultConditional () {
1414
return true
1515
}
1616

17-
function directive (e: PointerEvent, el: HTMLElement, binding: ClickOutsideDirective): void {
18-
const handler = typeof binding.value === 'function' ? binding.value : binding.value!.handler
19-
20-
const isActive = (typeof binding.value === 'object' && binding.value.closeConditional) || defaultConditional
21-
17+
function checkEvent (e: PointerEvent, el: HTMLElement, binding: ClickOutsideDirective): boolean {
2218
// The include element callbacks below can be expensive
2319
// so we should avoid calling them when we're not active.
2420
// Explicitly check for false to allow fallback compatibility
2521
// with non-toggleable components
26-
if (!e || isActive(e) === false) return
22+
if (!e || checkIsActive(e, binding) === false) return false
2723

2824
// Check if additional elements were passed to be included in check
2925
// (click must be outside all included elements, if any)
@@ -36,8 +32,20 @@ function directive (e: PointerEvent, el: HTMLElement, binding: ClickOutsideDirec
3632
// Toggleable can return true if it wants to deactivate.
3733
// Note that, because we're in the capture phase, this callback will occur before
3834
// the bubbling click event on any outside elements.
39-
!elements.some(el => el.contains(e.target as Node)) && setTimeout(() => {
40-
isActive(e) && handler && handler(e)
35+
return !elements.some(el => el.contains(e.target as Node))
36+
}
37+
38+
function checkIsActive (e: PointerEvent, binding: ClickOutsideDirective): boolean | void {
39+
const isActive = (typeof binding.value === 'object' && binding.value.closeConditional) || defaultConditional
40+
41+
return isActive(e)
42+
}
43+
44+
function directive (e: PointerEvent, el: HTMLElement, binding: ClickOutsideDirective) {
45+
const handler = typeof binding.value === 'function' ? binding.value : binding.value!.handler
46+
47+
el._clickOutside!.lastMousedownWasOutside && checkEvent(e, el, binding) && setTimeout(() => {
48+
checkIsActive(e, binding) && handler && handler(e)
4149
}, 0)
4250
}
4351

@@ -49,21 +57,26 @@ export const ClickOutside = {
4957
// clicks on body
5058
inserted (el: HTMLElement, binding: ClickOutsideDirective) {
5159
const onClick = (e: Event) => directive(e as PointerEvent, el, binding)
52-
// iOS does not recognize click events on document
53-
// or body, this is the entire purpose of the v-app
54-
// component and [data-app], stop removing this
55-
const app = document.querySelector('[data-app]') ||
56-
document.body // This is only for unit tests
57-
app.addEventListener('click', onClick, true)
58-
el._clickOutside = onClick
60+
const onMousedown = (e: Event) => {
61+
el._clickOutside!.lastMousedownWasOutside = checkEvent(e as PointerEvent, el, binding)
62+
}
63+
64+
document.body.addEventListener('click', onClick, true)
65+
document.body.addEventListener('mousedown', onMousedown, true)
66+
67+
el._clickOutside = {
68+
lastMousedownWasOutside: true,
69+
onClick,
70+
onMousedown,
71+
}
5972
},
6073

6174
unbind (el: HTMLElement) {
6275
if (!el._clickOutside) return
6376

64-
const app = document.querySelector('[data-app]') ||
65-
document.body // This is only for unit tests
66-
app && app.removeEventListener('click', el._clickOutside, true)
77+
document.body.removeEventListener('click', el._clickOutside.onClick, true)
78+
document.body.removeEventListener('mousedown', el._clickOutside.onMousedown, true)
79+
6780
delete el._clickOutside
6881
},
6982
}

packages/vuetify/src/globals.d.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,11 @@ declare global {
2929
}
3030

3131
interface HTMLElement {
32-
_clickOutside?: EventListenerOrEventListenerObject
32+
_clickOutside?: {
33+
lastMousedownWasOutside: boolean
34+
onClick: EventListener
35+
onMousedown: EventListener
36+
}
3337
_onResize?: {
3438
callback: () => void
3539
options?: boolean | AddEventListenerOptions

packages/vuetify/src/styles/elements/_global.sass

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,8 @@ html.overflow-y-hidden
1616
::-ms-clear,
1717
::-ms-reveal
1818
display: none
19+
20+
// iOS Safari hack to allow click events on body
21+
@supports (-webkit-touch-callout: none)
22+
body
23+
cursor: pointer

0 commit comments

Comments
 (0)