diff --git a/src-web/components/core/Dropdown.tsx b/src-web/components/core/Dropdown.tsx index 2d425e23..6e1cc498 100644 --- a/src-web/components/core/Dropdown.tsx +++ b/src-web/components/core/Dropdown.tsx @@ -7,6 +7,7 @@ import type { MouseEvent, ReactElement, ReactNode, + RefObject, SetStateAction, } from 'react'; import React, { @@ -20,7 +21,8 @@ import React, { useRef, useState, } from 'react'; -import { useClickAway, useKey, useWindowSize } from 'react-use'; +import { useKey, useWindowSize } from 'react-use'; +import { useClickOutside } from '../../hooks/useClickOutside'; import type { HotkeyAction } from '../../hooks/useHotKey'; import { useHotKey } from '../../hooks/useHotKey'; import { useStateWithDeps } from '../../hooks/useStateWithDeps'; @@ -99,10 +101,6 @@ export const Dropdown = forwardRef(function Dropdown setDefaultSelectedIndex(undefined); }, [setIsOpen]); - const openDropdown = useCallback(() => { - setIsOpen((o) => !o); - }, [setIsOpen]); - useImperativeHandle( ref, () => ({ @@ -113,18 +111,18 @@ export const Dropdown = forwardRef(function Dropdown else this.close(); }, open() { - openDropdown(); + setIsOpen(true); }, close() { handleClose(); }, }), - [handleClose, isOpen, openDropdown], + [handleClose, isOpen, setIsOpen], ); useHotKey(hotKeyAction ?? null, () => { setDefaultSelectedIndex(0); - openDropdown(); + setIsOpen(true); }); const child = useMemo(() => { @@ -134,17 +132,17 @@ export const Dropdown = forwardRef(function Dropdown ...existingChild.props, ref: buttonRef, 'aria-haspopup': 'true', - onClick: + onMouseDown: existingChild.props?.onClick ?? ((e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); setDefaultSelectedIndex(undefined); - openDropdown(); + setIsOpen((o) => !o); // Toggle dropdown }), }; return cloneElement(existingChild, props); - }, [children, openDropdown]); + }, [children, setIsOpen]); useEffect(() => { buttonRef.current?.setAttribute('aria-expanded', isOpen.toString()); @@ -163,6 +161,7 @@ export const Dropdown = forwardRef(function Dropdown ; } const Menu = forwardRef, MenuProps>( @@ -231,6 +231,7 @@ const Menu = forwardRef(null); - useClickAway(menuRef, handleClose); + useClickOutside(menuRef, handleClose, triggerRef); return ( <> @@ -437,7 +438,7 @@ const Menu = forwardRef { + onContextMenu={(e) => { // Prevent showing any ancestor context menus e.stopPropagation(); e.preventDefault(); diff --git a/src-web/hooks/useClickOutside.ts b/src-web/hooks/useClickOutside.ts new file mode 100644 index 00000000..9d149430 --- /dev/null +++ b/src-web/hooks/useClickOutside.ts @@ -0,0 +1,33 @@ +import type { RefObject } from 'react'; +import { useEffect, useRef } from 'react'; + +/** + * Get notified when a mouse click happens outside the target ref + * @param ref The element to be notified when a mouse click happens outside it + * @param onClickAway + * @param ignored Optional outside element to ignore (useful for dropdown triggers) + */ +export function useClickOutside( + ref: RefObject, + onClickAway: (event: MouseEvent) => void, + ignored?: RefObject, +) { + const savedCallback = useRef(onClickAway); + useEffect(() => { + savedCallback.current = onClickAway; + }, [onClickAway]); + useEffect(() => { + const handler = (event: MouseEvent) => { + if (ref.current == null || !(event.target instanceof HTMLElement)) return; + const isIgnored = ignored?.current?.contains(event.target); + const clickedOutside = !ref.current.contains(event.target); + if (!isIgnored && clickedOutside) { + savedCallback.current(event); + } + }; + document.addEventListener('click', handler); + return () => { + document.removeEventListener('click', handler); + }; + }, [ignored, ref]); +}