tailwindlabs / headlessui

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

Popover does not close #358

Closed blasterbug closed 3 years ago

blasterbug commented 3 years ago

What package within Headless UI are you using?

@headlessui/react

What version of that package are you using?

v1.0.0

What browser are you using?

chrome, safari, firefox

Reproduction repository

https://github.com/blasterbug/headlessui-popover-does-not-close.git

As state in the doc "Popovers are perfect for floating panels with arbitrary content like navigation menus, mobile menus and flyout menus." so the expected behaviour is that the panel closes when an item inside is clicked. The use case is when a user opens such a menu to navigate and they click on a link within the panel then the panel disappears.

rajohan commented 3 years ago

I think it works as expected considering that the Popover is made to be able to contain any element. If it would close whenever any item inside is clicked you would have problems implementing a Popover containing a input as an example.

But I agree that it should be possible to make it close when specific items are clicked. I think the best solution may be to expose a render prop along with the "open" prop that makes you able to toggle the state.

Then you would be able to do something like this

const MyPopover = () => (
  <Popover>
    {({ open, setOpen }) => (
      <>
        <Popover.Button>
          <span className="sr-only">Open main menu</span>
          {open ? <XIcon /> : <MenuIcon />}
        </Popover.Button>
        <Popover.Panel>
          // clicking inside this wont make the popover close
          <input type="text" />
          // clicking any of these links will close the popover
          <a href="/link1" onClick={() => setOpen(false)}>
            Link 1
          </a>
          <a href="/link2" onClick={() => setOpen(false)}>
            Link 2
          </a>
          <a href="/link3" onClick={() => setOpen(false)}>
            Link 3
          </a>
          <a href="/link4" onClick={() => setOpen(false)}>
            Link 4
          </a>
        </Popover.Panel>
      </>
    )}
  </Popover>
);
blasterbug commented 3 years ago

@rajohan I guess another solution could be to have a prop on Popover to specify if the panel should close when an element inside is clicked.

desaintflorent commented 3 years ago

I agree this is really confusing, I would also expect the popover to close when I a link is clicked inside...

nathanheffley commented 3 years ago

@desaintflorent does your popover only have links inside of it, or is it contain a mix of elements where some should close the popover and some shouldn't?

desaintflorent commented 3 years ago

Only links ! this is for an header menu. I'm using vue and can't find in the doc how to programmatically close the popover when the link is clicked ?

nathanheffley commented 3 years ago

Why not use the Menu component that is specifically built for being a dropdown navigation element, which closes when you select an option?

rajohan commented 3 years ago

Using the Menu component should probably be preferred when you only have links. But, its not possible if you need other elements as well. In my opinion the Popover component should have a way to programmatically close it.

There is also the consideration of accessibility as the Menu uses arrow keys to go up and down in the menu and the Popover component uses tab. For a nav menu I think tab to navigate the links is what's usually used.

nathanheffley commented 3 years ago

@rajohan tab may be the way that is most common, but that is not the most accessible. It is only most common because that is what browsers do by default and most developers don't fix it. If you refer to the W3's examples of best practices for a dropdown menu, you can see that arrow keys are the preferred method of navigating a dropdown nav menu: https://www.w3.org/TR/wai-aria-practices-1.1/examples/menu-button/menu-button-links.html

rajohan commented 3 years ago

@nathanheffley I agree that using the arrow keys might be easier. So i guess you have to choose between common vs easier to use.

You also have to consider that the navigation in many cases are a hamburger menu on small screens and on one row on larger screens. So by using the Menu component on small screens by default you would get 2 different ways of navigating the menu. Arrow keys on small screens, tab on larger screens. You could of course fix this with some JavaScript.

blasterbug commented 3 years ago

@nathanheffley FYI, on TailwindUI, as far as I know they only use Popover for navigation (ie panels that contains only links) for header menu...

nathanheffley commented 3 years ago

@blasterbug well if you use a links in the popover like Tailwind UI does then it wouldn't be an issue because navigating to a new page would "close" the popover.

I agree a programmatic way to close it would be nice probably, but this issue seems to be resolvable by using a Menu and submitting a new, clear feature request as this convo has gotten pretty wide in terms of subject matter.

blasterbug commented 3 years ago

@nathanheffley This is not accurate, for instance with react-router, the popover stays open. See the exemple I provided.

nathanheffley commented 3 years ago

@blasterbug I'm still confused as to your issue. Does the Menu element not work for your use case?

blasterbug commented 3 years ago

@nathanheffley sure Menu might work, as long as the content of the panel stay simple. But regarding TailwindUI I should use Popover. And reading the doc, that the components that seems more logical to use.

RobinMalfait commented 3 years ago

Hey! Thank you for your bug report! Much appreciated! 🙏

We can't automatically close the Popover because we don't know what kind of elements people will use in their applications. Some people will add some content where they expect that the Popover stays open, some people will have links that redirect somewhere and then they expect the Popover to close. Automatically closing is not an option here. We could add a prop that controls the auto close effect (as mentioned by @blasterbug in https://github.com/tailwindlabs/headlessui/issues/358#issuecomment-821245238), but even then you could have multiple pieces of content where part A requires the Popover to close and part B requires the Popover to stay open.

There is another issue, you are using a router library, but Headless UI is not aware of this router, so there is no way to know when the route changed. In a non-spa world, the browser would refresh and the new content would be there and the Popover would be closed, but now that's not the case.

What are some of the possible solutions?

When I install and run your reproduction application, this is what screenreader users will hear:

https://user-images.githubusercontent.com/1834413/115256417-7bf32800-a12f-11eb-9286-47ce9813600e.mov

A few things to notice here:


In the next video I applied a fix (checkout the pr) so that whenever the content changes, the focus gets moved to the new content.

https://user-images.githubusercontent.com/1834413/115256852-e2784600-a12f-11eb-97a4-add5a020fcfe.mov

A few things to notice here:

If you use a router like Reach Router then they manage the focus for you so that you get the expected result.


Long story short, sometimes there is a bit of friction, but the friction is good for everyone.


I also see some confusion about using a Popover vs a Menu. We talk about that in the docs here: https://headlessui.dev/react/menu#when-to-use-a-menu and https://headlessui.dev/react/popover#when-to-use-a-popover

A Menu is good for things like profile dropdowns, or an actions dropdown at the end of a table row. A Popover for navigation is preferred because you can use the normal tab order to move through items. It's also possible to put multiple Popover's in a Popover group, which means that if you are at the end of Popover A, press tab and go to Popover B, then Popover A is still open. This is useful for when you press shift+tab so that you end up at the end of the Popover A, without going through the full list again.

A good use case for that is on pages like webshops:

example of a webshop with an open menu with a lot of items where the very last item of the first category is selected

Imagine I used tab to go to the next category because I accidentally was too quick. If you would close the currently open Popover then keyboard users have to go through the full list again, just to be at the very last item.


Does this make sense, and does this answer your question? Checkout the PR I provided: https://github.com/blasterbug/headlessui-popover-does-not-close/pull/1

blasterbug commented 3 years ago

Hi @RobinMalfait And thank you for the detailed reply, very good as well as for the PR. However,

If you use a router like Reach Router then they manage the focus for you so that you get the expected result.

This is actually always the case. Or what do you mean they manage the focus for you?

RobinMalfait commented 3 years ago

Reach Router will move the focus when there is new content: https://github.com/reach/router/blob/d2c9ad06715c9d48c2d16f55f7cd889b626d2521/src/index.js#L291

image

However as far as I know, React Router doesn't do this, hence why I did it manually in the PR.

amantiwari1 commented 3 years ago

so where is the solution?

devrsi0n commented 3 years ago

Found a trick to make it work, not perfect though.

const buttonRef = useRef();
const handleClickPanel = () => buttonRef.current?.click();

<Popover>
  <Popover.Button ref={buttonRef}>Click me</Popover.Button>
  <Popover.Panel onClick={handleClickPanel} onKeyDown={handleClickPanel} role="button" tabIndex={0}>
   {content}
  </Popover.Panel>
</Popover>
tvarwig commented 3 years ago

Found a trick to make it work, not perfect though.

const buttonRef = useRef();
const handleClickPanel = () => buttonRef.current?.click();

<Popover>
  <Popover.Button ref={buttonRef}>Click me</Popover.Button>
  <Popover.Panel onClick={handleClickPanel} onKeyDown={handleClickPanel} role="button" tabIndex={0}>
   {content}
  </Popover.Panel>
</Popover>

This example does not work in typescript. The error is pointing to ref={buttonRef}

Type error: Type 'MutableRefObject<undefined>' is not assignable to type 'LegacyRef<HTMLButtonElement> | undefined'.
Type 'MutableRefObject<undefined>' is not assignable to type 'RefObject<HTMLButtonElement>'.
Types of property 'current' are incompatible.
Type 'undefined' is not assignable to type 'HTMLButtonElement | null'.
desaintflorent commented 3 years ago

I have to tell, what a nightmare to simply close a popover when we click on a link. I read again all the comments one by one, and what I understand is that we should find a way to "focus out" when route is changing, instead of just closing the popover manually.

I'm using Intertiajs with Vue 3, and there is a route change event but the focus is not changing automatically. So what I did, is adding a div to wrap my app and execute :

import { Inertia } from "@inertiajs/inertia";

Inertia.on("success", (event) => {
    document.getElementById("wrap").focus();
});

To be able to focus on a div we also have to add tabindex to the wrap div ( tabindex="-1" ) Look dirty to me, but didn't find any other solution online. I will ask inertia if there is another solution.

nibtime commented 2 years ago

Hi,

I am currently working on a new project with Tailwind and Tailwind UI and I was also struggling with closing popovers on navigation.

I am using Next.js and found that the best solution, in this case, is a hook useShiftFocusOnNavigation I include in _app.tsx that decides how to shift focus on internal navigation events from next/router.

Here is the code as-is from my current project. I wanted to share it in case somebody with Next.js can save some time on this and also to get feedback .With such a11y-impacting things it's somehow very easy to think to have a good solution, while in reality it really isn't :).

utils/browser.ts ```tsx export const selectFocusable = ( el: Element | null | undefined ): HTMLElement | null | undefined => { return el?.querySelector( `button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])` ); }; export const isElementVisible = (el: Element | null | undefined) => { if (!el) return false; const rect = el.getBoundingClientRect(); if (!rect.height && !rect.width) { return false; } if (getComputedStyle(el).visibility === 'hidden') { return false; } return true; }; export const selectAllVisible = ( selector: string, querySelectorAll?: (selector: string) => NodeListOf ) => { const elements = querySelectorAll?.(selector) || document.querySelectorAll(selector); return [...elements].filter(isElementVisible); }; ```
hooks/useShiftFocusOnNavigation.tsx ```tsx import { useRouter } from 'next/router'; import { useEffect } from 'react'; import { selectFocusable, selectAllVisible } from 'utils/browser'; const hashChangedHandler = (url: string) => { const [, fragment] = url.split('#'); selectFocusable(document.getElementById(fragment))?.focus(); }; const routeChangedHandler = (url: string) => { const [, fragment] = url.split('#'); const focusable = // shift focus to the first focusable element in the fragment section (fragment && selectFocusable(document.getElementById(fragment))) || // or shift focus to the first call-to-action in main selectFocusable(document.querySelector('main')?.querySelector('.cta')) || // or shift focus to the first focusable within h1 of main (titles with self-link) selectFocusable(document.querySelector('main')?.querySelector('h1')) || // or shift focus to the first visible navigation link selectFocusable(selectAllVisible('.nav-focus-container')[0]); focusable?.focus(); }; const useShiftFocusOnNavigation = () => { const { events } = useRouter(); useEffect(() => { events.on('hashChangeComplete', hashChangedHandler); events.on('routeChangeComplete', routeChangedHandler); return () => { events.off('hashChangeComplete', hashChangedHandler); events.off('routeChangeComplete', routeChangedHandler); }; }, []); }; export default useShiftFocusOnNavigation; ```

For this, to work you also need to provide components/elements that can be sensibly focused (e.g. slugify section titles and auto-link the section title to its own section fragment), and if the HTML of pages is sound and consistent this will be helpful as well.

Then you get the popover closing for free :)

CR1AT0RS commented 2 years ago

For those looking answer for this. Answer is here: https://github.com/tailwindlabs/headlessui/issues/427#issuecomment-916925396