-
Notifications
You must be signed in to change notification settings - Fork 1.1k
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
Conversation
The latest updates on your projects. Learn more about Vercel for Git ↗︎
|
// 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) |
There was a problem hiding this comment.
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.
useIsoMorphicEffect(() => { | ||
if (!enabled) return | ||
|
||
stackMachine.actions.push(id) | ||
return () => stackMachine.actions.pop(id) | ||
}, [stackMachine, id, enabled]) |
There was a problem hiding this comment.
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.
515b119
to
a3b10cf
Compare
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.
a3b10cf
to
2b7e1e5
Compare
packages/@headlessui-react/src/components/listbox/listbox-machine.ts
Outdated
Show resolved
Hide resolved
packages/@headlessui-react/src/components/combobox/combobox-machine-glue.tsx
Outdated
Show resolved
Hide resolved
// Handle outside click | ||
let outsideClickEnabled = comboboxState === ComboboxState.Open | ||
let outsideClickEnabled = isTopLayer | ||
useOutsideClick(outsideClickEnabled, [buttonElement, inputElement, optionsElement], () => |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
✅
? window.document.activeElement | ||
: null | ||
}) | ||
}) |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
Fixes: #3701
This PR fixes an issue where an open
Menu
is not closed when opening a newMenu
. This is also fixed forListbox
andCombobox
that used the same techniques.This happened because we recently shipped an improvement where the
Menu
opens onpointerdown
instead of onclick
. This means that theuseOutsideClick
hook was not correct anymore because it relies onclick
.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 nativedialog
). 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:
Assuming that both the
Dialog
andMenu
are open, clicking outside or pressing escape should only close theMenu
. Once theMenu
is closed, we should close theDialog
.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
andCombobox
should immediately close when they are not the top-layer anymore. ADialog
can stay open, because you can have interactable elements like the example above in theDialog
.Luckily, these components that should immediately close already use their own state machine. This allows us to listen to the
OpenMenu
(orOpenListbox
,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 theMenu
, 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 anotherMenu
or anotherListbox
, 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