Commit Graph

448 Commits

Author SHA1 Message Date
zakstucke 2a64c131bd Update Radio's OptionFn effect deps to fix infinite loop. (#2758) 2023-09-19 11:24:59 +02:00
Robin Malfait f016dc51db Add virtual prop to Combobox component (#2740)
* type timezones in playground data

* add `@tanstack/react-virtual` and `@tanstack/vue-virtual`

* use latest stable Tailwind CSS version

* add Combobox with virtual prop example

* add `virtual` prop to `Combobox`

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

* add tests for `virtual` prop

- Also wrap `click` helpers in `act` for React (use `rawClick` without
  `act` in tests related to `Transition`)

* update changelog

---------

Co-authored-by: Jordan Pittman <jordan@cryptica.me>
2023-09-15 14:29:18 +02: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
Robin Malfait d4aa86d128 Allow changes to the className prop when the <Transition /> component is currently not transitioning (#2722)
* allow changes to the `className` when we are _not_ transitioning

* update changelog
2023-08-31 16:06:48 +02:00
Timur Zurbaev fa952626c7 Add immediate prop to <Combobox /> for immediately opening the Combobox when the input receives focus (#2686)
* Allow to open combobox on input focus

* Close focused combobox with openOnFocus prop when clicking the button

* ensure tabbing through a few fields, doesn't result in an incorrectly selected item

When you have a fwe inputs such as:

```html
<form>
   <input />
   <input />
   <input />
   <Combobox>
      <Combobox.Input />
   </Combobox>
   <input />
   <input />
   <input />
</form>
```

Tabbing through this list will open the combobox once you are on the
input field. When you continue tabbing, the first item would be
selected. However, if the combobox is not marked as nullable, it means
that just going through the form means that we set a value we can't
unset anymore.

We still want to open the combobox, we just don't want to select
anything in this case.

* only `openOnFocus` if the `<Combobox.Input />` is focused from the
outside

If the focus is coming from the `<Combobox.Button />` or as a side
effect of selecting an `<Combobox.Option />` then we don't want to
re-open the `<Combobox />`

* update tests to ensure that the `Combobox.Input` is the active element

* order `handleBlur` and `handleFocus` the same way in Vue & React

* only select the active option when the Combobox wasn't opened by focusing the input field

* convert to `immediate` prop on the `Combobox` itself

* update changelog

* ensure we see the "relatedTarget" in Safari

Safari doesn't fire a `focus` event when clicking a button, therefore it
does not become the `document.activeElement`, and events like `blur` or
`focus` doesn't set the button as the `event.relatedTarget`.

Keeping track of a history like this solves that problem. We already had
the code for the `FocusTrap` component.

---------

Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
2023-08-31 15:23:56 +02:00
Robin Malfait 134c0fb221 Ensure blurring the Combobox.Input component closes the Combobox (#2712)
* ensure blurring the `Combobox.Input` component closes the `Combobox`

* update changelog

* select the value on blur if we are in single value mode
2023-08-29 16:24:37 +02:00
Jordan Pittman c6ac69237a Update changelog 2023-08-28 17:14:47 -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
Robin Malfait fd17c26490 Ensure hidden TabPanel components are hidden from the accessibility tree (#2708)
* explicitly add the `aria-hidden="true"` attribute

The `Hidden` component only adds the `aria-hidden` by default if the
`Focusable` feature is passed. In our case we don't want it to be
focusable so therefore we didn't pass this feature flag.

Because we didn't pass the `Focusable` feature, the `display: hidden`
was used which makes it completely unfocusable to the keyboard of the
user which is what we want.

However, the VoiceOver cursor _can_ get into those elements. Adding the
`aria-hidden` manually to these tabs solves the issue.

* update changelog
2023-08-28 17:49:26 +02: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
Davide Francescon Sophany 6a88fd57fe fix: double onClose on mobile dialog outClick (#2690)
* fix: double onClose on mobile dialog outClick

* Fix CS

* Add fix to Vue useOutsideClick

* Update changelog

* Fix CS

---------

Co-authored-by: Jordan Pittman <jordan@cryptica.me>
2023-08-21 11:57:08 -04:00
Jordan Pittman 0b8bd32865 1.7.17 - @headlessui/react 2023-08-17 10:58:55 -04:00
Jonathan Reinink c2bf9dd0e9 Prevent scrolling when focusing a tab (#2674)
* Prevent scrolling when focusing a tab

* Update changelog
2023-08-17 10:53:58 -04:00
Jordan Pittman a317866342 Fix hydration of components inside <Suspense> (#2663)
* Add repro for suspense

wip

* Refactor to wildcard import

* Targeted fix for react 18 + suspense

* Update changelog

* Update types

* Add styling

* update styling
2023-08-10 10:39:39 -04:00
Robin Malfait 88b068cff1 Improve resetting values when using the nullable prop on the Combobox component (#2660)
* move `nullable` handling to `onChange` of `Combobox.Input` itself

We were specifically handling backspace/delete keys to verify if the
`Combobox.Input` becomes empty then we can clear the value if we are in
single value and in nullable mode.

However, this doesn't capture other ways of clearing the
`Combobox.Input`, for example when use `cmd+x` or `ctrl+y` in the input.

Moving the logic, gives us some of these cases for free.

* ensure pressing `escape` also clears the input in nullable, single value mode without an active value

* adjust test to ensure we don't have a selected option instead of an active option

We still will have an active option (because we default to the first
option if nothing is active while the combobox is open). But since we
cleared the value when using the `nullable` prop, then it means the
`selected` option should be cleared.

* ensure `input` event is fired when firing keydown events

* ensure `defaultToFirstOption` is always set when going to an option

We recently made a Vue improvement that delayed the going to an option,
but this also included a bug where the `defaultToFirstOption` was not
set at the right time anymore.

* update changelog

* fix `than` / `then` typo
2023-08-08 19:09:01 +02:00
Robin Malfait 842890d054 Ensure appear works using the Transition component (even when used with SSR) (#2646)
* ensure `appear` works in combination with SSR

* add appear transition example

* update changelog

* add scale to appear example

* trigger immediate transition once the DOM is ready

* ensure React doesn't change the `className` underneath us

* handle all base classes

We are bypassing React when handling classes in the Transition
component. Let's ensure the base classes from the prop are also added
correctly.

* add missing `base` to tests

* simplify `useTransition` hook

* add react-hot-toast example

* make TS happy

* ensure the `classNames` are unique

* remove classNames if it results in an empty string

This will ensure that we don't end up with `class=""` in the DOM

* ensure `unmount` is defaulting to `true`

* do not read from `prevShow` in render

After fixing the other bugs, this part only caused bugs right now. Even
when re-rendering the Transition component while transitioning. Dropping
this fixes that behaviour.

* extend `appear` demo with appear, show, unmount booleans

+ a `lazily` one to mimic a conditional render on the client instead of
  a fresh page refresh.
2023-08-07 17:59:40 +02:00
Robin Malfait 88a0138d92 Fix incorrectly focused Combobox.Input component on page load (#2654)
* only call `setSelectionRange` when the input is the focused input

* update changelog
2023-08-07 15:03:12 +02:00
Robin Malfait c22a8c19c3 Don't assume <Tab /> components are available when setting the next index (#2642)
* only set the next index when tabs are available

Currently we were assuming that the `tabs` were always there, but this

* update changelog
2023-08-03 12:02:17 +02:00
Robin Malfait 6f9de8925e Disable smooth scrolling when opening/closing Dialog components on iOS (#2635)
* disable smooth scrolling when opening/closing Dialogs

For iOS workaround related purposes we have to capture the scroll
position and offset the margin top with that amount and then
`scrollTo(0,0,)` to prevent all kinds of funny UI jumps.

However, if you have `scroll-behavior: smooth` enabled on your `html`,
then offseting the margin-top and later `scrollTo(0,0)` would be
handled in a smooth way, which means that the actual position would be
off.

To solve this, we disable smooth scrolling entirely in order to make the
position of the Dialog correct. This shouldn't be a problem in practice
since the page itself isn't suppose to scroll anyway.

Once the Dialog closes we reset it such that everything else keeps
working as expected in a (smooth) way.

* add `microTask` to disposables

* ensure the fix works in React's double rendering dev mode

* update changelog
2023-08-02 17:45:06 +02: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 a3fa86b6f4 only check if _mainTreeNodeRef is passed in
It can be that it is passed in but still `null`, that's fine because it
means it will be passed in later once the DOM is ready.
2023-08-02 13:43:28 +02:00
Robin Malfait 8a37854da3 Render <MainTreeNode /> indicators in Popover.Group only (#2634)
* only render `<MainTreeNode />` in `Popover.Group` instead of after every `Popover`

* make Vue Popover consistent

* apply same `MainTreeNode` logic to Vue version

* update changelog
2023-08-02 13:25:27 +02:00
Jordan Pittman b380d03ccc Use correct value when resetting <Listbox multiple> and <Combobox multiple> (#2626)
* Fix bug with non-controlled, multiple combobox in Vue

It thought it was always controlled which broke things

* Use correct value when resetting `<Listbox multiple>` and `<Combobox multiple>`

* Update changelog
2023-07-28 11:46:33 -04:00
Robin Malfait 9b42dafd7f improve attemptSubmit submission (#2625)
When you pass in an element to the `attemptSubmit` that has a
`type="submit"`, then the `attemptSubmit` will just click this element.

We want to skip the current one and fallback to `form.requestSubmit()`
instead.
2023-07-28 16:33:55 +02:00
Robin Malfait 0501475575 1.7.16 - @headlessui/react 2023-07-27 16:23:46 +02:00
Robin Malfait ac859fe6b1 Submit form on Enter even if no submit-like button was found (#2613)
* `requestSubmit` when a submit-like button cannot be found

* add tests

* update changelog
2023-07-25 15:48:35 +02:00
Robin Malfait 1739edb479 Calculate aria-expanded purely based on the open/closed state (#2610)
* define `aria-expanded` based on open/closed state

You shouldn't be able to open a Listbox/Menu/Combobox/... when the
component is in a disabled state, however if you open it, and then
disable it then it is still in an open state. Therefore the
`aria-expanded` should still be present.

This is also how other libraries behave.

It is also how the native `<select>` behaves. You can open it, disable
it programmatically and then you are still able to make a selection.

This seems enough evidence that this change is an improvement without
being a breaking change.

Fixes: #2602

* update changelog
2023-07-24 12:51:21 +02:00
Tim Robertson 076b03cf49 Skip updating state if sentinel has been unmounted (#2585)
* Skip updating state if sentinel has been unmounted

* use existing `useIsMounted` hook

* reformat code paths

---------

Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
2023-07-10 10:42:31 +02:00
Robin Malfait 00fdb7e3cd Ensure IME works on Android devices (#2580)
* simplify `isComposing`

We had an issue #2409 where typing using an IME (Input Method Editor,
E.g.: Japanese — Romaji) already submitted characters while the user was
still composing the characters together.

1. Type `wa`
2. Expected result: `わ`
3. Actual result: `wあ` (where `あ` is the character `a`)

This was solved by not triggering change events at all until the
`compositionend` event was triggered. This worked fine for this use
case. However this also meant that only at the end of your typing
session (when you press `enter`/`space`) the actual value was submitted.

Fast forward to today, we received a new issue #2575 where this
behaviour completely broke on Android devices. Android _always_ use the
IME APIs for handling input... if we think about our solution form
above, it also means that while you are typing on an Android device no
options are being filtered at all. The moment you hit enter/space the
combobox will open and results will be filtered.

This is where this fix comes in. The goals are simple:

1. Make it work
2. Try to make the current code simpler

I started digging to see _why_ this `wあ` was even submitted. A normal
input field doesn't do that?! We have some code that does the following
things:

1. Sync the selected value with the `input` such that if you update the
   value from the outside, then the value in the `input` is up-to-date
   with the `displayValue` of that incoming value.
2. A fix for macOS VoiceOver to improve the VoiceOver experience when
   opening the `Combobox` component. This is done by manually resetting
   the value of the `input` field.

   1. Keep track of the current value
   2. Keep track of the current selection range (start/end) state
   3. Reset the input to an empty string `''`
   4. Restore the value to the captured value
   5. Restore the selection range

When you are typing, the input field doesn't have to update yet because
typing doesn't cause an option to become the `selected` option,
therefore it doesn't have to sync the value yet. So (1.) isn't the issue
here.

However, when you start typing, the Combobox should open and then we
trigger the macOS VoiceOver fix. This is touching the `input` field
because we change the value & selection.

Because we touched the `input` while the user was still in a composing
mode, it bailed and submitted whatever characters it had. This is the
part that we don't want. Not applying the macOS VoiceOver fix while
typing solves this issue. In addition, because _we_ are touching the
input field, VoiceOver is acting normally.

In hindsight, the solution is very simple: do not touch the input field
when the user is typing.

We still keep track whether the user `isComposing` so that we can bail
on the default `Enter` behaviour (marking the current option as the
selected option) because pressing `Enter` while composing should get out
of the IME.

Fixes: #2575

* update changelog
2023-07-06 14:35:35 +02:00
Robin Malfait a9e85634a9 Improve "outside click" behaviour in combination with 3rd party libraries (#2572)
* listen for both `mousedown` and `pointerdown` events

This is necessary for calculating the target where the focus will
eventually move to. Some other libraries will use an
`event.preventDefault()` and if we are not listening for all "down"
events then we might not capture the necessary target.

We already tried to ensure this was always captured by using the
`capture` phase of the event but that's not enough.

This change won't be enough on its own, but this will improve the
experience with certain 3rd party libraries already.

* refactor one-liners

* listen for `touchend` event to improve "outside click" on mobile devices

* update changelog
2023-07-03 16:21:03 +02:00
Robin Malfait 04fc6cfa06 Ensure the caret is in a consistent position when syncing the Combobox.Input value (#2568)
* ensure the caret is at the end of the input, after syncing the value

This will ensure the caret is always in a consistent location once the
input value has synced. We will _not_ do this while the user is typing
because changing the position while typing will result in odd results.

Safari already kept the caret at the end, Chrome put the caret at the
end but once you synced the value once the caret was in front of the
text.

* update changelog

* add selection guards

Ensure that we only move the caret to the end, if the selection didn't
change in the meantime yet. For example when you have code like this:
```js
<Combobox.Input onFocus={e => e.target.select()} />
```

This will select all the text in the input field, if we just move the
caret position without keeping this into account then we would undo this
behaviour.

* add tests
2023-06-30 12:58:44 +02:00
Jordan Pittman 0a9276d205 Ignore disconnected elements for outside clicks (#2544) 2023-06-19 16:02:38 -04:00
Robin Malfait a1d7c69b69 1.7.15 - @headlessui/react 2023-06-01 12:40:00 +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 9dff5456fa Handle clicks inside iframes (#2485)
* Handle clicks inside iframes

* Update changelog
2023-05-08 11:51:40 -04:00
Robin Malfait 67f3c4d824 Improve control over Menu and Listbox options while searching (#2471)
* add `get-text-value` helper

* use `getTextValue` in `Listbox` component

* use `getTextValue` in `Menu` component

* update changelog

* ensure we handle multiple values for `aria-labelledby`

* hoist regex

* drop child nodes instead of replacing its innerText

This makes it a bit slower but also more correct. We can use a cache on
another level to ensure that we are not creating useless work.

* add `useTextValue` to improve performance of `getTextValue`

This will add a cache and only if the `innerText` changes, only then
will we calculate the new text value.

* use better `useTextValue` hook
2023-05-04 14:41:44 +02:00
Mateusz Burzyński 0505e92b83 Move types condition to the front (#2469)
* move `types` condition to the front

* Update changelog

---------

Co-authored-by: Jordan Pittman <jordan@cryptica.me>
2023-05-01 12:21:28 -04:00
Jordan Pittman 7c0cbbbca7 Stop <Transition appear> from overwriting classes on re-render (#2457)
* Add raw layout support to Vue playground

We can’t use ?raw here because Vite uses that itself for stuff. So here we opt for ?layout=raw instead

* Fix Transition for `appear` overwriting classes on re-render

* Set initial state just before animating

* Remove unused import

* Refactor

* Capture snapshot of element just after first render

With the new `setInitial` call before the animation starts — we don’t see the actual initial render result in this test because the queue has been emptied by the time it ends

* Update changelog
2023-04-28 13:06:39 -04:00
Muhammad Ilham Mubarak 4c102946fe refactor Portal to use useOnUnmount hook (#2458) 2023-04-27 12:44:22 +02:00
Robin Malfait 74c987351f ensure cb in useOnUnmount is a stable reference 2023-04-26 14:56:06 +02:00
Muhammad Ilham Mubarak 5cfbb4b5e5 Ensure FocusTrap is only active when the given enabled value is true (#2456)
* fix(tabs): wrong tab focus when Tab contains a Dialog

* refactor(focus-trap): rename variable and move logic

* test(tabs): improve test by asserting the active element

* ensure `FocusTrap` is not active when `enabled = false`

* fix: move the enabled check to unmounting

* refactor to `useOnUnmount` hook

This will allow us to make the code relatively similar between React and
Vue.

* update changelog

---------

Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
2023-04-26 14:51:01 +02:00
Robin Malfait 08127dd2ad [internal] add demo mode to Menu and Popover components (#2448)
* add demo mode to `Menu` and `Popover`

* update changelog
2023-04-21 13:51:26 +02:00
Jordan Pittman b98e642a67 Correctly handle IME composition in <Combobox.Input> (#2426)
* Don’t try to open combobox when composing characters

* wip

* Delay IME composition end until after keydown events

* Use `d.nextFrame` to handle `compositionend` event

* Update changelog
2023-04-17 10:17:28 -04:00
Robin Malfait c5d2e3b053 1.7.14 - @headlessui/react 2023-04-12 19:05:04 +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
Jordan Pittman 2063132f1a Merge className correctly when it’s a function (#2412)
* Add className tests for `render`

Fix snapshots

* Merge `className` correctly when it’s a function

* Update changelog
2023-04-03 14:43:09 -04:00
Robin Malfait c92a84782f Improve Combobox types to improve false positives (#2411)
* swap `Combobox` type order

If you use the default `Combobox` component then it defaults to
`Fragment`. This also means that if you provide an additional prop that
it would be forwarded to the `Fragment` but that won't work.

You do get a runtime error, but the types aren't 100% clear on what's
going on. In fact, they make it very confusing because it will use the
last "fallback" in all the `Combobox` definitions which marks the value
as "multiple".

Concretely, this means:
```ts
let [value, setValue] = useState('Tom Cook')

<Combobox
  value={value}
  onChange={setValue}
  placeholder="Hello!"
/>
```

Starts complaining about the `value` and `onChange` not handling the
`multiple` case correctly. But it should complain about the `placeholder`.

Switching the order _does_ solve this, but it is not the cleanest
solution.

Maybe we should be explicit about the `Fragment` case somehow.

However, there is a use case where I don't think TypeScript will be able
to help and it's a bit unfortunate.

```ts
let [value, setValue] = useState('Tom Cook')

<Combobox value={value} onChange={setValue} placeholder="Hello!">
   <div>
      {/* ... */}
   </div>
</Combobox>
```

This is valid because at runtime we will forward all the props to the
`div`. So not 100% sure what we should do here instead.

* update changelog
2023-04-03 17:46:20 +02:00