249 lines
9.1 KiB
JavaScript
249 lines
9.1 KiB
JavaScript
import { afterSleep, afterTick, executeCallbacks, onDestroyEffect, } from "svelte-toolbelt";
|
|
import { watch } from "runed";
|
|
import { on } from "svelte/events";
|
|
import {} from "../../../internal/events.js";
|
|
import { debounce } from "../../../internal/debounce.js";
|
|
import { noop } from "../../../internal/noop.js";
|
|
import { getOwnerDocument, isOrContainsTarget } from "../../../internal/elements.js";
|
|
import { isElementOrSVGElement } from "../../../internal/is.js";
|
|
import { isClickTrulyOutside } from "../../../internal/dom.js";
|
|
import { CONTEXT_MENU_CONTENT_ATTR, CONTEXT_MENU_TRIGGER_ATTR, } from "../../menu/menu.svelte.js";
|
|
globalThis.bitsDismissableLayers ??= new Map();
|
|
export class DismissibleLayerState {
|
|
static create(opts) {
|
|
return new DismissibleLayerState(opts);
|
|
}
|
|
opts;
|
|
#interactOutsideProp;
|
|
#behaviorType;
|
|
#interceptedEvents = {
|
|
pointerdown: false,
|
|
};
|
|
#isResponsibleLayer = false;
|
|
#isFocusInsideDOMTree = false;
|
|
#documentObj = undefined;
|
|
#onFocusOutside;
|
|
#unsubClickListener = noop;
|
|
constructor(opts) {
|
|
this.opts = opts;
|
|
this.#behaviorType = opts.interactOutsideBehavior;
|
|
this.#interactOutsideProp = opts.onInteractOutside;
|
|
this.#onFocusOutside = opts.onFocusOutside;
|
|
$effect(() => {
|
|
this.#documentObj = getOwnerDocument(this.opts.ref.current);
|
|
});
|
|
let unsubEvents = noop;
|
|
const cleanup = () => {
|
|
this.#resetState();
|
|
globalThis.bitsDismissableLayers.delete(this);
|
|
this.#handleInteractOutside.destroy();
|
|
unsubEvents();
|
|
};
|
|
watch([() => this.opts.enabled.current, () => this.opts.ref.current], () => {
|
|
if (!this.opts.enabled.current || !this.opts.ref.current)
|
|
return;
|
|
afterSleep(1, () => {
|
|
if (!this.opts.ref.current)
|
|
return;
|
|
globalThis.bitsDismissableLayers.set(this, this.#behaviorType);
|
|
unsubEvents();
|
|
unsubEvents = this.#addEventListeners();
|
|
});
|
|
return cleanup;
|
|
});
|
|
onDestroyEffect(() => {
|
|
this.#resetState.destroy();
|
|
globalThis.bitsDismissableLayers.delete(this);
|
|
this.#handleInteractOutside.destroy();
|
|
this.#unsubClickListener();
|
|
unsubEvents();
|
|
});
|
|
}
|
|
#handleFocus = (event) => {
|
|
if (event.defaultPrevented)
|
|
return;
|
|
if (!this.opts.ref.current)
|
|
return;
|
|
afterTick(() => {
|
|
if (!this.opts.ref.current || this.#isTargetWithinLayer(event.target))
|
|
return;
|
|
if (event.target && !this.#isFocusInsideDOMTree) {
|
|
this.#onFocusOutside.current?.(event);
|
|
}
|
|
});
|
|
};
|
|
#addEventListeners() {
|
|
return executeCallbacks(
|
|
/**
|
|
* CAPTURE INTERACTION START
|
|
* mark interaction-start event as intercepted.
|
|
* mark responsible layer during interaction start
|
|
* to avoid checking if is responsible layer during interaction end
|
|
* when a new floating element may have been opened.
|
|
*/
|
|
on(this.#documentObj, "pointerdown", executeCallbacks(this.#markInterceptedEvent, this.#markResponsibleLayer), { capture: true }),
|
|
/**
|
|
* BUBBLE INTERACTION START
|
|
* Mark interaction-start event as non-intercepted. Debounce `onInteractOutsideStart`
|
|
* to avoid prematurely checking if other events were intercepted.
|
|
*/
|
|
on(this.#documentObj, "pointerdown", executeCallbacks(this.#markNonInterceptedEvent, this.#handleInteractOutside)),
|
|
/**
|
|
* HANDLE FOCUS OUTSIDE
|
|
*/
|
|
on(this.#documentObj, "focusin", this.#handleFocus));
|
|
}
|
|
#handleDismiss = (e) => {
|
|
let event = e;
|
|
if (event.defaultPrevented) {
|
|
event = createWrappedEvent(e);
|
|
}
|
|
this.#interactOutsideProp.current(e);
|
|
};
|
|
#handleInteractOutside = debounce((e) => {
|
|
if (!this.opts.ref.current) {
|
|
this.#unsubClickListener();
|
|
return;
|
|
}
|
|
const isEventValid = this.opts.isValidEvent.current(e, this.opts.ref.current) ||
|
|
isValidEvent(e, this.opts.ref.current);
|
|
if (!this.#isResponsibleLayer || this.#isAnyEventIntercepted() || !isEventValid) {
|
|
this.#unsubClickListener();
|
|
return;
|
|
}
|
|
let event = e;
|
|
if (event.defaultPrevented) {
|
|
event = createWrappedEvent(event);
|
|
}
|
|
if (this.#behaviorType.current !== "close" &&
|
|
this.#behaviorType.current !== "defer-otherwise-close") {
|
|
this.#unsubClickListener();
|
|
return;
|
|
}
|
|
if (e.pointerType === "touch") {
|
|
this.#unsubClickListener();
|
|
this.#unsubClickListener = on(this.#documentObj, "click", this.#handleDismiss, {
|
|
once: true,
|
|
});
|
|
}
|
|
else {
|
|
this.#interactOutsideProp.current(event);
|
|
}
|
|
}, 10);
|
|
#markInterceptedEvent = (e) => {
|
|
this.#interceptedEvents[e.type] = true;
|
|
};
|
|
#markNonInterceptedEvent = (e) => {
|
|
this.#interceptedEvents[e.type] = false;
|
|
};
|
|
#markResponsibleLayer = () => {
|
|
if (!this.opts.ref.current)
|
|
return;
|
|
this.#isResponsibleLayer = isResponsibleLayer(this.opts.ref.current);
|
|
};
|
|
#isTargetWithinLayer = (target) => {
|
|
if (!this.opts.ref.current)
|
|
return false;
|
|
return isOrContainsTarget(this.opts.ref.current, target);
|
|
};
|
|
#resetState = debounce(() => {
|
|
for (const eventType in this.#interceptedEvents) {
|
|
this.#interceptedEvents[eventType] = false;
|
|
}
|
|
this.#isResponsibleLayer = false;
|
|
}, 20);
|
|
#isAnyEventIntercepted() {
|
|
const i = Object.values(this.#interceptedEvents).some(Boolean);
|
|
return i;
|
|
}
|
|
#onfocuscapture = () => {
|
|
this.#isFocusInsideDOMTree = true;
|
|
};
|
|
#onblurcapture = () => {
|
|
this.#isFocusInsideDOMTree = false;
|
|
};
|
|
props = {
|
|
onfocuscapture: this.#onfocuscapture,
|
|
onblurcapture: this.#onblurcapture,
|
|
};
|
|
}
|
|
export function getTopMostDismissableLayer(layersArr = [
|
|
...globalThis.bitsDismissableLayers,
|
|
]) {
|
|
return layersArr.findLast(([_, { current: behaviorType }]) => behaviorType === "close" || behaviorType === "ignore");
|
|
}
|
|
function isResponsibleLayer(node) {
|
|
const layersArr = [...globalThis.bitsDismissableLayers];
|
|
/**
|
|
* We first check if we can find a top layer with `close` or `ignore`.
|
|
* If that top layer was found and matches the provided node, then the node is
|
|
* responsible for the outside interaction. Otherwise, we know that all layers defer so
|
|
* the first layer is the responsible one.
|
|
*/
|
|
const topMostLayer = getTopMostDismissableLayer(layersArr);
|
|
if (topMostLayer)
|
|
return topMostLayer[0].opts.ref.current === node;
|
|
const [firstLayerNode] = layersArr[0];
|
|
return firstLayerNode.opts.ref.current === node;
|
|
}
|
|
function isValidEvent(e, node) {
|
|
const target = e.target;
|
|
if (!isElementOrSVGElement(target))
|
|
return false;
|
|
const targetIsContextMenuTrigger = Boolean(target.closest(`[${CONTEXT_MENU_TRIGGER_ATTR}]`));
|
|
if ("button" in e && e.button > 0 && !targetIsContextMenuTrigger)
|
|
return false;
|
|
if ("button" in e && e.button === 0 && targetIsContextMenuTrigger)
|
|
return true;
|
|
const nodeIsContextMenu = Boolean(node.closest(`[${CONTEXT_MENU_CONTENT_ATTR}]`));
|
|
if (targetIsContextMenuTrigger && nodeIsContextMenu)
|
|
return false;
|
|
const ownerDocument = getOwnerDocument(target);
|
|
const isValid = ownerDocument.documentElement.contains(target) &&
|
|
!isOrContainsTarget(node, target) &&
|
|
isClickTrulyOutside(e, node);
|
|
return isValid;
|
|
}
|
|
function createWrappedEvent(e) {
|
|
const capturedCurrentTarget = e.currentTarget;
|
|
const capturedTarget = e.target;
|
|
let newEvent;
|
|
if (e instanceof PointerEvent) {
|
|
newEvent = new PointerEvent(e.type, e);
|
|
}
|
|
else {
|
|
newEvent = new PointerEvent("pointerdown", e);
|
|
}
|
|
// track the prevented state separately
|
|
let isPrevented = false;
|
|
// Create a proxy to intercept property access and method calls
|
|
const wrappedEvent = new Proxy(newEvent, {
|
|
get: (target, prop) => {
|
|
if (prop === "currentTarget") {
|
|
return capturedCurrentTarget;
|
|
}
|
|
if (prop === "target") {
|
|
return capturedTarget;
|
|
}
|
|
if (prop === "preventDefault") {
|
|
return () => {
|
|
isPrevented = true;
|
|
if (typeof target.preventDefault === "function") {
|
|
target.preventDefault();
|
|
}
|
|
};
|
|
}
|
|
if (prop === "defaultPrevented") {
|
|
return isPrevented;
|
|
}
|
|
if (prop in target) {
|
|
// oxlint-disable-next-line no-explicit-any
|
|
return target[prop];
|
|
}
|
|
// oxlint-disable-next-line no-explicit-any
|
|
return e[prop];
|
|
},
|
|
});
|
|
return wrappedEvent;
|
|
}
|