import { createContext, useContext, createSignal, createEffect, createRenderEffect, onCleanup, Show, getOwner, runWithOwner, JSXElement } from "solid-js"; import { Portal } from "solid-js/web"; export type Placement = "top" | "top-start" | "top-end" | "bottom" | "bottom-start" | "bottom-end" | "left" | "left-start" | "left-end" | "right" | "right-start" | "right-end"; export interface PositionOptions { placement?: Placement; offset?: number; flip?: boolean; shift?: boolean; shiftPadding?: number; } interface Position { top: number; left: number; placement: string; } class FloatingManager { activeCloseCallback: (() => void) | null = null; register(closeCallback: () => void) { if (this.activeCloseCallback && this.activeCloseCallback !== closeCallback) { this.activeCloseCallback(); } this.activeCloseCallback = closeCallback; } unregister(closeCallback: () => void) { if (this.activeCloseCallback === closeCallback) { this.activeCloseCallback = null; } } closeActive() { if (this.activeCloseCallback) { this.activeCloseCallback(); this.activeCloseCallback = null; } } } const floatingManager = new FloatingManager(); // Open floating-content elements in open order, so outside-click handling can tell // a descendant (opened later, e.g. a menu inside a popover) from an ancestor: a // floating stays open for clicks inside itself or a later-opened floating, and // closes for clicks anywhere else (including its parent popover). const openFloatings: HTMLElement[] = []; export interface FloatingContextValue { isOpen: () => boolean; setIsOpen: (open: boolean) => void; readonly triggerRef: HTMLElement | undefined; setTriggerRef: (el: HTMLElement) => void; readonly floatingRef: HTMLElement | undefined; setFloatingRef: (el: HTMLElement) => void; position: () => Position | null; options: Required; cancelHoverClose: () => void; scheduleHoverClose: (delay: number) => void; } const FloatingContext = createContext(null); export function useFloatingContext(): FloatingContextValue { const context = useContext(FloatingContext); if (!context) { throw new Error("Floating components must be used within a FloatingRoot"); } return context; } function calculatePosition(triggerRect: DOMRect, floatingRect: DOMRect, options: Required): Position { const { placement, offset, flip, shift, shiftPadding } = options; const parts = placement.split("-"); const basePlacement = parts[0]; const alignment = parts[1] || "center"; let top = 0; let left = 0; let finalPlacement: string = placement; switch (basePlacement) { case "top": top = triggerRect.top - floatingRect.height - offset; break; case "bottom": top = triggerRect.bottom + offset; break; case "left": left = triggerRect.left - floatingRect.width - offset; break; case "right": left = triggerRect.right + offset; break; } if (basePlacement === "top" || basePlacement === "bottom") { switch (alignment) { case "start": left = triggerRect.left; break; case "end": left = triggerRect.right - floatingRect.width; break; default: left = triggerRect.left + (triggerRect.width - floatingRect.width) / 2; } } else { switch (alignment) { case "start": top = triggerRect.top; break; case "end": top = triggerRect.bottom - floatingRect.height; break; default: top = triggerRect.top + (triggerRect.height - floatingRect.height) / 2; } } if (flip) { const vh = window.innerHeight; const vw = window.innerWidth; if (basePlacement === "bottom" && top + floatingRect.height > vh - shiftPadding) { const flippedTop = triggerRect.top - floatingRect.height - offset; if (flippedTop >= shiftPadding) { top = flippedTop; finalPlacement = placement.replace("bottom", "top"); } } else if (basePlacement === "top" && top < shiftPadding) { const flippedTop = triggerRect.bottom + offset; if (flippedTop + floatingRect.height <= vh - shiftPadding) { top = flippedTop; finalPlacement = placement.replace("top", "bottom"); } } else if (basePlacement === "right" && left + floatingRect.width > vw - shiftPadding) { const flippedLeft = triggerRect.left - floatingRect.width - offset; if (flippedLeft >= shiftPadding) { left = flippedLeft; finalPlacement = placement.replace("right", "left"); } } else if (basePlacement === "left" && left < shiftPadding) { const flippedLeft = triggerRect.right + offset; if (flippedLeft + floatingRect.width <= vw - shiftPadding) { left = flippedLeft; finalPlacement = placement.replace("left", "right"); } } } if (shift) { const vh = window.innerHeight; const vw = window.innerWidth; if (left < shiftPadding) left = shiftPadding; else if (left + floatingRect.width > vw - shiftPadding) left = vw - floatingRect.width - shiftPadding; if (top < shiftPadding) top = shiftPadding; else if (top + floatingRect.height > vh - shiftPadding) top = vh - floatingRect.height - shiftPadding; } return { top, left, placement: finalPlacement }; } interface FloatingRootProps { // Solid's `h` auto-invokes zero-arg function props on read, so each // of these is simply the unwrapped value inside the component body. open?: boolean; onOpenChange?: (open: boolean) => void; placement?: Placement; offset?: number; flip?: boolean; shift?: boolean; shiftPadding?: number; // Opt out of the global single-open manager. Use for a floating nested // inside another (e.g. a tooltip inside a popover) so opening it doesn't // close its ancestor, and so the ancestor opening doesn't close it. standalone?: boolean; children?: JSXElement; } export function FloatingRoot(props: FloatingRootProps) { const [internalOpen, setInternalOpen] = createSignal(false); const isControlled = () => props.open !== undefined; const isOpen = () => isControlled() ? !!props.open : internalOpen(); let triggerRef: HTMLElement | undefined; let floatingRef: HTMLElement | undefined; const [position, setPosition] = createSignal(null); let hoverCloseTimeout: ReturnType | null = null; const options: Required = { placement: props.placement ?? "bottom-start", offset: props.offset ?? 4, flip: props.flip ?? true, shift: props.shift ?? true, shiftPadding: props.shiftPadding ?? 8, }; const closeThis = () => { if (isControlled()) props.onOpenChange?.(false); else setInternalOpen(false); }; const setIsOpen = (open: boolean) => { if (!props.standalone) { if (open) floatingManager.register(closeThis); else floatingManager.unregister(closeThis); } if (isControlled()) props.onOpenChange?.(open); else setInternalOpen(open); if (open) { requestAnimationFrame(() => updatePosition()); } else { setPosition(null); } }; onCleanup(() => { floatingManager.unregister(closeThis); if (hoverCloseTimeout) clearTimeout(hoverCloseTimeout); }); const cancelHoverClose = () => { if (hoverCloseTimeout) { clearTimeout(hoverCloseTimeout); hoverCloseTimeout = null; } }; const scheduleHoverClose = (delay: number) => { cancelHoverClose(); hoverCloseTimeout = setTimeout(() => setIsOpen(false), delay); }; const updatePosition = () => { if (!triggerRef || !floatingRef) return; const triggerRect = triggerRef.getBoundingClientRect(); const floatingRect = floatingRef.getBoundingClientRect(); setPosition(calculatePosition(triggerRect, floatingRect, options)); }; createRenderEffect(() => { if (!isOpen()) { setPosition(null); return; } const rafId = requestAnimationFrame(updatePosition); const handleUpdate = () => updatePosition(); window.addEventListener("scroll", handleUpdate, true); window.addEventListener("resize", handleUpdate); onCleanup(() => { cancelAnimationFrame(rafId); window.removeEventListener("scroll", handleUpdate, true); window.removeEventListener("resize", handleUpdate); }); }); const value: FloatingContextValue = { isOpen, setIsOpen, get triggerRef() { return triggerRef; }, setTriggerRef: (el: HTMLElement) => { triggerRef = el; }, get floatingRef() { return floatingRef; }, setFloatingRef: (el: HTMLElement) => { floatingRef = el; }, position, options, cancelHoverClose, scheduleHoverClose, }; return {props.children}; } interface FloatingTriggerProps { openOnHover?: boolean; hoverDelay?: number; hoverCloseDelay?: number; class?: string; title?: string; children?: JSXElement; } export function FloatingTrigger(props: FloatingTriggerProps) { const ctx = useFloatingContext(); let hoverOpenTimeout: ReturnType | null = null; const clearOpenTimeout = () => { if (hoverOpenTimeout) { clearTimeout(hoverOpenTimeout); hoverOpenTimeout = null; } }; onCleanup(clearOpenTimeout); const handleMouseEnter = () => { if (!props.openOnHover) return; ctx.cancelHoverClose(); clearOpenTimeout(); hoverOpenTimeout = setTimeout(() => ctx.setIsOpen(true), props.hoverDelay ?? 0); }; const handleMouseLeave = () => { if (!props.openOnHover) return; clearOpenTimeout(); ctx.scheduleHoverClose(props.hoverCloseDelay ?? 150); }; const handleClick = () => { if (props.openOnHover) return; ctx.setIsOpen(!ctx.isOpen()); }; const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); ctx.setIsOpen(!ctx.isOpen()); } else if (e.key === "Escape" && ctx.isOpen()) { ctx.setIsOpen(false); } }; return ( ); } interface FloatingContentProps { class?: string; style?: Record; onMouseEnter?: () => void; onMouseLeave?: () => void; children?: JSXElement; } export function FloatingContent(props: FloatingContentProps) { const ctx = useFloatingContext(); // Capture FloatingContent's own owner (which sits inside whatever provider // wraps us — e.g. Menu's MenuContext). We resolve props.children under this // owner below instead of letting the resolve them in its deferred // scope: children handed straight to a Portal get instantiated in the // Portal's owner, dropping the surrounding provider from their owner chain, // so a MenuItem inside a Menu's Portal throws "must be used within a Menu" // (this bites when a solid-js/html page like AppLayout.ts feeds children in). // Using runWithOwner (not the children() helper) keeps the exact same one-shot // insert behavior the div had before — no extra reactive memo over the content, // which that "always render" design is sensitive to. const owner = getOwner(); // Always render the div — toggle visibility via CSS. Avoids the // Show-based mount/unmount thrash where reactive scope disposal // was immediately destroying the inner component on open. const handleClickOutside = (e: MouseEvent) => { if (!ctx.isOpen()) return; const el = e.target instanceof Element ? e.target : null; const self = ctx.floatingRef; // Inside our own content or trigger → keep open. if (el && self && self.contains(el)) return; if (el && ctx.triggerRef && ctx.triggerRef.contains(el)) return; // Keep open when the click lands in a descendant floating layer: either a // FloatingContent opened AFTER us (higher in the open stack), or an // unregistered floating (e.g. a combobox/select dropdown opened from inside // us — these never join the stack and are always leaf descendants). Only a // click in an ancestor/sibling FloatingContent, or fully outside, closes us. const clicked = el && (el.closest("[data-floating-content]") as HTMLElement | null); if (clicked && self) { const ci = openFloatings.indexOf(clicked); if (ci < 0 || ci > openFloatings.indexOf(self)) return; } ctx.setIsOpen(false); }; const handleEscape = (e: KeyboardEvent) => { if (!ctx.isOpen() || e.key !== "Escape") return; // Only the topmost open floating closes on Escape, so dismissing a nested // menu (e.g. an insert menu inside an editor popover) doesn't also close // its parent. The stack is in open order, so the last entry is innermost. const self = ctx.floatingRef; if (self && openFloatings.length > 0 && openFloatings[openFloatings.length - 1] !== self) return; ctx.setIsOpen(false); }; document.addEventListener("mousedown", handleClickOutside); document.addEventListener("keydown", handleEscape); // Track open order so descendant vs ancestor can be distinguished above. createEffect(() => { const self = ctx.floatingRef; if (!self) return; const i = openFloatings.indexOf(self); if (ctx.isOpen()) { if (i < 0) openFloatings.push(self); } else if (i >= 0) openFloatings.splice(i, 1); }); onCleanup(() => { document.removeEventListener("mousedown", handleClickOutside); document.removeEventListener("keydown", handleEscape); const self = ctx.floatingRef; const i = self ? openFloatings.indexOf(self) : -1; if (i >= 0) openFloatings.splice(i, 1); }); // Render through a Portal (to document.body) so the popover escapes any // ancestor that establishes a containing block for `position: fixed` — most // importantly the Modal's animated `transform`, which would otherwise make // our viewport-relative top/left resolve relative to the modal instead. return (
ctx.setFloatingRef(el)} role="menu" data-floating-content="true" class={props.class || ""} style={{ position: "fixed", display: ctx.isOpen() ? "block" : "none", // Kept laid-out-but-invisible until position() is computed (one // rAF after open) so it never flashes at the top-left 0,0 origin. visibility: ctx.isOpen() && ctx.position() ? "visible" : "hidden", top: (ctx.position()?.top ?? 0) + "px", left: (ctx.position()?.left ?? 0) + "px", // Above the Modal container (z-[100]) so popovers opened from // inside a modal aren't hidden behind it now that we portal. "z-index": 110, ...(props.style || {}), }} onMouseEnter={() => props.onMouseEnter?.()} onMouseLeave={() => props.onMouseLeave?.()}>{runWithOwner(owner, () => props.children)}
); } export function useFloatingHover(openOnHover: boolean, hoverCloseDelay: number = 150) { const ctx = useFloatingContext(); const handleMouseEnter = () => { if (!openOnHover) return; ctx.cancelHoverClose(); }; const handleMouseLeave = () => { if (!openOnHover) return; ctx.scheduleHoverClose(hoverCloseDelay); }; return { onMouseEnter: handleMouseEnter, onMouseLeave: handleMouseLeave }; }