UNPKG

clickout-lite

Version:

A lightweight utility to detect outside clicks on elements — compatible with Vue, React, and vanilla JavaScript.

115 lines (114 loc) 4.13 kB
const noop = () => { }; // Utility function to get value from ref or getter function toValue(val) { return typeof val === 'function' ? val() : val && typeof val === 'object' && 'value' in val ? val.value : val; } // Utility function to unwrap element function unrefElement(elRef) { const plain = toValue(elRef); return plain; } // Check if running in iOS const isIOS = (() => { if (typeof window === 'undefined' || !window.navigator) return false; return (/iP(?:ad|hone|od)/.test(window.navigator.userAgent) || (window.navigator.maxTouchPoints > 2 && /iPad|Macintosh/.test(window.navigator.userAgent))); })(); // Event listener utility function useEventListener(target, event, listener, options) { target.addEventListener(event, listener, options); return () => target.removeEventListener(event, listener, options); } let _iOSWorkaround = false; export function onClickOutside(target, handler, options = {}) { const { window = typeof globalThis !== 'undefined' ? globalThis.window : undefined, ignore = [], capture = true, detectIframe = false, controls = false, } = options; if (!window) { return controls ? { stop: noop, cancel: noop, trigger: noop } : noop; } if (isIOS && !_iOSWorkaround) { _iOSWorkaround = true; const listenerOptions = { passive: true }; Array.from(window.document.body.children).forEach((el) => el.addEventListener('click', noop, listenerOptions)); window.document.documentElement.addEventListener('click', noop, listenerOptions); } let shouldListen = true; const shouldIgnore = (event) => { return toValue(ignore).some((target) => { if (typeof target === 'string') { return Array.from(window.document.querySelectorAll(target)).some((el) => el === event.target || event.composedPath().includes(el)); } else { const el = unrefElement(target); return (el && (event.target === el || event.composedPath().includes(el))); } }); }; const listener = (event) => { if (!event.target) return; const el = unrefElement(target); if (!el) return; if (el === event.target || event.composedPath().includes(el)) return; if ('detail' in event && event.detail === 0) shouldListen = !shouldIgnore(event); if (!shouldListen) { shouldListen = true; return; } handler(event); }; let isProcessingClick = false; const cleanup = [ useEventListener(window, 'click', (event) => { if (!isProcessingClick) { isProcessingClick = true; setTimeout(() => { isProcessingClick = false; }, 0); listener(event); } }, { passive: true, capture }), useEventListener(window, 'pointerdown', (e) => { const el = unrefElement(target); shouldListen = !shouldIgnore(e) && !!(el && !e.composedPath().includes(el)); }, { passive: true }), detectIframe && useEventListener(window, 'blur', (event) => { setTimeout(() => { const el = unrefElement(target); if (window.document.activeElement?.tagName === 'IFRAME' && !el?.contains(window.document.activeElement)) { handler(event); } }, 0); }, { passive: true }), ].filter(Boolean); const stop = () => cleanup.forEach((fn) => fn()); if (controls) { return { stop, cancel: () => { shouldListen = false; }, trigger: (event) => { shouldListen = true; listener(event); shouldListen = false; }, }; } return stop; }