"use client"; import { EllipsisVertical } from "lucide-react"; import { ComponentProps, ReactNode, useEffect, useState } from "react"; import { cn } from "@/lib/utils"; import { Tooltip, TooltipContent, TooltipTrigger } from "../tooltip"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "./dropdown"; const ACTION_TRIGGER_STYLES = { table: "hover:bg-bg-neutral-tertiary rounded-full p-1 transition-colors", bordered: "hover:bg-bg-neutral-tertiary rounded-md p-1.5 transition-colors", } as const; type ActionDropdownVariant = keyof typeof ACTION_TRIGGER_STYLES; interface ActionDropdownProps { /** The dropdown trigger element. Defaults to a vertical dots icon button */ trigger?: ReactNode; /** Trigger style variant. "table" = compact pill, "bordered" = card action */ variant?: ActionDropdownVariant; /** Alignment of the dropdown content */ align?: "start" | "center" | "end"; /** Additional className for the content */ className?: string; /** Accessible label for the trigger */ ariaLabel?: string; children: ReactNode; } export function ActionDropdown({ trigger, variant = "table", align = "end", className, ariaLabel = "Open actions menu", children, }: ActionDropdownProps) { const [open, setOpen] = useState(false); // Close dropdown when any ancestor scrolls (capture phase catches all scroll events), // but ignore scrolls originating inside a nested dialog (e.g. pasting into a modal // textarea) so they don't unmount a modal rendered within this menu. useEffect(() => { if (!open) return; const handleScroll = (event: Event) => { const target = event.target; if ( target instanceof Element && target.closest('[data-slot="dialog-content"]') ) { return; } setOpen(false); }; window.addEventListener("scroll", handleScroll, true); return () => window.removeEventListener("scroll", handleScroll, true); }, [open]); return ( {trigger ?? ( )} {children} ); } interface ActionDropdownItemProps extends Omit, "children"> { /** Icon displayed before the label */ icon?: ReactNode; /** Main label text */ label: ReactNode; /** Optional description text below the label */ description?: string; /** Whether the item is destructive (danger styling) */ destructive?: boolean; /** Tooltip shown while the item remains interactive. */ tooltip?: string; /** Tooltip shown when the item is disabled. */ disabledTooltip?: string; } export function ActionDropdownItem({ icon, label, description, destructive = false, className, tooltip, disabledTooltip, disabled, onSelect, ...props }: ActionDropdownItemProps) { const item = ( { if (disabled) { event.preventDefault(); return; } onSelect?.(event); }} {...props} > {icon && ( svg]:size-4", destructive && "text-text-error-primary", )} > {icon} )}
{label} {description && ( {description} )}
); const tooltipContent = tooltip ?? (disabled ? disabledTooltip : undefined); if (tooltipContent) { return ( {item} {tooltipContent} ); } return item; } export function ActionDropdownDangerZone({ children, }: { children: ReactNode; }) { return ( <> Danger zone {children} ); }