react-spectrum icon indicating copy to clipboard operation
react-spectrum copied to clipboard

Toggle Popover on click and hover

Open alekseykurylev opened this issue 2 years ago โ€ข 7 comments

Provide a general summary of the feature here

Add the ability to display Popover simultaneously on hover and on click

๐Ÿค” Expected Behavior?

Displayed by click and hover

๐Ÿ˜ฏ Current Behavior

It is displayed only by click

๐Ÿ’ Possible Solution

No response

๐Ÿ”ฆ Context

In conventional js frameworks such as Bootstrap or UIKit, it has always been possible to open Popover or Tooltip or Dropdow simultaneously using a hover and a click. And it works great on all devices. I don't understand why you decided that opening Popover is possible only by clicking, and opening Tooltip only by hover, which makes it useless on a mobile device.

๐Ÿ’ป Examples

https://getbootstrap.com/docs/5.3/components/popovers/ https://getuikit.com/docs/drop

A great example of a Popover reaction component from Mantine https://mantine.dev/core/popover/

๐Ÿงข Your Company/Team

No response

๐Ÿ•ท Tracking Issue

No response

alekseykurylev avatar Dec 06 '23 05:12 alekseykurylev

Thanks for the issue. You can do this by controlling the open state https://codesandbox.io/p/sandbox/wandering-star-5ncllz?file=%2Fsrc%2FApp.js%3A13%2C32 (you could further clean it up to ignore a press on the button if it's already open)

Tooltips are definitely not meant for touch, and we say that in our docs https://react-spectrum.adobe.com/react-aria/Tooltip.html#accessibility This is because people frequently put both tooltips and some other popover (menu/dialog/etc) on the same element OR they have an action which should happen on press. So we can't support opening Tooltips on touch. Instead, as the docs recommend, a popover should be used.

As for why Popovers don't open by default for mouse hover, we don't want people to target hover as an interaction because it excludes so many users. So if you're going to do it, it should be very intentional. It also runs the risk of colliding with Tooltips.

Hopefully that answers your questions.

snowystinger avatar Dec 06 '23 06:12 snowystinger

You can do this by controlling the open state https://codesandbox.io/p/sandbox/wandering-star-5ncllz?file=%2Fsrc%2FApp.js%3A13%2C32 (you could further clean it up to ignore a press on the button if it's already open)

Thanks! I agree with you on Tooltips. But let's go back to Popover, how can I control the state of the Popover when it opens on the hover, for example, automatic hiding if the cursor is removed from the Popover?

alekseykurylev avatar Dec 06 '23 07:12 alekseykurylev

Button has onHoverStart and onHoverEnd events you could probably use

devongovett avatar Dec 06 '23 14:12 devongovett

It seemed to me that this was an obvious UI element of the interface. I don't understand why many UI libraries for React skip it. For example, many sites have a drop-down menu or drop-down content (an authorization form or a list of products in the shopping cart) that is displayed when the cursor is hovered over. Check out this great example of a similar component https://getuikit.com/docs/drop or https://www.naiveui.com/en-US/os-theme/components/dropdown

Popover in 'react-aria-components' is a great component, but it lacks trigger hover with additional behavior options.

Button has onHoverStart and onHoverEnd events you could probably use

@devongovett, I meant when the cursor is removed from the Popover. Have you tried to do what you recommend to me?

alekseykurylev avatar Dec 06 '23 15:12 alekseykurylev

You could add a pointerLeave to the div inside the popover, though keep in mind some people do not have fine control over a mouse and may leave accidentally, thereby closing the popover unintentionally.

Trying to do it for the trigger button is a bit more difficult because there is an invisible underlay which sits over the trigger blocking interactions with the background. This is intentional for several reasons: focus, scrolling, and unintentional interactions, to name a few.

So to handle the unhover for the button, you'd probably want to track the position of the mouse and the button's bounding box to do your own hit testing.

snowystinger avatar Dec 06 '23 23:12 snowystinger

I have a need for this as well. Are there any good examples around?

DoReVo avatar Jan 09 '25 08:01 DoReVo

This is an example of what you would need to do to make it work:

function NavItemMenu({
  children,
}: {
  children: React.ReactNode;
}) {
  const [isOpen, setIsOpen] = useState(false);
  const ref = useRef<HTMLDivElement>(null);
  const popoverRef = useRef<HTMLDivElement>(null);
  const timeoutRef = useRef<NodeJS.Timeout | null>(null);

  const clearCloseTimeout = useCallback(() => {
    if (timeoutRef.current) {
      clearTimeout(timeoutRef.current);
      timeoutRef.current = null;
    }
  }, []);

  const scheduleClose = useCallback(() => {
    clearCloseTimeout();
    timeoutRef.current = setTimeout(() => {
      setIsOpen(false);
    }, 50);
  }, [clearCloseTimeout]);

  const isPointInSafeArea = useCallback((x: number, y: number) => {
    if (!ref.current || !popoverRef.current) return false;

    const triggerRect = ref.current.getBoundingClientRect();
    const popoverRect = popoverRef.current.getBoundingClientRect();

    const safeArea = {
      left: Math.min(triggerRect.left, popoverRect.left) - 5,
      right: Math.max(triggerRect.right, popoverRect.right) + 5,
      top: triggerRect.top,
      bottom: triggerRect.bottom,
    };

    return (
      x >= safeArea.left &&
      x <= safeArea.right &&
      y >= safeArea.top &&
      y <= safeArea.bottom
    );
  }, []);

  const handleMouseMove = useCallback((event: MouseEvent) => {
    if (!isOpen) return;

    const { clientX, clientY } = event;

    if (isPointInSafeArea(clientX, clientY)) {
      clearCloseTimeout();
    } else {
      scheduleClose();
    }
  }, [isOpen, clearCloseTimeout, scheduleClose, isPointInSafeArea]);

  const handleTriggerMouseEnter = useCallback(() => {
    clearCloseTimeout();
    setIsOpen(true);
  }, [clearCloseTimeout]);

  useEffect(() => {
    if (isOpen) {
      document.addEventListener('mousemove', handleMouseMove);

      return () => {
        document.removeEventListener('mousemove', handleMouseMove);
        clearCloseTimeout();
      };
    }
  }, [isOpen]);

  return (
    <>
      <Popover
        ref={popoverRef}
        triggerRef={ref}
        isOpen={isOpen}
        placement='right'
        onOpenChange={(open) => {
          if (!open) {
            setIsOpen(false);
            clearCloseTimeout();
          }
        }}
      >
        <div
          className="flex flex-col gap-1 p-1 bg-base-200"
          onMouseEnter={clearCloseTimeout}
          onMouseLeave={scheduleClose}
        >
          Content
        </div>
      </Popover>
      <div
        ref={ref}
        onMouseEnter={handleTriggerMouseEnter}
      >
        {children}
      </div>
    </>
  );
}

I think it sucks to have to deal with the whole safe area business since this has probably already been implemented in the tooltip component

romansndlr avatar May 27 '25 13:05 romansndlr