import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode, } from "react"; import { createPortal } from "react-dom"; import { computeFloatingPosition, type FloatingPlacement } from "@nodedc/ui-core"; import { cn } from "./cn.js"; export interface DropdownTriggerApi { open: boolean; show: () => void; close: () => void; toggle: () => void; setAnchorRef: (node: HTMLElement | null) => void; setTriggerRef: (node: HTMLElement | null) => void; surfaceId: string; } export interface DropdownProps { trigger: (api: DropdownTriggerApi) => ReactNode; children: ReactNode | ((api: { close: () => void }) => ReactNode); placement?: FloatingPlacement; minWidth?: number; width?: number | "anchor"; offset?: number; disabled?: boolean; className?: string; surfaceClassName?: string; surfaceRole?: "menu" | "listbox" | "dialog"; onOpenChange?: (open: boolean) => void; } export function Dropdown({ trigger, children, placement = "bottom-start", minWidth = 180, width = "anchor", offset = 8, disabled = false, className, surfaceClassName, surfaceRole = "menu", onOpenChange, }: DropdownProps) { const instanceId = useId(); const surfaceId = `${instanceId.replaceAll(":", "")}-surface`; const [isOpen, setIsOpen] = useState(false); const [anchorElement, setAnchorElement] = useState(null); const [triggerElement, setTriggerElement] = useState(null); const surfaceRef = useRef(null); const [surfaceStyle, setSurfaceStyle] = useState({ visibility: "hidden" }); useEffect(() => { onOpenChange?.(isOpen); }, [isOpen, onOpenChange]); const close = useCallback(() => setIsOpen(false), []); const show = useCallback(() => { if (!disabled) setIsOpen(true); }, [disabled]); const toggle = useCallback(() => { if (!disabled) setIsOpen((current) => !current); }, [disabled]); const updatePosition = useCallback(() => { const anchorNode = anchorElement ?? triggerElement; if (!anchorNode || !surfaceRef.current) return; const anchor = anchorNode.getBoundingClientRect(); const anchorVisible = ( anchor.bottom > 0 && anchor.top < window.innerHeight && anchor.right > 0 && anchor.left < window.innerWidth ); if (!anchorVisible) { close(); return; } const measured = surfaceRef.current.getBoundingClientRect(); const surfaceWidth = typeof width === "number" ? width : Math.max(minWidth, anchor.width, measured.width); const surfaceHeight = Math.max(1, surfaceRef.current.scrollHeight); const position = computeFloatingPosition({ anchor, surfaceWidth, surfaceHeight, placement, offset, }); setSurfaceStyle({ top: position.top, left: position.left, width: surfaceWidth, maxHeight: position.maxHeight, visibility: "visible", }); }, [anchorElement, close, minWidth, offset, placement, triggerElement, width]); useLayoutEffect(() => { if (!isOpen) return; updatePosition(); }, [isOpen, updatePosition]); useEffect(() => { if (!isOpen) return; const handlePointerDown = (event: PointerEvent) => { const target = event.target; if (!(target instanceof Node)) return; if (anchorElement?.contains(target) || triggerElement?.contains(target) || surfaceRef.current?.contains(target)) return; close(); }; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { close(); event.preventDefault(); event.stopPropagation(); triggerElement?.focus(); } }; const handleOtherOpen = (event: Event) => { const detail = (event as CustomEvent<{ id?: string }>).detail; if (detail.id !== instanceId) close(); }; const handleViewportChange = () => updatePosition(); window.dispatchEvent(new CustomEvent("nodedc-dropdown-open", { detail: { id: instanceId } })); document.addEventListener("pointerdown", handlePointerDown); document.addEventListener("keydown", handleKeyDown, true); window.addEventListener("nodedc-dropdown-open", handleOtherOpen as EventListener); window.addEventListener("resize", handleViewportChange); window.addEventListener("scroll", handleViewportChange, true); return () => { document.removeEventListener("pointerdown", handlePointerDown); document.removeEventListener("keydown", handleKeyDown, true); window.removeEventListener("nodedc-dropdown-open", handleOtherOpen as EventListener); window.removeEventListener("resize", handleViewportChange); window.removeEventListener("scroll", handleViewportChange, true); }; }, [anchorElement, close, instanceId, isOpen, triggerElement, updatePosition]); return ( {trigger({ open: isOpen, show, close, toggle, setAnchorRef: setAnchorElement, setTriggerRef: setTriggerElement, surfaceId })} {isOpen && typeof document !== "undefined" ? createPortal(
{typeof children === "function" ? children({ close }) : children}
, document.body, ) : null}
); }