Commit dfc0d376 authored by Steven's avatar Steven

refactor: extract submenu hover delay logic into reusable hook

- Create useDropdownMenuSubHoverDelay hook in dropdown-menu component
- Encapsulates hover delay behavior for preventing accidental submenu closure
- Eliminates code duplication at component usage sites
- Simplifies InsertMenu by removing 45 lines of timeout/state management code
- Hook provides handleTriggerEnter/Leave and handleContentEnter/Leave handlers
- Configurable closeDelay parameter (default 150ms)

This makes the hover behavior pattern reusable across any dropdown menu submenus.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: 's avatarClaude Haiku 4.5 <noreply@anthropic.com>
parent 332d32bd
...@@ -13,6 +13,7 @@ import { ...@@ -13,6 +13,7 @@ import {
DropdownMenuSubContent, DropdownMenuSubContent,
DropdownMenuSubTrigger, DropdownMenuSubTrigger,
DropdownMenuTrigger, DropdownMenuTrigger,
useDropdownMenuSubHoverDelay,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import type { Location, MemoRelation } from "@/types/proto/api/v1/memo_service_pb"; import type { Location, MemoRelation } from "@/types/proto/api/v1/memo_service_pb";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
...@@ -35,10 +36,13 @@ const InsertMenu = observer((props: Props) => { ...@@ -35,10 +36,13 @@ const InsertMenu = observer((props: Props) => {
const [linkDialogOpen, setLinkDialogOpen] = useState(false); const [linkDialogOpen, setLinkDialogOpen] = useState(false);
const [locationDialogOpen, setLocationDialogOpen] = useState(false); const [locationDialogOpen, setLocationDialogOpen] = useState(false);
const [moreSubmenuOpen, setMoreSubmenuOpen] = useState(false);
// Abort controller for canceling geocoding requests // Abort controller for canceling geocoding requests
const { abort: abortGeocoding, abortAndCreate: createGeocodingSignal } = useAbortController(); const { abort: abortGeocoding, abortAndCreate: createGeocodingSignal } = useAbortController();
const { handleTriggerEnter, handleTriggerLeave, handleContentEnter, handleContentLeave } = useDropdownMenuSubHoverDelay(150, setMoreSubmenuOpen);
const { fileInputRef, selectingFlag, handleFileInputChange, handleUploadClick } = useFileUpload((newFiles: LocalFile[]) => { const { fileInputRef, selectingFlag, handleFileInputChange, handleUploadClick } = useFileUpload((newFiles: LocalFile[]) => {
if (context.addLocalFiles) { if (context.addLocalFiles) {
context.addLocalFiles(newFiles); context.addLocalFiles(newFiles);
...@@ -138,7 +142,7 @@ const InsertMenu = observer((props: Props) => { ...@@ -138,7 +142,7 @@ const InsertMenu = observer((props: Props) => {
return ( return (
<> <>
<DropdownMenu> <DropdownMenu modal={false}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" className="shadow-none" disabled={isUploading}> <Button variant="outline" size="icon" className="shadow-none" disabled={isUploading}>
{isUploading ? <LoaderIcon className="size-4 animate-spin" /> : <PlusIcon className="size-4" />} {isUploading ? <LoaderIcon className="size-4 animate-spin" /> : <PlusIcon className="size-4" />}
...@@ -158,13 +162,18 @@ const InsertMenu = observer((props: Props) => { ...@@ -158,13 +162,18 @@ const InsertMenu = observer((props: Props) => {
{t("tooltip.select-location")} {t("tooltip.select-location")}
</DropdownMenuItem> </DropdownMenuItem>
{/* View submenu with Focus Mode */} {/* View submenu with Focus Mode */}
<DropdownMenuSub> <DropdownMenuSub open={moreSubmenuOpen} onOpenChange={setMoreSubmenuOpen}>
<DropdownMenuSubTrigger> <DropdownMenuSubTrigger onPointerEnter={handleTriggerEnter} onPointerLeave={handleTriggerLeave}>
<MoreHorizontalIcon className="w-4 h-4" /> <MoreHorizontalIcon className="w-4 h-4" />
{t("common.more")} {t("common.more")}
</DropdownMenuSubTrigger> </DropdownMenuSubTrigger>
<DropdownMenuSubContent> <DropdownMenuSubContent onPointerEnter={handleContentEnter} onPointerLeave={handleContentLeave}>
<DropdownMenuItem onClick={props.onToggleFocusMode}> <DropdownMenuItem
onClick={() => {
props.onToggleFocusMode?.();
setMoreSubmenuOpen(false);
}}
>
<Maximize2Icon className="w-4 h-4" /> <Maximize2Icon className="w-4 h-4" />
{t("editor.focus-mode")} {t("editor.focus-mode")}
<span className="ml-auto text-xs text-muted-foreground opacity-60">⌘⇧F</span> <span className="ml-auto text-xs text-muted-foreground opacity-60">⌘⇧F</span>
......
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"; import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import * as React from "react"; import * as React from "react";
import { useEffect, useRef } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const DropdownMenu = React.forwardRef< const DropdownMenu = React.forwardRef<
...@@ -200,6 +201,58 @@ function DropdownMenuSubContent({ className, ...props }: React.ComponentProps<ty ...@@ -200,6 +201,58 @@ function DropdownMenuSubContent({ className, ...props }: React.ComponentProps<ty
); );
} }
/**
* Hook for managing submenu hover behavior with delayed close.
* Prevents accidental submenu closure on quick mouse movements.
*
* @param closeDelay - Delay in ms before closing submenu when leaving trigger/content
* @param onOpenChange - Callback to update submenu open state
* @returns Object with event handlers and state management utilities
*/
function useDropdownMenuSubHoverDelay(closeDelay = 150, onOpenChange?: (open: boolean) => void) {
const closeTimeoutRef = useRef<number | null>(null);
const clearCloseTimeout = () => {
if (closeTimeoutRef.current) {
clearTimeout(closeTimeoutRef.current);
closeTimeoutRef.current = null;
}
};
const scheduleClose = (delay = 0) => {
clearCloseTimeout();
closeTimeoutRef.current = window.setTimeout(() => onOpenChange?.(false), delay);
};
const handleTriggerEnter = () => {
clearCloseTimeout();
onOpenChange?.(true);
};
const handleTriggerLeave = () => {
scheduleClose(closeDelay);
};
const handleContentEnter = () => {
clearCloseTimeout();
};
const handleContentLeave = () => {
scheduleClose();
};
useEffect(() => {
return () => clearCloseTimeout();
}, []);
return {
handleTriggerEnter,
handleTriggerLeave,
handleContentEnter,
handleContentLeave,
};
}
export { export {
DropdownMenu, DropdownMenu,
DropdownMenuPortal, DropdownMenuPortal,
...@@ -216,4 +269,5 @@ export { ...@@ -216,4 +269,5 @@ export {
DropdownMenuSub, DropdownMenuSub,
DropdownMenuSubTrigger, DropdownMenuSubTrigger,
DropdownMenuSubContent, DropdownMenuSubContent,
useDropdownMenuSubHoverDelay,
}; };
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment