import { useClickOutside } from "@mantine/hooks"; import { clampNumber } from "core/utils"; import { type ComponentPropsWithoutRef, Fragment, type ReactElement, type ReactNode, cloneElement, useState, } from "react"; import { twMerge } from "tailwind-merge"; import { useEvent } from "ui/hooks/use-event"; export type DropdownItem = | (() => ReactNode) | { label: string | ReactElement; icon?: any; onClick?: () => void; destructive?: boolean; disabled?: boolean; title?: string; [key: string]: any; }; export type DropdownClickableChild = ReactElement<{ onClick: () => void }>; export type DropdownProps = { className?: string; openEvent?: "onClick" | "onContextMenu"; defaultOpen?: boolean; title?: string | ReactElement; dropdownWrapperProps?: Omit, "style">; position?: "bottom-start" | "bottom-end" | "top-start" | "top-end"; hideOnEmpty?: boolean; items: (DropdownItem | undefined | boolean)[]; itemsClassName?: string; children: DropdownClickableChild; onClickItem?: (item: DropdownItem) => void; renderItem?: ( item: DropdownItem, props: { key: number; onClick: (e: any) => void }, ) => DropdownClickableChild; }; export function Dropdown({ children, defaultOpen = false, openEvent = "onClick", position: initialPosition = "bottom-start", dropdownWrapperProps, items, title, hideOnEmpty = true, onClickItem, renderItem, itemsClassName, className, }: DropdownProps) { const [open, setOpen] = useState(defaultOpen); const [position, setPosition] = useState(initialPosition); const clickoutsideRef = useClickOutside(() => setOpen(false)); const menuItems = items.filter(Boolean) as DropdownItem[]; const [_offset, _setOffset] = useState(0); const toggle = useEvent((delay: number = 50) => setTimeout(() => setOpen((prev) => !prev), typeof delay === "number" ? delay : 0), ); const onClickHandler = openEvent === "onClick" ? (e) => { e.stopPropagation(); toggle(); } : undefined; const onContextMenuHandler = useEvent((e) => { if (openEvent !== "onContextMenu") return; e.preventDefault(); if (open) { toggle(0); setTimeout(() => { setPosition(initialPosition); _setOffset(0); }, 10); return; } // minimal popper impl, get pos and boundaries const x = e.clientX - e.currentTarget.getBoundingClientRect().left; const { left = 0, right = 0 } = clickoutsideRef.current?.getBoundingClientRect() ?? {}; // only if boundaries gien if (left > 0 && right > 0) { const safe = clampNumber(x, left, right); // if pos less than half, go left if (x < (left + right) / 2) { setPosition("bottom-start"); _setOffset(safe); } else { setPosition("bottom-end"); _setOffset(right - safe); } } else { setPosition(initialPosition); _setOffset(0); } toggle(); }); const offset = 4; const dropdownStyle = { "bottom-start": { top: "100%", left: _offset, marginTop: offset }, "bottom-end": { right: _offset, top: "100%", marginTop: offset }, "top-start": { bottom: "100%", marginBottom: offset }, "top-end": { bottom: "100%", right: _offset, marginBottom: offset }, }[position]; const internalOnClickItem = useEvent((item) => { if (item.onClick) item.onClick(); if (onClickItem) onClickItem(item); toggle(50); }); if (menuItems.length === 0 && hideOnEmpty) return null; const space_for_icon = menuItems.some((item) => "icon" in item && item.icon); const itemRenderer = renderItem || ((item, { key, onClick }) => typeof item === "function" ? ( {item()} ) : ( )); return (
{cloneElement(children as any, { onClick: onClickHandler })} {open && (
{title && (
{title}
)} {menuItems.map((item, i) => itemRenderer(item, { key: i, onClick: (e) => { e.stopPropagation(); internalOnClickItem(item); }, }), )}
)}
); }