Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

next: escape layer #481

Merged
merged 3 commits into from
Apr 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/bits-ui/src/lib/bits/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<script lang="ts">
import type { EscapeLayerProps } from "./types.js";
import { escapeLayerState } from "./escape-layer.svelte.js";
import { readonlyBox } from "$lib/internal/box.svelte.js";
import { noop } from "$lib/index.js";

let { behaviorType = "close", onEscape = noop, children }: EscapeLayerProps = $props();

escapeLayerState({
behaviorType: readonlyBox(() => behaviorType),
onEscape: readonlyBox(() => onEscape),
});
</script>

{@render children?.()}
Original file line number Diff line number Diff line change
@@ -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<EscapeLayerState, ReadonlyBox<EscapeBehaviorType>>();

type EscapeLayerStateProps = ReadonlyBoxedValues<Required<Omit<EscapeLayerProps, "children">>>;

export class EscapeLayerState {
#onEscapeProp: ReadonlyBox<EventCallback<KeyboardEvent>>;
#behaviorType: ReadonlyBox<EscapeBehaviorType>;

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;
}
3 changes: 3 additions & 0 deletions packages/bits-ui/src/lib/bits/utilities/escape-layer/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { default as Root } from "./escape-layer.svelte";

export type { EscapeLayerProps as Props } from "./types.js";
27 changes: 27 additions & 0 deletions packages/bits-ui/src/lib/bits/utilities/escape-layer/types.ts
Original file line number Diff line number Diff line change
@@ -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;
};
Loading