"use client"; import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; import { useControllableState } from "@radix-ui/react-use-controllable-state"; import { Check } from "lucide-react"; import Link from "../../embed-shims/next-link"; import React, { useCallback } from "react"; import { Chevron02RightIcon, Ellipsis01Icon } from "../icons-v2-generated"; import { cn } from "../../utils/cn"; import { Button } from "./button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger, } from "./dropdown-menu"; import { COLLISION_PADDING_PX, useCollisionBoundary, usePortalContainer, } from "./portal-container"; import { useReportOverlayOpen } from "./overlay-open-registry"; /** Trigger movement (px) that counts as "the user scrolled away" rather than * touch-momentum jitter or the sub-pixel settle right after opening. */ const SCROLL_DISMISS_THRESHOLD_PX = 12; export interface ActionsMenuItemIconAction { icon: React.ReactNode; "aria-label": string; onClick?: () => void; href?: string; openInNewTab?: boolean; disabled?: boolean; } export interface ActionsMenuItem { id: string; label: string; icon?: React.ReactNode; onClick?: () => void; disabled?: boolean; type?: "item" | "checkbox" | "submenu" | "separator"; checked?: boolean; /** * Keep the dropdown open after this item is clicked instead of closing it * (e.g. multi-select add). Only affects ActionsMenuDropdown; `checkbox` and * `submenu` items always keep the menu open regardless. Defaults to closing. */ closeOnSelect?: boolean; submenu?: ActionsMenuItem[]; /** Render the row in the error/destructive color (label + icon). */ danger?: boolean; /** Optional URL for navigation items */ href?: string; /** Open the main-row `href` in a new tab (external app deep-links). */ openInNewTab?: boolean; /** * Optional secondary action — a 40px-wide button on the right of the row * with a vertical divider. The main row keeps its primary click target; * the secondary is independently clickable (e.g. "open in new tab"). */ iconAction?: ActionsMenuItemIconAction; } export interface ActionsMenuGroup { id?: string; items: ActionsMenuItem[]; separator?: boolean; } export interface ActionsMenuProps { groups: ActionsMenuGroup[]; className?: string; onItemClick?: (item: ActionsMenuItem) => void; } interface MenuItemProps { item: ActionsMenuItem; onItemClick?: (item: ActionsMenuItem) => void; } const ROW_CLASSES = "flex flex-1 min-w-0 items-center gap-[var(--spacing-system-xsf)] p-[var(--spacing-system-s)] cursor-pointer transition-colors bg-ods-bg outline-none"; const WRAPPER_CLASSES = "relative flex items-stretch border-b border-ods-border last:border-b-0"; const SECONDARY_ACTION_CLASSES = "flex p-[var(--spacing-system-s)] shrink-0 items-center justify-center self-stretch border-l border-ods-border transition-colors hover:bg-ods-bg-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ods-focus [&_svg]:w-4 [&_svg]:h-4 md:[&_svg]:w-6 md:[&_svg]:h-6"; const SecondaryAction: React.FC<{ action: ActionsMenuItemIconAction }> = ({ action }) => { const handleClick = useCallback( (e: React.MouseEvent) => { e.stopPropagation(); if (action.disabled) { e.preventDefault(); return; } action.onClick?.(); }, [action], ); const classes = cn( SECONDARY_ACTION_CLASSES, action.openInNewTab && "max-md:hidden", action.disabled && "cursor-not-allowed opacity-60 pointer-events-none", ); if (action.href) { return ( {action.icon} ); } return ( ); }; const MenuItem: React.FC = ({ item, onItemClick }) => { // Submenus below portal + collide against the OWNING surface, matching the // root menu (`DropdownMenuContent`). Read unconditionally — hooks first. const portalContainer = usePortalContainer(); const collisionBoundary = useCollisionBoundary(); const activate = useCallback(() => { if (item.disabled) return; if (item.type === "checkbox") { item.onClick?.(); onItemClick?.(item); return; } if (item.type === "submenu") return; item.onClick?.(); onItemClick?.(item); }, [item, onItemClick]); const handleClick = useCallback( (e: React.MouseEvent) => { e.stopPropagation(); e.preventDefault(); activate(); }, [activate], ); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key !== "Enter" && e.key !== " ") return; e.preventDefault(); e.stopPropagation(); activate(); }, [activate], ); const handleLinkClick = useCallback( (e: React.MouseEvent) => { if (item.disabled) { e.preventDefault(); e.stopPropagation(); return; } item.onClick?.(); onItemClick?.(item); }, [item, onItemClick], ); if (item.type === "separator") { return
; } const itemClasses = cn( ROW_CLASSES, item.disabled ? "text-ods-text-secondary cursor-not-allowed pointer-events-none opacity-60" : "text-ods-text-primary hover:bg-ods-bg-hover", ); const subTriggerClasses = cn( itemClasses, "data-[state=open]:bg-ods-bg-active focus:bg-ods-bg-hover", ); const renderAsLink = !!item.href && item.type !== "submenu" && item.type !== "checkbox"; const rowContent = ( <> {item.icon && (
{item.icon}
)} {item.label} {item.type === "checkbox" && (
{item.checked && ( )}
)} {item.type === "submenu" && ( )} ); if (renderAsLink && item.href) { return (
{rowContent} {item.iconAction && }
); } if (item.type === "submenu" && item.submenu) { return (
{rowContent} {item.submenu.map((subItem, index) => ( ))} {item.iconAction && }
); } return (
{rowContent}
{item.iconAction && }
); }; const GroupSeparator: React.FC = () => (
); export const ActionsMenu: React.FC = ({ groups, className = "", onItemClick, }) => { return (
{groups.map((group, groupIndex) => { const groupKey = group.id || group.items.map((i) => i.id).join("|"); return ( {group.items.map((item, itemIndex) => ( ))} {group.separator && groupIndex < groups.length - 1 && ( )} ); })}
); }; export interface ActionsMenuDropdownProps extends ActionsMenuProps { trigger?: React.ReactNode; /** Replace the entire default trigger button. When set, rendered directly as the DropdownMenuTrigger child. */ customTrigger?: React.ReactNode; triggerAriaLabel?: string; triggerClassName?: string; contentClassName?: string; align?: "start" | "center" | "end"; side?: "top" | "right" | "bottom" | "left"; sideOffset?: number; /** Controlled open state. Pair with `onOpenChange`. Uncontrolled by default. */ open?: boolean; /** Open-state change handler (also fires when an item closes the menu). */ onOpenChange?: (open: boolean) => void; /** Forwarded to the dropdown content — e.g. `e.preventDefault()` to stop * Radix returning focus (and its focus ring) to the trigger on close. */ onCloseAutoFocus?: (event: Event) => void; } export const ActionsMenuDropdown: React.FC = ({ groups, onItemClick, className, trigger, customTrigger, triggerAriaLabel = "More actions", triggerClassName, contentClassName, align = "end", side = "bottom", sideOffset = 6, open: openProp, onOpenChange, onCloseAutoFocus, }) => { const [open = false, setOpen] = useControllableState({ prop: openProp, defaultProp: false, onChange: onOpenChange, }); // Tell the surrounding surface an overlay is open, so it can stop moving // the ground under it (the chat thread suspends its follow-the-bottom // auto-scroll — see `OverlayOpenRegistryProvider`). Inert without a // provider, so menus elsewhere are unaffected. useReportOverlayOpen(open); // Dismiss once the USER scrolls the trigger away. // // Two different situations, two different answers. Content moving on its own // (a streaming reply) is handled above by suspending the auto-scroll — the // menu must survive that, nobody asked for it to close. A deliberate scroll // is the opposite: the reader left, and an anchored menu would ride along // until it hovers over unrelated chrome. `hideWhenDetached` only kicks in // once the trigger is FULLY clipped, so a half-scrolled trigger still drags // a visible menu across the header. // // Scoped deliberately: // • only scrolls of containers that actually contain the trigger — a // scroll in a neighbouring column is none of our business; // • only past `SCROLL_DISMISS_THRESHOLD_PX` of movement, so touch // momentum and the ~1px settle after opening don't close the menu the // user is reaching for. const triggerRef = React.useRef(null); React.useEffect(() => { if (!open) return; if (typeof document === "undefined") return; const trigger = triggerRef.current; if (!trigger) return; const anchorTop = trigger.getBoundingClientRect().top; const onScroll = (event: Event) => { const target = event.target as Node | null; // `document` fires for the page scroll and contains everything. const scrolledTheTrigger = target === document || (target instanceof Node && target.contains(trigger)); if (!scrolledTheTrigger) return; const moved = Math.abs(trigger.getBoundingClientRect().top - anchorTop); if (moved > SCROLL_DISMISS_THRESHOLD_PX) setOpen(false); }; document.addEventListener("scroll", onScroll, { capture: true, passive: true }); return () => document.removeEventListener("scroll", onScroll, { capture: true }); }, [open, setOpen]); const handleItemClick = useCallback( (item: ActionsMenuItem) => { onItemClick?.(item); if ( item.type !== "checkbox" && item.type !== "submenu" && item.closeOnSelect !== false ) { setOpen(false); } }, [onItemClick, setOpen], ); return ( {customTrigger ?? (