tailwindlabs / headlessui

Completely unstyled, fully accessible UI components, designed to integrate beautifully with Tailwind CSS.
https://headlessui.com
MIT License
26.13k stars 1.08k forks source link

Feature request: Menu dropdown opening on hover #239

Closed therealgilles closed 3 years ago

therealgilles commented 3 years ago

A fair number of websites use navigation menus that open on hover. One of the reasons is because the top menu item is sometimes also a link and clicking on it directs to a different page. Other site use hover so that visitors don't have to click to see the dropdown items.

I think it would be a great option to add.

therealgilles commented 3 years ago

I ended up adapting the dropdown code externally to add: • open on hover or click on menu button • menu button can be a link (will be followed on 2nd click as first one opens or closes dropdown) • close on moving away from button / dropdown • close on click outside dropdown

PS: Styling is a work in progress. I could not find focus:shadow-outline-blue so used focus:ring-blue-300 for now.

const Dropdown = ({ name, url, subItems }) => {
  const dropdownRef = useRef(null)
  // NOTE: useDetectOutsideClick is not necessary with hover, useState(false) would do here
  const [openDropdown, setOpenDropdown] = useDetectOutsideClick(dropdownRef, false)
  const [mouseOverButton, setMouseOverButton] = useState(false)
  const [mouseOverMenu, setMouseOverMenu] = useState(false)

  const timeoutDuration = 200
  let timeoutButton
  let timeoutMenu

  const onMouseEnterButton = () => {
    clearTimeout(timeoutButton)
    setOpenDropdown(true)
    setMouseOverButton(true)
  }
  const onMouseLeaveButton = () => {
    timeoutButton = setTimeout(() => setMouseOverButton(false), timeoutDuration)
  }

  const onMouseEnterMenu = () => {
    clearTimeout(timeoutMenu)
    setMouseOverMenu(true)
  }
  const onMouseLeaveMenu = () => {
    timeoutMenu = setTimeout(() => setMouseOverMenu(false), timeoutDuration)
  }

  const show = openDropdown && (mouseOverMenu || mouseOverButton)

  return (
    <Menu>
      {({ open }) => (
        <>
          <div
            css={tw`rounded-md shadow-sm`}
            onClick={() => setOpenDropdown(!openDropdown)}
            onMouseEnter={onMouseEnterButton}
            onMouseLeave={onMouseLeaveButton}
            onKeyPress={null}
            role="button"
            tabIndex="0"
          >
            <Menu.Button
              css={[
                tw`inline-flex justify-center w-full px-4 py-2 font-medium`,
                tw`leading-5 text-gray-700 transition duration-150 ease-in-out bg-white`,
                tw`border border-gray-300 rounded-md hover:text-gray-500`,
                tw`focus:outline-none focus:border-blue-300 focus:ring-blue-300`,
                tw`active:bg-gray-50 active:text-gray-800 cursor-pointer`,
              ]}
              as="a"
              href={url}
            >
              <span>{name}</span>
              <svg css={tw`w-5 h-5 ml-2 -mr-1`} viewBox="0 0 20 20" fill="currentColor">
                <path
                  fillRule="evenodd"
                  d={
                    `M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 ` +
                    `1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z`
                  }
                  clipRule="evenodd"
                />
              </svg>
            </Menu.Button>
          </div>

          <Transition
            show={show}
            enter="transition ease-out duration-100"
            enterFrom="transform opacity-0 scale-95"
            enterTo="transform opacity-100 scale-100"
            leave="transition ease-in duration-75"
            leaveFrom="transform opacity-100 scale-100"
            leaveTo="transform opacity-0 scale-95"
          >
            <Menu.Items
              ref={dropdownRef}
              onMouseEnter={onMouseEnterMenu}
              onMouseLeave={onMouseLeaveMenu}
              static
              css={[
                tw`absolute right-0 w-56 mt-2 origin-top-right bg-white`,
                tw`border border-gray-200 divide-y divide-gray-100 rounded-md shadow-lg outline-none`,
              ]}
            >
              <div css={tw`py-1`}>
                {subItems.map(item => (
                  <Menu.Item key={item.key} onClick={() => setOpenDropdown(false)}>
                    {({ active }) => (
                      <a
                        href={item.url}
                        css={[
                          active ? tw`bg-gray-100 text-gray-900` : tw`text-gray-700`,
                          tw`flex justify-between w-full px-4 py-2 leading-5 text-left`,
                        ]}
                      >
                        {item.name}
                      </a>
                    )}
                  </Menu.Item>
                ))}
              </div>
            </Menu.Items>
          </Transition>
        </>
      )}
    </Menu>
  )
}

Here is the code for useDetectOutsideClick. It may be better to use useOnClickOutside here.

const useDetectOutsideClick = (el, initialState) => {
    const [isActive, setIsActive] = useState(initialState)

    useEffect(() => {
      const pageClickEvent = e => {
        const elements = Array.isArray(el) ? el : [el]
        let outside = true

        // If the active element exists and is clicked outside of
        elements.forEach(element => {
          if (element.current !== null && element.current.contains(e.target)) {
            outside = false
          }
        })

        if (outside) setIsActive(false)
      }

      // If the item is active (ie open) then listen for clicks
      if (isActive) {
        window.addEventListener('click', pageClickEvent)
      }

      return () => {
        window.removeEventListener('click', pageClickEvent)
      }
    }, [isActive, el])

    return [isActive, setIsActive]
  }

PS: useDetectOutsideClick is not necessary with hover.

therealgilles commented 3 years ago

I am noticing accessibility through keyboard keys does not work with the open on hover. Not sure yet if it's possible to fix that from the outside.

singleseeker commented 3 years ago

@therealgilles Really good job.

therealgilles commented 3 years ago

Thanks @singleseeker.

@RobinMalfait: If you're willing to give control on 'open', it would be great if dispatch and the actions were passed as props, so that it's possible to still benefit from the internals (like accessibility).

Taking that back, would not be enough.

therealgilles commented 3 years ago

Here is a better and simplified version:

const Dropdown = ({ name, url, subItems, useHover }) => {
  const buttonRef = useRef(null)
  const dropdownRef = useRef(null)
  const timeoutDuration = 200
  let timeout

  const openMenu = () => buttonRef?.current.click()
  const closeMenu = () =>
    dropdownRef?.current?.dispatchEvent(
      new KeyboardEvent('keydown', {
        key: 'Escape',
        bubbles: true,
        cancelable: true,
      })
    )

  const onMouseEnter = closed => {
    clearTimeout(timeout)
    closed && openMenu()
  }
  const onMouseLeave = open => {
    open && (timeout = setTimeout(() => closeMenu(), timeoutDuration))
  }

  return (
    <Menu>
      {({ open }) => (
        <>
          {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
          <div
            css={tw`rounded-md shadow-sm focus:outline-none focus:border-white focus:ring-white`}
            onClick={openMenu}
            onMouseEnter={() => useHover && onMouseEnter(!open)}
            onMouseLeave={() => useHover && onMouseLeave(open)}
          >
            <Menu.Button
              ref={buttonRef}
              css={[
                tw`inline-flex justify-center w-full px-4 py-2 font-medium`,
                tw`leading-5 text-white transition duration-150 ease-in-out bg-header`,
                tw`rounded-md hover:bg-header-dark`,
                tw`cursor-pointer`,
                open && tw`bg-header-dark`,
              ]}
              as={useHover ? 'a' : 'button'}
              href={useHover ? url : null}
            >
              <span>{name}</span>
              <svg css={tw`w-5 h-5 ml-2 -mr-1`} viewBox="0 0 20 20" fill="currentColor">
                <path
                  fillRule="evenodd"
                  d={
                    `M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 ` +
                    `1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z`
                  }
                  clipRule="evenodd"
                />
              </svg>
            </Menu.Button>
          </div>

          <Transition
            show={open}
            enter="transition ease-out duration-100"
            enterFrom="transform opacity-0 scale-95"
            enterTo="transform opacity-100 scale-100"
            leave="transition ease-in duration-75"
            leaveFrom="transform opacity-100 scale-100"
            leaveTo="transform opacity-0 scale-95"
          >
            <Menu.Items
              ref={dropdownRef}
              onMouseEnter={() => useHover && onMouseEnter()}
              onMouseLeave={() => useHover && onMouseLeave(open)}
              static
              css={[
                tw`absolute right-0 w-56 mt-2 origin-top-right bg-white`,
                tw`border border-gray-200 divide-y divide-gray-100 rounded-md shadow-lg outline-none`,
              ]}
            >
              <div css={tw`py-1`}>
                {subItems.map(item => (
                  <Menu.Item key={item.key}>
                    {({ active }) => (
                      <a
                        href={item.url}
                        css={[
                          active ? tw`bg-gray-100 text-gray-900` : tw`text-gray-700`,
                          tw`flex justify-between w-full px-4 py-2 leading-5 text-left`,
                        ]}
                      >
                        {item.name}
                      </a>
                    )}
                  </Menu.Item>
                ))}
              </div>
            </Menu.Items>
          </Transition>
        </>
      )}
    </Menu>
  )
}
therealgilles commented 3 years ago

@RobinMalfait: I've been experimenting with using an icon or image for the dropdown menu. I'm doing all this with Gatsby so I use the StaticImage component from gatsy-plugin-image (currently in beta).

I've hit an unexpected behavior of mousenter/mouseleave over the image wrapper and had to put a 'pointer-events: none' tag on the wrapper for the dropdown to work properly. Here is a code sandbox: https://codesandbox.io/s/gatsby-plugin-image-dropdown-g7s77

PS: Click on Browser(:8000) to see the browser output.

If you have any clue why that could be, I'll be interested to learn. The StaticImage component has zero code related to those events or event bubbling as far as I can see.

RobinMalfait commented 3 years ago

Hey! Thank you for your suggestion! Much appreciated! 🙏

I can see a future where we may want to expose such an option, however the use case you described sounds not ideal from a UX perspective in my opinion.

  1. People see a dropdown (because typically you also add some form of little arrow to indicate that)
  2. People will click it
  3. People will expect a dropdown/menu to open

In your scenario, it will go more like this

  1. People see a dropdown
  2. People will click it
  3. This triggered a hover first
  4. People will get redirected to the page (if you link a page to the initial click)
  5. People will get confused

Website like GitHub, fix that by adding the "link" to the profile page for example as the first item in the list:

image

The Signed in as and Your profile are both clickable and both are going to your actual profile page.


Currently you can't implement this in user land, because the internals still require the correct open state within the Menu itself. Maybe we can expose that in the future too, but I strongly believe that a limited and consistent set of props/functionality is better in the long wrong.

I also think that if we provide an option to open the Menu on hover (or make the Menu a controlled component), that the target element should not be a link to another page, just to prevent confusion from a usability perspective.

TL;DR: For now, we will not be adding such an option until we see a very good use case for it. If we change our mind, or get a very good use case I will make sure to ping you once this is implemented.

therealgilles commented 3 years ago

@RobinMalfait: Thank you for your detailed response. I agree with you on the confusing UX if the dropdown is itself a link, so I'm sold on that. Weirdly the default Wordpress behavior (which had influenced my thinking) is to always add a link to every menu item.

I will think some more about potentially abandoning the hover. This is more of an 'app' behavior, compared to a more usual (old fashioned?) website behavior. Many sites (e.g. stripe.com) still use hover, often without an indication that the menu is a dropdown. One could argue that having to click is more "work" for the user. Obviously the argument is not valid on a touch device.

My above hover implementation works well and uses the internal open state, so it is usable. If you have a chance to look at the codesandbox and have an idea why the 'pointer-events: none' is necessary, let me know.

I am thinking of purchasing the tailwindui library. I saw that a number of places say 'Requires JS'. Is the idea with this project to provide that code?

Thanks again :)

DoctorDerek commented 3 years ago

Hey @therealgilles Thanks for the code, I was able to substitute your version for Popper to create a hover effect with dropdown menus.

This is a great solution. People with difficulty using a mouse like a slight delay (200ms in this implementation) for hover effects. At the same time, most people are not going to click in the <200ms range on a menu item on desktop. If they do, it would close the menu, and they'd immediately understand it opens on hover. They would click again to open it or just hover onto the next dropdown menu in the navigation list, see that menu open, and then hover back over the first item.

Opening dropdown menus on hover is indeed a common use case (see: jQuery, Bootstrap, custom AJAX menus, etc.). And Stripe uses it.

(FWIW I find the Stripe mouse + keyboard experience odd, if I have my mouse over the menu and try to tab through it bugs out. That's because they don't let the user tab over and click on the menu item, which would be a more intuitive behavior, instead they just auto-open it on Tab. That's to solve their styling problem where they don't have a `)


This solution by @therealgilles has the big advantage over others in that the user experience is still buttery smooth with the keyboard, thanks to Headless UI. I agree that not every site in the world is clamoring for an open-on-hover UI, @RobinMalfait ... but at the same time I think open-on-hover (after a delay) is a better user experience on desktop than requiring click-to-open, at least for website navigation headers. For that common use case, I think the better UX should be offered as an option.

I see what @haniotis was going for, but I think custom modes are confusing. So I think you should consider the feature request of "add simple open-on-hover after delay" with a prop like useHover={true} or openOnHover={true}. It's unlikely that someone looking to add a "hover" effect to a Popover or Menu is looking for something else other than open on hover. If they are, it would seem easy enough for them to add their custom use case with onMouseEnter and onMouseLeave. But open-on-hover seems like a core-adjacent feature if not a proper core feature for Headless UI.

I know I personally was missing it from the Tailwind UI flyover menu components.

ScottJr commented 3 years ago

@DoctorDerek - Can you post your code here?

Also much agreed that the experience people are asking for on hover. I've tried on multiple projects building ecomm stores to not do hover and it always gets kicked back.

DoctorDerek commented 3 years ago

@ScottJr Sure let me post the abbreviated version. I don't have a demo in a CodeSandbox repo at the moment, though I'll be able to post one once I hear back on another issue about tabbing through nested popovers (seems to be bugged at the moment per #426).

FlyoutMenu.tsx (WIP)

import { Fragment, useRef } from "react"
import { Popover, Transition } from "@headlessui/react"
import { ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/solid"
import Link from "@/components/Link"

function classNames(...classes: string[]) {
  return classes.filter(Boolean).join(" ")
}

/// <reference types="next" />
/// <reference types="next/types/global" />
declare module "*.svg" {
  const content: React.FunctionComponent<React.SVGAttributes<SVGElement>>
  export default content
}

/**
 * Recursive navigation menu type
 *
 * @typeParam title - The title of the link to display in the nav menu.
 * @typeParam href_or_submenu - The actual href (absolute URL) for a link,
 *                              OR another NAVIGATION_MENU (a submenu).
 *
 * @remarks
 * This type expects an array of tuples, where each array has a "title"
 * for the navigation menu that either points to a link or submenu. The
 * link ("href") is a plain string. The submenu is this type, recursively.
 *
 * Reference:
 * https://www.typescriptlang.org/play#example/recursive-type-references
 *
 */
type NAVIGATION_MENU_TYPE =
  | [
      title: string,
      href_or_submenu: string | NAVIGATION_MENU_TYPE | NAVIGATION_MENU_TYPE[]
    ]
  | NAVIGATION_MENU_TYPE[]

export default function FlyoutMenu({
  title,
  hrefOrSubmenu,
  layout,
}: {
  title: string
  hrefOrSubmenu: NAVIGATION_MENU_TYPE
  layout: "outer" | "inner"
}) {
  const timeoutDuration = 200
  let timeout: NodeJS.Timeout
  const useHover = true
  const buttonRef = useRef<HTMLButtonElement>(null)
  const dropdownRef = useRef<HTMLDivElement>(null)
  const toggleMenu = () => buttonRef?.current?.click()
  /*let closeMenu = () =>
    dropdownRef?.current?.dispatchEvent(
      new KeyboardEvent("keydown", {
        key: "Escape",
        bubbles: true,
        cancelable: true,
      })
    )*/
  const onMouseHover = (open: boolean) => {
    clearTimeout(timeout)
    open && (timeout = setTimeout(() => toggleMenu(), timeoutDuration))
  }

  /* py-5 px-1 */
  const LINK_STYLES = classNames(
    "p-5 text-base text-gray-900 uppercase transition duration-150 ease-in-out hover:text-blue-800 w-full font-bold"
  )

  return (
    <Popover className="relative">
      {({ open }) => (
        <div
          onMouseEnter={() => useHover && onMouseHover(!open)}
          onMouseLeave={() => useHover && onMouseHover(open)}
        >
          <Popover.Button
            className={classNames(
              open ? "text-blue-800" : "text-gray-800",
              "bg-white rounded-md inline-flex items-center",
              LINK_STYLES
            )}
            ref={buttonRef}
          >
            <span className="uppercase">{title}</span>
            {layout === "outer" && (
              <ChevronDownIcon
                className={classNames(
                  open ? "text-gray-600 translate-y-1.5" : "text-gray-400",
                  "ml-2 h-5 w-5 transform transition-all"
                )}
                aria-hidden="true"
              />
            )}
            {layout === "inner" && (
              <ChevronRightIcon
                className={classNames(
                  open ? "text-gray-600 translate-x-4" : "text-gray-400",
                  "ml-2 h-5 w-5 transform transition-all group-hover:text-gray-500"
                )}
                aria-hidden="true"
              />
            )}
          </Popover.Button>

          <Transition
            show={open}
            as={Fragment}
            enter="transition ease-out duration-200"
            enterFrom="opacity-0 translate-y-1"
            enterTo="opacity-100 translate-y-0"
            leave="transition ease-in duration-150"
            leaveFrom="opacity-100 translate-y-0"
            leaveTo="opacity-0 translate-y-1"
          >
            <Popover.Panel
              static
              className={classNames(
                (layout === "inner" &&
                  "absolute top-0 z-10 w-64 left-44") as string,
                (layout === "outer" &&
                  "absolute left-[-1.75rem] z-10 w-64 px-2 mt-2") as string
              )}
              ref={dropdownRef}
            >
              <div
                className={classNames(
                  (layout === "inner" &&
                    "relative grid space-y-[2.5px] top-[-4px] border-2 border-solid bg-white border-blue-800 divide-y-2 rounded-md") as string,
                  (layout === "outer" &&
                    "relative grid space-y-[2px] bg-white border-2 border-gray-300 border-solid divide-y-2 rounded-md") as string
                )}
              >
                {typeof hrefOrSubmenu === "string" && (
                  <Link
                    key={title + hrefOrSubmenu}
                    href={hrefOrSubmenu}
                    className={LINK_STYLES}
                  >
                    {title}
                  </Link>
                )}
                {typeof hrefOrSubmenu === "object" &&
                  (hrefOrSubmenu as NAVIGATION_MENU_TYPE[]).map(
                    ([title, hrefOrSubmenu]: NAVIGATION_MENU_TYPE) => {
                      const href =
                        typeof hrefOrSubmenu === "string"
                          ? hrefOrSubmenu
                          : undefined
                      const submenu =
                        typeof hrefOrSubmenu === "object"
                          ? hrefOrSubmenu
                          : undefined
                      return (
                        <>
                          {href && (
                            <Link
                              key={title + href}
                              href={href}
                              className={LINK_STYLES}
                            >
                              {title}
                            </Link>
                          )}
                          {submenu && (
                            <Popover.Group>
                              <FlyoutMenu
                                title={title as string}
                                hrefOrSubmenu={submenu}
                                layout="inner"
                              />
                            </Popover.Group>
                          )}
                        </>
                      )
                    }
                  )}
              </div>
            </Popover.Panel>
          </Transition>
        </div>
      )}
    </Popover>
  )
}

Basically it's just a TypeScript version of the code by @therealgilles except I swapped out Escape key for closeMenu with just a simple toggleMenu. It seems to work correctly for the hover effect with a slight delay (200ms) both on open and close. When I had 0ms delay on open it was buggy with this implementation, but it works great with the delay.

image

There's currently another wrapper Component (DropdownMenu) that invokes FlyoutMenu, though I want to refactor it down to one component with 3 layouts ("top", "outer", "inner"). I also have a "key prop" issue to solve, but that's easy:

DropdownMenu.tsx

import { Popover } from "@headlessui/react"
import { useRef, useEffect, useState } from "react"
import PropTypes from "prop-types"
import FlyoutMenu from "@/components/FlyoutMenu"
import Link from "@/components/Link"

const NAVIGATION_MENU: NAVIGATION_MENU_TYPE[] = [
  ["Home", "/"],
  [
    "Services",
    [
      [
        "Residential",
        [
          [
            "Residential Internet",
            "/residential-internet",
          ],
          [
            "High Speed Cable",
            "/residential-high-speed-cable",
          ],
        ],
      ],
      [
        "Business",
        [
          ["Business Internet", "/business-internet/"],
          ["High Speed Cable", "/business-high-speed-cable/"],
        ],
      ],
      ["Payment Options", "/payment-options"],
    ],
  ],
  [
    "About Us",
    [
      ["Our Company", "/about/"],
      ["Coverage Area", "/coverage/"],
    ],
  ],
  ["Order Now", "/order/"],
]

function classNames(...classes: string[]) {
  return classes.filter(Boolean).join(" ")
}

export default function DropdownMenu() {
  const [navIsOpen, setNavIsOpen] = useState(false)
  // <nav> is closed by default on mobile display

  const onToggleNav = () => {
    setNavIsOpen((status) => !status)
  }

  const closeNavIfOpen = () => {
    setNavIsOpen((status) => false)
  }

  /**
   * Hook that alerts clicks outside of the passed ref
   */

  /**
   * Component that alerts if you click outside of it
   */
  function OutsideAlerter(props: any) {
    const wrapperRef = useRef<HTMLInputElement>(null)

    const handleClickOutside = (e: MouseEvent) => {
      if (!(wrapperRef.current! as any).contains(e.target)) {
        closeNavIfOpen()
      }
    }

    function useOutsideAlerter(ref: React.RefObject<HTMLInputElement>) {
      useEffect(() => {
        /**
         * Close mobile navigation menu if clicked on outside of element
         */

        // Bind the event listener
        document.addEventListener("mousedown", handleClickOutside)
        return () => {
          // Unbind the event listener on clean up
          document.removeEventListener("mousedown", handleClickOutside)
        }
      }, [ref])
    }

    useOutsideAlerter(wrapperRef)

    return <div ref={wrapperRef}>{props.children}</div>
  }

  OutsideAlerter.propTypes = {
    children: PropTypes.element.isRequired,
  }

  return (
    <>
      <OutsideAlerter>
        {/* OutsideAlerter is for the mobile menu --
        close with toggle or by clicking outside */}
        <Popover.Group
          as="nav"
          role="navigation"
          className="flex flex-col items-center"
        >
          <button
            type="button"
            aria-label="Toggle Menu"
            onClick={() => onToggleNav()}
            className="flex mx-auto my-4 text-2xl sm:hidden"
          >
            <div className="w-8 h-8 rounded">
              <svg
                xmlns="http://www.w3.org/2000/svg"
                viewBox="0 0 20 20"
                fill="currentColor"
                className="text-gray-900 dark:text-gray-100"
              >
                {/* menu icon when closed and X icon when open*/}
                {navIsOpen ? (
                  <path
                    fillRule="evenodd"
                    d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
                    clipRule="evenodd"
                  />
                ) : (
                  <path
                    fillRule="evenodd"
                    d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 15a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z"
                    clipRule="evenodd"
                  />
                )}
              </svg>
            </div>
            Menu
          </button>

          <div
            className={classNames(
              navIsOpen ? "flex" : "hidden sm:flex",
              "justify-between w-full flex-wrap px-4 py-6 mx-auto max-w-7xl sm:px-6 lg:px-8 group bg-white rounded-md text-base font-medium hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
            )}
          >
            {NAVIGATION_MENU.map(
              ([title, hrefOrSubmenu]: NAVIGATION_MENU_TYPE) => {
                return (
                  <Popover.Group key={title as string}>
                    <FlyoutMenu
                      title={title as string}
                      hrefOrSubmenu={hrefOrSubmenu as NAVIGATION_MENU_TYPE}
                      layout="outer"
                    />
                  </Popover.Group>
                )
              }
            )}
          </div>
        </Popover.Group>
      </OutsideAlerter>
    </>
  )
}

For context this is a Next.js / TypeScript / React project using the Next.js Link component ("next/link") and Headless UI Popover for the flyout menus.

Keyboard Tab navigation works, except inner flyout-menus tab two at a time as mentioned in my comment #426.

Sorry there's no CodeSandbox version, but I'm happy to make one after I hear back about that issue from #426 -- I posted this nested popover example there https://codesandbox.io/s/headlessui-popover-multiple-modals-bugs-forked-from-dialogs-534xd there (JavaScript, not TypeScript) and can fork it to convert that example to "hover" ... just not today.

DopamineDriven commented 3 years ago

@RobinMalfait: Thank you for your detailed response. I agree with you on the confusing UX if the dropdown is itself a link, so I'm sold on that. Weirdly the default Wordpress behavior (which had influenced my thinking) is to always add a link to every menu item.

I will think some more about potentially abandoning the hover. This is more of an 'app' behavior, compared to a more usual (old fashioned?) website behavior. Many sites (e.g. stripe.com) still use hover, often without an indication that the menu is a dropdown. One could argue that having to click is more "work" for the user. Obviously the argument is not valid on a touch device.

My above hover implementation works well and uses the internal open state, so it is usable. If you have a chance to look at the codesandbox and have an idea why the 'pointer-events: none' is necessary, let me know.

I am thinking of purchasing the tailwindui library. I saw that a number of places say 'Requires JS'. Is the idea with this project to provide that code?

Thanks again :)

100000% agree. I have been building headless WP sites for clients for nearly a year now and the hover feature seems ideal to me for top-level nav links with children pages/posts

heh, you can see the mess of anchors, subanchors, and subsubanchors I have going on here with a headless wp build (namely in mobile)

I'm refactoring it currently

The Fade Room Inc. Headless WordPress + Next

DoctorDerek commented 3 years ago

I need to fix my code for mobile -- clicks on mobile trigger hover effects, so the menu doesn't open on mobile at the moment.

DoctorDerek commented 3 years ago

Hey @therealgilles and @haniotis -- I thought you might be interested in my updated "open on hover" code, which also handles "clicking" in addition to hovering.

I can confirm this works on mobile, but please let me know if you happen to notice any issues with it.

Hope this helps!


Notes


Here's the Codesandbox reproduction: https://codesandbox.io/s/github/DoctorDerek/headlessui-example-close-popover-dynamically-in-react-hover-flyout-dropdown-menu/tree/main/?file=/src/App.js

And the code:

import { Fragment, useRef, useState, useEffect } from "react"
import { Popover, Transition } from "@headlessui/react"
import { ChevronDownIcon } from "@heroicons/react/solid"

function classNames(...classes) {
  return classes.filter(Boolean).join(" ")
}

export default function FlyoutMenu({
  menuTitle = "Hover Popover",
  linksArray = [
    // [[title: string, href: string], ...]
    ["Home", "/"],
    ["About", "/about"],
    ["Blog", "/blog"]
  ]
}) {
  let timeout // NodeJS.Timeout
  const timeoutDuration = 400

  const buttonRef = useRef(null) // useRef<HTMLButtonElement>(null)
  const [openState, setOpenState] = useState(false)

  const toggleMenu = (open) => {
    // log the current open state in React (toggle open state)
    setOpenState((openState) => !openState)
    // toggle the menu by clicking on buttonRef
    buttonRef?.current?.click() // eslint-disable-line
  }

  // Open the menu after a delay of timeoutDuration
  const onHover = (open, action) => {
    // if the modal is currently closed, we need to open it
    // OR
    // if the modal is currently open, we need to close it
    if (
      (!open && !openState && action === "onMouseEnter") ||
      (open && openState && action === "onMouseLeave")
    ) {
      // clear the old timeout, if any
      clearTimeout(timeout)
      // open the modal after a timeout
      timeout = setTimeout(() => toggleMenu(open), timeoutDuration)
    }
    // else: don't click! 😁
  }

  const handleClick = (open) => {
    setOpenState(!open) // toggle open state in React state
    clearTimeout(timeout) // stop the hover timer if it's running
  }

  const LINK_STYLES = classNames(
    "py-5 px-1 w-48",
    "text-base text-gray-900 uppercase font-bold",
    "transition duration-500 ease-in-out",
    "bg-gray-100 hover:text-blue-700 hover:bg-blue-100"
  )
  const handleClickOutside = (event) => {
    if (buttonRef.current && !buttonRef.current.contains(event.target)) {
      event.stopPropagation()
    }
  }
  useEffect(() => {
    document.addEventListener("mousedown", handleClickOutside)

    return () => {
      document.removeEventListener("mousedown", handleClickOutside)
    }
  })
  return (
    <div
      className={classNames(
        "w-full h-full absolute inset-0 pt-8",
        "bg-gradient-to-r md:bg-gradient-to-l",
        "from-yellow-400 via-red-500 to-pink-500"
      )}
    >
      <Popover className="relative mx-auto w-48">
        {({ open }) => (
          <div
            onMouseEnter={() => onHover(open, "onMouseEnter")}
            onMouseLeave={() => onHover(open, "onMouseLeave")}
            className="flex flex-col"
          >
            <Popover.Button ref={buttonRef}>
              <div
                className={classNames(
                  open ? "text-blue-800" : "text-gray-800",
                  "bg-white rounded-md",
                  "border-2 border-black border-solid",
                  "flex justify-center",
                  LINK_STYLES
                )}
                onClick={() => handleClick(open)}
              >
                <span className="uppercase">
                  {menuTitle} ({openState ? "open" : "closed"})
                  <ChevronDownIcon
                    className={classNames(
                      open ? "text-gray-600 translate-y-6" : "text-gray-400",
                      "h-9 w-9 inline-block",
                      "transform transition-all duration-500"
                    )}
                    aria-hidden="true"
                  />
                </span>
              </div>
            </Popover.Button>

            <Transition
              show={open}
              as={Fragment}
              enter="transition ease-out duration-200"
              enterFrom="opacity-0 translate-y-1"
              enterTo="opacity-100 translate-y-0"
              leave="transition ease-in duration-150"
              leaveFrom="opacity-100 translate-y-0"
              leaveTo="opacity-0 translate-y-1"
            >
              <Popover.Panel static className="z-10 w-48 mx-auto">
                <div
                  className={classNames(
                    "relative grid space-y-[2px]",
                    "bg-white border-2 border-gray-300 border-solid",
                    "divide-y-2 rounded-md text-center"
                  )}
                >
                  {linksArray.map(([title, href]) => (
                    <Fragment key={"PopoverPanel<>" + title + href}>
                      <a href={href} className={LINK_STYLES}>
                        {title}
                      </a>
                    </Fragment>
                  ))}
                </div>
              </Popover.Panel>
            </Transition>
          </div>
        )}
      </Popover>
    </div>
  )
}

Crossposted to #427

DoctorDerek commented 3 years ago

fix: add event.stopPropagation() to allow onMouseLeave (hover event "mouse out") to trigger.

I updated the code above. Here is the new code I added:

  const handleClickOutside = (event) => {
    if (buttonRef.current && !buttonRef.current.contains(event.target)) {
      event.stopPropagation()
    }
  }
  useEffect(() => {
    document.addEventListener("mousedown", handleClickOutside)

    return () => {
      document.removeEventListener("mousedown", handleClickOutside)
    }
  })
gn0rt0n commented 2 years ago

I would love to see a similar solution for Vue

HerrSammyDE commented 2 years ago

pls add :)

garrettmaring commented 2 years ago

Would love 💚

shadeemerhi commented 2 years ago

Would love this 🚀

HerrSammyDE commented 2 years ago

Ohhh yeah 🤝

ahmadaldabouqii commented 1 year ago

I would love to see a similar solution for Vue :')

benjamincanac commented 1 year ago

We made it work on Nuxt UI Dropdown component, here is the code if that can help: https://github.com/nuxt/ui/blob/dev/src/runtime/components/elements/Dropdown.vue#L148

jrhart08 commented 7 months ago

I packaged up a small hook for this to just programmatically click the button when hovering over the area, and click again when leaving. It's hacky but it's working well for me, and it works for both the Menu and Popover with minimal boilerplate.

// hooks/usePopoverHover.ts
export const usePopoverHover = () => {
  const popoverRef = useRef<HTMLElement>(null);
  const buttonRef = useRef<HTMLButtonElement>(null);

  useEffect(() => {
    const popover = popoverRef.current;
    const button = buttonRef.current;

    if (!popover || !button) {
      console.warn('usePopoverHover: Please assign popoverRef and buttonRef for this to function correctly.');
      return () => {};
    }

    const clickButton = () => {
      button.click();
    };

    popover.addEventListener('mouseenter', clickButton);
    popover.addEventListener('mouseleave', clickButton);

    return () => {
      popover.removeEventListener('mouseenter', clickButton);
      popover.removeEventListener('mouseleave', clickButton);
    };
  }, []);

  return { popoverRef, buttonRef };
};

Then it's used like so:

// components/MyMenu.tsx
function MyMenu({ text, items }: MyMenuProps) {
  const { popoverRef, buttonRef } = usePopoverHover();

  return (
    <Menu ref={popoverRef}>
      <Menu.Button ref={buttonRef}>
        Hover Here
      </Menu.Button>
      <Menu.Items>
        {/* ... */}
      </Menu.Items>
    </Menu>
 );
}
DoctorDerek commented 7 months ago

That's an interesting solution! Thanks for sharing @jrhart08

vladimirrostok commented 7 months ago

Thanks for sharing your implementation @jrhart08, this works great for me with minimal code required!

oliviercperrier commented 7 months ago

Cleanest solution i was able to come with. With the previous solution given i wasn't able to make the trigger clickable as a link.

import Link from "next/link";

import { Menu, Transition } from "@headlessui/react";
import { ReactNode, useRef } from "react";

export type TMenuDropdownItem = {
  title: string;
  href: string;
};

export type TMenuDropdownProps = {
  trigger: ReactNode;
  triggerHref?: string;
  items: TMenuDropdownItem[];
};

const MenuDropdown = ({ trigger, triggerHref, items }: TMenuDropdownProps) => {
  const openRef = useRef(false);
  const closeFuncRef = useRef<() => void>();

  return (
    <Menu
      as="div"
      className="relative inline-block text-left"
      onMouseEnter={({ target }) =>
        openRef.current ? "" : (target as HTMLDivElement).click()
      }
      onMouseLeave={() => {
        if (openRef.current) closeFuncRef.current?.();
      }}
    >
      {({ open, close }) => {
        openRef.current = open;
        closeFuncRef.current = close;

        return (
          <div>
            <Menu.Button
              as={triggerHref ? Link : undefined}
              className="outline-none block"
              href={triggerHref || ""}
            >
              {trigger}
            </Menu.Button>
            {items.length > 0 && (
              <Transition
                as={Fragment}
                enter="transition ease-out duration-100"
                enterFrom="transform opacity-0 scale-95"
                enterTo="transform opacity-100 scale-100"
                leave="transition ease-in duration-75"
                leaveFrom="transform opacity-100 scale-100"
                leaveTo="transform opacity-0 scale-95"
              >
                <Menu.Items
                  className="absolute -left-4 w-[175px] bg-white shadow-lg focus:outline-none"
                  onMouseLeave={close}
                >
                  {items.map((item) => (
                    <Menu.Item key={item.title}>
                      {({ active }) => (
                        <Link
                          href={item.href}
                          onClick={close}
                          className={`${
                            active ? "bg-primary text-white" : ""
                          } group flex w-full items-center px-4 py-3 text-xs`}
                        >
                          {item.title}
                        </Link>
                      )}
                    </Menu.Item>
                  ))}
                </Menu.Items>
              </Transition>
            )}
          </div>
        );
      }}
    </Menu>
  );
};

export default MenuDropdown;
therealgilles commented 7 months ago

Thanks @oliviercperrier for your contribution. I'm restarting working on a related project after a long while, so I may give it a go (thought I mostly moved away from using hover for menus).

Is menuItemsRef not needed anymore then?

PS: Could you add the comment highlighting tag so that we get syntax highlighting? Usually looks like this: ```TSX ... ```

oliviercperrier commented 7 months ago

Thanks @oliviercperrier for your contribution. I'm restarting working on a related project after a long while, so I may give it a go (thought I mostly moved away from using hover for menus).

Is menuItemsRef not needed anymore then?

PS: Could you add the comment highlighting tag so that we get syntax highlighting? Usually looks like this:

```TSX

...

```

@therealgilles Done. I also remove menuItemsRef. Forgot to remove it.

Eviwang commented 6 months ago

I packaged up a small hook for this to just programmatically click the button when hovering over the area, and click again when leaving. It's hacky but it's working well for me, and it works for both the Menu and Popover with minimal boilerplate.

// hooks/usePopoverHover.ts
export const usePopoverHover = () => {
  const popoverRef = useRef<HTMLElement>(null);
  const buttonRef = useRef<HTMLButtonElement>(null);

  useEffect(() => {
    const popover = popoverRef.current;
    const button = buttonRef.current;

    if (!popover || !button) {
      console.warn('usePopoverHover: Please assign popoverRef and buttonRef for this to function correctly.');
      return () => {};
    }

    const clickButton = () => {
      button.click();
    };

    popover.addEventListener('mouseenter', clickButton);
    popover.addEventListener('mouseleave', clickButton);

    return () => {
      popover.removeEventListener('mouseenter', clickButton);
      popover.removeEventListener('mouseleave', clickButton);
    };
  }, []);

  return { popoverRef, buttonRef };
};

Then it's used like so:

// components/MyMenu.tsx
function MyMenu({ text, items }: MyMenuProps) {
  const { popoverRef, buttonRef } = usePopoverHover();

  return (
    <Menu ref={popoverRef}>
      <Menu.Button ref={buttonRef}>
        Hover Here
      </Menu.Button>
      <Menu.Items>
        {/* ... */}
      </Menu.Items>
    </Menu>
 );
}

Optimizing the jitter issue that can occur between the menu and button with gaps

import { useEffect, useRef } from 'react'

export const usePopoverHover = () => {
  const popoverRef = useRef<HTMLElement>(null)
  const buttonRef = useRef<HTMLButtonElement>(null)

  useEffect(() => {
    const popover = popoverRef.current
    const button = buttonRef.current
    let timer: any = 0

    if (!popover || !button) {
      console.warn(
        'usePopoverHover: Please assign popoverRef and buttonRef for this to function correctly.'
      )
      return () => {}
    }

    const enterButton = () => {
      clearTimeout(timer)
      if (popover.dataset.headlessuiState === 'open') {
        return
      }
      button.click()
    }

    const leaveButton = () => {
      timer = setTimeout(() => {
        button.click()
      }, 100)
    }

    popover.addEventListener('mouseenter', enterButton)
    popover.addEventListener('mouseleave', leaveButton)

    return () => {
      popover.removeEventListener('mouseenter', enterButton)
      popover.removeEventListener('mouseleave', leaveButton)
    }
  }, [])

  return { popoverRef, buttonRef }
}