feat: enhance context menu functionality in DocumentNode and FolderNode components

This commit is contained in:
Anish Sarkar 2026-03-27 23:14:10 +05:30
parent ddccba0df8
commit 13f4b175a6
3 changed files with 45 additions and 35 deletions

View file

@ -10,14 +10,12 @@ import {
ContextMenu, ContextMenu,
ContextMenuContent, ContextMenuContent,
ContextMenuItem, ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger, ContextMenuTrigger,
} from "@/components/ui/context-menu"; } from "@/components/ui/context-menu";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import type { DocumentTypeEnum } from "@/contracts/types/document.types"; import type { DocumentTypeEnum } from "@/contracts/types/document.types";
@ -41,6 +39,8 @@ interface DocumentNodeProps {
onEdit: (doc: DocumentNodeDoc) => void; onEdit: (doc: DocumentNodeDoc) => void;
onDelete: (doc: DocumentNodeDoc) => void; onDelete: (doc: DocumentNodeDoc) => void;
onMove: (doc: DocumentNodeDoc) => void; onMove: (doc: DocumentNodeDoc) => void;
contextMenuOpen?: boolean;
onContextMenuOpenChange?: (open: boolean) => void;
} }
export const DocumentNode = React.memo(function DocumentNode({ export const DocumentNode = React.memo(function DocumentNode({
@ -52,6 +52,8 @@ export const DocumentNode = React.memo(function DocumentNode({
onEdit, onEdit,
onDelete, onDelete,
onMove, onMove,
contextMenuOpen,
onContextMenuOpenChange,
}: DocumentNodeProps) { }: DocumentNodeProps) {
const statusState = doc.status?.state ?? "ready"; const statusState = doc.status?.state ?? "ready";
const isSelectable = statusState !== "pending" && statusState !== "processing"; const isSelectable = statusState !== "pending" && statusState !== "processing";
@ -76,7 +78,7 @@ export const DocumentNode = React.memo(function DocumentNode({
const isProcessing = statusState === "pending" || statusState === "processing"; const isProcessing = statusState === "pending" || statusState === "processing";
return ( return (
<ContextMenu> <ContextMenu onOpenChange={onContextMenuOpenChange}>
<ContextMenuTrigger asChild> <ContextMenuTrigger asChild>
{/* biome-ignore lint/a11y/useSemanticElements: div required for drag ref */} {/* biome-ignore lint/a11y/useSemanticElements: div required for drag ref */}
<div <div
@ -131,7 +133,7 @@ export const DocumentNode = React.memo(function DocumentNode({
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-6 w-6 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity" className="hidden sm:inline-flex h-6 w-6 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<MoreHorizontal className="h-3.5 w-3.5" /> <MoreHorizontal className="h-3.5 w-3.5" />
@ -152,7 +154,6 @@ export const DocumentNode = React.memo(function DocumentNode({
<Move className="mr-2 h-4 w-4" /> <Move className="mr-2 h-4 w-4" />
Move to... Move to...
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem
className="text-destructive focus:text-destructive" className="text-destructive focus:text-destructive"
disabled={isProcessing} disabled={isProcessing}
@ -166,6 +167,7 @@ export const DocumentNode = React.memo(function DocumentNode({
</div> </div>
</ContextMenuTrigger> </ContextMenuTrigger>
{contextMenuOpen && (
<ContextMenuContent className="w-40"> <ContextMenuContent className="w-40">
<ContextMenuItem onClick={() => onPreview(doc)}> <ContextMenuItem onClick={() => onPreview(doc)}>
<Eye className="mr-2 h-4 w-4" /> <Eye className="mr-2 h-4 w-4" />
@ -181,7 +183,6 @@ export const DocumentNode = React.memo(function DocumentNode({
<Move className="mr-2 h-4 w-4" /> <Move className="mr-2 h-4 w-4" />
Move to... Move to...
</ContextMenuItem> </ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem <ContextMenuItem
className="text-destructive focus:text-destructive" className="text-destructive focus:text-destructive"
disabled={isProcessing} disabled={isProcessing}
@ -191,6 +192,7 @@ export const DocumentNode = React.memo(function DocumentNode({
Delete Delete
</ContextMenuItem> </ContextMenuItem>
</ContextMenuContent> </ContextMenuContent>
)}
</ContextMenu> </ContextMenu>
); );
}); });

View file

@ -66,6 +66,8 @@ interface FolderNodeProps {
onReorderFolder?: (folderId: number, beforePos: string | null, afterPos: string | null) => void; onReorderFolder?: (folderId: number, beforePos: string | null, afterPos: string | null) => void;
siblingPositions?: { before: string | null; after: string | null }; siblingPositions?: { before: string | null; after: string | null };
disabledDropIds?: Set<number>; disabledDropIds?: Set<number>;
contextMenuOpen?: boolean;
onContextMenuOpenChange?: (open: boolean) => void;
} }
function getDropZone( function getDropZone(
@ -99,6 +101,8 @@ export const FolderNode = React.memo(function FolderNode({
onReorderFolder, onReorderFolder,
siblingPositions, siblingPositions,
disabledDropIds, disabledDropIds,
contextMenuOpen,
onContextMenuOpenChange,
}: FolderNodeProps) { }: FolderNodeProps) {
const [renameValue, setRenameValue] = useState(folder.name); const [renameValue, setRenameValue] = useState(folder.name);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
@ -213,7 +217,7 @@ export const FolderNode = React.memo(function FolderNode({
const FolderIcon = isExpanded ? FolderOpen : Folder; const FolderIcon = isExpanded ? FolderOpen : Folder;
return ( return (
<ContextMenu> <ContextMenu onOpenChange={onContextMenuOpenChange}>
<ContextMenuTrigger asChild disabled={isRenaming}> <ContextMenuTrigger asChild disabled={isRenaming}>
{/* biome-ignore lint/a11y/useSemanticElements: div required for drag/drop refs */} {/* biome-ignore lint/a11y/useSemanticElements: div required for drag/drop refs */}
<div <div
@ -279,7 +283,7 @@ export const FolderNode = React.memo(function FolderNode({
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-6 w-6 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity" className="hidden sm:inline-flex h-6 w-6 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<MoreHorizontal className="h-3.5 w-3.5" /> <MoreHorizontal className="h-3.5 w-3.5" />
@ -313,7 +317,6 @@ export const FolderNode = React.memo(function FolderNode({
<Move className="mr-2 h-4 w-4" /> <Move className="mr-2 h-4 w-4" />
Move to... Move to...
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem
className="text-destructive focus:text-destructive" className="text-destructive focus:text-destructive"
onClick={(e) => { onClick={(e) => {
@ -330,7 +333,7 @@ export const FolderNode = React.memo(function FolderNode({
</div> </div>
</ContextMenuTrigger> </ContextMenuTrigger>
{!isRenaming && ( {!isRenaming && contextMenuOpen && (
<ContextMenuContent className="w-40"> <ContextMenuContent className="w-40">
<ContextMenuItem onClick={() => onCreateSubfolder(folder.id)}> <ContextMenuItem onClick={() => onCreateSubfolder(folder.id)}>
<FolderPlus className="mr-2 h-4 w-4" /> <FolderPlus className="mr-2 h-4 w-4" />
@ -344,7 +347,6 @@ export const FolderNode = React.memo(function FolderNode({
<Move className="mr-2 h-4 w-4" /> <Move className="mr-2 h-4 w-4" />
Move to... Move to...
</ContextMenuItem> </ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem <ContextMenuItem
className="text-destructive focus:text-destructive" className="text-destructive focus:text-destructive"
onClick={() => onDelete(folder)} onClick={() => onDelete(folder)}

View file

@ -2,7 +2,7 @@
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { TreePine } from "lucide-react"; import { TreePine } from "lucide-react";
import { useCallback, useMemo } from "react"; import { useCallback, useMemo, useState } from "react";
import { DndProvider } from "react-dnd"; import { DndProvider } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend"; import { HTML5Backend } from "react-dnd-html5-backend";
import { renamingFolderIdAtom } from "@/atoms/documents/folder.atoms"; import { renamingFolderIdAtom } from "@/atoms/documents/folder.atoms";
@ -80,6 +80,8 @@ export function FolderTreeView({
return counts; return counts;
}, [folders, foldersByParent, docsByFolder]); }, [folders, foldersByParent, docsByFolder]);
const [openContextMenuId, setOpenContextMenuId] = useState<string | null>(null);
// Single subscription for rename state — derived boolean passed to each FolderNode // Single subscription for rename state — derived boolean passed to each FolderNode
const [renamingFolderId, setRenamingFolderId] = useAtom(renamingFolderIdAtom); const [renamingFolderId, setRenamingFolderId] = useAtom(renamingFolderIdAtom);
const handleStartRename = useCallback( const handleStartRename = useCallback(
@ -157,6 +159,8 @@ export function FolderTreeView({
onDropIntoFolder={onDropIntoFolder} onDropIntoFolder={onDropIntoFolder}
onReorderFolder={onReorderFolder} onReorderFolder={onReorderFolder}
siblingPositions={siblingPositions} siblingPositions={siblingPositions}
contextMenuOpen={openContextMenuId === `folder-${f.id}`}
onContextMenuOpenChange={(open) => setOpenContextMenuId(open ? `folder-${f.id}` : null)}
/> />
); );
@ -177,6 +181,8 @@ export function FolderTreeView({
onEdit={onEditDocument} onEdit={onEditDocument}
onDelete={onDeleteDocument} onDelete={onDeleteDocument}
onMove={onMoveDocument} onMove={onMoveDocument}
contextMenuOpen={openContextMenuId === `doc-${d.id}`}
onContextMenuOpenChange={(open) => setOpenContextMenuId(open ? `doc-${d.id}` : null)}
/> />
); );
} }