add timeout to prevent accidental context submenu close (#1552)

This commit is contained in:
jeffvli
2026-01-17 02:31:09 -08:00
parent ac944c43bb
commit e64d77feba
@@ -3,7 +3,16 @@ import type { Dispatch, SetStateAction } from 'react';
import * as RadixContextMenu from '@radix-ui/react-context-menu'; import * as RadixContextMenu from '@radix-ui/react-context-menu';
import clsx from 'clsx'; import clsx from 'clsx';
import { AnimatePresence, motion } from 'motion/react'; 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'; import styles from './context-menu.module.css';
@@ -49,9 +58,11 @@ interface LabelProps extends React.ComponentPropsWithoutRef<'div'> {
} }
interface SubmenuContext { interface SubmenuContext {
cancelCloseTimeout: () => void;
disabled?: boolean; disabled?: boolean;
isCloseDisabled?: boolean; isCloseDisabled?: boolean;
open: boolean; open: boolean;
setCloseTimeout: (timeout: NodeJS.Timeout) => void;
setOpen: Dispatch<SetStateAction<boolean>>; setOpen: Dispatch<SetStateAction<boolean>>;
} }
@@ -164,8 +175,36 @@ interface SubmenuTargetProps {
function Submenu(props: SubmenuProps) { function Submenu(props: SubmenuProps) {
const { children, disabled, isCloseDisabled, open: isManuallyOpen } = props; const { children, disabled, isCloseDisabled, open: isManuallyOpen } = props;
const [open, setOpen] = useState(isManuallyOpen ?? false); const [open, setOpen] = useState(isManuallyOpen ?? false);
const closeTimeoutRef = useRef<NodeJS.Timeout | null>(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( const context = useMemo(
() => ({ disabled, isCloseDisabled, open, setOpen }), () => ({
cancelCloseTimeout,
disabled,
isCloseDisabled,
open,
setCloseTimeout,
setOpen,
}),
[disabled, isCloseDisabled, open], [disabled, isCloseDisabled, open],
); );
@@ -178,7 +217,25 @@ function Submenu(props: SubmenuProps) {
function SubmenuContent(props: SubmenuContentProps) { function SubmenuContent(props: SubmenuContentProps) {
const { children, stickyContent } = props; 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 ( return (
<Fragment> <Fragment>
@@ -186,12 +243,8 @@ function SubmenuContent(props: SubmenuContentProps) {
<RadixContextMenu.Portal forceMount> <RadixContextMenu.Portal forceMount>
<RadixContextMenu.SubContent <RadixContextMenu.SubContent
className={styles.content} className={styles.content}
onMouseEnter={() => setOpen(true)} onMouseEnter={handleMouseEnter}
onMouseLeave={() => { onMouseLeave={handleMouseLeave}
if (!isCloseDisabled) {
setOpen(false);
}
}}
> >
<motion.div <motion.div
animate="show" animate="show"
@@ -211,14 +264,52 @@ function SubmenuContent(props: SubmenuContentProps) {
function SubmenuTarget(props: SubmenuTargetProps) { function SubmenuTarget(props: SubmenuTargetProps) {
const { children } = props; const { children } = props;
const { disabled, setOpen } = useContext(SubmenuContext) as SubmenuContext; const { cancelCloseTimeout, disabled, setCloseTimeout, setOpen } = useContext(
SubmenuContext,
) as SubmenuContext;
const openTimeoutRef = useRef<NodeJS.Timeout | null>(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 ( return (
<RadixContextMenu.SubTrigger <RadixContextMenu.SubTrigger
className={clsx({ [styles.disabled]: disabled })} className={clsx({ [styles.disabled]: disabled })}
disabled={disabled} disabled={disabled}
onMouseEnter={() => setOpen(true)} onMouseEnter={handleMouseEnter}
onMouseLeave={() => setOpen(false)} onMouseLeave={handleMouseLeave}
> >
{children} {children}
</RadixContextMenu.SubTrigger> </RadixContextMenu.SubTrigger>