Commit Graph

247 Commits

Author SHA1 Message Date
Robin Malfait c31136032b prepare 1.6.5 2022-06-20 16:22:05 +02:00
Robin Malfait 63417d5fd9 Fix missing aria-expanded for ComboboxInput component (#1605)
* add test to verify `Combobox.Input` state

* incorrect missing `aria-expanded` on `ComboboxInput`

* update changelog
2022-06-20 11:53:45 +02:00
Robin Malfait f2fc6d823b fix TypeScript issues (#1587)
We had a bunch of unused imports sitting around
2022-06-14 15:04:22 +02:00
Robin Malfait ec84c72ef9 Fix scrolling issue in Tab component when using arrow keys (#1584)
* 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
2022-06-13 15:54:18 +02:00
Robin Malfait ea5f21af8e Improve Combobox input cursor position (#1574)
* fix(combobox): fix focus on option select

* update changelog

Co-authored-by: Dan Roujinsky <d.roujinsky@island.io>
2022-06-10 17:53:36 +02:00
Jordan Pittman f2a813ebc3 Detect outside clicks from within <iframe> elements (#1552)
* Refactor

* Detect “outside clicks” inside `<iframe>` elements

* Update changelog
2022-06-03 17:26:46 -04:00
Robin Malfait bdd1b3b785 Improve outside click of Dialog component (#1546)
* 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
2022-06-03 16:20:56 +02:00
Robin Malfait 4e13a0ea4f Fix incorrect transitionend/transitioncancel events for the Transition component (#1537)
* 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
2022-05-31 17:25:44 +02:00
Robin Malfait 1d53ac312f Revert "prepare 1.6.4"
This reverts commit 842d07146e.
2022-05-29 14:57:35 +02:00
Robin Malfait 655107962f 1.6.4 2022-05-29 14:54:44 +02:00
Robin Malfait 842d07146e prepare 1.6.4 2022-05-29 14:51:38 +02:00
Robin Malfait 912af7ed7c Fix render prop data in RadioGroup component (#1522)
* 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
2022-05-29 14:03:45 +02:00
Robin Malfait d19f797a35 update changelog 2022-05-28 23:32:27 +02:00
Robin Malfait ce12406ec2 fix transition enter bug (#1519)
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.
2022-05-28 23:28:51 +02:00
Robin Malfait d3ed3f5d26 Split CHANGELOG.md into file per package (#1516)
* 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.
2022-05-28 18:25:41 +02:00
Robin Malfait 21bdf529de Fix event handlers with arity > 1 (#1515)
* fix event handlers with arity > 1

* update changelog
2022-05-28 17:32:00 +02:00
Robin Malfait a7154dca1f Remove leftover code in Combobox component (#1514)
* 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
2022-05-28 17:17:19 +02:00
Robin Malfait eefc03ce16 Ensure Escape propagates correctly in Combobox component (#1511)
* ensure `Escape` propagates correctly in Combobox component

* update changelog
2022-05-27 17:57:28 +02:00
Robin Malfait 08b419e9b7 Revert "prepare 1.6.3"
This reverts commit 3aaf20b9ac.
2022-05-25 15:25:48 +02:00
Robin Malfait 3c32369851 1.6.3 2022-05-25 15:18:01 +02:00
Robin Malfait 3aaf20b9ac prepare 1.6.3 2022-05-25 15:13:09 +02:00
Robin Malfait deb4b1bc95 Ensure the Transition stops once DOM Nodes are hidden (#1500)
* ensure that the transitions `stops` once the DOM Node is hidden

* update changelog
2022-05-25 14:54:20 +02:00
Robin Malfait 39c5bd3230 Add @headlessui/tailwindcss plugin (#1487)
* 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
2022-05-24 22:51:02 +02:00
Robin Malfait ebf19ca289 cleanup unused import 2022-05-23 12:43:31 +02:00
Robin Malfait 2396c497f6 small cleanup
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.
2022-05-23 12:16:19 +02:00
Robin Malfait dafcc2d1c0 Only render the FocusSentinel if required in the Tabs component (#1493)
* only render the `FocusSentinel` if required

Fixes: #1491

* update changelog
2022-05-23 12:13:42 +02:00
Robin Malfait e819c0a7b2 General/random internal cleanup (part 1) (#1484)
* 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
2022-05-23 11:26:22 +02:00
Robin Malfait d200be5f6f Add by prop for Listbox, Combobox and RadioGroup (#1482)
* Add `by` prop for `Listbox`, `Combobox` and `RadioGroup`

* update changelog
2022-05-20 23:01:10 +02:00
Robin Malfait cc6aaa234a Ensure the the <Popover.Panel focus> closes correctly (#1477)
* ensure the the `<Popover.Panel focus>` closes correctly

* update changelog
2022-05-19 20:49:48 +02:00
Robin Malfait 9280d92d24 Allow to override the type on the Combobox.Input (#1476)
* allow to override the `type` on the `Combobox.Input`

This still defaults to `text`.

* update changelog
2022-05-19 16:57:35 +02:00
Robin Malfait 46aab52ba5 1.6.2 - @headlessui/react 2022-05-19 16:33:21 +02:00
Robin Malfait b0ccc78f12 Ensure the ref is forwarded on the Transition.Child component (#1473)
* use `forwardRef` on the `Transition.Child` component

* update changelog
2022-05-19 12:43:11 +02:00
Robin Malfait 9885008357 Make the ref optional in the Popover component (#1465)
* make the ref optional in the `Popover` component

We "required" the prop to calculate the `ownerDocument`. But if you
don't provide a ref, then we will use the `Popover.Button` to calculate
it. If that's not defined, then we can fallback to the default
`document`.

* update changelog
2022-05-18 13:46:45 +02:00
Robin Malfait 7344846fad Improve "Scroll lock" scrollbar width for Dialog component (#1457)
* improve scroll lock, scrollbarWidth

The idea is as follow:
If you currently have a scrollbar, and you open a Dialog then we enable
a "Scroll lock" so that you can't scroll in the background behind the
modal. We can achieve this by adding a `overflow: hidden;` to the
`html`.

The issue is that by doing this, we lose the scrollbar and therefore the
page will jump to right because now there is a bit more room.

To account for this, we set a `padding-right` on the `html` of the
scrollbarWidth in pixels. This counteracts the visual jump you would
see.

The issue with this approach is that there could *still* be a scrollbar
once we add the `overflow: hidden`. This can happen if you use new css
features like the `scrollbar-gutter: stable;`.

To take this into account, we will measure the scrollbar again after we
set the `overflow: hidden`. Now we will only apply that counteracting
offset if there would actually be a jump by measuring the before and
after widths and applying the diff if there is one.

* update changelog
2022-05-16 12:47:19 +02:00
Robin Malfait 9a39fc349f Ensure the Popover.Panel is clickable without closing the Popover (#1443)
* ensure the `Popover.Panel` is clickable without closing

* update changelog
2022-05-13 16:04:08 +02:00
Robin Malfait e1ee36a6ea Simplify Popover Tab logic by using sentinel nodes instead of keydown event interception (#1440)
* improve `Popover` keyboard usage

Use `TabSentinel` instead of intercepting the `Tab` keydown events.

* use Buttons in Popover example

* update changelog
2022-05-13 00:21:10 +02:00
Robin Malfait bf0d1120d3 Improve FocusTrap behaviour (#1432)
* refactor `VisuallyHidden` to `Hidden` component

This new component will also make sure that it is visually hidden to
sighted users. However, it contains a few more features that are going
to be useful in other places as well. These features include:

1. Make visually hidden to sighted users (default)
2. Hide from assistive technology via `features={Features.Hidden}`
   (will add `display: none;`)
3. Hide from assistive technology but make the element focusable via
   `features={Features.Focusable}` (will add `aria-hidden="true"`)

* add `useEvent` hook

This will behave the same (roughly) as the new to be released `useEvent`
hook in React 18.X

This hook allows you to have a stable function that can "see" the latest
data it is using. We already had this concept using:

```js
let handleX = useLatestValue(() => {
  // ...
})
```

But this returned a stable ref so you had to call `handleX.current()`.
This new hook is a bit nicer to work with but doesn't change much in the
end.

* add `useTabDirection` hook

This keeps track of the direction people are tabbing in. This returns a
ref so no re-renders happen because of this hook.

* add `useWatch` hook

This is similar to the `useEffect` hook, but only executes if values are
_actually_ changing... 😒

* add `microTask` util

* refactor `useFocusTrap` hook to `FocusTrap` component

Using a component directly allows us to simplify the focus trap logic
itself. Instead of intercepting the <kbd>Tab</kbd> keydown event and
figuring out the correct element to focus, we will now add 2 "guard"
buttons (hence why we require a component now). These buttons will
receive focus and if they do, redirect the focus to the first/last
element inside the focus trap.

The sweet part is that all the tabs in between those buttons will now be
handled natively by the browser. No need to find the first non disabled,
non hidden with correct tabIndex element!

* refactor the `Dialog` component to use the `FocusTrap` component

Also added a hidden button so that we know the correct "main" tree of
the application. Before this we were assuming the previous active
element which will still be correct in most cases but we don't have
access to that anymore since the logic is encapsulated inside the
FocusTrap component.

* ensure `<Portal />` properly cleans up

We make sure that the Portal is cleaning up its `element` properly.
We also make sure to call the `target.appendChild(element)`
conditionally because I ran into a super annoying bug where a focused
element got blurred because I believe that this re-mounts the element
instead of 'moving' it or just ignoring it, if it already is in the
correct spot.

* refactor: use `useEvent` instead of `useLatestValue`

Not really necessary, just cleaner.

* update changelog
2022-05-11 15:03:54 +02:00
Robin Malfait c494fa36e9 Ignore Escape when event got prevented in Dialog component (#1424)
* ignore `Escape` when event got prevented

Some external libraries only use `event.preventDefault()` and not
`event.stopPropagation()`. This means that the Dialog can still receive
an `Escape` keydown event which closes the Dialog.

We can also think about the `Escape` behaviour inside the modal as the
"default behaviour" once the Dialog is open. Therefore, we can also
check the `event.defaultPrevented` and ignore this event when this is
the case.

* update changelog
2022-05-09 15:07:57 +02:00
Robin Malfait c4e35f3879 Fix closing of Popover.Panel in React 18 (#1409)
* remove unnecessary `SetPanel` action

* update changelog
2022-05-05 23:16:39 +02:00
Robin Malfait 695bf299ed 1.6.1 - @headlessui/react 2022-05-03 13:19:16 +02:00
Robin Malfait b71fcb3b46 temporary ignore flaky tests 2022-05-02 20:52:02 +02:00
Robin Malfait 1ce86e2184 Fix hydration issue with Tab component (#1393)
* fix hydration issues with Tabs component

* update changelog
2022-05-02 20:30:30 +02:00
Robin Malfait 1e14d5159c 1.6.0 2022-04-25 15:58:55 +02:00
Robin Malfait cb7a969951 General cleanup of README files (#1361)
* cleanup README files

* ignore flakey tests for now

There are a handful of tests that are pretty flakey and fail every once
in a while on CI, I don't want to remove them yet, but rather ignore
them for now.

I am going to experiment with using Playwright/Puppeteer to use a real
browser instead.
2022-04-24 01:05:17 +02:00
Robin Malfait c8cf26ffcb Prefer incoming open prop over OpenClosed state (#1360)
* prefer incoming `open` prop over OpenClosed state

* update changelog
2022-04-23 23:27:19 +02:00
Robin Malfait 0c34fe802c Add explicit multiple prop (#1355)
* add explicit `multiple` prop to the `Combobox`

This allows you to set the value to a **tuple** in `single-value` mode,
which was not possible before the `multiple` prop was introduced,
because then it resulted in `multi-value` mode instead of `single-value`
mode.

* add explicit `multiple` prop to the `Listbox`

This allows you to set the value to a **tuple** in `single-value` mode,
which was not possible before the `multiple` prop was introduced,
because then it resulted in `multi-value` mode instead of `single-value`
mode.

* update changelog

* update playground to use `multiple` prop
2022-04-22 18:55:55 +02:00
Robin Malfait 591b32861a use older syntax instead of .at()
Fixes: #1344
2022-04-18 22:32:17 +02:00
Robin Malfait b4a4e0b307 Add Dialog.Backdrop and Dialog.Panel components (#1333)
* implement `Dialog.Backdrop` and `Dialog.Panel`

* cleanup TypeScript warnings

* update changelog
2022-04-14 17:15:43 +02:00
Robin Malfait 0162c57d88 add React 18 compatibility (#1326)
* bump dev dependencies to React 18

* setup Jest to include `IS_REACT_ACT_ENVIRONMENT`

* prefer `useId` from React 18 if it exists

In React 16 & 17, where `useId` doesn't exist, we will fallback to our
implementation we have been using up until now.

The `useId` exposed by React 18, ensures stable references even in SSR
environments.

* update expected events

React 18 now uses the proper events:
- `blur` -> `focusout`
- `focus` -> `focusin`

* ensure to wait a bit longer

This is a bit unfortunate, but since React 18 now does an extra
unmount/remount in `StrictMode` to ensure that your code is
ConcurrentMode ready, it takes a bit longer to settle what the DOM sees.

That said, this is a temporary "hack". We are going to experiment with
using tools like Puppeteer/Playwright to run our tests in an actual
browser instead to eliminate all the weird details that we have to keep
in mind.

* prefer `.focus()` over `fireEvent.focus(el)`

* abstract `microTask` polyfill code

* prefer our `focus(el)` function over `el.focus()`

Internally we would still use `el.focus()`, but this allows us to have
more control over that `focus` function.

* add React 18 to the React Playground

* improve hooks for React 18

- Improving the cleanup of useEffect hooks
- useIsoMorphicEffect instead of normal useEffect, so that we can use
  useLayoutEffect to be a bit quicker.

* improve disposables

- This allows us to add event listeners on a node, and get automatic
  cleanup once `dispose` gets called.
- We also return all the `d.add` calls, so that we can cleanup specific
  parts only instead of everything or nothing.

* reimplement the Transition component to be React 18 ready

* wait an additional frame for everything to settle

* update playground examples

* suppressConsoleLogs for RadioGroup components

* update changelog

* keep the `to` classes for a smoother transition

In the next transition we will remove _all_ classes provided and re-add
the once we need.

---

Some extra special thanks:

- Thanks @silvenon for your initial work on the `transition` events in #926
- Thanks @thecrypticace for doing late-night debugging sessions

Co-authored-by: =?UTF-8?q?Matija=20Marohni=C4=87?= <matija.marohnic@gmail.com>
Co-authored-by: Jordan Pittman <jordan@cryptica.me>
2022-04-13 22:07:01 +02:00
Robin Malfait ab6310c278 Implement nullable mode on Combobox in single value mode (#1295)
* implement `backspace` behaviour in tests

* add `Delete` Key

* implement `nullable` mode on Combobox in single value mode

If you pass a `nullable` prop to the Combobox, then it's possible to
unset the Combobox value by setting it to `null`.
This is triggered by removing all text from the input which will reset
the value itself as well.

* update changelog
2022-03-31 23:35:04 +02:00