INIT
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m11s

This commit is contained in:
eewing
2026-02-18 15:17:47 -06:00
parent e74c106f85
commit ec317eb17c
11532 changed files with 1631690 additions and 1 deletions
@@ -0,0 +1,29 @@
<script lang="ts">
import { boxWith } from "svelte-toolbelt";
import type { DismissibleLayerImplProps } from "./types.js";
import { DismissibleLayerState } from "./use-dismissable-layer.svelte.js";
import { noop } from "../../../internal/noop.js";
let {
interactOutsideBehavior = "close",
onInteractOutside = noop,
onFocusOutside = noop,
id,
children,
enabled,
isValidEvent = () => false,
ref,
}: DismissibleLayerImplProps = $props();
const dismissibleLayerState = DismissibleLayerState.create({
id: boxWith(() => id),
interactOutsideBehavior: boxWith(() => interactOutsideBehavior),
onInteractOutside: boxWith(() => onInteractOutside),
enabled: boxWith(() => enabled),
onFocusOutside: boxWith(() => onFocusOutside),
isValidEvent: boxWith(() => isValidEvent),
ref,
});
</script>
{@render children?.({ props: dismissibleLayerState.props })}
@@ -0,0 +1,4 @@
import type { DismissibleLayerImplProps } from "./types.js";
declare const DismissibleLayer: import("svelte").Component<DismissibleLayerImplProps, {}, "">;
type DismissibleLayer = ReturnType<typeof DismissibleLayer>;
export default DismissibleLayer;
@@ -0,0 +1,2 @@
export { default as DismissibleLayer } from "./dismissible-layer.svelte";
export type { DismissibleLayerProps } from "./types.js";
+1
View File
@@ -0,0 +1 @@
export { default as DismissibleLayer } from "./dismissible-layer.svelte";
+49
View File
@@ -0,0 +1,49 @@
import type { Snippet } from "svelte";
import type { WritableBox } from "svelte-toolbelt";
export type InteractOutsideEvent = PointerEvent;
export type InteractOutsideEventHandler = (e: PointerEvent) => void;
export type InteractOutsideBehaviorType = "close" | "defer-otherwise-close" | "defer-otherwise-ignore" | "ignore";
export type DismissibleLayerProps = {
/**
* Callback fired when an outside interaction event completes,
* which is either a `pointerup`, `mouseup`, or `touchend`
* event, depending on the user's input device.
*/
onInteractOutside?: InteractOutsideEventHandler;
/**
* Interact outside behavior type.
* `close`: Closes the element immediately.
* `defer-otherwise-close`: Delegates the action to the parent element. If no parent is found, it closes the element.
* `defer-otherwise-ignore`: Delegates the action to the parent element. If no parent is found, nothing is done.
* `ignore`: Prevents the element from closing and also blocks the parent element from closing in response to an outside interaction.
*
* @defaultValue `close`
*/
interactOutsideBehavior?: InteractOutsideBehaviorType;
/**
* Callback fired when focus leaves the dismissible layer.
*/
onFocusOutside?: (event: FocusEvent) => void;
};
export type DismissibleLayerImplProps = {
/**
* Whether the layer is enabled.
*/
enabled: boolean;
/**
* ID of the layer.
*/
id: string;
/**
* Provide a custom event validator function to determine if the event should be
* considered a dismissal event. This was added to handle the case where a user
* has one context menu open and right clicks on another context menu. We normally ignore
* right clicks for "close" events, but in this case we want to close the context menu when
* another is open.
*/
isValidEvent?: (e: PointerEvent, node: HTMLElement) => boolean;
children?: Snippet<[{
props: Record<string, unknown>;
}]>;
ref: WritableBox<HTMLElement | null>;
} & DismissibleLayerProps;
+1
View File
@@ -0,0 +1 @@
export {};
@@ -0,0 +1,20 @@
import { type ReadableBox, type WritableBox, type ReadableBoxedValues } from "svelte-toolbelt";
import type { DismissibleLayerImplProps, InteractOutsideBehaviorType } from "./types.js";
interface DismissibleLayerStateOpts extends ReadableBoxedValues<Required<Omit<DismissibleLayerImplProps, "children" | "ref">>> {
ref: WritableBox<HTMLElement | null>;
}
export declare class DismissibleLayerState {
#private;
static create(opts: DismissibleLayerStateOpts): DismissibleLayerState;
readonly opts: DismissibleLayerStateOpts;
constructor(opts: DismissibleLayerStateOpts);
props: {
onfocuscapture: () => void;
onblurcapture: () => void;
};
}
export declare function getTopMostDismissableLayer(layersArr?: [DismissibleLayerState, ReadableBox<InteractOutsideBehaviorType>][]): [DismissibleLayerState, ReadableBox<InteractOutsideBehaviorType>] | undefined;
export type FocusOutsideEvent = CustomEvent<{
originalEvent: FocusEvent;
}>;
export {};
@@ -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;
}