UNPKG

reka-ui

Version:

Vue port for Radix UI Primitives.

205 lines (178 loc) 6.67 kB
import type { MaybeRefOrGetter, Ref } from 'vue' import { isClient } from '@vueuse/shared' import { nextTick, ref, toValue, watchEffect } from 'vue' import { handleAndDispatchCustomEvent } from '@/shared' export type PointerDownOutsideEvent = CustomEvent<{ originalEvent: PointerEvent }> export type FocusOutsideEvent = CustomEvent<{ originalEvent: FocusEvent }> export const DISMISSABLE_LAYER_NAME = 'DismissableLayer' export const CONTEXT_UPDATE = 'dismissableLayer.update' export const POINTER_DOWN_OUTSIDE = 'dismissableLayer.pointerDownOutside' export const FOCUS_OUTSIDE = 'dismissableLayer.focusOutside' function isLayerExist(layerElement: HTMLElement, targetElement: HTMLElement) { const targetLayer = targetElement.closest( '[data-dismissable-layer]', ) const mainLayer = layerElement.dataset.dismissableLayer === '' ? layerElement : layerElement.querySelector( '[data-dismissable-layer]', ) as HTMLElement const nodeList = Array.from( layerElement.ownerDocument.querySelectorAll('[data-dismissable-layer]'), ) if (targetLayer && (mainLayer === targetLayer || nodeList.indexOf(mainLayer) < nodeList.indexOf(targetLayer)) ) { return true } else { return false } } /** * Listens for `pointerdown` outside a DOM subtree. We use `pointerdown` rather than `pointerup` * to mimic layer dismissing behaviour present in OS. * Returns props to pass to the node we want to check for outside events. */ export function usePointerDownOutside( onPointerDownOutside?: (event: PointerDownOutsideEvent) => void, element?: Ref<HTMLElement | undefined>, enabled: MaybeRefOrGetter<boolean> = true, ) { const ownerDocument: Document = element?.value?.ownerDocument ?? globalThis?.document const isPointerInsideDOMTree = ref(false) const handleClickRef = ref(() => {}) watchEffect((cleanupFn) => { if (!isClient || !toValue(enabled)) return const handlePointerDown = async (event: PointerEvent) => { const target = event.target as HTMLElement | undefined if (!element?.value || !target) return if (isLayerExist(element.value, target)) { isPointerInsideDOMTree.value = false return } if (event.target && !isPointerInsideDOMTree.value) { const eventDetail = { originalEvent: event } function handleAndDispatchPointerDownOutsideEvent() { handleAndDispatchCustomEvent( POINTER_DOWN_OUTSIDE, onPointerDownOutside, eventDetail, ) } /** * On touch devices, we need to wait for a click event because browsers implement * a ~350ms delay between the time the user stops touching the display and when the * browser executes events. We need to ensure we don't reactivate pointer-events within * this timeframe otherwise the browser may execute events that should have been prevented. * * Additionally, this also lets us deal automatically with cancellations when a click event * isn't raised because the page was considered scrolled/drag-scrolled, long-pressed, etc. * * This is why we also continuously remove the previous listener, because we cannot be * certain that it was raised, and therefore cleaned-up. */ if (event.pointerType === 'touch') { ownerDocument.removeEventListener('click', handleClickRef.value) handleClickRef.value = handleAndDispatchPointerDownOutsideEvent ownerDocument.addEventListener('click', handleClickRef.value, { once: true, }) } else { handleAndDispatchPointerDownOutsideEvent() } } else { // We need to remove the event listener in case the outside click has been canceled. // See: https://github.com/radix-ui/primitives/issues/2171 ownerDocument.removeEventListener('click', handleClickRef.value) } isPointerInsideDOMTree.value = false } /** * if this hook executes in a component that mounts via a `pointerdown` event, the event * would bubble up to the document and trigger a `pointerDownOutside` event. We avoid * this by delaying the event listener registration on the document. * This is how the DOM works, ie: * ``` * button.addEventListener('pointerdown', () => { * console.log('I will log'); * document.addEventListener('pointerdown', () => { * console.log('I will also log'); * }) * }); */ const timerId = window.setTimeout(() => { ownerDocument.addEventListener('pointerdown', handlePointerDown) }, 0) cleanupFn(() => { window.clearTimeout(timerId) ownerDocument.removeEventListener('pointerdown', handlePointerDown) ownerDocument.removeEventListener('click', handleClickRef.value) }) }) return { onPointerDownCapture: () => { if (!toValue(enabled)) return isPointerInsideDOMTree.value = true }, } } /** * Listens for when focus happens outside a DOM subtree. * Returns props to pass to the root (node) of the subtree we want to check. */ export function useFocusOutside( onFocusOutside?: (event: FocusOutsideEvent) => void, element?: Ref<HTMLElement | undefined>, enabled: MaybeRefOrGetter<boolean> = true, ) { const ownerDocument: Document = element?.value?.ownerDocument ?? globalThis?.document const isFocusInsideDOMTree = ref(false) watchEffect((cleanupFn) => { if (!isClient || !toValue(enabled)) return const handleFocus = async (event: FocusEvent) => { if (!element?.value) return await nextTick() await nextTick() const target = event.target as HTMLElement | undefined if (!element.value || !target || isLayerExist(element.value, target)) return if (event.target && !isFocusInsideDOMTree.value) { const eventDetail = { originalEvent: event } handleAndDispatchCustomEvent( FOCUS_OUTSIDE, onFocusOutside, eventDetail, ) } } ownerDocument.addEventListener('focusin', handleFocus) cleanupFn(() => ownerDocument.removeEventListener('focusin', handleFocus)) }) return { onFocusCapture: () => { if (!toValue(enabled)) return isFocusInsideDOMTree.value = true }, onBlurCapture: () => { if (!toValue(enabled)) return isFocusInsideDOMTree.value = false }, } } export function dispatchUpdate() { const event = new CustomEvent(CONTEXT_UPDATE) document.dispatchEvent(event) }