@melt-ui/svelte
Version:

164 lines (163 loc) • 7.87 kB
JavaScript
import { getOwnerDocument, isOrContainsTarget } from '../../helpers/elements.js';
import { addEventListener, isElement, executeCallbacks, noop, debounce, isShadowRoot, isHTMLElement, } from '../../helpers/index.js';
const layers = new Set();
export const useInteractOutside = ((node, config = {}) => {
let unsubEvents = noop;
let unsubPointerDown = noop;
let unsubPointerUp = noop;
let unsubResetInterceptedEvents = noop;
layers.add(node);
const documentObj = getOwnerDocument(node);
let isPointerDown = false;
let isPointerDownInside = false;
const interceptedEvents = {
pointerdown: false,
pointerup: false,
mousedown: false,
mouseup: false,
touchstart: false,
touchend: false,
click: false,
};
const resetInterceptedEvents = () => {
for (const eventType in interceptedEvents) {
interceptedEvents[eventType] = false;
}
};
const isAnyEventIntercepted = () => {
for (const isIntercepted of Object.values(interceptedEvents)) {
if (isIntercepted)
return true;
}
return false;
};
/**
* To determine event interception, events are marked as intercepted in the capture phase.
* If they are not invoked again in the bubbling phase, it indicates user interception.
*/
const setupCapturePhaseHandlerAndMarkAsIntercepted = (eventType, handler) => {
return addEventListener(documentObj, eventType, (e) => {
interceptedEvents[eventType] = true;
handler?.(e);
}, true);
};
/**
* An event listener for the bubbling phase marks events as not intercepted.
*/
const setupBubblePhaseHandlerAndMarkAsNotIntercepted = (eventType, handler) => {
return addEventListener(documentObj, eventType, (e) => {
interceptedEvents[eventType] = false;
const computedData = {};
if (isHTMLElement(e.target) && isShadowRoot(e.target.shadowRoot)) {
computedData.shadowTarget = e.composedPath()[0];
}
handler?.(e, computedData);
});
};
function update(config) {
unsubEvents();
unsubPointerDown();
unsubPointerUp();
unsubResetInterceptedEvents();
resetInterceptedEvents();
const { onInteractOutside, onInteractOutsideStart, enabled } = { enabled: true, ...config };
if (!enabled)
return;
let wasTopLayerInPointerDownCapture = false;
/**
* Debouncing `onPointerDown` ensures that other events can be flagged as not intercepted,
* allowing a comprehensive check for intercepted events thereafter.
*/
const onPointerDownDebounced = debounce((e, computedEventData) => {
if (!wasTopLayerInPointerDownCapture || isAnyEventIntercepted())
return;
if (onInteractOutside && isValidEvent(e, node))
onInteractOutsideStart?.(e);
const target = computedEventData?.shadowTarget ? computedEventData.shadowTarget : e.target;
if (isElement(target) && isOrContainsTarget(node, target)) {
isPointerDownInside = true;
}
isPointerDown = true;
}, 10);
unsubPointerDown = onPointerDownDebounced.destroy;
/**
* Debouncing `onPointerUp` ensures that other events can be flagged as not intercepted,
* allowing a comprehensive check for intercepted events thereafter.
*/
const onPointerUpDebounced = debounce((e) => {
if (wasTopLayerInPointerDownCapture &&
!isAnyEventIntercepted() &&
shouldTriggerInteractOutside(e)) {
onInteractOutside?.(e);
}
resetPointerState();
}, 10);
unsubPointerUp = onPointerUpDebounced.destroy;
/**
* Debounces `resetInterceptedEvents` to avoid premature resetting while events are still firing. The debounce
* delay is intentionally set longer than `onPointerUp`'s to ensure `onPointerUp` events are fully processed
* during the bubbling phase before `resetInterceptedEventsDebounced` executes in the capture phase.
*/
const resetInterceptedEventsDebounced = debounce(resetInterceptedEvents, 20);
unsubResetInterceptedEvents = resetInterceptedEventsDebounced.destroy;
const markTopLayerInPointerDown = () => {
wasTopLayerInPointerDownCapture = isHighestLayer(node);
};
unsubEvents = executeCallbacks(
/** Capture Events For Interaction Start */
setupCapturePhaseHandlerAndMarkAsIntercepted('pointerdown', markTopLayerInPointerDown), setupCapturePhaseHandlerAndMarkAsIntercepted('mousedown', markTopLayerInPointerDown), setupCapturePhaseHandlerAndMarkAsIntercepted('touchstart', markTopLayerInPointerDown),
/**
* Intercepted events are reset only at the end of an interaction, allowing
* interception at the start while still capturing the entire interaction.
* Additionally, intercepted events are reset in the capture phase with `resetInterceptedEventsDebounced`,
* accommodating events not invoked in the bubbling phase due to user interception.
*/
setupCapturePhaseHandlerAndMarkAsIntercepted('pointerup', resetInterceptedEventsDebounced), setupCapturePhaseHandlerAndMarkAsIntercepted('mouseup', resetInterceptedEventsDebounced), setupCapturePhaseHandlerAndMarkAsIntercepted('touchend', resetInterceptedEventsDebounced), setupCapturePhaseHandlerAndMarkAsIntercepted('click', resetInterceptedEventsDebounced),
/** Bubbling Events For Interaction Start */
setupBubblePhaseHandlerAndMarkAsNotIntercepted('pointerdown', onPointerDownDebounced), setupBubblePhaseHandlerAndMarkAsNotIntercepted('mousedown', onPointerDownDebounced), setupBubblePhaseHandlerAndMarkAsNotIntercepted('touchstart', onPointerDownDebounced),
/**
* To effectively detect an end of an interaction, we must monitor all relevant events,
* not just `click` events. This is because on touch devices, actions like pressing,
* moving the finger, and lifting it off the screen may not trigger a `click` event,
* but should still invoke `onPointerUp` to properly handle the interaction.
*/
setupBubblePhaseHandlerAndMarkAsNotIntercepted('pointerup', onPointerUpDebounced), setupBubblePhaseHandlerAndMarkAsNotIntercepted('mouseup', onPointerUpDebounced), setupBubblePhaseHandlerAndMarkAsNotIntercepted('touchend', onPointerUpDebounced), setupBubblePhaseHandlerAndMarkAsNotIntercepted('click', onPointerUpDebounced));
}
function shouldTriggerInteractOutside(e) {
if (isPointerDown && !isPointerDownInside && isValidEvent(e, node)) {
return true;
}
return false;
}
function resetPointerState() {
isPointerDown = false;
isPointerDownInside = false;
}
update(config);
return {
update,
destroy() {
unsubEvents();
unsubPointerDown();
unsubPointerUp();
unsubResetInterceptedEvents();
layers.delete(node);
},
};
});
function isValidEvent(e, node) {
if ('button' in e && e.button > 0)
return false;
const target = e.target;
if (!isElement(target))
return false;
// if the target is no longer in the document (e.g. it was removed) ignore it
const ownerDocument = target.ownerDocument;
if (!ownerDocument || !ownerDocument.documentElement.contains(target)) {
return false;
}
return node && !isOrContainsTarget(node, target);
}
function isHighestLayer(node) {
return Array.from(layers).at(-1) === node;
}