* 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
* 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.
* 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
* 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>
* only render `<MainTreeNode />` in `Popover.Group` instead of after every `Popover`
* make Vue Popover consistent
* apply same `MainTreeNode` logic to Vue version
* update changelog
* 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
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.
* 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
* Skip updating state if sentinel has been unmounted
* use existing `useIsMounted` hook
* reformat code paths
---------
Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
* 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
* 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
* 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
* 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`
* 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
* 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
* 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>
* 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
* 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
* feat: addEventListener on document loaded
* Refactor
* Fix import
* Update changelog
* use function instead of arrow function
* make callback in `onDocumentReady` mandatory
---------
Co-authored-by: lkr <lkr@bytedance.com>
Co-authored-by: Jordan Pittman <jordan@cryptica.me>
Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
* [vue] Fix Combobox input disabled state
* Add tests for disabled input in React and Vue
* Update changelog
---------
Co-authored-by: Jordan Pittman <jordan@cryptica.me>
* update playground examples to use a shared `Button`
* expose a `ui-focus-visible` variant
* keep track of a `data-headlessui-focus-visible` attribute
* do not set the `tabindex`
The focus was always set, but the ring wasn't showing up. This was also
focusing a ring when the browser decided not the add one.
Let's make the browser decide when to show this or not.
* update changelog
* 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
* add a bunch of tests to ensure we won't regress on this again
* fix incorrect warning when using multiple `Popover.Button` inside `Popover.Panel`
* update changelog
* 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
* add (failing) test to verify moving focus to 3rd party containers work
* pass `resolveRootContainers` to `FocusTrap`
* handle lazy containers in `FocusTrap`
* update changelog
* add native label behavior for switch
* Add reference tests for React and Vue
These don’t work in JSDOM so they’re skipped but we can use these to reference expected behavior once we have playwright-based tests
* Fix Vue playground switch example
* Only prevent default when the element is a label
* Port change to Vue
* Update changelog
---------
Co-authored-by: Jordan Pittman <jordan@cryptica.me>
* make `disposables` consistent
Also added a `group` function, this allows us to spawn a _sub_
disposables group that can be disposed on its own, but will also be
disposed the moment the "parent" is disposed.
* ensure Transition component works when nothing is transitioning
* update changelog
* 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
Let's wrap the test in `act` to get rid of the warning. In practice
(while testing in the browser) the actual warning doesn't seem to affect
the user experience at all.
The `act` function is typed in a strange way (`Promise<undefined> &
void`). Yet the actual contents of the `act` callback is returned as
expected. Therefore we overrode the type of `act` to make sure this
reflects reality better. (Thanks @thecrypticace!)
Also added an additional check to make sure the actual `container` is
available to extra ensure we are not lying by overriding the type.