Dismiss utility with layer stack
This commit is contained in:
parent
18f4869b20
commit
be99077306
62
packages/core/src/utilities/dismiss/create-dismiss.ts
Normal file
62
packages/core/src/utilities/dismiss/create-dismiss.ts
Normal 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;
|
||||||
|
}
|
||||||
2
packages/core/src/utilities/dismiss/index.ts
Normal file
2
packages/core/src/utilities/dismiss/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { createDismiss } from "./create-dismiss";
|
||||||
|
export type { CreateDismissOptions, Dismiss } from "./create-dismiss";
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
80
packages/core/tests/utilities/dismiss.test.ts
Normal file
80
packages/core/tests/utilities/dismiss.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user