169 lines
5.4 KiB
TypeScript
169 lines
5.4 KiB
TypeScript
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<HTMLElement | null>(null);
|
|
const [triggerElement, setTriggerElement] = useState<HTMLElement | null>(null);
|
|
const surfaceRef = useRef<HTMLDivElement>(null);
|
|
const [surfaceStyle, setSurfaceStyle] = useState<CSSProperties>({ 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 (
|
|
<span className={cn("nodedc-dropdown-anchor", className)}>
|
|
{trigger({ open: isOpen, show, close, toggle, setAnchorRef: setAnchorElement, setTriggerRef: setTriggerElement, surfaceId })}
|
|
{isOpen && typeof document !== "undefined"
|
|
? createPortal(
|
|
<div
|
|
ref={surfaceRef}
|
|
id={surfaceId}
|
|
className={cn("nodedc-dropdown-surface", surfaceClassName)}
|
|
role={surfaceRole}
|
|
style={surfaceStyle}
|
|
>
|
|
{typeof children === "function" ? children({ close }) : children}
|
|
</div>,
|
|
document.body,
|
|
)
|
|
: null}
|
|
</span>
|
|
);
|
|
}
|