Files
NODEDC_DESIGN_GUIDELINE/packages/ui-react/src/Dropdown.tsx
T

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