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";
|
||||
|
||||
// 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