* 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>
* 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
* 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>
* 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>
* Merge vnode refs when rendering
In some cases if we used our own ref (we do this in `<TransitionRoot>` for instance) and rendered slot children we would wipe out user-specified refs. So we set a flag when calling `cloneVNode` to merge our refs and any user-specified refs.
* Update changelog
* Tweak `dom()` helper
The helper can currently return a component instance when it should only ever return a DOM element. So, we fix the implementation to return null if it’s not an `Element` _and_ adjust the types such that if a `ComponentPublicInstance` is passed we change the return type to `Element`.
* Specialize DOM helper to HTML elements
Technically it could be an SVG element but much of Headless UI assumes HTML elements all over. So we’ll adjust the types to assume HTMLElement instead.
* Allow `dom()` helper to return any `Node` type
It doesn’t actually always return an HTMLElement but we have behavior that relies on it returning and checking for `Comment` nodes
* Detect `<button>` nested in components inside `<PopoverButton>`
* Update changelog
* add scripts to help with automating releases
* add prepare-release and release workflows
* bump actions from v2 to v3
* use `github.ref_name` for getting the tag name
* ensure we use `**` for matching tags with slashes in them
* 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>
* 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
* Register portal based on element presence in the DOM
This always coincides with `onMounted` currently but that’s about to change
* Mount element lazily for portals
This prevent’s SSR hydration issues and matches the behavior of React’s `<Portal>` element
* Fix portal tests
* Update comment
* Update changelog
* 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
* add countries data and combobox example
* directly push to the options array
No need to spread here because this will slow things down tremendously.
If you add 5 options, then this will happen:
```js
let options = []
options = [...options, 1]
options = [...options, 2]
options = [...options, 3]
options = [...options, 4]
options = [...options, 5]
```
It's making a lot of copies and has to spread everything it just copied
again. Instead, let's just push to the array:
```js
let options = []
options.push(1)
options.push(2)
options.push(3)
options.push(4)
options.push(5)
```
* only sort options by DOM node once
When registering 50 options, we will re-sort the options based on the DOM
node position for every option that gets registered. This is overkill
and unnecessary. The re-sorting is useful if an option is injected
between 2 options. When we sort the full list for _every_ `registerOption`
call then the result after the last `registerOption` will be the same if
we only sorted after the last `registerOption` call only.
This will ensure that we are skipping a ton of work and only do the work
once at the end where it matters.
* debounce the `goToOption` call until the next frame
This will allow us to perform the task of `goToOption` once instead of
`n` times if they happen really fast after eachother.
This can happen when you are leaving option B and going to option A.
Then the following steps happen:
- Leaving Option B `goToOption(Nothing)`
- Entering Option A `goToOption(A)`
Both will re-render everything because the internal active option index
would be changed. But only the last `goToOption(A)` is the interesting
version here.
We could also move the `goToOption(Nothing)` to the `ComboboxOptions`
(wrapper) itself instead of adding these listeners on each individual
`ComboboxOption`. However, if you add an additional visual piece of DOM
above or between the options, hovering that would not leave the
`ComboboxOptions` and therefore the last `ComboboxOption` would still be
active which we don't want.
* 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