Commit Graph

7 Commits

Author SHA1 Message Date
Robin Malfait ad7300b076 Fix closing Menu when other Menu is opened (#3726)
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:


https://github.com/user-attachments/assets/f2efd94b-9aa2-404c-ad54-c8747b4d46ac

After:


https://github.com/user-attachments/assets/25c78fc4-c1da-4e51-89b6-4270f2804ab0
2025-05-12 23:45:59 +02:00
Robin Malfait 1461b65810 Add a quick trigger action to the Menu, Listbox and Combobox components (#3700)
This PR adds a new quick trigger feature to the `Menu`. Not sure what
the best
name for this is, but essentially this is the behavior:

Recently we made sure that the `Menu` opens on `mousedown` (not just
`click`).

This means that we can perform the following quick action:
1. `mousedown` on the `MenuButton` — this will open the `Menu`
2. Without releasing the mouse button yet, move your mouse over one of
the `MenuItem`s — this will highlight the currently active `MenuItem`.
3. Release the mouse button — this will invoke the currently active
`MenuItem` and close the `Menu`.

This now means that you can perform actions very quickly.

What this PR doesn't do yet is if you have a scrollable list, then it
won't scroll up or down when you reach the ends of the list. For this we
would need to introduce some new elements. The native Menu items on
macOS show a little placeholder arrow. If you put your cursor in that
area, it starts scrolling:

<img width="489" alt="image"
src="https://github.com/user-attachments/assets/e3a90d5a-daa7-4711-9e19-050578be3e02"
/>


## Test plan

1. Everything still works as expected
2. Quick release has been added:

- Listbox:
https://headlessui-react-git-feat-quick-trigger-tailwindlabs.vercel.app/listbox/listbox-with-pure-tailwind
- Menu:
https://headlessui-react-git-feat-quick-trigger-tailwindlabs.vercel.app/menu/menu
- Combobox:
https://headlessui-react-git-feat-quick-trigger-tailwindlabs.vercel.app/combobox/combobox-countries
2025-04-24 16:01:38 +02:00
Robin Malfait e10f54bc12 Migrate React playground to Tailwind CSS v4 (#3695)
This PR bumps the internal React playground to use Tailwind CSS v4
2025-04-11 19:28:04 +02:00
Robin Malfait 8c7cbb3b09 Add string shorthand for the anchor prop (#3133)
* allow to define `anchor` as a string. E.g.: `anchor="bottom"`

* use `--anchor-gap`, `--anchor-offset` and `--anchor-padding` variables by default

This way simply adding `anchor="bottom"` to one of the anchorable
components will also use these variables defined on the component.

* update playgrounds to use new string-based `anchor` prop

+ CSS variables

* update changelog
2024-04-25 02:13:25 +02:00
Robin Malfait 8fa5caf0dc Change default tag from div to Fragment on Transition components (#3110)
* use `Fragment` as the default element for `Transition` components

* update tests to reflect default tag change

* only error on missing `ref` if it's actually required

If the `<Transition />` component "root" is used as a root placeholder
(for state management) and not making actual transitions itself, then we
don't require a `ref` element.

* add test to ensure we don't error on missing `ref` when not required

+ add `className="…"` to some places to indicate that we _do_ want to
  perform a transition and thus have to fail if the `ref` is missing.

* improve `requiresRef` check

Also ensure that a ref is required if the `as` prop is provided and
it's not a `Fragment`

* add `shouldForwardRef` helper

* fix broken tests

These tests were rendering a `Debug` element that didn't render any DOM
nodes. Adding `as="div"` ensures that we are forwarding the ref
correctly.

* update changelog

* update playgrounds to reflect tag change

* Tweak changelog

---------

Co-authored-by: Jonathan Reinink <jonathan@reinink.ca>
2024-04-19 16:15:11 +02:00
Robin Malfait c8037fc96e Fix enter transitions for the Transition component (#3074)
* improve `transition` logic

I spend a lot of time trying to debug this, and I'm not 100% sure that
I'm correct yet. However, what we used to do is apply the "before" set
of classes, wait a frame and then apply the "after" set of classes which
should trigger a transition.

However, for some reason, applying the "before" classes already start a
transition. My current thinking is that our component is doing a lot of
work and therfore prematurely applying the classes and actually
triggering the transition.

For example, if we want to go from `opacity-0` to `opacity-100`, it
looks like setting `opacity-0` is already transitioning (from 100%
because that's the default value). Then, we set `opacity-100`, but our
component was just going from 100 -> 0 and we were only at let's say 98%.
It looks like we cancelled the transition and only went from 98% ->
100%.

I can't fully explain it purely because I don't 100% get what's going
on.

However, this commit fixes it in a way where we first prepare the
transition by explicitly cancelling all in-flight transitions. Once that
is done, we can apply the "before" set of classes, then we can apply the
"after" set of classes.

This seems to work consistently (even though we have failing tests,
might be a JSDOM issue).

This tells me that at least parts of my initial thoughts are correct
where some transition is already happening (for some reason). I'm not
sure what the reason is exactly. Are we doing too much work and blocking
the main thread? That would be my guess...

* simplify check

* updating playgrounds to improve transitions

* update changelog

* track in-flight transitions

* document `disposables()` and `useDisposables()` functions

* ensure we alway return a cleanup function

* move comment

* apply `enterTo` or `leaveTo` classes once we are done transitioning

* cleanup & simplify logic

* update comment

* fix another bug + update tests

* add failing test as playground

* simplify event callbacks

Instead of always ensuring that there is an event, let's use the `?.`
operator and conditionally call the callbacks instead.

* use existing `useOnDisappear` hook

* remove repro

* only unmount if we are not transitioning ourselves

* `show` is already guaranteed to be a boolean

In a previous commit we checked for `undefined` and threw an error.
Which means that this part is unreachable if `show` is undefined.

* cleanup temporary test changes

* Update packages/@headlessui-react/src/components/transition/utils/transition.ts

Co-authored-by: Jonathan Reinink <jonathan@reinink.ca>

* Update packages/@headlessui-react/src/components/transition/utils/transition.ts

Co-authored-by: Jonathan Reinink <jonathan@reinink.ca>

* Update packages/@headlessui-react/src/components/transition/utils/transition.ts

Co-authored-by: Jonathan Reinink <jonathan@reinink.ca>

* Update packages/@headlessui-react/src/components/transition/utils/transition.ts

Co-authored-by: Jonathan Reinink <jonathan@reinink.ca>

* Update packages/@headlessui-react/src/utils/disposables.ts

Co-authored-by: Jonathan Reinink <jonathan@reinink.ca>

* Update packages/@headlessui-react/src/components/transition/transition.tsx

Co-authored-by: Jonathan Reinink <jonathan@reinink.ca>

* Update packages/@headlessui-react/src/components/transition/transition.tsx

Co-authored-by: Jonathan Reinink <jonathan@reinink.ca>

* run prettier

---------

Co-authored-by: Jonathan Reinink <jonathan@reinink.ca>
2024-04-05 16:05:06 +02:00
Robin Malfait a73007388f Ensure playgrounds work + switch to npm workspaces (#2907)
* bump Next in playground

* convert legacy Link after Next.js bump

* update yarn.lock

* switch to npm workspaces

* move `packages/playground-*` to `playgrounds/*`

* use `npm` instead of `yarn`

* sync package-lock.json

* use node 20 for insiders releases
2024-01-03 14:26:12 +01:00