INIT
This commit is contained in:
Generated
Vendored
+248
@@ -0,0 +1,248 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user