Commit Graph

72 Commits

Author SHA1 Message Date
Robin Malfait b6aa1d6d24 Add portal prop to Combobox, Listbox, Menu and Popover components (#3124)
* move duplicated `useScrollLock` to dedicated hook

* accept `enabled` prop on `Portal` component

This way we can always use `<Portal>`, but enable / disable it
conditionally.

* use `useSyncRefs` in portal

This allows us to _not_ provide the ref is no ref was passed in.

* refactor inner workings of `useInert`

moved logic from the `useEffect`, to module scope. We will re-use this
logic in a future commit.

* add `useInertOthers` hook

Mark all elements on the page as inert, except for the ones that are allowed.

We move up the tree from the allowed elements, and mark all their
siblings as `inert`. If any of the children happens to be a parent of
one of the elements, then that child will not be marked as `inert`.

```
<body>                                    <!-- Stop at body -->
  <header></header>                       <!-- Inert, sibling of parent of allowed element -->
  <main>                                  <!-- Not inert, parent of allowed element -->
    <div>Sidebar</div>                    <!-- Inert, sibling of parent of allowed element -->
    <div>                                 <!-- Not inert, parent of allowed element -->
      <Listbox>                           <!-- Not inert, parent of allowed element -->
        <ListboxButton></ListboxButton>   <!-- Not inert, allowed element -->
        <ListboxOptions></ListboxOptions> <!-- Not inert, allowed element -->
      </Listbox>
    </div>
  </main>
  <footer></footer>                       <!-- Inert, sibling of parent of allowed element -->
</body>
```

* add `portal` prop, and change meaning of `modal` prop on `MenuItems`

- This adds a `portal` prop that renders the `MenuItems` in a portal.
  Defaults to `false`.
  - If you pass an `anchor` prop, the `portal` prop will always be set
    to `true`.
- The `modal` prop enables the following behavior:
  - Scroll locking is enabled when the `modal` prop is passed and the
    `Menu` is open.
  - Other elements but the `Menu` are marked as `inert`.

* add `portal` prop, and change meaning of `modal` prop on `ListboxOptions`

- This adds a `portal` prop that renders the `ListboxOptions` in a
  portal. Defaults to `false`.
  - If you pass an `anchor` prop, the `portal` prop will always be set
    to `true`.
- The `modal` prop enables the following behavior:
  - Scroll locking is enabled when the `modal` prop is passed and the
    `Listbox` is open.
  - Other elements but the `Listbox` are marked as `inert`.

* add `portal` and `modal` prop on `ComboboxOptions`

- This adds a `portal` prop that renders the `ComboboxOptions` in a
  portal. Defaults to `false`.
  - If you pass an `anchor` prop, the `portal` prop will always be set
    to `true`.
- The `modal` prop enables the following behavior:
  - Scroll locking is enabled when the `modal` prop is passed and the
    `Combobox` is open.
  - Other elements but the `Combobox` are marked as `inert`.

* add `portal` prop, and change meaning of `modal` prop on `PopoverPanel`

- This adds a `portal` prop that renders the `PopoverPanel` in a portal.
  Defaults to `false`.
  - If you pass an `anchor` prop, the `portal` prop will always be set
    to `true`.
- The `modal` prop enables the following behavior:
  - Scroll locking is enabled when the `modal` prop is passed and the
    `Panel` is open.

* simplify popover playground, use provided `anchor` prop

* remove internal `Modal` component

This is now implemented on a per component basis with some hooks.

* remove `Modal` handling from `Dialog`

The `Modal` component is removed, so there is no need to handle this in
the `Dialog`. It's also safe to remove because the components with
"portals" that are rendered inside the `Dialog` are portalled into the
`Dialog` and not as a sibling of the `Dialog`.

* ensure we use `groupTarget` if it is already available

Before this, we were waiting for a "next render" to mount the portal if
it was used inside a specific group. This happens when using `<Portal/>`
inside of a `<Dialog/>`.

* update changelog

* add tests for `useInertOthers`

* ensure we stop before the `body`

We used to have a `useInertOthers` hook, but it also made everything
inside `document.body` inert. This means that third party packages or
browser extensions that inject something in the `document.body` were
also marked as `inert`. This is something we don't want.

We fixed that previously by introducing a simpler `useInert` where we
explicitly marked certain elements as inert: https://github.com/tailwindlabs/headlessui/pull/2290

But I believe this new implementation is better, especially with this
commit where we stop once we hit `document.body`. This means that we
will never mark `body > *` elements as `inert`.

* add `allowed` and `disallowed` to `useInertOthers`

This way we have a list of allowed and disallowed containers. The
`disallowed` elements will be marked as inert as-is.

The allowed elements will not be marked as `inert`, but it will mark its
children as inert. Then goes op the parent tree and repeats the process.

* simplify `useInertOthers` in `Dialog` code

* update `use-inert` tests to always use `useInertOthers`

* remove `useInert` hook in favor of `useInertOthers`

* rename `use-inert` to `use-inert-others`

* cleanup default values for `useInertOthers`
2024-04-24 17:10:41 +02:00
Robin Malfait b517a39445 Ensure anchored components are properly stacked on top of Dialog components (#3111)
* ensure `Dialog` knows about `Modal`s via the `StackProvider`

When you render a `Listbox` in a `Dialog`, then clicking outside of the
`Listbox` will only close the `Listbox` and not the `Dialog`.

This is because the `Listbox` is rendered _inside_ the `Dialog`, and the
`useOutsideClick` hook will prevent the event from propagating to the
`Dialog` therefore it stays open.

Then, if you add the `anchor` prop to the `ListboxOptions` then a few
things will happen:

1. We will render the `ListboxOptions` in a `Modal`, which portals the
   component to the end of the `body` (aka, it won't be in the `Dialog`
   anymore).
2. The `anchor` prop, will use Floating UI to position the element
   correctly.

If you now click outside of the open `Listbox`, then the `Dialog` will
receive the click event (because it is rendered somewhere else in the
DOM) and therefore the `Listbox` **and** the `Dialog` will close.

The `Dialog` also uses a `StackProvider` to know if it is the top-level
`Dialog` or not. The problem is that the `Modal` doesn't use that
`StackProvider` to tell the `Dialog` that something is stacked on top of
the current `Dialog`.

That's what this commit fixes, the `Modal` will now use a
`StackProvider` to tell the `Dialog` that it's not the top-most element
anymore so it shouldn't enable the `useOutsideClick` behavior.

That said, this is one of the things that will be changed in the future
to make "parallel" dialogs possible. Essentially, we will track a global
stack and the top-most element (last one that was "opened") will win.

Then hooks such as `useOutsideClick` and `useScrollLock` will use that
information to know if they should undo scroll locking for example if
another element is still open.

* update CHANGELOG
2024-04-19 18:31:14 +02:00
Robin Malfait b86737b698 Add new CloseButton component and useClose hook (#3096)
* add `useClose` hook and `CloseButton` component

* expose `useClose` hook and `CloseButton` components

* use `CloseProvider` in the `Popover` component

* use `CloseProvider` in the `Dialog` component

* use `CloseProvider` in the `Disclosure` component

* update changelog
2024-04-12 15:54:01 +02:00
Robin Malfait 8652f806eb replace as unknown as XYZ with as XYZ (#3091) 2024-04-11 11:26:19 +02:00
Robin Malfait ae8c253c21 Fix typos (#3086)
* fix a bunch of typos

* fix typos in `@headlessui/vue`
2024-04-08 23:31:50 +02:00
Robin Malfait c1d3b7ecda Close the Combobox, Dialog, Listbox, Menu and Popover components when the trigger disappears (#3075)
* add `useOnDisappear` hook

This hook allows us to trigger a callback if the element becomes
"hidden". We use the bounding client rect and check the dimensions to
know wether we are "hidden" or not.

* use new `useOnDisappear` hook in components with the `anchor` prop

* update changelog

* document `useOnDisappear`
2024-04-03 15:10:58 +02:00
Robin Malfait aff438eb06 Prevent default behaviour when clicking outside of a Dialog.Panel (#2919)
* use `event.preventDefault()` in the `useOutsideClick` on Dialog's

When using a `Dialog`, we should prevent the default behaviour of the
event that triggered the "close" in the `useOutsideClick` call.

We recently made improvements to improve outside click behaviour on
touch devices (https://github.com/tailwindlabs/headlessui/pull/2572) but
due to the `touchend` event, the touch is still forwarded and therefore
a potential button _behind_ the "backdrop" will also be clicked. This is
not what we want.

Added the `event.preventDefault()` for the Dialog specifically because
there are other places where we use `useOutsideClick` and where we _do_
want the behaviour where the click just continues. A concrete example of
this is 2 `Menu`'s next to eachother where you open the first one, and
then click on the second one. This should close first one (outside
click) and open the second one (by not preventing the event)

* update changelog
2024-01-09 20:06:55 +01:00
Robin Malfait e662f12398 2.0.0 Alpha prep (#2887)
* bump React & React DOM dependencies

* fix typo `TOmitableProps` → `TOmittableProps`

* bump prettier

* run prettier after prettier version bump

* bump TypeScript

* run prettier after TypeScript version bump

* enable `verbatimModuleSyntax`

This ensures all imported types are using the `type` keyword.

* add `type` to type related imports

* add common testing scenarios

Will be used in the new and existing components.

* add script to make Next.js happy

Right now Next.js does barrel file optimization and re-writing imports
to a real path in the `dist` folder. Most of those rewrites don't
actually exist because they have an assumption:

```js
import { FooBar } from '@headlessui/react'
```

is rewritten as:
```js
import { FooBar } from '@headlessui/react/dist/components/foo-bar/foo-bar'
```

This script will make sure these paths exist...

* improve `by` prop, introduce `useByComparator`

This hook has a default implementation when comparing objects. If the
object contains an `id`, then we will compare the objects by their
`id`'s without the user of the library needing to specify `by="id"`.

If the objects don't have an `id` prop, then the default is still to
compare by reference (unless specicified otherwise).

* sync yarn.lock

* rename `Features` to `HiddenFeatures` for `Hidden` component

* rename `Features` to `FocusTrapFeatures` in `FocusTrap` component

* rename `Features` to `RenderFeatures` in `render` util

* add `floating-ui` as a dependency + introduce internal floating related components

* bump Vue dependencies

* ensure scroll bar calculations can't go negative

* improve types in `@headlessui/vue`

* use snapshot tests for `Transition` tests in `@headlessui/vue`

* use snapshot tests for `portal` tests in `@headlessui/vue`

* rename `src/components/transitions/` to `src/components/transition/` (singular)

This is so that we can be consistent with the other components.

* drop custom `toMatchFormattedCss`, prefer snapshot tests instead

* use snapshot tests for `Label` tests in `@headlessui/vue`

* use snapshot tests for `Description` tests in `@headlessui/vue`

* sort exported components in tests for `@headlessui/vue`

* use snapshot tests in `@headlessui/tailwindcss`

* rename `mergeProps` to `mergePropsAdvanced`

This is a more complex version of a soon to be exported `mergeProps`
that we will be using in our components.

* do not expose `aria-labelledby` if it is only referencing itself

* expose boolean state as `kebab-case` instead of `camelCase`

These are the ones being exposed inside `data-headlessui-state="..."`

* expose boolean data attributes

A slot with `{active,focus,hover}` will be exposed as:
```html
<span data-headlessui-state="active focus hover"></span>
```

But also as boolean attributes:
```html
<span data-active data-focus data-hover></span>
```

* improve internal types for `className` in `render` util

* ensure we keep exposed data attributes into account when trying to forward them to the component inside the `Fragment`

* add small typescript type fix

This is internal code, and the public API is not influenced by this
`:any`. It does make TypeScript happy.

* introduce `mergeProps` util to be used in our components

This will help us to merge props, when event handlers are available they
will be merged by wrapping them in a function such that both (or more)
event handlers are called for the same `event`.

* add new internal `Modal` component

* fix: when using `Focus.Previous` with `activeIndex = -1` should start at the end

* prefer `window.scrollY` instead of `window.pageYOffset`

Because `window.pageYOffset` is deprecated.

* add `'use client'` directives on client only components

These components use hooks that won't work in server components and you
will receive an error otherwise.

* drop `import 'client-only'` in favor of the `'use client'` directive

* add React Aria dependencies

* pin beta dependencies

* prettier bump formatting

* improve TypeScript types in tests

* use new Jest matchers instead of deprecated ones

* improve typescript types in Vue

* prefer `useLabelledBy` and `useDescribedBy`

* add internal `DisabledProvider`

* add internal `IdProvider`

* add internal `useDidElementMove` hook

* add internal `useElementSize` hook

* add internal `useIsTouchDevice` hook

* add internal `useActivePress` hook

* use snapshot tests for `Description` tests

* use snapshot tests for `Label` tests

* use snapshot tests for `Portal` tests

* use snapshot tests for `render` tests

* add (private) `Tooltip` component

Currently this one is not ready yet, so its not publicly exposed yet.

* add internal `FormFields` component

This one adds a component to render (hidden) inputs for native form
support. It also ensures that form fields can be hoisted to the end of
the nearest `Field`. If the components are not inside a `Field` they
will be rendered in place.

* add new `Button` component

* add new `Checkbox` component

* add new `DataInteractive` component

* add new `Field` component

* add new `Fieldset` component

* add new `Legend` component

* add new `Input` component

* add new `Select` component

* add new `Textarea` component

* export new components

* WIP

* remove `within: true`

This only makes sense if anything inside the current element receives
focus, which is not the case for `input`, `select`, `textarea` or
`Radio/RadioOption`.

* group focus/hover/active hooks together

* conditionally link anchor panel

* immediately focus the container

* prevent premature disabling of `Listbox`'s floating integration

+ Track whether the button moved or not when disabling such that we can
  disable the transitions earlier.

* improve scroll locking on iOS

* skip hydration tests for now

* skip certain focus trap tests for now

* update CHANGELOG.md

* add missing requires

* drop unused `@ts-expect-error`

* ignore type issues in playgrounds

These playgrounds are mainly test playgrounds. Lower priority for now,
we will get back to them.

* add yarn resolutions to solve swc bug
2023-12-20 19:57:57 +01:00
Robin Malfait f2179f36c0 further improve import sorting 2023-09-11 19:09:53 +02:00
Robin Malfait 76dd10ea55 Sort imports automatically (#2741)
* add `prettier-plugin-organize-imports` and `prettier-plugin-tailwindcss`

* format

* bump Tailwind CSS

* format playgrounds using updated Tailwind CSS and Prettier plugins

* use import syntax
2023-09-11 18:36:30 +02:00
Jordan Pittman 8e93cd0630 Export component interfaces and mark them as internal (#2313)
* export component interfaces, and mark them as internal

This is not ideal because we don't want these to be public. However, if
you are creating components on top of Headless UI, the TypeScript
compiler needs access to them.

So now they are public in a sense, but you shouldn't be interacting with
them directly.

Co-authored-by: Jordan Pittman <jordan@cryptica.me>

* Update changelog

---------

Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
2023-08-31 11:38:18 -04:00
Jordan Pittman a6a2382706 Add support for role="alertdialog" to <Dialog> component (#2709)
* WIP

* Add warning for unsupported roles to `<Dialog>`

* Update assertions

* Add test for React

* Add support for `role=alertdialog` to Vue

---------

Co-authored-by: Adam Wathan <4323180+adamwathan@users.noreply.github.com>
2023-08-28 13:37:53 -04:00
Jordan Pittman 5a3d556f29 Lazily resolve default containers in <Dialog> (#2697)
* Lazily resolve default containers in `<Dialog>`

* Update changelog
2023-08-22 11:57:47 -04:00
Jordan Pittman 34275dae74 Revert "Fix hydration of components inside <Suspense> (#2633)"
This reverts commit 35012893e9.
2023-08-02 10:35:32 -04:00
Jordan Pittman 35012893e9 Fix hydration of components inside <Suspense> (#2633)
* Only use useServerHandoffComplete in React < 18

It’s only useful for the useId hook. It is not compatible with `<Suspense>` because hydration is delayed then.

* Make sure portals first render matches the server by rendering nothing

Since Portals cannot SSR the first render MUST also return `null`. React really needs an `isHydrating` API.

* Lazily resolve root containers

This fixes a problem where clicks were assumed to be outside because of the delayed `<Portal>` render. The second portal render doesn’t cause the dialog to re-render thus the initial ref values were stale.

* Update changelog

---------

Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
2023-08-02 16:20:22 +02:00
Robin Malfait 8adaeeda45 Ensure moving focus within a Portal component, does not close the Popover component (#2492)
* abstract resolving root containers to hook

This way we can reuse it in other components when needed.

* allow registering a `Portal` component to a parent

This allows us to find all the `Portal` components that are nested in a
given component without manually adding refs to every `Portal` component
itself.

This will come in handy in the `Popover` component where we will allow
focus in the child `Portal` components otherwise a focus outside of the
`Popover` will close the it. In other components we often crawl the DOM
directly using `[data-headlessui-portal]` data attributes, however this
will fetch _all_ the `Portal` components, not the ones that started in
the current component.

* allow focus in portalled containers

The `Popover` component will close by default if focus is moved outside
of it. However, if you use a `Portal` comopnent inside the
`Popover.Panel` then from a DOM perspective you are moving the focus
outside of the `Popover.Panel`. This prevents the closing, and allows
the focus into the `Portal`.

It currently only allows for `Portal` components that originated from
the `Popover` component. This means that if you open a `Dialog`
component from within the `Popover` component, the `Dialog` already
renders a `Portal` but since this is part of the `Dialog` and not the
`Popover` it will close the `Popover` when focus is moved to the
`Dialog` component.

* ensure `useNestedPortals` register/unregister with the parent

This ensures that if you have a structure like this:

```jsx
<Dialog> {/* Renders a portal internally */}
   <Popover>
      <Portal> {/* First level */}
         <Popover.Panel>
            <Menu>
               <Portal> {/* Second level */}
                  <Menu.Items>
                  {/* ... */}
                  </Menu.Items>
               </Portal>
            </Menu>
         </Popover.Panel>
      </Portal>
   </Popover>
</Dialog>
```

That working with the `Menu` doesn't close the `Popover` or the `Dialog`.

* cleanup `useRootContainers` hook

This will allow you to pass in portal elements as well. + cleanup of
the resolving of all DOM nodes.

* handle nested portals in `Dialog` component

* expose `contains` function from `useRootContainers`

Shorthand to check if any of the root containers contains the given
element.

* add tests to verify that actions in `Portal` components won't close the `Popover`

* update changelog

* re-order use-outside-click logic

To make it similar between React & Vue

* inject the `PortalWrapper` context in the correct spot

* ensure to forward the incoming `attrs`
2023-05-19 15:37:01 +02:00
Jordan Pittman 7ec06528d9 Don't scroll lock when transition isn't showing (#2422)
* Add tests

* Make transition initial state based on computed `show` prop

* Update changelog
2023-04-10 10:29:33 -04:00
Robin Malfait 7e150e408c Fix restore focus to buttons in Safari, when Dialog component closes (#2326)
* update dialog playground example

Includes a generic `Button` component that has explicit focus styles.

* keep track of "focus" history

Safari doesn't "focus" buttons when you mousedown on them. This means
that we don't capture the correct element to restore focus to when
closing a `Dialog` for example.

Now, we will make sure to keep track of a list of last "focused" items.
We do this by also capturing elements when you "click", "mousedown" or
"focus".

* let's use a button instead of a div in tests

* make `target` for Vue consistent with React

* update changelog
2023-03-03 18:24:57 +01:00
Robin Malfait 989cd6b040 Fix XYZPropsWeControl and cleanup internal TypeScript types (#2329)
* cleanup `XYZPropsWeControl`

The idea behind the `PropsWeControl` is that we can omit all the fields
that we are controlling entirely. In this case, passing a prop like
`role`, but if we already set the role ourselves then the prop won't do
anything at all. This is why we want to alert the end user that it is an
"error".

It can also happen that we "control" the value by default, but keep
incoming props into account. For example we generate a unique ID for
most components, but you can provide your own to override it. In this
case we _don't_ want to include the ID in the `XYZPropsWeControl`.

Additionally, we introduced some functionality months ago where we call
event callbacks (`onClick`, ...) from the incoming props before our own
callbacks. This means that by definition all `onXYZ` callbacks can be
provided.

* improve defining types

Whenever we explicitly provide custom types for certain props, then we
make sure to omit those keys first from the original props (of let's say
an `input`). This is important so that TypeScript doesn't try to "merge"
those types together.

* cleanup: move `useEffect`

* add `defaultValue` explicitly

* ensure tests are not using `any` because of `onChange={console.log}`

The `console.log` is typed as `(...args: any[]) => void` which means
that it will incorrectly mark its incoming data as `any` as well.
Converting it to `x => console.log(x)` makes TypeScript happy. Or in
this case, angry since it found a bug.

This is required because it _can_ be that your value (e.g.: the value of
a Combobox) is an object (e.g.: a `User`), but it is also nullable.

Therefore we can provide the value `null`. This would mean that
eventually this resolves to `keyof null` which is `never`, but we just
want a string in this case.

```diff
-export type ByComparator<T> = (keyof T & string) | ((a: T, b: T) => boolean)
+export type ByComparator<T> =
+  | (T extends null ? string : keyof T & string)
+  | ((a: T, b: T) => boolean)
```

* improve the internal types of the `Combobox` component

* improve the internal types of the `Disclosure` component

* improve the internal types of the `Listbox` component

* improve the internal types of the `Menu` component

* improve the internal types of the `Popover` component

* improve the internal types of the `Tabs` component

* improve the internal types of the `Transition` component

* use `Override` in `Hidden` as well

* cleanup unused code

* don't check the `useSyncExternalStoreShimClient`

* don't check the `useSyncExternalStoreShimServer`

* improve types in the render tests

* fix `Ref<TTag>` to be `Ref<HTMLElement>`

* improve internal types of the `Transition` component (Vue)

+ add `attrs.class` as well

* use different type for `AnyComponent`

* update changelog
2023-03-02 22:50:41 +01:00
Robin Malfait 948ae73608 Allow root containers from the Dialog component in the FocusTrap component (#2322)
* add (failing) test to verify moving focus to 3rd party containers work

* pass `resolveRootContainers` to `FocusTrap`

* handle lazy containers in `FocusTrap`

* update changelog
2023-03-01 18:13:07 +01:00
Robin Malfait 9ecd8dd926 Fix Dialog cleanup when the Dialog becomes hidden (#2303)
* use the Dialog's parent as the root for the Intersection observer

We have some code that allows us to auto-close the dialog the moment it
gets hidden. This is useful if you use a dialog for a mobile menu and
you resizet he browser. If you wrap the dialog in a `md:hidden` then it
auto closes. If we don't do this, then the dialog is still locking the
scrolling, keeping the focus in the dialog, ... but it is not visible.

To solve this we use an `IntersectionObserver` to verify that the
`boundingClientRect` is "gone" (x = 0, y = 0, width = 0 and height = 0).

However, the intersection observer is not always triggered. This happens
if the main content is scrollable.

Setting the `root` of the `IntersectionObserver` to the parent of the
`Dialog` does seem to solve it.

Not 100% sure what causes this behaviour exactly.

* use a `ResizeObserver` instead of `IntersectionObserver`

* implement a `ResizeObserver` for the tests

* update changelog
2023-02-24 13:22:29 +01:00
Jordan Pittman b8c214eebb Make React types more compatible with other libraries (#2282)
* Export explicit props types

* wip

* wip

* wip

* wip dialog types

* wip

* Fix build

* Upgrade esbuild

* Add aliased types for ComponentLabel and ComponentDescription

* Update lockfile

* Update changelog

* Update exported prop type names

* Make onChange optional

* Update tests

* Use `never` in CleanProps

Using a branded type doesn’t work properly with unions

* Fix types

* wip

* work on types

* wip

* wip

* Tweak types in render helpers

* Fix CS

* Fix changelog

* Tweak render prop types for combobox

* Update hidden props type name

* remove unused type

* Tweak types

* Update TypeScript version
2023-02-20 12:26:17 -05:00
Robin Malfait 10efaa921d Ensure the main tree and parent Dialog components are marked as inert (#2290)
* drop `@ts-expect-error`, because `inert` is available now

* fix logical error

We want to apply `inert` when we _don't_ have nested dialogs, because if
we _do_ have nested dialogs, then the inert should be applied from the
nested dialog (or visually the top most dialog).

* update changelog

* replace `useInertOthers` with `useInert`

* add `assertInert` and `assertNotInert` accessibility assertion helpers

* ensure the `main tree` root is marked as inert

As well as the parent dialogs in case of nested dialogs.
2023-02-17 16:49:41 +01:00
Robin Malfait adfe121678 Start cleanup phase of the Dialog component when going into the Closing state (#2264)
* introduce `opening` and `closing` states

Also represent them as bits so that we can easily combine them while we
are transitioning from one state to the other.

* update `open/closed` state checks

Instead of checking whether it is in one state or an other, we can check
if the current state contains some potential sub-state.

This allows us to still check if we are in the `Open` state, while also
`Closing` because the state will be `S.Open | S.Closing`.

* expose `flags` from the `useFlags` hook

* add the `Closing` and `Opening` states to the Open/Closed state

* create dedicated `abcEnabled` variables

* keep the `State.Closing` into account for `scroll locking` and `inert others`

* add a test for the `Closing` state impacting the `Dialog` component

* cleanup unused imports

* add `unmount` util to the Vue Test renderer

* update changelog
2023-02-15 12:14:03 +01:00
Robin Malfait d146b78a97 Revert "Use the import * as React from 'react' pattern (#2242)"
This reverts commit 0276231c31.
2023-02-06 12:30:30 +01:00
Robin Malfait 0276231c31 Use the import * as React from 'react' pattern (#2242)
* use the `import * as React from 'react'` pattern

We use named imports, but we have to import `React` itself as well for
JSX because it compiles to `React.createElement`. We could get rid of
our own JSX and use it directly, or we can use this `import * as React
from 'react'` syntax.

This fixes an issue for people using `allowSyntheticDefaultImports: false` in TypeScript.

Fixes: #2117

* update changelog
2023-02-02 15:34:16 +01:00
Jordan Pittman 2f99644ed7 Don't break overflow when multiple dialogs are open at the same time (#2215)
* Fix overflow when swapping dialogs that use transition

* Refactor

* refactor

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* Inline shim for ESM support

Until the official package adds an ESM version with a wildcard import we can’t use it. This version was copied from Remix Router

* Add dialog shadow root examples

* Fix SSR error

* Add repro for iOS scrolling issue

* Try to fix vercel build

idk what’s wrong here

* Update repro

A transition is required to delay closing enough to demonstrate the bug

* Port global dialog state to Vue

* Add dialog test to Vue

* wip

* wip

* Workaround bug

This shouldn’t happen at all and we need to find the source of the bug but this should “fix” things for the time being

* wip

* Rebuild overflow locking with simpler API

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* Update deps

* wip

* simplify

* Port to Vue

* wip

* wip

* Tweak tests

* Update changelog

* Ensure meta callbacks are cleaned up

* cleanup

* wip
2023-02-01 16:08:34 -05:00
Adventune f2d9ea08b6 fix(@headless-react / @headless-vue): update WAI-ARIA reference links (#2230)
* fix(@headlessui/react): WAI-ARIA reference links

* fix(@headlessui-vue): Fix WAI-ARIA reference links

---------

Co-authored-by: lumilno <nooa.lumilaakso@gmail.com>
2023-01-31 11:42:02 -05:00
Robin Malfait d8b263cb42 Fix shadow-root bug closing Dialog containers (#2217)
* ensure we consider `html > *` as valid containers as well

* update changelog
2023-01-27 14:49:43 +01:00
Robin Malfait dbcfb23bc3 Fix FocusTrap in Dialog when there is only 1 focusable element (#2172)
* add tests to guarantee `FocusTrap` with a single element works as expected

* it should keep the focus in the Dialog

Even if there is only 1 element. We were skipping the current active
element so the container didn't have any elements anymore and just
continued to the next focusable element in line. This will prevent that
and ensure that we can only skip elements if there are multiple ones.

* update changelog
2023-01-25 13:18:51 +01:00
Robin Malfait 599428373e improve scrolling to in-page location 2022-12-16 17:31:55 +01:00
Robin Malfait 45fde141d2 improve scroll offset 2022-12-15 16:46:13 +01:00
Robin Malfait 962528c216 Improve scroll locking on iOS (#2100)
* improve types for addEventListener inside disposables

* improve scroll locking

Instead of using the "simple" hack with the `position: fixed;` we now
went back to the `touchmove` implementation.

The `position: fixed;` causes some annoying issues. For starters, on iOS
you will now get a strange gap (due to safe areas). Some applications
also saw "blank" screens based on how the page was implemented.

We also saw some issues internally, where clicking changing the scroll
position on the main page from within the Dialog.

Think about something along the lines of:
```html
<a href="#interesting-link-on-the-current-page">Interesting link on the page</a>
```

This doesn't work becauase the page is now fixed, and there is nothing
to scroll...

Instead, we now use the `touchmove` again. The problem with this last
time was that this disabled _all_ touch move events. This is obviously
not good.

Luckily, we already have a concept of "safe containers". This is what we
use for the `outside click` behaviour as well. Basically in a Dialog,
your `Dialog.Panel` is the safe container. But also third party DOM
elements that are rendered inside that Panel (or as a sibling of the
Dialog, but not your main app).

We can re-use this knowledge of "safe containers", and only cancel the
`touchmove` behaviour if this didn't happen in any of the safe
containers.

* update changelog
2022-12-15 16:09:33 +01:00
Robin Malfait d31bb5c08e Fix FocusTrap escape due to strange tabindex values (#2093)
* sort DOM nodes using tabIndex first

It will still keep the same DOM order if tabIndex matches, thanks to
stable sorts!

* refactor `focusIn` API

All the arguments resulted in usage like `focusIn(container,
Focus.First, true, null)`, and to make things worse, we need to add
something else to this list in the future.

Instead, let's keep the `container` and the type of `Focus` as known
params, all the other things can sit in an options object.

* fix FocusTrap escape due to strange tabindex values

This code will now ensure that we can't escape the FocusTrap if you use
`<tab>` and you happen to tab to an element outside of the FocusTrap
because the next item in line happens to be outside of the FocusTrap and
we never hit any of the focus guard elements.

How it works is as follows:

1. The `onBlur` is implemented on the `FocusTrap` itself, this will give
   us some information in the event itself.
   - `e.target` is the element that is being blurred (think of it as `from`)
   - `e.currentTarget` is the element with the event listener (the dialog)
   - `e.relatedTarget` is the element we are going to (think of it as `to`)
2. If the blur happened due to a `<tab>` or `<shift>+<tab>`, then we
   will move focus back inside the FocusTrap, and go from the `e.target`
   to the next or previous value.
3. If the blur happened programmatically (so no tab keys are involved,
   aka no direction is known), then the focus is restored to the
   `e.target` value.

Fixes: #1656

* update changelog
2022-12-14 16:26:38 +01:00
Jordan Pittman 724ee378fc Allow clicks inside dialog panel when target is inside shadow root (#2079)
* Allow clicks inside dialog panel when target is inside shadow root

* fixup

* Update changelog
2022-12-08 16:37:01 -05:00
Robin Malfait 219901c84f Allow passing in your own id prop (#2060)
* accept `id` as a prop where it is currently hardcoded (React)

Continuation of #2020

Co-authored-by: Olivier Louvignes <olivier@mgcrea.io>

* accept `id` as a prop where it is currently hardcoded (Vue)

* update changelog

* apply React's hook rules

Co-authored-by: Olivier Louvignes <olivier@mgcrea.io>
2022-12-02 23:56:33 +01:00
Robin Malfait 397ba5c8c2 Improve iOS scroll locking (#1830)
* use a simpler `position: fixed` approach to prevent scroll locking

This isn't super ideal, but just preventing the default behavior on the
entire document while `touchmove`-ing isn't ideal either because then
you can't scroll inside the dialog or on the backdrop if your dialog
panel is larger than the viewport.

Again, this is not 100% correct, but it is better because you will be
able to scroll the dialog, and not the body.

* update changelog
2022-09-07 12:32:46 +02:00
Robin Malfait 25a4e7f721 Improve scroll lock on iOS (#1824)
* improve `Dialog` scroll lock on iOS

* add Dialog example to playground that's scrollable

* update changelog
2022-09-05 23:54:54 +02:00
Jordan Pittman 6ecd448c48 Don’t overwrite element.focus on popover panels (#1719)
* Don’t overwrite `element.focus` on popover panels

* Update changelog

* Add test

This test isn’t exactly right for JSDOM but it does mirror what we would do in the browser to reproduce the problem
2022-07-26 16:06:10 -04:00
Robin Malfait f2c2d3c4e0 Fix incorrect scrolling to the bottom when opening a Dialog (#1716)
* add `?raw` option to playground

This will render the component as-is without the wrapper.

* delay initial focus and make consistent between React and Vue

This will delay the initial focus and makes it consistent between React
and Vue.

Some explanation from within the code why this is happening:

   Delaying the focus to the next microtask ensures that a few
   conditions are true:

   - The container is rendered
   - Transitions could be started

   If we don't do this, then focusing an element will immediately cancel
   any transitions. This is not ideal because transitions will look
   broken. There is an additional issue with doing this immediately. The
   FocusTrap is used inside a Dialog, the Dialog is rendered inside of a
   Portal and the Portal is rendered at the end of the `document.body`.
   This means that the moment we call focus, the browser immediately
   tries to focus the element, which will still be at the bodem
   resulting in the page to scroll down. Delaying this will prevent the
   page to scroll down entirely.

* update test to reflect initial focus delay

Now that we are triggering the initial focus inside a `queueMicroTask`
we have to make sure that our tests wait a frame so that the micro task
could run, otherwise we will have incorrect results.

Also make the implementation similar in React and Vue

* update changelog
2022-07-26 15:29:49 +02:00
Jordan Pittman 5af3bd4b71 Don't scroll lock when a Transition + Dialog is mounted but hidden (#1681)
* Refer to context for initial Transition Tree state

* Update changelog
2022-07-15 11:43:45 -04:00
Jordan Pittman f1daa1e52b Adjust outside click handling (#1667)
* 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
2022-07-14 14:20:04 -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 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 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 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 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 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 c1023f7934 Fix incorrect closing while interacting with third party libraries in Dialog component (#1268)
* ensure to keep the Dialog open when clicking on 3rd party elements

* update playground with a Flatpickr example

* update changelog
2022-03-24 20:02:57 +01:00