Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
at main 99 lines 3.2 kB view raw
1import React, { useState, useRef, useEffect } from "react"; 2import { MoreHorizontal } from "lucide-react"; 3import { clsx } from "clsx"; 4 5export interface MoreMenuItem { 6 label: string; 7 icon?: React.ReactNode; 8 onClick: () => void; 9 variant?: "default" | "danger"; 10 disabled?: boolean; 11} 12 13interface MoreMenuProps { 14 items: MoreMenuItem[]; 15 className?: string; 16} 17 18export default function MoreMenu({ items, className }: MoreMenuProps) { 19 const [isOpen, setIsOpen] = useState(false); 20 const buttonRef = useRef<HTMLButtonElement>(null); 21 const menuRef = useRef<HTMLDivElement>(null); 22 23 useEffect(() => { 24 if (!isOpen) return; 25 26 const handleClickOutside = (e: MouseEvent) => { 27 if ( 28 menuRef.current && 29 !menuRef.current.contains(e.target as Node) && 30 buttonRef.current && 31 !buttonRef.current.contains(e.target as Node) 32 ) { 33 setIsOpen(false); 34 } 35 }; 36 37 const handleScroll = () => setIsOpen(false); 38 const handleEscape = (e: KeyboardEvent) => { 39 if (e.key === "Escape") setIsOpen(false); 40 }; 41 42 document.addEventListener("mousedown", handleClickOutside); 43 document.addEventListener("scroll", handleScroll, true); 44 document.addEventListener("keydown", handleEscape); 45 46 return () => { 47 document.removeEventListener("mousedown", handleClickOutside); 48 document.removeEventListener("scroll", handleScroll, true); 49 document.removeEventListener("keydown", handleEscape); 50 }; 51 }, [isOpen]); 52 53 if (items.length === 0) return null; 54 55 return ( 56 <div className={clsx("relative", className)}> 57 <button 58 ref={buttonRef} 59 onClick={() => setIsOpen(!isOpen)} 60 className="flex items-center px-2 py-1.5 rounded-lg text-surface-400 dark:text-surface-500 hover:text-surface-600 dark:hover:text-surface-300 hover:bg-surface-100 dark:hover:bg-surface-800 transition-all" 61 title="More options" 62 > 63 <MoreHorizontal size={16} /> 64 </button> 65 66 {isOpen && ( 67 <div 68 ref={menuRef} 69 className="absolute right-0 top-full mt-1 z-50 min-w-[180px] bg-white dark:bg-surface-900 border border-surface-200 dark:border-surface-700 rounded-xl shadow-lg py-1 animate-fade-in" 70 > 71 {items.map((item, i) => ( 72 <button 73 key={i} 74 onClick={() => { 75 item.onClick(); 76 setIsOpen(false); 77 }} 78 disabled={item.disabled} 79 className={clsx( 80 "w-full flex items-center gap-2.5 px-3.5 py-2 text-sm transition-colors text-left", 81 item.variant === "danger" 82 ? "text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20" 83 : "text-surface-700 dark:text-surface-300 hover:bg-surface-50 dark:hover:bg-surface-800", 84 item.disabled && "opacity-50 cursor-not-allowed", 85 )} 86 > 87 {item.icon && ( 88 <span className="flex-shrink-0 w-4 h-4 flex items-center justify-center"> 89 {item.icon} 90 </span> 91 )} 92 {item.label} 93 </button> 94 ))} 95 </div> 96 )} 97 </div> 98 ); 99}