tailwindlabs / headlessui

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

Control whether Menu closes on Menu.Item click #152

Closed employee451 closed 2 years ago

employee451 commented 3 years ago

Hi there! I have a use case for the Menu component where I don't necessarily want the Menu to close when I click on a Menu.Item. Or, in other words, I would like to be able to control when the menu closes in some way.

bytehala commented 3 years ago

While I agree that there should be a way to do this with less boilerplate, I looked at the docs and found one way to do it without having to wait for that feature:

You can omit the open destructuring, as this variable is internally tracked by the Menu component and you basically just ignore it and have full control of when you want your Menu to open/close based on customOpen.

function MyDropdown() {

  const [display, setDisplay] = useState('display here');
  const [customOpen, setCustomOpen] = useState(false);

  function buttonClicked() {
    setCustomOpen(prev => !prev);
  }

  return (
    <>
    <Menu>
      {({open}) => (
        <>
          <Menu.Button onClick={buttonClicked}>More</Menu.Button>
          {customOpen && (
          <Menu.Items static>
            <Menu.Item>
              {({ active }) => (
                <a className={`${active && 'bg-blue-500'}`} onClick={() => setDisplay('Account Settings')}>
                  Account settings
                </a>
              )}
            </Menu.Item>
            <Menu.Item>
              {({ active }) => (
                <a className={`${active && 'bg-blue-500'}`} onClick={() => setDisplay('Documentation')}>
                  Documentation
                </a>
              )}
            </Menu.Item>
            <Menu.Item disabled>
              <span className="opacity-75">Invite a friend (coming soon!)</span>
            </Menu.Item>
          </Menu.Items>)
          }
        </>
      )
      }
    </Menu>
    <br/><br/>
    <div>{display} was clicked</div>
  </>
  )
}
employee451 commented 3 years ago

Thanks @bytehala. This is what I'll have to do for now. The annoying thing about doing it that way is that I now have to implement that the menu closes on 'escape' press, when you click outside the menu, etc. myself.

leandroruel commented 3 years ago

the click outside is the worst, i'm strugling trying implement it myself. no luck, a lot of bugs

mysteriouscenter commented 3 years ago

@bytehala Is there a good way to use something like this to allow adding form fields / text areas / submit button to Menu Items like this?

I am clearly in over my head in understanding how this might work.

RobinMalfait commented 3 years ago

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

@employee451 could you talk more about this use case you have? A Menu typically contains items that let you invoke some action or let you navigate to another page. Think about it as your native OS menu bar. As far as I know, they all close the menu once the "action" is invoked. Here is a screenshot of the macOS Menu for example: image

The reason that I ask about your use case is because I'm trying to understand what you try to achieve. Because initially I am thinking about that you might be "abusing" the Menu for a different use case than it is intended for.

employee451 commented 3 years ago

Of course!🙏 @RobinMalfait

One use case where this functionality would be useful is when the "action" is asynchronous, e.g. you have to wait for an API call to complete before closing the menu.

In my particular use case, the Menu acts as an option menu for a calendar event. One of the options in the menu should cancel the event. When this option is selected, I would like to show a loading spinner on the menu item, then a check icon once the action has been completed. Currently, I'm showing the loading state elsewhere in the UI.

Zertz commented 3 years ago

I am running into a similar situation and while <Disclosure /> mostly fits the bill, there is one thing lacking: a way to detect an outside click and the escape key being pressed.

Feel free to chip in but I think having access to this would solve this issue!

react-use has useClickAway and useKey but it'd be nice to have it integrated here!

Mad-Kat commented 3 years ago

@RobinMalfait great job on this library. I really like it :)

I have exactly the same problem that the menu doesn't close automatically with the <Link></Link> tag from NextJS. When I want to have this behavior I would have to implement the open/close functionality again and clicking outside detection is not so easy :laughing:

Do you know why the menu doesn't close with NextJS? Could it be that when we have client side routing, that the closing mechanism doesn't work 100%?

RobinMalfait commented 3 years ago

@Mad-Kat That's an issue with next/link, checkout: https://github.com/tailwindlabs/headlessui/issues/120#issuecomment-717174190

blasterbug commented 3 years ago

I think there is the same issue with Popover. I would expect when users click on one of the items inside Popover.Panel, then the panel will close.

employee451 commented 3 years ago

I think there is the same issue with Popover. I would expect when users click on one of the items inside Popover.Panel, then the panel will close.

This issue is about the opposite problem, as the Menu component is closing in situations where we might not want it to.

BrianHung commented 3 years ago

As far as I know, they all close the menu once the "action" is invoked. Here is a screenshot of the macOS Menu for example:

One use case if you have a Switch button in your menu, then you'd want the user to be able to see the toggle go through before closing.

arielitovsky commented 3 years ago

@bytehala's solution didn't completely work for me – I had to expand it slightly by observing the change to the internal open state (for my complex layout, opening the menu didn't work reliably) by using a useEffect

function MyDropdown() {
const [open, setOpen] = useState(true);

  return (
    <Menu>
      {( { open: internalOpen }) => {

        useEffect(() => {
          if (internalOpen && !open) {
            setOpen(true)
          }
        }, [internalOpen])
  return (
          <Menu.Button onClick={buttonClicked}>More</Menu.Button>
          {customOpen && (
          <Menu.Items static>
            <Menu.Item>
              {({ active }) => (
                <a className={`${active && 'bg-blue-500'}`} onClick={() => setDisplay('Account Settings')}>
                  Account settings
                </a>
              )}
            </Menu.Item>
            <Menu.Item>
              {({ active }) => (
                <a className={`${active && 'bg-blue-500'}`} onClick={() => setDisplay('Documentation')}>
                  Documentation
                </a>
              )}
            </Menu.Item>
            <Menu.Item disabled>
              <span className="opacity-75">Invite a friend (coming soon!)</span>
            </Menu.Item>
          </Menu.Items>)
)
}

If you have many internal divs, you may also need to toggle the open state on those if the events don't bubble up.

kamaladenalhomsi commented 3 years ago

For anyone suffering to keep the dropdown opened if an item clicked, here is my workaround:


 const [menuOpened, setMenuOpened] = useState(false)

 const CustomMenuButton = function ({ children }) {
    return <button onClick={() => setMenuOpened(!menuOpened)}>{children}</button>
  }

function myComponent () {
  return (
    <Menu>
      <Menu.Button as={CustomMenuButton}>Button</Menu.Button>
      <Menu.Items static>
        {menuOpened && (
          <Menu.Item>This is item<MenuItem>
        )}
      </Menu.Items>
    </Menu>
  )
}

TL;DR: just create a custom component for button and do what ever you want (and ofc set static to true for Menu.Items)

luciodale commented 3 years ago

I think what makes more sense is to invoke event.stopProgatation() from within the Menu.Item component. In this way, the dropdown still closes when clicking outside the dialog and there's no need to reimplement any logic.

ShivamJoker commented 3 years ago

When this is going to be fixed ? It's really annoying to do so much just to stop closing the menu there should be a prop 😞

ShivamJoker commented 3 years ago

For anyone suffering to keep the dropdown opened if an item clicked, here is my workaround:

 const [menuOpened, setMenuOpened] = useState(false)

 const CustomMenuButton = function ({ children }) {
    return <button onClick={() => setMenuOpened(!menuOpened)}>{children}</button>
  }

function myComponent () {
  return (
    <Menu>
      <Menu.Button as={CustomMenuButton}>Button</Menu.Button>
      <Menu.Items static>
        {menuOpened && (
          <Menu.Item>This is item<MenuItem>
        )}
      </Menu.Items>
    </Menu>
  )
}

TL;DR: just create a custom component for button and do what ever you want (and ofc set static to true for Menu.Items)

The this will throw a forward ref error

Wrap it in forwardRef

  const CustomMenuButton = forwardRef(({ children }, ref) => (
    <button onClick={() => setMenuOpened(!menuOpened)} ref={ref}>
      {children}
    </button>
  ));
luciodale commented 3 years ago

I've found that the popover element https://headlessui.dev/react/popover provides a close() prop that you need to invoke manually otherwise the menu doesn't close. Perfect to my use case. I resorted to that and got rid of the dropdown.

harshamv commented 3 years ago

@luciodale can you share some boiler code on how u implemented it on menu click and click away?

stevebauman commented 2 years ago

If anyone encounters this today in Vue, here's a template to get you started on a "Dropdown" popover:

<template>
    <Popover class="relative">
        <PopoverButton class="inline-flex items-center font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 rounded-md shadow-sm text-gray-700 border border-gray-300 bg-white hover:bg-gray-50 focus:ring-blue-500">
            Dropdown Title

            <ChevronDownIcon class="w-5 h-5 ml-2" />
        </PopoverButton>

        <transition
            enter-active-class="transition duration-200 ease-out"
            enter-from-class="translate-y-1 opacity-0"
            enter-to-class="translate-y-0 opacity-100"
            leave-active-class="transition duration-150 ease-in"
            leave-from-class="translate-y-0 opacity-100"
            leave-to-class="translate-y-1 opacity-0"
        >
            <PopoverPanel
                class="absolute w-56 py-1 mt-2 bg-white rounded-md shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
            >
                <slot />
            </PopoverPanel>
        </transition>
    </Popover>
</template>

<script>
import { ChevronDownIcon } from '@heroicons/vue/solid';
import { Popover, PopoverButton, PopoverPanel } from '@headlessui/vue';

export default {
    components: { Popover, PopoverButton, PopoverPanel, ChevronDownIcon },
};
</script>
MartinSeeler commented 2 years ago

I just ran into the same problem with Menu and had to read the docs very carefully. The solution is pretty easy!

In React, I had this problem when rendering a <Link to="/foo/bar/" ... /> element. The page transition did happen, but the menu did not close. The fix was to use the <Menu.Item as={Link} to="/foo/bar" ... />, as this handles the click event properly.

This should work for next.js navigation as well. Not sure about vue, though.

BrianHung commented 2 years ago

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

@employee451 could you talk more about this use case you have? A Menu typically contains items that let you invoke some action or let you navigate to another page. Think about it as your native OS menu bar. As far as I know, they all close the menu once the "action" is invoked. Here is a screenshot of the macOS Menu for example: image

The reason that I ask about your use case is because I'm trying to understand what you try to achieve. Because initially I am thinking about that you might be "abusing" the Menu for a different use case than it is intended for.

Hm, one question related to that image is how would you build that "share" submenu? Since right now, when you press enter, it closes the menu; but for the submenu, you'd expect it to open a second dropdown menu on the right.

livthomas commented 2 years ago

I would also need this feature in Headless UI. My use case is simple: I have a menu item which copies a link to clipboard. When it is clicked, the menu needs to stay open because the menu item text changes to "Link Copied!" for a few seconds. This provides a visual feedback to users so they know that the action has been successfully executed.

So what I really need is a prop on a menu item which would control whether the menu should be closed when the menu item is clicked. I have tried to use event.stopPropagation() or event.preventDefault() as a workaround but the menu is still being closed on click.

jordn commented 2 years ago

Kinda gross, but here's a workaround. Use a ref to the Menu.Button and force a click event to reopen it immediately afterwards (in a timeout so it is queued up immediately after the current event is handled)

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

export const MenuThatDoesntClose = () => {
  const ref = useRef(null);

  return (
    <Menu>
      <Menu.Button ref={ref}> More</Menu.Button>
      <Menu.Items>
        <Menu.Item
          as={"button"}
          onClick={() => {
            setTimeout(() => {
              ref.current?.click();
            }, 0);
          }}
        >
          will not close
        </Menu.Item>
        <Menu.Item as={"button"}>Will close</Menu.Item>
      </Menu.Items>
    </Menu>
  );
};