All files / src/components/ui dropdown-menu.tsx

90% Statements 18/20
93.75% Branches 15/16
90% Functions 9/10
93.75% Lines 15/16

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98                                107x 107x   107x 101x     3x       1x       12x 12x       6x 6x 6x     107x             13x                                           27x         6x                                            
import { useState, useRef, useEffect, type ReactNode } from "react";
 
export interface DropdownMenuItem {
	label: string;
	icon?: ReactNode;
	onClick: () => void;
	variant?: "default" | "destructive";
	disabled?: boolean;
}
 
interface DropdownMenuProps {
	trigger: ReactNode;
	items: DropdownMenuItem[];
}
 
export function DropdownMenu({ trigger, items }: DropdownMenuProps) {
	const [isOpen, setIsOpen] = useState(false);
	const containerRef = useRef<HTMLDivElement>(null);
 
	useEffect(() => {
		if (!isOpen) return;
 
		function handleMouseDown(event: MouseEvent) {
			if (
				containerRef.current &&
				!containerRef.current.contains(event.target as Node)
			) {
				setIsOpen(false);
			}
		}
 
		document.addEventListener("mousedown", handleMouseDown);
		return () => document.removeEventListener("mousedown", handleMouseDown);
	}, [isOpen]);
 
	function handleItemClick(item: DropdownMenuItem) {
		Iif (item.disabled) return;
		setIsOpen(false);
		item.onClick();
	}
 
	return (
		<div ref={containerRef} className="relative">
			{/* Trigger */}
			<button
				type="button"
				aria-haspopup="menu"
				aria-expanded={isOpen}
				onClick={() => setIsOpen((prev) => !prev)}
				className="cursor-pointer"
			>
				{trigger}
			</button>
 
			{/* Mobile backdrop */}
			{isOpen && (
				<div
					className="fixed inset-0 z-40 sm:hidden"
					aria-hidden="true"
					onClick={() => setIsOpen(false)}
				/>
			)}
 
			{/* Dropdown panel */}
			{isOpen && (
				<div
					role="menu"
					className="absolute left-0 top-full mt-1 z-50 min-w-[160px] rounded-lg border border-border-default bg-background-elevated shadow-lg transition-opacity duration-150"
				>
					{items.map((item) => (
						<button
							key={item.label}
							type="button"
							role="menuitem"
							disabled={item.disabled}
							onClick={() => handleItemClick(item)}
							className={[
								"flex w-full items-center gap-2 px-3 py-2 text-sm first:rounded-t-lg last:rounded-b-lg",
								"disabled:pointer-events-none disabled:opacity-50",
								item.variant === "destructive"
									? "text-error hover:bg-error-light"
									: "text-foreground-default hover:bg-background-muted",
							].join(" ")}
						>
							{item.icon && (
								<span className="shrink-0 [&_svg]:h-4 [&_svg]:w-4">
									{item.icon}
								</span>
							)}
							{item.label}
						</button>
					))}
				</div>
			)}
		</div>
	);
}