diff --git a/packages/bits-ui/src/lib/bits/index.ts b/packages/bits-ui/src/lib/bits/index.ts index 3af6da940..9122e0fdd 100644 --- a/packages/bits-ui/src/lib/bits/index.ts +++ b/packages/bits-ui/src/lib/bits/index.ts @@ -33,5 +33,6 @@ export * as Toggle from "./toggle/index.js"; export * as ToggleGroup from "./toggle-group/index.js"; export * as Toolbar from "./toolbar/index.js"; export * as Tooltip from "./tooltip/index.js"; +export * as EscapeLayer from "./utilities/escape-layer/index.js"; export * as PreventTextSelectionOverflowLayer from "./utilities/prevent-text-selection-overflow-layer/index.js"; export * as DismissableLayer from "./utilities/dismissable-layer/index.js"; diff --git a/packages/bits-ui/src/lib/bits/utilities/escape-layer/escape-layer.svelte b/packages/bits-ui/src/lib/bits/utilities/escape-layer/escape-layer.svelte new file mode 100644 index 000000000..baba6f377 --- /dev/null +++ b/packages/bits-ui/src/lib/bits/utilities/escape-layer/escape-layer.svelte @@ -0,0 +1,15 @@ + + +{@render children?.()} diff --git a/packages/bits-ui/src/lib/bits/utilities/escape-layer/escape-layer.svelte.ts b/packages/bits-ui/src/lib/bits/utilities/escape-layer/escape-layer.svelte.ts new file mode 100644 index 000000000..fdd30553c --- /dev/null +++ b/packages/bits-ui/src/lib/bits/utilities/escape-layer/escape-layer.svelte.ts @@ -0,0 +1,57 @@ +import { onDestroy } from "svelte"; +import type { EscapeBehaviorType, EscapeLayerProps } from "./types.js"; +import type { ReadonlyBox, ReadonlyBoxedValues } from "$lib/internal/box.svelte.js"; +import { type EventCallback, addEventListener } from "$lib/internal/events.js"; +import { kbd } from "$lib/internal/kbd.js"; + +const layers = new Map>(); + +type EscapeLayerStateProps = ReadonlyBoxedValues>>; + +export class EscapeLayerState { + #onEscapeProp: ReadonlyBox>; + #behaviorType: ReadonlyBox; + + constructor(props: EscapeLayerStateProps) { + this.#behaviorType = props.behaviorType; + this.#onEscapeProp = props.onEscape; + layers.set(this, this.#behaviorType); + const unsubEvents = this.#addEventListener(); + onDestroy(() => { + unsubEvents(); + layers.delete(this); + }); + } + + #addEventListener() { + return addEventListener(document, "keydown", this.#onkeydown, { passive: false }); + } + + #onkeydown = (e: KeyboardEvent) => { + if (e.key !== kbd.ESCAPE || !isResponsibleEscapeLayer(this)) return; + e.preventDefault(); + const behaviorType = this.#behaviorType.value; + if (behaviorType !== "close" && behaviorType !== "defer-otherwise-close") return; + this.#onEscapeProp.value(e); + }; +} + +export function escapeLayerState(props: EscapeLayerStateProps) { + return new EscapeLayerState(props); +} + +function isResponsibleEscapeLayer(instance: EscapeLayerState) { + const layersArr = [...layers]; + /** + * 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 escape. Otherwise, we know that all layers defer so + * the first layer is the responsible one. + */ + const topMostLayer = layersArr.findLast( + ([_, { value: behaviorType }]) => behaviorType === "close" || behaviorType === "ignore" + ); + if (topMostLayer) return topMostLayer[0] === instance; + const [firstLayerNode] = layersArr[0]!; + return firstLayerNode === instance; +} diff --git a/packages/bits-ui/src/lib/bits/utilities/escape-layer/index.ts b/packages/bits-ui/src/lib/bits/utilities/escape-layer/index.ts new file mode 100644 index 000000000..18e7fd7dd --- /dev/null +++ b/packages/bits-ui/src/lib/bits/utilities/escape-layer/index.ts @@ -0,0 +1,3 @@ +export { default as Root } from "./escape-layer.svelte"; + +export type { EscapeLayerProps as Props } from "./types.js"; diff --git a/packages/bits-ui/src/lib/bits/utilities/escape-layer/types.ts b/packages/bits-ui/src/lib/bits/utilities/escape-layer/types.ts new file mode 100644 index 000000000..59f21dc40 --- /dev/null +++ b/packages/bits-ui/src/lib/bits/utilities/escape-layer/types.ts @@ -0,0 +1,27 @@ +import type { Snippet } from "svelte"; + +export type EscapeBehaviorType = + | "close" + | "defer-otherwise-close" + | "defer-otherwise-ignore" + | "ignore"; + +export type EscapeLayerProps = { + children?: Snippet; + + /** + * Callback fired when escape is pressed. + */ + onEscape?: (e: KeyboardEvent) => void; + + /** + * Escape 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 escape key press. + * + * @defaultValue `close` + */ + behaviorType?: EscapeBehaviorType; +};