(READ ONLY) Margin is an open annotation layer for the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
1import React, { useState, useRef, useEffect, useCallback } from "react";
2import { MoreHorizontal, X } 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 [isMobile, setIsMobile] = useState(false);
21 const buttonRef = useRef<HTMLButtonElement>(null);
22 const menuRef = useRef<HTMLDivElement>(null);
23 const sheetRef = useRef<HTMLDivElement>(null);
24 const dragStartY = useRef(0);
25 const dragCurrentY = useRef(0);
26
27 useEffect(() => {
28 const check = () => setIsMobile(window.innerWidth < 640);
29 check();
30 window.addEventListener("resize", check);
31 return () => window.removeEventListener("resize", check);
32 }, []);
33
34 useEffect(() => {
35 if (!isOpen || isMobile) return;
36
37 const handleClickOutside = (e: MouseEvent) => {
38 if (
39 menuRef.current &&
40 !menuRef.current.contains(e.target as Node) &&
41 buttonRef.current &&
42 !buttonRef.current.contains(e.target as Node)
43 ) {
44 setIsOpen(false);
45 }
46 };
47
48 const handleScroll = () => setIsOpen(false);
49 const handleEscape = (e: KeyboardEvent) => {
50 if (e.key === "Escape") setIsOpen(false);
51 };
52
53 document.addEventListener("mousedown", handleClickOutside);
54 document.addEventListener("scroll", handleScroll, true);
55 document.addEventListener("keydown", handleEscape);
56
57 return () => {
58 document.removeEventListener("mousedown", handleClickOutside);
59 document.removeEventListener("scroll", handleScroll, true);
60 document.removeEventListener("keydown", handleEscape);
61 };
62 }, [isOpen, isMobile]);
63
64 const handleTouchStart = useCallback((e: React.TouchEvent) => {
65 dragStartY.current = e.touches[0].clientY;
66 if (sheetRef.current) sheetRef.current.style.transition = "none";
67 }, []);
68
69 const handleTouchMove = useCallback((e: React.TouchEvent) => {
70 const delta = e.touches[0].clientY - dragStartY.current;
71 dragCurrentY.current = delta;
72 if (delta > 0 && sheetRef.current) {
73 sheetRef.current.style.transform = `translateY(${delta}px)`;
74 }
75 }, []);
76
77 const handleTouchEnd = useCallback(() => {
78 if (sheetRef.current) {
79 sheetRef.current.style.transition = "transform 0.3s ease";
80 if (dragCurrentY.current > 100) {
81 sheetRef.current.style.transform = "translateY(100%)";
82 setTimeout(() => setIsOpen(false), 300);
83 } else {
84 sheetRef.current.style.transform = "translateY(0)";
85 }
86 }
87 dragCurrentY.current = 0;
88 }, []);
89
90 if (items.length === 0) return null;
91
92 return (
93 <div className={clsx("relative", className)}>
94 <button
95 ref={buttonRef}
96 onClick={() => setIsOpen(!isOpen)}
97 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"
98 title="More options"
99 >
100 <MoreHorizontal size={16} />
101 </button>
102
103 {isOpen && isMobile && (
104 <>
105 <div
106 className="fixed inset-0 bg-black/40 z-[999]"
107 onClick={() => setIsOpen(false)}
108 />
109 <div className="fixed bottom-0 left-0 right-0 z-[1000] animate-slide-up">
110 <div
111 ref={sheetRef}
112 className="mx-2 mb-2 bg-white dark:bg-surface-900 rounded-2xl shadow-xl border border-surface-200 dark:border-surface-700 overflow-hidden"
113 style={{ paddingBottom: "env(safe-area-inset-bottom)" }}
114 >
115 <div
116 className="flex justify-center pt-3 pb-1 cursor-grab active:cursor-grabbing touch-none"
117 onTouchStart={handleTouchStart}
118 onTouchMove={handleTouchMove}
119 onTouchEnd={handleTouchEnd}
120 >
121 <div className="w-8 h-1 bg-surface-200 dark:bg-surface-700 rounded-full" />
122 </div>
123 <div className="flex items-center justify-between px-4 pt-1 pb-2">
124 <span className="text-sm font-semibold text-surface-900 dark:text-white">
125 Options
126 </span>
127 <button
128 onClick={() => setIsOpen(false)}
129 className="p-1 rounded-lg text-surface-400 hover:text-surface-600 dark:hover:text-surface-200 hover:bg-surface-100 dark:hover:bg-surface-700 transition-colors"
130 >
131 <X size={16} />
132 </button>
133 </div>
134 <div className="px-2 pb-2">
135 {items.map((item, i) => (
136 <button
137 key={i}
138 onClick={() => {
139 item.onClick();
140 setIsOpen(false);
141 }}
142 disabled={item.disabled}
143 className={clsx(
144 "w-full flex items-center gap-3 px-3 py-2.5 text-[14px] font-medium transition-colors rounded-lg",
145 item.variant === "danger"
146 ? "text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20"
147 : "text-surface-700 dark:text-surface-200 hover:bg-surface-50 dark:hover:bg-surface-800",
148 item.disabled && "opacity-50 cursor-not-allowed",
149 )}
150 >
151 {item.icon && (
152 <span className="flex items-center justify-center w-5 h-5 text-surface-400 dark:text-surface-500">
153 {item.icon}
154 </span>
155 )}
156 {item.label}
157 </button>
158 ))}
159 </div>
160 </div>
161 </div>
162 </>
163 )}
164
165 {isOpen && !isMobile && (
166 <div
167 ref={menuRef}
168 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"
169 >
170 {items.map((item, i) => (
171 <button
172 key={i}
173 onClick={() => {
174 item.onClick();
175 setIsOpen(false);
176 }}
177 disabled={item.disabled}
178 className={clsx(
179 "w-full flex items-center gap-2.5 px-3.5 py-2 text-sm transition-colors text-left",
180 item.variant === "danger"
181 ? "text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20"
182 : "text-surface-700 dark:text-surface-300 hover:bg-surface-50 dark:hover:bg-surface-800",
183 item.disabled && "opacity-50 cursor-not-allowed",
184 )}
185 >
186 {item.icon && (
187 <span className="flex-shrink-0 w-4 h-4 flex items-center justify-center">
188 {item.icon}
189 </span>
190 )}
191 {item.label}
192 </button>
193 ))}
194 </div>
195 )}
196 </div>
197 );
198}