Files
patchy631--ai-engineering-hub/open-agent-builder/components/ui/menu.tsx
T
2026-07-13 12:37:47 +08:00

566 lines
17 KiB
TypeScript

//@ts-nocheck
"use client";
import * as React from "react";
import { createPortal } from "react-dom";
import { motion, AnimatePresence } from "framer-motion";
import type { HTMLMotionProps } from "framer-motion";
import { cn } from "@/utils/cn";
import { useDropdownHover } from "@/components/hooks/use-dropdown-hover";
interface MenuContextValue {
open: boolean;
setOpen: (open: boolean) => void;
triggerRef: React.RefObject<HTMLElement>;
}
const MenuContext = React.createContext<MenuContextValue | undefined>(
undefined,
);
function useMenu() {
const context = React.useContext(MenuContext);
if (!context) {
throw new Error("useMenu must be used within a Menu");
}
return context;
}
/* -------------------------------------------------------------------------------------------------
* Menu
* -----------------------------------------------------------------------------------------------*/
interface MenuProps {
open?: boolean;
onOpenChange?: (open: boolean) => void;
children: React.ReactNode;
}
export function Menu({
open: controlledOpen,
onOpenChange,
children,
}: MenuProps) {
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false);
const triggerRef = React.useRef<HTMLElement>(null);
const open = controlledOpen !== undefined ? controlledOpen : uncontrolledOpen;
const setOpen = React.useCallback(
(value: boolean) => {
if (controlledOpen === undefined) {
setUncontrolledOpen(value);
}
onOpenChange?.(value);
},
[controlledOpen, onOpenChange],
);
return (
<MenuContext.Provider value={{ open, setOpen, triggerRef }}>
{children}
</MenuContext.Provider>
);
}
/* -------------------------------------------------------------------------------------------------
* MenuTrigger
* -----------------------------------------------------------------------------------------------*/
interface MenuTriggerProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
asChild?: boolean;
}
export const MenuTrigger = React.forwardRef<
HTMLButtonElement,
MenuTriggerProps
>(({ onClick, asChild, children, ...props }, forwardedRef) => {
const { open, setOpen, triggerRef } = useMenu();
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
onClick?.(e);
if (!e.defaultPrevented) {
setOpen(!open);
}
};
const setRefs = React.useCallback(
(node: HTMLElement | null) => {
if (forwardedRef) {
if (typeof forwardedRef === "function") {
forwardedRef(node as HTMLButtonElement);
} else {
forwardedRef.current = node as HTMLButtonElement;
}
}
(triggerRef as React.MutableRefObject<HTMLElement | null>).current = node;
},
[forwardedRef, triggerRef],
);
if (asChild && React.isValidElement(children)) {
const childOnClick = (children as any).props?.onClick as
| ((e: React.MouseEvent<any>) => void)
| undefined;
return React.cloneElement(children as any, {
...props,
ref: setRefs,
onClick: (e: React.MouseEvent<any>) => {
// Invoke child's onClick first so it can prevent default behavior
childOnClick?.(e);
if (e.defaultPrevented) return;
// Fall back to MenuTrigger's own click handler
handleClick(e as any);
},
});
}
return (
<button ref={setRefs} onClick={handleClick} {...props}>
{children}
</button>
);
});
MenuTrigger.displayName = "MenuTrigger";
/* -------------------------------------------------------------------------------------------------
* MenuContent
* -----------------------------------------------------------------------------------------------*/
interface MenuContentProps extends Omit<HTMLMotionProps<"div">, "ref"> {
align?: "start" | "center" | "end";
side?: "top" | "bottom" | "left" | "right";
sideOffset?: number;
alignOffset?: number;
matchTriggerWidth?: boolean;
}
function MenuPortal({
children,
open,
}: {
children: React.ReactNode;
open: boolean;
}) {
const [shouldRender, setShouldRender] = React.useState(false);
React.useEffect(() => {
if (open) {
setShouldRender(true);
}
}, [open]);
if (!shouldRender) return null;
return createPortal(
<AnimatePresence
onExitComplete={() => {
if (!open) {
setShouldRender(false);
}
}}
>
{children}
</AnimatePresence>,
document.body,
);
}
export const MenuContent = React.forwardRef<HTMLDivElement, MenuContentProps>(
(
{
className,
align = "start",
side = "bottom",
sideOffset = 8,
alignOffset = 0,
matchTriggerWidth = false,
children,
...props
},
ref,
) => {
const { open, setOpen, triggerRef } = useMenu();
const [position, setPosition] = React.useState<{
top: number;
left: number;
} | null>(null);
const [menuWidth, setMenuWidth] = React.useState<number>(0);
const menuRef = React.useRef<HTMLDivElement | null>(null);
React.useEffect(() => {
if (open && triggerRef.current) {
const updatePosition = () => {
if (!triggerRef.current) return;
const rect = triggerRef.current.getBoundingClientRect();
const menuPosition = { top: 0, left: 0 };
// Calculate vertical position based on side
if (side === "top") {
menuPosition.top = rect.top - sideOffset;
} else if (side === "bottom") {
menuPosition.top = rect.bottom + sideOffset;
} else if (side === "left" || side === "right") {
menuPosition.top = rect.top;
}
// Calculate horizontal position based on side and alignment
if (side === "left") {
menuPosition.left = rect.left - sideOffset;
} else if (side === "right") {
menuPosition.left = rect.right + sideOffset;
} else if (side === "top" || side === "bottom") {
// For top/bottom placement, handle alignment
if (align === "start") {
menuPosition.left = rect.left + alignOffset;
} else if (align === "center") {
menuPosition.left = rect.left + rect.width / 2;
} else if (align === "end") {
menuPosition.left = rect.right + alignOffset;
}
}
setPosition(menuPosition);
if (matchTriggerWidth) {
setMenuWidth(rect.width);
}
};
// Initial position calculation
updatePosition();
// Update position on scroll/resize
window.addEventListener("scroll", updatePosition, true);
window.addEventListener("resize", updatePosition);
return () => {
window.removeEventListener("scroll", updatePosition, true);
window.removeEventListener("resize", updatePosition);
};
}
}, [
open,
side,
align,
sideOffset,
alignOffset,
matchTriggerWidth,
triggerRef,
]);
// Separate effect to handle menu width calculations after render
React.useEffect(() => {
if (open && position && menuRef.current && triggerRef.current) {
const rect = triggerRef.current.getBoundingClientRect();
const menuRect = menuRef.current.getBoundingClientRect();
// Adjust position for end alignment
if (align === "end" && (side === "top" || side === "bottom")) {
setPosition((prev) => ({
...prev!,
left: rect.right - menuRect.width + alignOffset,
}));
} else if (
align === "center" &&
(side === "top" || side === "bottom")
) {
setPosition((prev) => ({
...prev!,
left: rect.left + rect.width / 2 - menuRect.width / 2,
}));
}
}
}, [open, position, align, side, alignOffset]);
return (
<MenuPortal open={open}>
{open && position && (
<>
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[90]"
onClick={() => setOpen(false)}
/>
{/* Menu */}
<motion.div
ref={(node: HTMLDivElement | null) => {
if (ref) {
if (typeof ref === "function") {
ref(node);
}
}
menuRef.current = node;
}}
initial={{ opacity: 0, y: -6, scale: 1, filter: "blur(1px)" }}
animate={{ opacity: 1, y: 0, scale: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, y: 8, scale: 0.98, filter: "blur(1px)" }}
transition={{
ease: [0.1, 0.1, 0.25, 1],
duration: 0.2,
}}
className={cn(
"fixed z-[100] min-w-[8rem] overflow-hidden rounded-16 border border-border-faint bg-white",
className,
)}
style={{
top: position.top,
left: position.left,
boxShadow:
"0px 12px 24px rgba(0, 0, 0, 0.08), 0px 4px 8px rgba(0, 0, 0, 0.04)",
width: matchTriggerWidth && menuWidth ? menuWidth : undefined,
transform: (() => {
let transforms = [];
// Handle vertical transforms for top/bottom sides
if (side === "top") {
transforms.push("translateY(-100%)");
}
// Handle horizontal transforms for left/right sides
if (side === "left") {
transforms.push("translateX(-100%)");
}
// No transform needed for align="end" since we calculate position directly
if (side === "top" || side === "bottom") {
if (align === "center") {
// Center alignment already handled in position calculation
}
} else if (side === "left" || side === "right") {
if (align === "center") {
transforms.push("translateY(-50%)");
} else if (align === "end") {
transforms.push("translateY(-100%)");
}
}
return transforms.join(" ") || undefined;
})(),
}}
{...props}
>
{children}
</motion.div>
</>
)}
</MenuPortal>
);
},
);
MenuContent.displayName = "MenuContent";
/* -------------------------------------------------------------------------------------------------
* MenuItem
* -----------------------------------------------------------------------------------------------*/
interface MenuItemProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
inset?: boolean;
variant?: "default" | "danger" | "active" | "ghost";
}
export const MenuItem = React.forwardRef<HTMLButtonElement, MenuItemProps>(
(
{ className, inset, onClick, variant = "default", children, ...props },
ref,
) => {
const { setOpen } = useMenu();
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
onClick?.(e);
if (!e.defaultPrevented) {
setOpen(false);
}
};
const baseStyles =
"relative flex w-full cursor-pointer select-none items-center gap-8 rounded-12 px-8 py-6 min-h-[36px] text-sm outline-none transition-all data-[disabled]:pointer-events-none data-[disabled]:opacity-50 active:scale-[0.98]";
const variantStyles = {
default:
"text-foreground-dimmer hover:text-foreground hover:bg-transparent",
danger: "text-red-600/70 hover:text-red-600 hover:bg-red-50",
active: "text-heat-100 bg-heat-8 hover:bg-heat-12",
ghost: "hover:bg-transparent",
};
return (
<button
ref={ref}
className={cn(
baseStyles,
variantStyles[variant],
inset && "pl-14",
className,
)}
onClick={handleClick}
{...props}
>
{children}
</button>
);
},
);
MenuItem.displayName = "MenuItem";
/* -------------------------------------------------------------------------------------------------
* MenuLabel
* -----------------------------------------------------------------------------------------------*/
interface MenuLabelProps extends React.HTMLAttributes<HTMLDivElement> {
inset?: boolean;
}
export const MenuLabel = React.forwardRef<HTMLDivElement, MenuLabelProps>(
({ className, inset, ...props }, ref) => (
<div
ref={ref}
className={cn(
"px-8 py-1.5 text-sm font-semibold",
inset && "pl-8",
className,
)}
{...props}
/>
),
);
MenuLabel.displayName = "MenuLabel";
/* -------------------------------------------------------------------------------------------------
* MenuSeparator
* -----------------------------------------------------------------------------------------------*/
export const MenuSeparator = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("mx-0 my-1 h-px bg-border-faint", className)}
{...props}
/>
));
MenuSeparator.displayName = "MenuSeparator";
/* -------------------------------------------------------------------------------------------------
* MenuGroup
* -----------------------------------------------------------------------------------------------*/
interface MenuGroupProps extends React.HTMLAttributes<HTMLDivElement> {
withHoverBackground?: boolean;
}
export const MenuGroup = React.forwardRef<HTMLDivElement, MenuGroupProps>(
({ className, withHoverBackground = true, children, ...props }, ref) => {
const { backgroundRef, onMouseEnter, onMouseLeave, onClick } =
useDropdownHover();
if (withHoverBackground) {
// Clone children and add hover handlers
const enhancedChildren = React.Children.map(children, (child) => {
if (React.isValidElement(child) && child.type === MenuItem) {
const isActive = child.props.variant === "active";
const isDanger = child.props.variant === "danger";
// Don't apply hover background to active or danger items
const shouldApplyHoverBg = !isActive && !isDanger;
return React.cloneElement(child as React.ReactElement<any>, {
onMouseEnter: shouldApplyHoverBg
? (e: React.MouseEvent<HTMLButtonElement>) => {
onMouseEnter(e);
child.props.onMouseEnter?.(e);
}
: child.props.onMouseEnter,
onMouseLeave: shouldApplyHoverBg
? (e: React.MouseEvent<HTMLButtonElement>) => {
onMouseLeave();
child.props.onMouseLeave?.(e);
}
: child.props.onMouseLeave,
onClick: (e: React.MouseEvent<HTMLButtonElement>) => {
if (shouldApplyHoverBg) onClick(e);
child.props.onClick?.(e);
},
});
}
return child;
});
return (
<div
ref={ref}
className={cn("relative space-y-2 px-8 py-2", className)}
{...props}
>
<div
className="absolute top-0 left-8 right-8 opacity-0 z-[1] pointer-events-none bg-black-alpha-4 rounded-12 h-36"
ref={backgroundRef}
/>
{enhancedChildren}
</div>
);
}
return (
<div
ref={ref}
className={cn("space-y-2 px-8 py-2", className)}
{...props}
>
{children}
</div>
);
},
);
MenuGroup.displayName = "MenuGroup";
/* -------------------------------------------------------------------------------------------------
* MenuHeader
* -----------------------------------------------------------------------------------------------*/
export { MenuHeader } from "./menu-header";
/*
* Example Usage:
*
* <Menu>
* <MenuTrigger asChild>
* <button>Open Menu</button>
* </MenuTrigger>
* <MenuContent className="w-256">
* <MenuHeader title="Title" onClose={() => setOpen(false)} />
*
* <MenuGroup>
* <MenuItem onClick={handleAction}>
* <Icon className="w-16 h-16" />
* <span>Action Item</span>
* </MenuItem>
*
* <MenuItem variant="active">
* <span>Active Item</span>
* </MenuItem>
*
* <MenuSeparator className="!my-2" />
*
* <MenuItem variant="danger" onClick={handleDelete}>
* <span>Delete</span>
* </MenuItem>
* </MenuGroup>
* </MenuContent>
* </Menu>
*
* MenuGroup automatically handles hover background animations
* MenuItem has built-in styling with variants: default, active, danger, ghost
* All padding and spacing is handled centrally
*/