diff --git a/packages/core/src/utilities/dismiss/create-dismiss.ts b/packages/core/src/utilities/dismiss/create-dismiss.ts new file mode 100644 index 0000000..50e05b2 --- /dev/null +++ b/packages/core/src/utilities/dismiss/create-dismiss.ts @@ -0,0 +1,62 @@ +export interface CreateDismissOptions { + /** Returns the content container element. */ + getContainer: () => HTMLElement | null; + /** Called when a dismiss event occurs. */ + onDismiss: () => void; + /** Whether Escape key triggers dismiss. Default: true. */ + dismissOnEscape?: boolean; + /** Whether pointer outside container triggers dismiss. Default: true. */ + dismissOnPointerOutside?: boolean; +} + +export interface Dismiss { + attach: () => void; + detach: () => void; +} + +/** + * Handles dismiss interactions: Escape key and pointer-outside. + * Uses a global layer stack so nested overlays only dismiss the topmost layer. + */ + +// Global stack of active dismiss handlers (topmost is last) +const layerStack: Dismiss[] = []; + +export function createDismiss(options: CreateDismissOptions): Dismiss { + const dismissOnEscape = options.dismissOnEscape ?? true; + const dismissOnPointerOutside = options.dismissOnPointerOutside ?? true; + + const handleKeyDown = (e: KeyboardEvent) => { + if (!dismissOnEscape) return; + if (e.key !== "Escape") return; + // Only dismiss the topmost layer + if (layerStack[layerStack.length - 1] !== dismiss) return; + e.preventDefault(); + options.onDismiss(); + }; + + const handlePointerDown = (e: PointerEvent) => { + if (!dismissOnPointerOutside) return; + if (layerStack[layerStack.length - 1] !== dismiss) return; + const container = options.getContainer(); + if (!container) return; + if (container.contains(e.target as Node)) return; + options.onDismiss(); + }; + + const dismiss: Dismiss = { + attach() { + layerStack.push(dismiss); + document.addEventListener("keydown", handleKeyDown); + document.addEventListener("pointerdown", handlePointerDown); + }, + detach() { + const index = layerStack.indexOf(dismiss); + if (index !== -1) layerStack.splice(index, 1); + document.removeEventListener("keydown", handleKeyDown); + document.removeEventListener("pointerdown", handlePointerDown); + }, + }; + + return dismiss; +} diff --git a/packages/core/src/utilities/dismiss/index.ts b/packages/core/src/utilities/dismiss/index.ts new file mode 100644 index 0000000..00b4a32 --- /dev/null +++ b/packages/core/src/utilities/dismiss/index.ts @@ -0,0 +1,2 @@ +export { createDismiss } from "./create-dismiss"; +export type { CreateDismissOptions, Dismiss } from "./create-dismiss"; diff --git a/packages/core/tests/setup.ts b/packages/core/tests/setup.ts index d0de870..ea5c7ed 100644 --- a/packages/core/tests/setup.ts +++ b/packages/core/tests/setup.ts @@ -1 +1,13 @@ import "@testing-library/jest-dom"; + +// jsdom does not implement PointerEvent — polyfill it for tests that need it +if (typeof PointerEvent === "undefined") { + class PointerEvent extends MouseEvent { + pointerId?: number; + constructor(type: string, params: PointerEventInit = {}) { + super(type, params); + this.pointerId = params.pointerId; + } + } + (globalThis as unknown as Record).PointerEvent = PointerEvent; +} diff --git a/packages/core/tests/utilities/dismiss.test.ts b/packages/core/tests/utilities/dismiss.test.ts new file mode 100644 index 0000000..a44a035 --- /dev/null +++ b/packages/core/tests/utilities/dismiss.test.ts @@ -0,0 +1,80 @@ +import { createRoot } from "solid-js"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createDismiss } from "../../src/utilities/dismiss/create-dismiss"; + +describe("createDismiss", () => { + afterEach(() => { + document.body.innerHTML = ""; + }); + + it("calls onDismiss when Escape key is pressed", () => { + const container = document.createElement("div"); + document.body.appendChild(container); + + createRoot((dispose) => { + const onDismiss = vi.fn(); + const dismiss = createDismiss({ getContainer: () => container, onDismiss }); + dismiss.attach(); + + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + expect(onDismiss).toHaveBeenCalledTimes(1); + + dismiss.detach(); + dispose(); + }); + }); + + it("calls onDismiss when pointer is pressed outside container", () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const outside = document.createElement("button"); + document.body.appendChild(outside); + + createRoot((dispose) => { + const onDismiss = vi.fn(); + const dismiss = createDismiss({ getContainer: () => container, onDismiss }); + dismiss.attach(); + + outside.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })); + expect(onDismiss).toHaveBeenCalledTimes(1); + + dismiss.detach(); + dispose(); + }); + }); + + it("does not call onDismiss when pointer is inside container", () => { + const container = document.createElement("div"); + const inner = document.createElement("button"); + container.appendChild(inner); + document.body.appendChild(container); + + createRoot((dispose) => { + const onDismiss = vi.fn(); + const dismiss = createDismiss({ getContainer: () => container, onDismiss }); + dismiss.attach(); + + inner.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })); + expect(onDismiss).not.toHaveBeenCalled(); + + dismiss.detach(); + dispose(); + }); + }); + + it("does not call onDismiss after detach", () => { + const container = document.createElement("div"); + document.body.appendChild(container); + + createRoot((dispose) => { + const onDismiss = vi.fn(); + const dismiss = createDismiss({ getContainer: () => container, onDismiss }); + dismiss.attach(); + dismiss.detach(); + + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + expect(onDismiss).not.toHaveBeenCalled(); + dispose(); + }); + }); +});