Write on the margins of the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
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}