tailwindlabs / headlessui

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

Fix outside click in nested portalled `Popover` components #3362

Closed RobinMalfait closed 2 months ago

RobinMalfait commented 2 months ago

This PR fixes an issue where nested Popover components that are using the anchor prop (and thus are portalled) are not correctly closed when clicking outside.

The issue stems from incorrect DOM node references to know whether we are allowed to click on a certain DOM element or not.

As a general recap, when an outside click happens, we need to react to it and typically use the useOutsideClick hook for this.

We also require the context of "allowed root containers", this means that clicking on a 3rd party toast when a dialog is open, that we allow this even though we are technically clicking outside of the dialog. This is simply because we don't have control over these elements.

We also need a reference to know what the "main tree" container is, because this is the container where your application lives and we know that we are not allowed to click on anything in this container. The complex part is getting a reference to this element.

<html>
  <head>
    <title></title>
  </head>
  <body>
    <div id="app"> <-- main root container -->
      <div></div>
      <div>
        <Popover></Popover> <!-- Current component -->
      </div>
      <div></div>
    </div>

    <!-- Allowed container #1 -->
    <third-party-toast-container></third-party-toast-container>
  </body>

  <!-- Allowed container #2 -->
  <grammarly-extension></grammarly-extension>
</html>

Some examples:

This is where the MainTreeProvider comes in handy. This component will use the passed in node as the main tree node reference and pass this via the context through the React tree. This means that a nested Popover will still use a reference from the parent Popover.

In case of the Dialog, we wrap the Dialog itself with this provider which means that the provider will be in the main tree and can be used inside the portalled Dialog.

Another part of the MainTreeProvider is that if no node exists in the parent (reading from context), and no node is provided via props, then we will briefly render a hidden element, find the root node of the main tree (aka, the parent element that is a direct child of the body, body > *). Once we found it, we remove the hidden element again. This way we don't keep unnecessary DOM nodes around.

This also fixes another issue #3319, because we will now briefly render the hidden element to determine the main tree node.

Fixes: #3332 Fixes: #3319

vercel[bot] commented 2 months ago

The latest updates on your projects. Learn more about Vercel for Git ↗︎

Name Status Preview Comments Updated (UTC)
headlessui-react ✅ Ready (Inspect) Visit Preview 💬 Add feedback Jul 4, 2024 11:25pm
headlessui-vue ✅ Ready (Inspect) Visit Preview 💬 Add feedback Jul 4, 2024 11:25pm