refactor: replace ContextMenu with DropdownMenu in SearchSpaceAvatar for improved mobile and desktop interactions

This commit is contained in:
Anish Sarkar 2026-05-21 00:19:59 +05:30
parent f5f2456dfd
commit 8c91c9d76b

View file

@ -2,14 +2,9 @@
import { Settings, Trash2, Users } from "lucide-react"; import { Settings, Trash2, Users } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import type { MouseEvent } from "react";
import { useCallback, useRef, useState } from "react"; import { useCallback, useRef, useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@ -80,19 +75,28 @@ export function SearchSpaceAvatar({
const initials = getInitials(name); const initials = getInitials(name);
const sizeClasses = size === "sm" ? "h-8 w-8 text-xs" : "h-10 w-10 text-sm"; const sizeClasses = size === "sm" ? "h-8 w-8 text-xs" : "h-10 w-10 text-sm";
// Long-press state for mobile const [menuOpen, setMenuOpen] = useState(false);
const [longPressMenuOpen, setLongPressMenuOpen] = useState(false);
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null); const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const touchMoved = useRef(false); const touchMoved = useRef(false);
const openMenu = useCallback(() => {
setMenuOpen(true);
}, []);
const handleContextMenu = useCallback((event: MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
setMenuOpen(true);
}, []);
const handleTouchStart = useCallback(() => { const handleTouchStart = useCallback(() => {
touchMoved.current = false; touchMoved.current = false;
longPressTimer.current = setTimeout(() => { longPressTimer.current = setTimeout(() => {
if (!touchMoved.current) { if (!touchMoved.current) {
setLongPressMenuOpen(true); openMenu();
} }
}, 500); }, 500);
}, []); }, [openMenu]);
const handleTouchMove = useCallback(() => { const handleTouchMove = useCallback(() => {
touchMoved.current = true; touchMoved.current = true;
@ -120,12 +124,26 @@ export function SearchSpaceAvatar({
</div> </div>
); );
const avatarButton = ( const avatarButton = (withMenuHandlers = false) => (
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={onClick} onClick={onClick}
onPointerDown={
withMenuHandlers
? (event) => {
if (event.button === 0) {
event.preventDefault();
}
}
: undefined
}
onContextMenu={withMenuHandlers ? handleContextMenu : undefined}
onTouchStart={withMenuHandlers ? handleTouchStart : undefined}
onTouchMove={withMenuHandlers ? handleTouchMove : undefined}
onTouchEnd={withMenuHandlers ? handleTouchEnd : undefined}
onTouchCancel={withMenuHandlers ? handleTouchEnd : undefined}
className={cn( className={cn(
"relative rounded-lg font-semibold text-white transition-all select-none", "relative rounded-lg font-semibold text-white transition-all select-none",
"hover:text-white hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", "hover:text-white hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
@ -173,73 +191,41 @@ export function SearchSpaceAvatar({
</> </>
); );
// If delete or settings handlers are provided, wrap with context menu // If delete or settings handlers are provided, expose them through a dropdown menu.
if (onDelete || onSettings) { if (onDelete || onSettings) {
// Mobile: use long-press triggered DropdownMenu const trigger = (
if (disableTooltip) { <DropdownMenuTrigger asChild>
return ( {avatarButton(true)}
<DropdownMenu open={longPressMenuOpen} onOpenChange={setLongPressMenuOpen}> </DropdownMenuTrigger>
<DropdownMenuTrigger asChild> );
<div
className="inline-block"
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onTouchCancel={handleTouchEnd}
>
{avatarButton}
</div>
</DropdownMenuTrigger>
<DropdownMenuContent>{menuItems}</DropdownMenuContent>
</DropdownMenu>
);
}
// Desktop: use right-click ContextMenu + Tooltip
return ( return (
<ContextMenu> <DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
<Tooltip> {disableTooltip ? (
<TooltipTrigger asChild> trigger
<ContextMenuTrigger asChild> ) : (
<div className="inline-block">{avatarButton}</div> <Tooltip>
</ContextMenuTrigger> <TooltipTrigger asChild>{trigger}</TooltipTrigger>
</TooltipTrigger> <TooltipContent side="right" sideOffset={8}>
<TooltipContent side="right" sideOffset={8}> {tooltipContent}
{tooltipContent} </TooltipContent>
</TooltipContent> </Tooltip>
</Tooltip> )}
<ContextMenuContent> <DropdownMenuContent side="right" align="start">
{onSettings && ( {menuItems}
<ContextMenuItem onClick={onSettings}> </DropdownMenuContent>
<Settings className="mr-2 h-4 w-4" /> </DropdownMenu>
{tCommon("settings")}
</ContextMenuItem>
)}
{onDelete && isOwner && (
<ContextMenuItem onClick={onDelete}>
<Trash2 className="mr-2 h-4 w-4" />
{tCommon("delete")}
</ContextMenuItem>
)}
{onDelete && !isOwner && (
<ContextMenuItem onClick={onDelete}>
<Trash2 className="mr-2 h-4 w-4" />
{t("leave")}
</ContextMenuItem>
)}
</ContextMenuContent>
</ContextMenu>
); );
} }
// No context menu needed // No context menu needed
if (disableTooltip) { if (disableTooltip) {
return avatarButton; return avatarButton();
} }
return ( return (
<Tooltip> <Tooltip>
<TooltipTrigger asChild>{avatarButton}</TooltipTrigger> <TooltipTrigger asChild>{avatarButton()}</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}> <TooltipContent side="right" sideOffset={8}>
{tooltipContent} {tooltipContent}
</TooltipContent> </TooltipContent>