Skip to content

Fix closing Menu when other Menu is opened #3726

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 15 commits into from
May 12, 2025
Merged

Fix closing Menu when other Menu is opened #3726

merged 15 commits into from
May 12, 2025

Conversation

RobinMalfait
Copy link
Member

@RobinMalfait RobinMalfait commented May 12, 2025

Fixes: #3701

This PR fixes an issue where an open Menu is not closed when opening a new Menu. This is also fixed for Listbox and Combobox that used the same techniques.

This happened because we recently shipped an improvement where the Menu opens on pointerdown instead of on click. This means that the useOutsideClick hook was not correct anymore because it relies on click.

We could try and figure out that we should already close on pointerdown but this might not be expected for other components. Instead we want to simplify things a bit and ideally not even worry about what event caused a specific state change.

Instead of trying to fight timing issues when certain events happen, this PR takes a slightly different approach.

We already had the concept of a "top-layer" similar to the browser's #top-layer (when using native dialog). This essentially lets us know which component sits on top of the hierarchy.

This top-layer is important because when you have the following structure:

<Dialog>
  <Menu />
</Dialog>

Assuming that both the Dialog and Menu are open, clicking outside or pressing escape should only close the Menu. Once the Menu is closed, we should close the Dialog.

In this case, we can enable/disable the useOutsideClick hook based on whether the current component is the top-layer or not.

Some components like the Menu, Listbox and Combobox should immediately close when they are not the top-layer anymore. A Dialog can stay open, because you can have interactable elements like the example above in the Dialog.

Luckily, these components that should immediately close already use their own state machine. This allows us to listen to the OpenMenu (or OpenListbox, OpenCombobox) event, and if that happens, we can push the current component on the shared stack machine.

This now means that it doesn't matter how the Menu is opened, but the moment a user event (click, enter, ...) opens the Menu, we now that we are on top of the stack.

All other components could listen to push events on the stack. Once those happen, we can close the current component immediately. This has the nice side effect that we don't have to use a useEffect to check for state changes. We can just act immediately when an event happens.

The useOutsideClick hooks is still used and useful in situations where you literally just clicked somewhere else. But in case you are opening another Menu or another Listbox, we can immediately close the one that was open before.

Test plan

Before:

Screen.Recording.2025-05-12.at.19.19.42.mov

After:

Screen.Recording.2025-05-12.at.19.19.05.mov

Copy link

vercel bot commented May 12, 2025

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 May 12, 2025 9:43pm
headlessui-vue ✅ Ready (Inspect) Visit Preview 💬 Add feedback May 12, 2025 9:43pm

// In a perfect world this is pushed / popped when we open / close the Dialog
// for within an event listener. But since the state is controlled by the
// user, this is the next best thing to do.
let stackMachine = stackMachines.get(null)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe this should be safe because we are using a DefaultMap in module scope and this will always return the exact same reference.

Comment on lines +225 to +230
useIsoMorphicEffect(() => {
if (!enabled) return

stackMachine.actions.push(id)
return () => stackMachine.actions.pop(id)
}, [stackMachine, id, enabled])
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately the Dialog is state driven and not based on user events. So the only way we know that it sits on top or not is with an effect.

@RobinMalfait RobinMalfait marked this pull request as ready for review May 12, 2025 17:32
Base automatically changed from chore/popover-using-state-machines to main May 12, 2025 18:09
So the specific `mousedown` code can be removed.
This will allow us to act immediately when a new item is pushed on top
of the stack. In case that happens the `Menu`, `Listbox`, `Combobox`
will immediately close.

The `Dialog` will just push (or pop) onto the stack when it's opened.
// Handle outside click
let outsideClickEnabled = comboboxState === ComboboxState.Open
let outsideClickEnabled = isTopLayer
useOutsideClick(outsideClickEnabled, [buttonElement, inputElement, optionsElement], () =>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd just drop the extra var and use isTopLayer directly

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This also applies to a couple of other spots in the file btw

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

? window.document.activeElement
: null
})
})
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm guessing we didn't need to use the capture phase for this?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You know, I deleted them all for cleanup, but didn't test this case; Going to revert this part.

@RobinMalfait RobinMalfait merged commit ad7300b into main May 12, 2025
8 checks passed
@RobinMalfait RobinMalfait deleted the fix/issue-3701 branch May 12, 2025 21:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Menu behavior change with 2.2.2
3 participants