ConvoyPanel/resources/scripts/util/useOutsideClick.ts
2022-11-21 06:16:27 +00:00

143 lines
No EOL
4.7 KiB
TypeScript

import { MutableRefObject, useEffect, useRef } from 'react'
import { FocusableMode, isFocusableElement } from './focusManagement'
import { useDocumentEvent } from './useDocumentEvent'
type Container = MutableRefObject<HTMLElement | null> | HTMLElement | null
type ContainerCollection = Container[] | Set<Container>
type ContainerInput = Container | ContainerCollection
export function useOutsideClick(
containers: ContainerInput | (() => ContainerInput),
cb: (event: MouseEvent | PointerEvent | FocusEvent, target: HTMLElement) => void,
enabled: boolean = true
) {
// TODO: remove this once the React bug has been fixed: https://github.com/facebook/react/issues/24657
let enabledRef = useRef(false)
useEffect(
process.env.NODE_ENV === 'test'
? () => {
enabledRef.current = enabled
}
: () => {
requestAnimationFrame(() => {
enabledRef.current = enabled
})
},
[enabled]
)
function handleOutsideClick<E extends MouseEvent | PointerEvent | FocusEvent>(
event: E,
resolveTarget: (event: E) => HTMLElement | null
) {
if (!enabledRef.current) return
// Check whether the event got prevented already. This can happen if you use the
// useOutsideClick hook in both a Dialog and a Menu and the inner Menu "cancels" the default
// behaviour so that only the Menu closes and not the Dialog (yet)
if (event.defaultPrevented) return
let _containers = (function resolve(containers): ContainerCollection {
if (typeof containers === 'function') {
return resolve(containers())
}
if (Array.isArray(containers)) {
return containers
}
if (containers instanceof Set) {
return containers
}
return [containers]
})(containers)
let target = resolveTarget(event)
if (target === null) {
return
}
// Ignore if the target doesn't exist in the DOM anymore
if (!target.getRootNode().contains(target)) return
// Ignore if the target exists in one of the containers
for (let container of _containers) {
if (container === null) continue
let domNode = container instanceof HTMLElement ? container : container.current
if (domNode?.contains(target)) {
return
}
}
// This allows us to check whether the event was defaultPrevented when you are nesting this
// inside a `<Dialog />` for example.
if (
// This check alllows us to know whether or not we clicked on a "focusable" element like a
// button or an input. This is a backwards compatibility check so that you can open a <Menu
// /> and click on another <Menu /> which should close Menu A and open Menu B. We might
// revisit that so that you will require 2 clicks instead.
!isFocusableElement(target, FocusableMode.Loose) &&
// This could be improved, but the `Combobox.Button` adds tabIndex={-1} to make it
// unfocusable via the keyboard so that tabbing to the next item from the input doesn't
// first go to the button.
target.tabIndex !== -1
) {
event.preventDefault()
}
return cb(event, target)
}
let initialClickTarget = useRef<EventTarget | null>(null)
useDocumentEvent(
'mousedown',
(event) => {
if (enabledRef.current) {
initialClickTarget.current = event.composedPath?.()?.[0] || event.target
}
},
true
)
useDocumentEvent(
'click',
(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`
// is open, and you click outside of it in the `DialogPanel` the `Menu` should close. However,
// the `DialogPanel` has a `onClick(e) { e.stopPropagation() }` which would cancel this.
true
)
// When content inside an iframe is clicked `window` will receive a blur event
// This can happen when an iframe _inside_ a window is clicked
// Or, if headless UI is *in* the iframe, when a content in a window containing that iframe is clicked
// In this case we care only about the first case so we check to see if the active element is the iframe
// If so this was because of a click, focus, or other interaction with the child iframe
// and we can consider it an "outside click"
useDocumentEvent(
'blur',
(event) =>
handleOutsideClick(event, () =>
window.document.activeElement instanceof HTMLIFrameElement
? window.document.activeElement
: null
),
true
)
}