132 lines
4.2 KiB
TypeScript
132 lines
4.2 KiB
TypeScript
import { computeFloatingPosition, type FloatingPlacement } from "@nodedc/ui-core";
|
|
|
|
export interface FloatingLayerOptions {
|
|
trigger: HTMLElement;
|
|
surface: HTMLElement;
|
|
placement?: FloatingPlacement;
|
|
offset?: number;
|
|
minWidth?: number;
|
|
matchTriggerWidth?: boolean;
|
|
closeOnSelect?: boolean;
|
|
onOpenChange?: (open: boolean) => void;
|
|
}
|
|
|
|
export interface FloatingLayerController {
|
|
open: () => void;
|
|
close: () => void;
|
|
toggle: () => void;
|
|
reposition: () => void;
|
|
isOpen: () => boolean;
|
|
destroy: () => void;
|
|
}
|
|
|
|
export function createFloatingLayer({
|
|
trigger,
|
|
surface,
|
|
placement = "bottom-start",
|
|
offset = 8,
|
|
minWidth = 180,
|
|
matchTriggerWidth = true,
|
|
closeOnSelect = false,
|
|
onOpenChange,
|
|
}: FloatingLayerOptions): FloatingLayerController {
|
|
let openState = false;
|
|
const originalParent = surface.parentNode;
|
|
const originalNextSibling = surface.nextSibling;
|
|
|
|
surface.hidden = true;
|
|
surface.classList.add("nodedc-dropdown-surface");
|
|
document.body.append(surface);
|
|
|
|
const reposition = () => {
|
|
if (!openState) return;
|
|
const anchor = trigger.getBoundingClientRect();
|
|
const measured = surface.getBoundingClientRect();
|
|
const surfaceWidth = Math.max(minWidth, matchTriggerWidth ? anchor.width : 0, measured.width);
|
|
const surfaceHeight = Math.max(1, surface.scrollHeight);
|
|
const position = computeFloatingPosition({ anchor, surfaceWidth, surfaceHeight, placement, offset });
|
|
Object.assign(surface.style, {
|
|
position: "fixed",
|
|
top: `${position.top}px`,
|
|
left: `${position.left}px`,
|
|
width: `${surfaceWidth}px`,
|
|
maxHeight: `${position.maxHeight}px`,
|
|
visibility: "visible",
|
|
});
|
|
};
|
|
|
|
const open = () => {
|
|
if (openState || trigger.hasAttribute("disabled")) return;
|
|
openState = true;
|
|
surface.hidden = false;
|
|
trigger.setAttribute("aria-expanded", "true");
|
|
window.dispatchEvent(new CustomEvent("nodedc-floating-open", { detail: { surface } }));
|
|
requestAnimationFrame(reposition);
|
|
onOpenChange?.(true);
|
|
};
|
|
|
|
const close = () => {
|
|
if (!openState) return;
|
|
openState = false;
|
|
surface.hidden = true;
|
|
trigger.setAttribute("aria-expanded", "false");
|
|
onOpenChange?.(false);
|
|
};
|
|
|
|
const toggle = () => {
|
|
if (openState) close();
|
|
else open();
|
|
};
|
|
|
|
const handleTriggerClick = (event: MouseEvent) => {
|
|
event.preventDefault();
|
|
toggle();
|
|
};
|
|
const handleDocumentPointerDown = (event: PointerEvent) => {
|
|
if (!(event.target instanceof Node)) return;
|
|
if (!trigger.contains(event.target) && !surface.contains(event.target)) close();
|
|
};
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
if (event.key === "Escape" && openState) {
|
|
close();
|
|
trigger.focus();
|
|
}
|
|
};
|
|
const handleOtherOpen = (event: Event) => {
|
|
const otherSurface = (event as CustomEvent<{ surface?: HTMLElement }>).detail.surface;
|
|
if (otherSurface && otherSurface !== surface) close();
|
|
};
|
|
const handleSurfaceClick = (event: MouseEvent) => {
|
|
if (closeOnSelect && event.target instanceof HTMLElement && event.target.closest("[data-nodedc-option]")) close();
|
|
};
|
|
|
|
trigger.addEventListener("click", handleTriggerClick);
|
|
surface.addEventListener("click", handleSurfaceClick);
|
|
document.addEventListener("pointerdown", handleDocumentPointerDown);
|
|
document.addEventListener("keydown", handleKeyDown);
|
|
window.addEventListener("nodedc-floating-open", handleOtherOpen as EventListener);
|
|
window.addEventListener("resize", reposition);
|
|
window.addEventListener("scroll", reposition, true);
|
|
|
|
return {
|
|
open,
|
|
close,
|
|
toggle,
|
|
reposition,
|
|
isOpen: () => openState,
|
|
destroy: () => {
|
|
close();
|
|
trigger.removeEventListener("click", handleTriggerClick);
|
|
surface.removeEventListener("click", handleSurfaceClick);
|
|
document.removeEventListener("pointerdown", handleDocumentPointerDown);
|
|
document.removeEventListener("keydown", handleKeyDown);
|
|
window.removeEventListener("nodedc-floating-open", handleOtherOpen as EventListener);
|
|
window.removeEventListener("resize", reposition);
|
|
window.removeEventListener("scroll", reposition, true);
|
|
if (originalParent) originalParent.insertBefore(surface, originalNextSibling);
|
|
surface.removeAttribute("style");
|
|
},
|
|
};
|
|
}
|
|
|