This commit is contained in:
Generated
Vendored
+29
@@ -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 })}
|
||||
Generated
Vendored
+4
@@ -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;
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export { default as DismissibleLayer } from "./dismissible-layer.svelte";
|
||||
export type { DismissibleLayerProps } from "./types.js";
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { default as DismissibleLayer } from "./dismissible-layer.svelte";
|
||||
+49
@@ -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
@@ -0,0 +1 @@
|
||||
export {};
|
||||
Generated
Vendored
+20
@@ -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 {};
|
||||
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