* 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
* 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
* drop `d.enqueue` & `d.workQueue`
This was only used in tests and doesn't seem to be necessary.
* drop `handleChange` from the `ComboboxInput` component
This only emitted a `change` event, which Vue already emits as well.
* drop `onChange` from incoming props
This is an odd one. In Chrome this means that the `@change` is still
being called, but if we keep it, then the `@change` is _also_ called on
blur resulting in odd bugs.
Droping it fixes that issue.
That said, the `@change` is _still_ emitted and therefore the callback
is properly called and the `ComboboxInput` still can interact with the
`@change` event.
* update changelog
* 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
* ensure there is an animatable root node
This is a bit sad, but it is how Vue works...
We used to render just a simple PopoverPanel that resolved to let's say
a `<div>`, that's all good. Because the native `<transition>` component
requires that there is only 1 DOM child (regardless of the Vue "tree").
This is the sad part, because we simplified focus trapping for the
Popover by introducing sibling hidden buttons to capture focus instead
of managing this ourselves.
Since we can't just return multiple items we wrap them in a `Fragment`
component.
If you wrap items in a Fragment, then a lot of Vue's magic goes away
(automatically adding `class` to the root node). Luckily, Vue has a
solution for that, which is `inheritAttrs: false` and then manually
spreading the `attrs` onto the correct element.
This all works beautiful, but not for the `<transition>` component...
so... let's move the focus trappable elements inside the actual Panel
and update the logic slightly to go to the Next/Previous item instead of
the First/Last because the First/Last will now be the actual focus guards.
* update changelog
* make TypeScript a bit happier
* improve `default` slot in `PopoverPanel`
* sort props in error message
This will make the error message consistent regardless which props (and
in what order) they are applied.
* WIP
* `click()` on a disabled element should no-op
* incomingProps was already merged
* cleanup tests a bit and make it consistent with the React tests
* cleanup unused code
* update changelog
* bump dev dependencies to React 18
* setup Jest to include `IS_REACT_ACT_ENVIRONMENT`
* prefer `useId` from React 18 if it exists
In React 16 & 17, where `useId` doesn't exist, we will fallback to our
implementation we have been using up until now.
The `useId` exposed by React 18, ensures stable references even in SSR
environments.
* update expected events
React 18 now uses the proper events:
- `blur` -> `focusout`
- `focus` -> `focusin`
* ensure to wait a bit longer
This is a bit unfortunate, but since React 18 now does an extra
unmount/remount in `StrictMode` to ensure that your code is
ConcurrentMode ready, it takes a bit longer to settle what the DOM sees.
That said, this is a temporary "hack". We are going to experiment with
using tools like Puppeteer/Playwright to run our tests in an actual
browser instead to eliminate all the weird details that we have to keep
in mind.
* prefer `.focus()` over `fireEvent.focus(el)`
* abstract `microTask` polyfill code
* prefer our `focus(el)` function over `el.focus()`
Internally we would still use `el.focus()`, but this allows us to have
more control over that `focus` function.
* add React 18 to the React Playground
* improve hooks for React 18
- Improving the cleanup of useEffect hooks
- useIsoMorphicEffect instead of normal useEffect, so that we can use
useLayoutEffect to be a bit quicker.
* improve disposables
- This allows us to add event listeners on a node, and get automatic
cleanup once `dispose` gets called.
- We also return all the `d.add` calls, so that we can cleanup specific
parts only instead of everything or nothing.
* reimplement the Transition component to be React 18 ready
* wait an additional frame for everything to settle
* update playground examples
* suppressConsoleLogs for RadioGroup components
* update changelog
* keep the `to` classes for a smoother transition
In the next transition we will remove _all_ classes provided and re-add
the once we need.
---
Some extra special thanks:
- Thanks @silvenon for your initial work on the `transition` events in #926
- Thanks @thecrypticace for doing late-night debugging sessions
Co-authored-by: =?UTF-8?q?Matija=20Marohni=C4=87?= <matija.marohnic@gmail.com>
Co-authored-by: Jordan Pittman <jordan@cryptica.me>
* implement `backspace` behaviour in tests
* add `Delete` Key
* implement `nullable` mode on Combobox in single value mode
If you pass a `nullable` prop to the Combobox, then it's possible to
unset the Combobox value by setting it to `null`.
This is triggered by removing all text from the input which will reset
the value itself as well.
* update changelog
* Add combobox to Vue playground
* Update input props
* Wire up input event for changes
This fires changes whenever you type, not just on blur
* Fix playground
* Don't fire input event when pressing escape
The input event is only supposed to fire when the .value of the input changes. Pressing escape doesn't change the value of the input directly so it shouldn't fire.
* Add latest active option render prop
* Add missing active option props to Vue version
* cleanup
* Move test
* Fix error
* Add latest active option to Vue version
* Tweak active option to not re-render
* Remove refocusing on outside mousedown
* Update tests
* Forward refs on combobox to children
* Cleanup code a bit
* Fix lint problems on commit
* Fix typescript issues
* Update changelog
* use esbuild for React instead of tsdx
* remove tsdx from Vue
* use consistent names
* add jest and prettier
* update scripts
* ignore some folders for prettier
* run lint script instead of tsdx lint
* run prettier en-masse
This has a few changes because of the new prettier version.
* bump typescript to latest version
* make typescript happy
* cleanup playground package.json
* make esbuild a dev dependency
* make scripts consistent
* fix husky hooks
* add dedicated watch script
* add `yarn playground-react` and `yarn react-playground` (alias)
This will make sure to run a watcher for the actual @headlessui/react
package, and start a development server in the playground-react package.
* ignore formatting in the .next folder
* run prettier on playground-react package
* setup playground-vue
Still not 100% working, but getting there!
* add playground aliases in @headlessui/vue and @headlessui/react
This allows you to run `yarn react playground` or `yarn vue playground`
from the root.
* add `clean` script
* move examples folder in playground-vue to root
* ensure new lines for consistency in scripts
* fix typescript issue
* fix typescript issues in playgrounds
* make sure to run prettier on everything it can
* run prettier on all files
* improve error output
If you minify the code, then it could happen that the errors are a bit
obscure. This will hardcode the component name to improve errors.
* add the `prettier-plugin-tailwindcss` plugin, party!
* update changelog
* start of combobox
* start with a copy of the Listbox
* WIP
* Add Vue Combobox
* Update Vue version of combobox
* Update tests
* Fix typescript errors in combobox test
* Fix input label
The spec says that the combobox itself is labelled directly by the associated label. The button can however be labelled by the label or itself.
* Add active descendant to combobox/input
* Add listbox role to comobox options
Right now the option list *is* just a listbox. If we were to allow other types in the future this will need to be changable
* Update tests
* move React playground to dedicated package
* add react playground script to root
* ensure we only open/close the combobox when necessary
* ensure export order is correct
* remove leftover pages directory from React package
* Only add aria controls when combobox is open
* add missing next commands
* make typescript happy
* build @headlessui/react before building playground-react
* add empty public folder
This makes vercel happy
* wip
* Add todo
* Update tests
Still more updates to do but some are blocked on implementation
* change default combobox example slightly
* ensure that we sync the input with new state
When the <Combobox value={...} /> changes, then the input should change
as well.
* only sync the value with the input in a single spot
* WIP: object value to string
* WIP
* WIP
* WIP groups
* Add static search filtering to combobox
* Move mouse leave event to combobox
* Fix use in fragments
* Update
* WIP
* make all tests pass for the combobox in React
* remove unnecessary playground item
* remove listbox wip
* only fire change event on inputs
Potentially we also have to do this for all kinds of form inputs. But
this will do for now.
* disable combobox vue tests
* Fix vue typescript errors
* Vue tests WIP
* improve combobox playgrounds a tiny bit
* ensure to lookup the correct value
* make sure that we are using a div instead of a Fragment
* expose `activeItem`
This will be similar to `yourData[activeIndex]`, but in this case the
active option's data. Can probably rename this if necessary!
* Update comments
* Port react tests to Vue
* Vue tests WIP
* WIP
* Rename activeItem to activeOption
* Move display value to input
* Update playgrounds
* Remove static filtering
* Add tests for display value
* WIP Vue Tests
* WIP
* unfocus suite
* Cleanup react accessibility assertions code
* Vue WIP
* Cleanup errors in react interactions test utils
* Update vue implementation
closer :D
* Fix searching
* Update
* Add display value stubs
* Update tests
* move `<Combobox onSearch={} />` to `<Combobox.Input onChange={} />`
* use `useLatestValue` hook
* make `onChange` explicitly required
* remove unused variables
* move `<Combobox @search="" />` to `<ComboboxInput @change="" />`
* use correct event
* use `let` for consistency
* remove unnecessary hidden check
* implement displayValue for Vue
* update playground to reflect changes
* make sure that the activeOptionIndex stays correct
* update changelog
Co-authored-by: Jordan Pittman <jordan@cryptica.me>
* add Alert component
* expose Alert
* rename forgotten FLYOUT to POPOVER
* use PopoverRenderPropArg
* organize imports in a consistent way
* ensure Portals behave as expected
Portals can be nested from a React perspective, however in the DOM they
are rendered as siblings, this is mostly fine.
However, when they are rendered inside a Dialog, the Dialog itself is
marked with `role="modal"` which makes all the other content inert. This
means that rendering Menu.Items in a Portal or an Alert in a portal
makes it non-interactable. Alerts are not even announced.
To fix this, we ensure that we make the `root` of the Portal the actual
dialog. This allows you to still interact with it, because an open modal
is the "root" for the assistive technology.
But there is a catch, a Dialog in a Dialog *can* render as a sibling,
because you force the focus into the new Dialog. So we also ensured that
Dialogs are always rendered in the portal root, and not inside another
Dialog.
* add dialog with alert example
* add internal Description component
* add internal Label component
* add RadioGroup component
* expose RadioGroup
* add RadioGroup example
* ensure to include tha RadioGroup.Option own id
* update changelog
* split documentation
* fix outside click on span inside button works as expected
We have `outside click` behaviour implemented. Whenever the target
element is focusable we make sure that the newly clicked/focused element
stays focused. If it is not a focusable element we will make sure that
the Menu/Listbox button is re-focused so that screenreader users don't
get confused.
This is all fine, but it turns out that when you have a button with a
span, and you click on the span, then the event.target will be that
span. The span itself is not focusable of course, but the button will
get the focus. This results in the Menu/Listbox button being re-focused
which is incorrect.
For this we will introduce a FocusableMode on the `isFocusableElement`,
we will have a `Strict` mode, which means the actual element should be
focusable. And a `Loose` mode, which means that the actual element can
be inside a focusable element. E.g.: A span within a button.
* rename menu to listbox
Copy paste can be fun sometimes
* update changelog
* add Disclosure component
* expose the Disclosure component
* add Disclosure example component page
* temporary fix selector because of JSDOM bug
* add useFocusTrap hook
* add FocusTrap component
* expose FocusTrap
* add Dialog component
* add Dialog example component page
* expose Dialog
* random cleanup
* make TypeScript a bit more happy
* add Switch.Description component for React
* add Switch.Description component for Vue
* ensure focus event is triggered on click when element is focusable
* remove Dialog.Button and Dialog.Panel from accessibility assertions
* add Portal component
* expose Portal
* always render Dialog in a Portal
* add useInertOthers hook
This will allow us to mark everything but the current ref as "inert".
This is important for screenreaders, to ensure that screenreaders and
assistive technology can't interact with other content but the current
ref.
This implementation is not ideal yet. It doesn't take into account that
you can use the hook in 2 different components. For now this is fine,
since we only use it in a Dialog and you should also probably only have
a single Dialog open at a time.
Will improve this in the future!
* use the useInertOthers hook
* add scroll lock to the dialog
* ensure we respect autoFocus on form elements within the Dialog
If we have an autoFocus on an input, that input will receive focus. Once
we try to focus the first focusable element in the Dialog this could be
lead to unwanted behaviour. Therefore we check if the focus already is
within the Dialog, if it is, keep it like that.
* only mark aria-modal when Dialog is open
* add initialFocus option to Dialog, FocusTrap & useFocusTrap
* add tests and a few fixes for the initialFocusRef functionality
* forward ref to underlying Dialog component
* close Dialog when it becomes hidden
Could happen when this is in md:hidden for example
* prevent infinite loop
When we `Tab` in a FocusTrap it will try and focus the Next element. If
we are in a state where none of the elements inside the FocusTrap can be
focused, then we keep trying to focus the next one in line. This results
in an infinite loop...
To mitigate this issue, we check if we looped around, if we did, it
means that we tried all the other focusable elements, therefore we can
stop.
* isIntersecting doesn't work in every scenario
When page is scrollable, when dialog is translated of the page. Now just checking for sizes, which should be enough for md:hiden cases
* render Portal contents in a div
Otherwise you can't use multiple Portal components if you render multiple children inside each Portal
* ensure the props bag is typed
* add getByText and assertContainsActiveElement helpers
* add Popover component
* expose Popover
* add Popover example component page
* add quick checks to prevent useless renders
* drop incorrect close function
* update Changelog
* make test error more readable when comparing DOM nodes
* actually call .focus() on the element
This ensures that the document.activeElement becomes the focused element.
* improve useSyncRefs, because ...refs is *always* different
* add dedicated focus management utilities
* refactor useFocusTrap, use focus management utilities
* fix regression while using outside click
There might be a chance that you didn't even notice this *bug*. The idea
is that when you click outside, that the Menu or Listbox closes. However
there is another step that happens:
1. When you click on a focusable item, keep the focus on that item.
2. When you click on a non-focusable item, move focus back to the
Menu.Button or Listbox.Button
We broke part 2, we never returned to the Menu.Button or Listbox.Button.
This is (might) be important for screenreaders so that they don't "get lost",
because if you click on a non-focusable item, the document.body becomes
the active element. Confusing.
* add outside-click to Dialog itself
* update docs
- Made the use of `const` and `let` consistent
- import required functions and types from 'react' instead of using the
`React.` namespace.
- Added `Expand` type, which can expand complex types to their "final"
result.
- Ensured that we use `as const` for DEFAULT_XXX_TAG where we used a
string. So that we have the type of `div` instead of `string` for
example.
- Used `interface` over `type` where possible. I'm personally more of a
`type` fan. But the TypeScript recommends `interfaces` where possible
because they are faster, yield better error messages and so on.
* add right click option to the interactions
* add tests to ensure right click behaves as expected
Fixes: #142Fixes: #167
* fallback to mouse events if pointer events are not supported
When the pointer events are not supported, then this is essentially a
no-op. When they *are* supported, then both the pointer *and* mouse
events will fire.
To mitigate potential issues, we make sure that state changes (and
potential re-renders) are idempotent (we bail out on potential state
updates when we are already ina certain state).
Fixes: #173Fixes: #167
Browsers. Are. Crazy.
In JSDOM, when you fire an event, you only get that specific event. You
don't get all the magic that the browser gives you. For example, when
you are focused on a button and press to "Tab" then in JSDOM you would
only get a keydown event. However in the browser you get this chain of
events:
1. `keydown` on the current element
2. `blur` on the current element
3. `focus` on the new element
4. `keyup` on the new element
I implemented this "magic", for the `Tab`, `Enter` and `Space` key for
now. Those are the most important currently. `Enter` and `Space` also
trigger `click` events for example.
I also have a "generic" implementation, where a normal press results in:
1. `keydown`
2. `keypress` (in case it has a `charCode` and is "printable", so `alt`
is ignored)
3. `keyup`
I also ensured that the cancelation when you use an
`event.preventDefault()` happens correctly.
Here is a fun summary: https://twitter.com/malfaitrobin/status/1354472678128820234
Press "Enter" on a button
-> keydown, keypress, click, keyup
Press "Space" on a button
-> keydown, keypress, keyup, click
Press "Enter" or "Space" on a button, with event.preventDefault() in the keydown listener
-> keydown, keyup
Press "Enter" on a button, with event.preventDefault() in the keypress listener
-> keydown, keypress, keyup
Press "Space" on a button, with event.preventDefault() in the keypress listener
-> keydown, keypress, keyup, click
* add watch script
* make interactions in Vue and React consistent
* re-work focus restoration
When we click outside of the Menu or Listbox, we want to
restore the focus to the Button, *unless* we clicked on/in an element
that is focusable in itself. For example, when the Menu is open and you
click in an input field, the input field should stay focused. We should
also close the Menu itself at this point.
* add examples with multiple elements
* bump dependencies
* add Switch component
* add tests to verify that we can click the label to toggle the Switch
* use onKeyUp to prevent triggering the onClick in firefox
* make jest monorepo aware
* add @testing-library/jest-dom for custom matchers
This way we can use expect(element).toHaveAttribute(key, value?)
* abstract keys enum
* change type to unknown, because we don't know the return value
* update use-id hook, make it suspense aware
Thanks Reach UI!
* hoist the disposables collection
* add accessbility assertions for listbox
Also made it consistent for the Menu component and simplified some of the assertions
* add use-computed hook
This allows us re-render when hooks change, but also return a value. So this is a combination of useEffect and a useState value.
* add Listbox component
* bump dependencies
* add listbox example
* add lint-staged
This way we will only lint the files that have been staged and ready to be committed instead of the whole codebase
* add missing prevent defaults
* improve tests to verify that we can actually update the value of the listbox
* scroll the active listbox item into view
* small optimization, only focus "Nothing" on pointer leave when we are the active item
We used to always go to "Nothing" on pointer leave. And while this code
doesn't get called often, it *gets* called if you are using your arrow
keys and the mouse pointer is still over the list.
* bump dependencies
Also moved the tailwind dependencies to the root
* fix typo
* drop the default Transition inside the Menu and Listbox components
* update examples to reflect drop of default Transition wrapper
* rename Listbox.{Items,Item} to Listbox.{Options,Option}
Also rename all instances of `item` to `option` in tests and comments
and what have you...
* fix typo
* drop disabled prop, use aria-disabled only