Skip to content

Commit b724f4c

Browse files
committed
fix(vue): handle outsideClick touchend event on SVG elements
Closes: tailwindlabs#3752 Reuses code from: tailwindlabs#3704 Right now `touchend` only checks if target is HTMLElement, but this check will fail for some other elements (like SVG elements). This change brings dom utils from tailwindlabs#3704 to Vue to properly resolve target.
1 parent 2de2779 commit b724f4c

File tree

3 files changed

+45
-7
lines changed

3 files changed

+45
-7
lines changed

packages/@headlessui-vue/src/hooks/use-outside-click.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { computed, ref, type ComputedRef, type Ref } from 'vue'
2-
import { dom } from '../utils/dom'
2+
import { dom, isHTMLIframeElement, isHTMLorSVGElement } from '../utils/dom'
33
import { FocusableMode, isFocusableElement } from '../utils/focus-management'
44
import { isMobile } from '../utils/platform'
55
import { useDocumentEvent } from './use-document-event'
@@ -19,12 +19,15 @@ const MOVE_THRESHOLD_PX = 30
1919

2020
export function useOutsideClick(
2121
containers: ContainerInput | (() => ContainerInput),
22-
cb: (event: MouseEvent | PointerEvent | FocusEvent | TouchEvent, target: HTMLElement) => void,
22+
cb: (
23+
event: MouseEvent | PointerEvent | FocusEvent | TouchEvent,
24+
target: HTMLOrSVGElement & Element
25+
) => void,
2326
enabled: ComputedRef<boolean> = computed(() => true)
2427
) {
2528
function handleOutsideClick<E extends MouseEvent | PointerEvent | FocusEvent | TouchEvent>(
2629
event: E,
27-
resolveTarget: (event: E) => HTMLElement | null
30+
resolveTarget: (event: E) => (HTMLOrSVGElement & Element) | null
2831
) {
2932
// Check whether the event got prevented already. This can happen if you use the
3033
// useOutsideClick hook in both a Dialog and a Menu and the inner Menu "cancels" the default
@@ -162,7 +165,7 @@ export function useOutsideClick(
162165
}
163166

164167
return handleOutsideClick(event, () => {
165-
if (event.target instanceof HTMLElement) {
168+
if (isHTMLorSVGElement(event.target)) {
166169
return event.target
167170
}
168171
return null
@@ -188,7 +191,7 @@ export function useOutsideClick(
188191
'blur',
189192
(event) => {
190193
return handleOutsideClick(event, () => {
191-
return window.document.activeElement instanceof HTMLIFrameElement
194+
return isHTMLIframeElement(window.document.activeElement)
192195
? window.document.activeElement
193196
: null
194197
})

packages/@headlessui-vue/src/utils/dom.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,38 @@ export function dom<T extends HTMLElement | ComponentPublicInstance>(
2121

2222
return null
2323
}
24+
25+
// Source: https://github.com/tailwindlabs/headlessui/blob/2de2779a1e3a5a02c1684e611daf5a68e8002143/packages/%40headlessui-react/src/utils/dom.ts
26+
// Normally you can use `element instanceof HTMLElement`, but if you are in
27+
// different JS Context (e.g.: inside an iframe) then the `HTMLElement` will be
28+
// a different class and the check will fail.
29+
//
30+
// Instead, we will check for certain properties to determine if the element
31+
// is of a specific type.
32+
33+
export function isNode(element: unknown): element is Node {
34+
if (typeof element !== 'object') return false
35+
if (element === null) return false
36+
return 'nodeType' in element
37+
}
38+
39+
export function isElement(element: unknown): element is Element {
40+
return isNode(element) && 'tagName' in element
41+
}
42+
43+
export function isHTMLElement(element: unknown): element is HTMLElement {
44+
return isElement(element) && 'accessKey' in element
45+
}
46+
47+
// HTMLOrSVGElement doesn't inherit from HTMLElement or from Element. But this
48+
// is the type that contains the `tabIndex` property.
49+
//
50+
// Once we know that this is an `HTMLOrSVGElement` we also know that it is an
51+
// `Element` (that contains more information)
52+
export function isHTMLorSVGElement(element: unknown): element is HTMLOrSVGElement & Element {
53+
return isElement(element) && 'tabIndex' in element
54+
}
55+
56+
export function isHTMLIframeElement(element: unknown): element is HTMLIFrameElement {
57+
return isHTMLElement(element) && element.nodeName === 'IFRAME'
58+
}

packages/@headlessui-vue/src/utils/focus-management.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ export enum FocusableMode {
7575
}
7676

7777
export function isFocusableElement(
78-
element: HTMLElement,
78+
element: HTMLOrSVGElement & Element,
7979
mode: FocusableMode = FocusableMode.Strict
8080
) {
8181
if (element === getOwnerDocument(element)?.body) return false
@@ -85,7 +85,7 @@ export function isFocusableElement(
8585
return element.matches(focusableSelector)
8686
},
8787
[FocusableMode.Loose]() {
88-
let next: HTMLElement | null = element
88+
let next: Element | null = element
8989

9090
while (next !== null) {
9191
if (next.matches(focusableSelector)) return true

0 commit comments

Comments
 (0)