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
JavaScript
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;
}