UNPKG

bits-ui

Version:

The headless components for Svelte.

239 lines (238 loc) 8.82 kB
import { afterSleep, afterTick, executeCallbacks, onDestroyEffect, } from "svelte-toolbelt"; import { watch } from "runed"; import { on } from "svelte/events"; import { addEventListener } from "../../../internal/events.js"; import { debounce } from "../../../internal/debounce.js"; import { noop } from "../../../internal/noop.js"; import { getOwnerDocument, isOrContainsTarget } from "../../../internal/elements.js"; import { isElement } from "../../../internal/is.js"; import { isClickTrulyOutside } from "../../../internal/dom.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(); // @ts-expect-error - later this.#unsubClickListener = addEventListener(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, }; } function getTopMostLayer(layersArr) { 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 = getTopMostLayer(layersArr); if (topMostLayer) return topMostLayer[0].opts.ref.current === node; const [firstLayerNode] = layersArr[0]; return firstLayerNode.opts.ref.current === node; } function isValidEvent(e, node) { if ("button" in e && e.button > 0) return false; const target = e.target; if (!isElement(target)) 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) { // eslint-disable-next-line @typescript-eslint/no-explicit-any return target[prop]; } // eslint-disable-next-line @typescript-eslint/no-explicit-any return e[prop]; }, }); return wrappedEvent; }