diff --git a/src/shared/components/context-menu/context-menu.tsx b/src/shared/components/context-menu/context-menu.tsx index 19ca8577b..c08248a95 100644 --- a/src/shared/components/context-menu/context-menu.tsx +++ b/src/shared/components/context-menu/context-menu.tsx @@ -3,7 +3,16 @@ import type { Dispatch, SetStateAction } from 'react'; import * as RadixContextMenu from '@radix-ui/react-context-menu'; import clsx from 'clsx'; import { AnimatePresence, motion } from 'motion/react'; -import { createContext, Fragment, type ReactNode, useContext, useMemo, useState } from 'react'; +import { + createContext, + Fragment, + type ReactNode, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import styles from './context-menu.module.css'; @@ -49,9 +58,11 @@ interface LabelProps extends React.ComponentPropsWithoutRef<'div'> { } interface SubmenuContext { + cancelCloseTimeout: () => void; disabled?: boolean; isCloseDisabled?: boolean; open: boolean; + setCloseTimeout: (timeout: NodeJS.Timeout) => void; setOpen: Dispatch>; } @@ -164,8 +175,36 @@ interface SubmenuTargetProps { function Submenu(props: SubmenuProps) { const { children, disabled, isCloseDisabled, open: isManuallyOpen } = props; const [open, setOpen] = useState(isManuallyOpen ?? false); + const closeTimeoutRef = useRef(null); + + useEffect(() => { + return () => { + if (closeTimeoutRef.current) { + clearTimeout(closeTimeoutRef.current); + } + }; + }, []); + + const cancelCloseTimeout = () => { + if (closeTimeoutRef.current) { + clearTimeout(closeTimeoutRef.current); + closeTimeoutRef.current = null; + } + }; + + const setCloseTimeout = (timeout: NodeJS.Timeout) => { + closeTimeoutRef.current = timeout; + }; + const context = useMemo( - () => ({ disabled, isCloseDisabled, open, setOpen }), + () => ({ + cancelCloseTimeout, + disabled, + isCloseDisabled, + open, + setCloseTimeout, + setOpen, + }), [disabled, isCloseDisabled, open], ); @@ -178,7 +217,25 @@ function Submenu(props: SubmenuProps) { function SubmenuContent(props: SubmenuContentProps) { const { children, stickyContent } = props; - const { isCloseDisabled, open, setOpen } = useContext(SubmenuContext) as SubmenuContext; + const { cancelCloseTimeout, isCloseDisabled, open, setCloseTimeout, setOpen } = useContext( + SubmenuContext, + ) as SubmenuContext; + + const handleMouseEnter = () => { + cancelCloseTimeout(); + setOpen(true); + }; + + const handleMouseLeave = () => { + if (isCloseDisabled) { + const timeout = setTimeout(() => { + setOpen(false); + }, 150); + setCloseTimeout(timeout); + } else { + setOpen(false); + } + }; return ( @@ -186,12 +243,8 @@ function SubmenuContent(props: SubmenuContentProps) { setOpen(true)} - onMouseLeave={() => { - if (!isCloseDisabled) { - setOpen(false); - } - }} + onMouseEnter={handleMouseEnter} + onMouseLeave={handleMouseLeave} > (null); + + useEffect(() => { + return () => { + if (openTimeoutRef.current) { + clearTimeout(openTimeoutRef.current); + } + }; + }, []); + + const handleMouseEnter = () => { + if (disabled) return; + + cancelCloseTimeout(); + + if (openTimeoutRef.current) { + clearTimeout(openTimeoutRef.current); + } + + openTimeoutRef.current = setTimeout(() => { + setOpen(true); + openTimeoutRef.current = null; + }, 150); + }; + + const handleMouseLeave = () => { + if (openTimeoutRef.current) { + clearTimeout(openTimeoutRef.current); + openTimeoutRef.current = null; + } + + const timeout = setTimeout(() => { + setOpen(false); + }, 150); + setCloseTimeout(timeout); + }; return ( setOpen(true)} - onMouseLeave={() => setOpen(false)} + onMouseEnter={handleMouseEnter} + onMouseLeave={handleMouseLeave} > {children}