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"); }, }; }