Dismiss utility with layer stack

This commit is contained in:
Mats Bosson 2026-03-29 04:41:38 +07:00
parent 18f4869b20
commit be99077306
4 changed files with 156 additions and 0 deletions

View File

@ -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;
}

View File

@ -0,0 +1,2 @@
export { createDismiss } from "./create-dismiss";
export type { CreateDismissOptions, Dismiss } from "./create-dismiss";

View File

@ -1 +1,13 @@
import "@testing-library/jest-dom"; 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<string, unknown>).PointerEvent = PointerEvent;
}

View File

@ -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();
});
});
});