* improve event handler merging
This will ensure that an actual event is passed before checking the
`event.defaultPrevented`.
For React, we also have to make sure that we are not dealing with a
SyntehticEvent.
Thanks @Mookiepiece!
Co-authored-by: =?UTF-8?q?=E5=BD=BC=E8=A1=93=E5=90=91?= <48076971+Mookiepiece@users.noreply.github.com>
* update changelog
Co-authored-by: =?UTF-8?q?=E5=BD=BC=E8=A1=93=E5=90=91?= <48076971+Mookiepiece@users.noreply.github.com>
* ensure outside click works on Safari in iOS
When tapping on an element that is not clickable (like a div), then the
`click` and `mousedown` events will not reach the
`window.addEventListener('click')` listeners.
The only event that does that could be interesting for us is the
`pointerdown` event. The issue with this one is that we then run into
the big issue we ran in a few months ago where clicks on a scrollbar
*also* fired while a click doesn't.
This issue was not an issue in React land, the
`window.addEventListener('click')` was fired even when tapping on a
`div`. This was very very confusing, but we think this is because of the
syntethic event system, where the event listener is added to the root of
your application (E.g.: #app) and React manually bubbles the events.
Because this is done manually, it *does* reach the window as well.
The confusing part is, how does React convert a `pointerdown` event to a
`mousedown` and `click`. There is no code for that in their codebase?
Turns out they don't, and turns out the events **do** bubble, but up
until the `document`, not the `window`. But since they are manually
bubbling events it all makes sense.
So the solution? Let's switch from `window` to `document`...
* update Dialog example to use DialogPanel
* update changelog
* fix controlled tabs should not switch tabs
When the `Tabs` component is used ina a controlled way, then clicking on
a tab should call the `onChange` callback, but it should not change the
actual tab internally.
* update changelog
* menu should not trap focus for tab key
* introduce `focusFrom` focus management utility
This is internal API, and the actual API is not 100% ideal. I started
refactoring this in a separate branch but it got out of hand and touches
a bit more pieces of the codebase that aren't related to this PR at all.
The idea of this function is just so that we can go Next/Previous but
from the given element not from the document.activeElement. This is
important for this feature. We also bolted this ontop of the existing
code which now means that we have this API:
```js
focusIn([], Focus.Previouw, true, DOMNode)
```
Luckily it's internal API only!
* ensure closing via Tab works as expected
Just closing the Menu isn't 100% enough. If we do this, it means that
when the Menu is open, we press shift+tab, then we go to the
Menu.Button because the Menu.Items were the active element.
The other way is also incorrect because it can happen if you have an
`<a>` element as one of the Menu.Item elements then that `<a>` will
receive focus, then the `Menu` will close unmounting the focused `<a>`
and now that element is gone resulting in `document.body` being the
active element.
To fix this, we will make sure that we consider the `Menu` as 1 coherent
component. This means that using `<Tab>` will now go to the next element
after the `<Menu.Button>` once the Menu is closed.
Shift+Tab will go to the element before the `<Menu.Button>` even though
you are currently focused on the `Menu.Items` so depending on the timing
you go to the `Menu.Button` or not.
Considering the Menu as a single component it makes more sense use the
elements before / after the `Menu`
* update changelog
Co-authored-by: Enoch Riese <enoch.riese@gmail.com>
* Don’t close dialog if opened during mouse up event
* Don’t close dialog if drag starts inside dialog and ends outside dialog
* Handle closing of nested dialogs that are always mounted
* Fix focus trap restoration in Vue
* Update changelog
* check typeof document in addition to typeof window
* remove unused import
* Extract SSR check to a central spot
* Fix CS
* Update changelog
Co-authored-by: Jordan Pittman <jordan@cryptica.me>
* ensure there is an animatable root node
This is a bit sad, but it is how Vue works...
We used to render just a simple PopoverPanel that resolved to let's say
a `<div>`, that's all good. Because the native `<transition>` component
requires that there is only 1 DOM child (regardless of the Vue "tree").
This is the sad part, because we simplified focus trapping for the
Popover by introducing sibling hidden buttons to capture focus instead
of managing this ourselves.
Since we can't just return multiple items we wrap them in a `Fragment`
component.
If you wrap items in a Fragment, then a lot of Vue's magic goes away
(automatically adding `class` to the root node). Luckily, Vue has a
solution for that, which is `inheritAttrs: false` and then manually
spreading the `attrs` onto the correct element.
This all works beautiful, but not for the `<transition>` component...
so... let's move the focus trappable elements inside the actual Panel
and update the logic slightly to go to the Next/Previous item instead of
the First/Last because the First/Last will now be the actual focus guards.
* update changelog
* make TypeScript a bit happier
* improve `default` slot in `PopoverPanel`
* sort props in error message
This will make the error message consistent regardless which props (and
in what order) they are applied.
* WIP
* `click()` on a disabled element should no-op
* incomingProps was already merged
* cleanup tests a bit and make it consistent with the React tests
* cleanup unused code
* update changelog
* ensure cmd+backspace works
The issue is that cmd+backspace technically already does work, but we
only allowed it when the Combobox is in an open state. We can remove
this check and apply the proper logic always.
* update changelog
* prevent scrolling the page when using arrow keys in
* update changelog
* bump prettier
Does GitHub Actions have an incorrect cache somehow?
* use Active LTS in CI
* convert dialog in playground to use Dialog.Panel
* convert `tabs-in-dialog` example to use `Dialog.Panel`
* add scrollable dialog example to the playground
* simplify `outside click` behaviour
Here is a little story. We used to use the `click` event listener on the
window to try and detect whether we clicked outside of the main area we
are working in.
This all worked fine, until we got a bug report that it didn't work
properly on Mobile, especially iOS. After a bit of debugging we switched
this behaviour to use `pointerdown` instead of the `click` event
listener. Worked great! Maybe...
The reason the `click` didn't work was because of another bug fix. In
React if you render a `<form><Dialog></form>` and your `Dialog` contains
a button without a type, (or an input where you press enter) then the
form would submit... even though we portalled the `Dialog` to a
different location, but it bubbled the event up via the SyntethicEvent
System. To fix this, we've added a "simple" `onClick(e) { e.stopPropagation() }`
to make sure that click events didn't leak out.
Alright no worries, but, now that we switched to `pointerdown` we got
another bug report that it didn't work on older iOS devices. Fine, let's
add a `mousedown` next to the `pointerdown` event. Now this works all
great! Maybe...
This doesn't work quite as we expected because it could happen that both
events fire and then the `onClose` of the Dialog component would fire
twice. In fact, there is an open issue about this: #1490 at the time of
writing this commit message.
We tried to only call the close function once by checking if those
events happen within the same "tick", which is not always the case...
Alright, let's ignore that issue for a second, there is another issue
that popped up... If you have a Dialog that is scrollable (because it is
greater than the current viewport) then a wild scrollbar appears (what a
weird Pokémon). The moment you try to click the scrollbar or drag it the
Dialog closes. What in the world...?
Well... turns out that `pointerdown` gets fired if you happen to "click"
(or touch) on the scrollbar. A click event does not get fired. No
worries we can fix this! Maybe...
(Narrator: ... nope ...)
One thing we can try is to measure the scrollbar width, and if you
happen to click near the edge then we ignore this click. You can think
of it like `let safeArea = viewportWidth - scrollBarWidth`. Everything
works great now! Maybe...
Well, let me tell you about macOS and "floating" scrollbars... you can't
measure those... AAAAAAAARGHHHH
Alright, scratch that, let's add an invisible 20px gap all around the
viewport without measuring as a safe area. Nobody will click in the 20px
gap, right, right?! Everything works great now! Maybe...
Mobile devices, yep, Dialogs are used there as well and usually there is
not a lot of room around those Dialogs so you almost always hit the
"safe area". Should we now try and detect the device people are
using...?
/me takes a deep breath...
Inhales... Exhales...
Alright, time to start thinking again... The outside click with a
"simple" click worked on Menu and Listbox not on the Dialog so this
should be enough right?
WAIT A MINUTE
Remember this piece of code from earlier:
```js
onClick(event) {
event.stopPropagation()
}
```
The click event never ever reaches the `window` so we can't detect the
click outside...
Let's move that code to the `Dialog.Panel` instead of on the `Dialog`
itself, this will make sure that we stop the click event from leaking
if you happen to nest a Dialog in a form and have a submitable
button/input in the `Dialog.Panel`. But if you click outside of the
`Dialog.Panel` the "click" event will bubble to the `window` so that we
can detect a click and check whether it was outside or not.
Time to start cleaning:
- ☑️ Remove all the scrollbar measuring code...
- Closing works on mobile now, no more safe area hack
- ☑️ Remove the pointerdown & mousedown event
- Outside click doesn't fire twice anymore
- ☑️ Use a "simple" click event listener
- We can click the scrollbar and the browser ignores it for us
All issues have been fixed! (Until the next one of course...)
* ensure a `Dialog.Panel` exists
* cleanup unnecessary code
* use capture phase for outside click behaviour
* further improve outside click
We added event.preventDefault() & event.defaultPrevented checks to make
sure that we only handle 1 layer at a time.
E.g.:
```js
<Dialog>
<Menu>
<Menu.Button>Button</Menu.Button>
<Menu.Items>...</Menu.Items>
</Menu>
</Dialog>
```
If you open the Dialog, then open the Menu, pressing `Escape` will close
the Menu but not the Dialog, pressing `Escape` again will close the
Dialog.
Now this is also applied to the outside click behaviour.
If you open the Dialog, then open the Menu, clicking outside will close
the Menu but not the Dialog, outside again will close the Dialog.
* add explicit `enabled` value to the `useOutsideClick` hook
* ensure outside click properly works with Poratl components
Usually this works out of the box, however our Portal components will
render inside the Dialog component "root" to ensure that it is inside
the non-inert tree and is inside the Dialog visually.
This means that the Portal is not in a separate container and
technically outside of the `Dialog.Panel` which means that it will close
when you click on a non-interactive item inside that Portal...
This fixes that and allows all Portal components.
* update changelog
* fix incorrect transitionend/transitioncancel events
Due to bubbling, the `Transition` component also "finished" when you had
children that uses `transition-colors` for example.
This commit ensures that we only care about transition events related to
the actual DOM node that we defined the transitions on...
* update changelog
* fix `slot` state of `RadioGroup` component
The `useEvent` is 1 tick too late (due to the update of the callback
happening in useEffect). This isn't a problem for event listeners, but
it is for functions that need to run "now".
We can change the `useLatestValue` hook to do something like:
```diff
export function useLatestValue<T>(value: T) {
let cache = useRef(value)
- useIsoMorphicEffect(() => {
- cache.current = value
- }, [value])
+ cache.current = value
return cache
}
```
But then we are mutating our refs in render which isn't ideal.
* update changelog
* add test to verify that the correct slot data is exposed
We had an issue where an open Dialog got hidden by css didn't properly
unmount because the Transition never "finished". We fixed this by
checking if the node was hidden by using `getBoundingClientRect`.
Today I learned that just *reading* those values (aka call
`node.getBoundingClientRect()`) it for whatever reason completely stops
the transition. This causes the enter transitions to completely stop
working.
Instead, we move this code so that we only check the existence of the
Node when we try to transition out because this means that the Node is
definitely there, just have to check its bounding rect.
* splitup CHANGELOG.md file
Scope each changelog per package
* simplify CHANGELOG.md files
We don't need to scope them anymore, they are already scoped.
* remove leftover code
This code existed before we had the option to make the first option the
"active" one.
This also contains a bug in the React code where pressing "ArrowDown" in
a closed Combobox opens the combobox and goes to the second item instead
of the first option.
* update changelog
* add `@headlessui/tailwindcss` plugin
* expose `data-headlessui-state="..."` data attribute
All components that expose boolean props in their render prop / v-slot
will receive a `data-headlessui-state="..."` attribute.
If it exposes boolean values but all are false, then there will be an
empty `data-headlessui-state=""`. If the current component is rendering
a `Fragment` then we don't expose those attributes.
* use tailwindcss in `playground-react` and `playground-vue`
We were using the CDN, but now that we have the
`@headlessui/tailwindcss` plugin, it's a bit easier to configure it
natively and import the plugin.
* ensure to build the `@headlessui/tailwindcss` package before starting the playground
* refactor `listbox` example to use the @headlessui/tailwindcss plugin
* update changelog
* bump Tailwind CSS to latest insiders version
* correctly generate types
* type `tailwind.config.js` files for playgrounds
* add todo for when `:has()` is available
In the cleanup PR, we added the `Data` and `Actions` type, but we
already had a `Actions` type so had to rename it to something. Chose
`Command` but this is now inconsistent with the rest of the codebase.
Instead, let's revert that change and use these shorthands:
- `Data` -> `_Data`
- `Actions` -> `_Actions`
- `Commands` -> `Actions`
- `CommandTypes` -> `ActionTypes`
The `_` prefix is a little bit strange, but it is a private type and not
exposed so fine for now.
* sort React imports
* improve type signature of the `useEvent` hook
* use more correct `useIsoMorphicEffect` check in `useEvent`
* refactor `useCallback` to cleaner `useEvent`
* convert `const` to `let`
Just for consistency..
* cleanup `Tabs` code
Created explicit functions that can be called from child components
instead of calling `dispatch` directly. Introduced a `useData` and
`useActions` hook to make child components easier.
The seperation of `useData` allows us to pass down props directly
instead of going via the `useReducer` hook and dispatching actions to
make values up to date.
* cleanup `Combobox` code
* cleanup `RadioGroup` code