Files
spsays-training/components/social/comment/comment-menu.tsx
T

175 lines
5.3 KiB
TypeScript

"use client";
/**
* CommentMenu — the kebab (⋮) overflow menu on a comment: Pin / Report /
* Delete (each shown only when permitted). The menu is portaled (fixed
* position) so it isn't clipped by the comments drawer's overflow, and it
* dismisses via a deferred document `pointerdown`-capture listener + Escape +
* close-on-scroll; the trigger itself toggles. Closing only ever flips this
* menu's own state, so it never closes the surrounding vaul drawer.
*/
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Flag, MoreVertical, Pin, PinOff, Trash2 } from "lucide-react";
import { cn } from "@/lib/utils";
export function CommentMenu({
canPin,
isPinned,
canDelete,
canReport,
onPin,
onDelete,
onReport,
}: {
canPin: boolean;
isPinned: boolean;
canDelete: boolean;
canReport: boolean;
onPin: () => void;
onDelete: () => void;
onReport: () => void;
}) {
const [open, setOpen] = useState(false);
const [pos, setPos] = useState<{ top: number; right: number } | null>(null);
const btnRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
// Dismiss on any outside pointer-down, Escape, or scroll. Deferred by a tick
// so the same click that opened the menu doesn't immediately close it.
// Capture phase so it fires regardless of where the target lives in the tree.
useEffect(() => {
if (!open) return;
function onDown(e: PointerEvent) {
const t = e.target as Node;
if (menuRef.current?.contains(t) || btnRef.current?.contains(t)) return;
setOpen(false);
}
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") setOpen(false);
}
function onScroll() {
setOpen(false);
}
const id = window.setTimeout(() => {
document.addEventListener("pointerdown", onDown, true);
document.addEventListener("keydown", onKey);
window.addEventListener("scroll", onScroll, true);
}, 0);
return () => {
window.clearTimeout(id);
document.removeEventListener("pointerdown", onDown, true);
document.removeEventListener("keydown", onKey);
window.removeEventListener("scroll", onScroll, true);
};
}, [open]);
// Nothing actionable → no kebab at all.
if (!canPin && !canDelete && !canReport) return null;
function toggle() {
if (open) {
setOpen(false);
return;
}
const r = btnRef.current?.getBoundingClientRect();
if (r) setPos({ top: r.bottom + 4, right: window.innerWidth - r.right });
setOpen(true);
}
return (
<>
<button
ref={btnRef}
type="button"
onClick={toggle}
aria-label="Comment options"
aria-haspopup="menu"
aria-expanded={open}
className="-mr-1 -mt-0.5 size-7 shrink-0 inline-flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
>
<MoreVertical className="size-4" />
</button>
{open && pos && typeof document !== "undefined"
? createPortal(
<div
ref={menuRef}
role="menu"
style={{ position: "fixed", top: pos.top, right: pos.right }}
// pointer-events-auto: the comments drawer is modal (vaul sets
// body { pointer-events: none } while open); this portaled menu
// would be click-through without re-enabling it here.
className="pointer-events-auto z-[101] min-w-40 rounded-lg border border-border bg-card shadow-lg py-1 animate-in fade-in-0 zoom-in-95"
>
{canPin ? (
<MenuItem
icon={isPinned ? <PinOff className="size-4" /> : <Pin className="size-4" />}
onClick={() => {
setOpen(false);
onPin();
}}
>
{isPinned ? "Unpin" : "Pin"}
</MenuItem>
) : null}
{canReport ? (
<MenuItem
icon={<Flag className="size-4" />}
onClick={() => {
setOpen(false);
onReport();
}}
>
Report
</MenuItem>
) : null}
{canDelete ? (
<MenuItem
icon={<Trash2 className="size-4" />}
danger
onClick={() => {
setOpen(false);
onDelete();
}}
>
Delete
</MenuItem>
) : null}
</div>,
document.body,
)
: null}
</>
);
}
function MenuItem({
icon,
children,
onClick,
danger = false,
}: {
icon: React.ReactNode;
children: React.ReactNode;
onClick: () => void;
danger?: boolean;
}) {
return (
<button
type="button"
role="menuitem"
onClick={onClick}
className={cn(
"flex w-full items-center gap-2.5 px-3 py-2 text-left text-sm transition-colors",
danger
? "text-destructive hover:bg-destructive/10"
: "text-foreground hover:bg-muted",
)}
>
<span className="shrink-0">{icon}</span>
{children}
</button>
);
}