diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3bf2b8b8bb..b440cd54ce 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,7 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased - React]
-- Nothing yet!
+### Added
+
+- Add `Disclosure`, `Disclosure.Button` and `Disclosure.Panel` components ([#220](https://github.com/tailwindlabs/headlessui/pull/220))
+- Add `Dialog`, `Dialog.Overlay`, `Dialog.Tile` and `Dialog.Description` components ([#220](https://github.com/tailwindlabs/headlessui/pull/220))
+- Add `Portal` component ([#220](https://github.com/tailwindlabs/headlessui/pull/220))
+- Add `Switch.Description` component, which adds the `aria-describedby` to the actual Switch ([#220](https://github.com/tailwindlabs/headlessui/pull/220))
+- Add `FocusTrap` component ([#220](https://github.com/tailwindlabs/headlessui/pull/220))
+- Add `Flyout` component ([#220](https://github.com/tailwindlabs/headlessui/pull/220))
## [Unreleased - Vue]
diff --git a/packages/@headlessui-react/README.md b/packages/@headlessui-react/README.md
index 3086f1a51f..0fcaf0cb0d 100644
--- a/packages/@headlessui-react/README.md
+++ b/packages/@headlessui-react/README.md
@@ -31,6 +31,11 @@ _This project is still in early development. New components will be added regula
- [Menu Button (Dropdown)](#menu-button-dropdown)
- [Listbox (Select)](#listbox-select)
- [Switch (Toggle)](#switch-toggle)
+- [Disclosure](#disclosure)
+- [FocusTrap](#focustrap)
+- [Portal](#portal)
+- [Dialog](#dialog)
+- [Popover](#popover)
### Roadmap
@@ -38,10 +43,7 @@ This project is still in early development, but the plan is to build out all of
This includes things like:
-- Modals
- Tabs
-- Slide-overs
-- Mobile menus
- Accordions
...and more in the future.
@@ -287,24 +289,24 @@ function MyComponent({ isShowing }) {
##### Props
-| Prop | Type | Description |
-| ------------- | ------------------------------------- | ------------------------------------------------------------------------------------- |
-| `show` | Boolean | Whether the children should be shown or hidden. |
-| `as` | String Component _(Default: `'div'`)_ | The element or component to render in place of the `Transition` itself. |
-| `appear` | Boolean _(Default: `false`)_ | Whether the transition should run on initial mount. |
-| `unmount` | Boolean _(Default: `true`)_ | Whether the element should be `unmounted` or `hidden` based on the show state. |
-| `enter` | String _(Default: '')_ | Classes to add to the transitioning element during the entire enter phase. |
-| `enterFrom` | String _(Default: '')_ | Classes to add to the transitioning element before the enter phase starts. |
-| `enterTo` | String _(Default: '')_ | Classes to add to the transitioning element immediately after the enter phase starts. |
-| `leave` | String _(Default: '')_ | Classes to add to the transitioning element during the entire leave phase. |
-| `leaveFrom` | String _(Default: '')_ | Classes to add to the transitioning element before the leave phase starts. |
-| `leaveTo` | String _(Default: '')_ | Classes to add to the transitioning element immediately after the leave phase starts. |
-| `beforeEnter` | Function | Callback which is called before we start the enter transition. |
-| `afterEnter` | Function | Callback which is called after we finished the enter transition. |
-| `beforeLeave` | Function | Callback which is called before we start the leave transition. |
-| `afterLeave` | Function | Callback which is called after we finished the leave transition. |
-
-##### Render prop arguments
+| Prop | Type | Default | Description |
+| :------------ | :------------------ | :------ | :------------------------------------------------------------------------------------ |
+| `show` | Boolean | - | Whether the children should be shown or hidden. |
+| `as` | String \| Component | `div` | The element or component to render in place of the `Transition` itself. |
+| `appear` | Boolean | `false` | Whether the transition should run on initial mount. |
+| `unmount` | Boolean | `true` | Whether the element should be `unmounted` or `hidden` based on the show state. |
+| `enter` | String | `''` | Classes to add to the transitioning element during the entire enter phase. |
+| `enterFrom` | String | `''` | Classes to add to the transitioning element before the enter phase starts. |
+| `enterTo` | String | `''` | Classes to add to the transitioning element immediately after the enter phase starts. |
+| `leave` | String | `''` | Classes to add to the transitioning element during the entire leave phase. |
+| `leaveFrom` | String | `''` | Classes to add to the transitioning element before the leave phase starts. |
+| `leaveTo` | String | `''` | Classes to add to the transitioning element immediately after the leave phase starts. |
+| `beforeEnter` | Function | - | Callback which is called before we start the enter transition. |
+| `afterEnter` | Function | - | Callback which is called after we finished the enter transition. |
+| `beforeLeave` | Function | - | Callback which is called before we start the leave transition. |
+| `afterLeave` | Function | - | Callback which is called after we finished the leave transition. |
+
+##### Render prop object
- None
@@ -328,23 +330,23 @@ function MyComponent({ isShowing }) {
##### Props
-| Prop | Type | Description |
-| ------------- | ------------------------------------- | ------------------------------------------------------------------------------------- |
-| `as` | String Component _(Default: `'div'`)_ | The element or component to render in place of the `Transition.Child` itself. |
-| `appear` | Boolean _(Default: `false`)_ | Whether the transition should run on initial mount. |
-| `unmount` | Boolean _(Default: `true`)_ | Whether the element should be `unmounted` or `hidden` based on the show state. |
-| `enter` | String _(Default: '')_ | Classes to add to the transitioning element during the entire enter phase. |
-| `enterFrom` | String _(Default: '')_ | Classes to add to the transitioning element before the enter phase starts. |
-| `enterTo` | String _(Default: '')_ | Classes to add to the transitioning element immediately after the enter phase starts. |
-| `leave` | String _(Default: '')_ | Classes to add to the transitioning element during the entire leave phase. |
-| `leaveFrom` | String _(Default: '')_ | Classes to add to the transitioning element before the leave phase starts. |
-| `leaveTo` | String _(Default: '')_ | Classes to add to the transitioning element immediately after the leave phase starts. |
-| `beforeEnter` | Function | Callback which is called before we start the enter transition. |
-| `afterEnter` | Function | Callback which is called after we finished the enter transition. |
-| `beforeLeave` | Function | Callback which is called before we start the leave transition. |
-| `afterLeave` | Function | Callback which is called after we finished the leave transition. |
-
-##### Render prop arguments
+| Prop | Type | Default | Description |
+| :------------ | :------------------ | :------ | :------------------------------------------------------------------------------------ |
+| `as` | String \| Component | `div` | The element or component to render in place of the `Transition.Child` itself. |
+| `appear` | Boolean | `false` | Whether the transition should run on initial mount. |
+| `unmount` | Boolean | `true` | Whether the element should be `unmounted` or `hidden` based on the show state. |
+| `enter` | String | `''` | Classes to add to the transitioning element during the entire enter phase. |
+| `enterFrom` | String | `''` | Classes to add to the transitioning element before the enter phase starts. |
+| `enterTo` | String | `''` | Classes to add to the transitioning element immediately after the enter phase starts. |
+| `leave` | String | `''` | Classes to add to the transitioning element during the entire leave phase. |
+| `leaveFrom` | String | `''` | Classes to add to the transitioning element before the leave phase starts. |
+| `leaveTo` | String | `''` | Classes to add to the transitioning element immediately after the leave phase starts. |
+| `beforeEnter` | Function | - | Callback which is called before we start the enter transition. |
+| `afterEnter` | Function | - | Callback which is called after we finished the enter transition. |
+| `beforeLeave` | Function | - | Callback which is called before we start the leave transition. |
+| `afterLeave` | Function | - | Callback which is called after we finished the leave transition. |
+
+##### Render prop object
- None
@@ -658,13 +660,13 @@ function MyDropdown() {
##### Props
| Prop | Type | Default | Description |
-| ---- | ------------------- | --------------------------------------- | ----------------------------------------------------- |
+| :--- | :------------------ | :-------------------------------------- | :---------------------------------------------------- |
| `as` | String \| Component | `React.Fragment` _(no wrapper element_) | The element or component the `Menu` should render as. |
##### Render prop object
| Prop | Type | Description |
-| ------ | ------- | -------------------------------- |
+| :----- | :------ | :------------------------------- |
| `open` | Boolean | Whether or not the menu is open. |
#### Menu.Button
@@ -683,13 +685,13 @@ function MyDropdown() {
##### Props
| Prop | Type | Default | Description |
-| ---- | ------------------- | -------- | ------------------------------------------------------------ |
+| :--- | :------------------ | :------- | :----------------------------------------------------------- |
| `as` | String \| Component | `button` | The element or component the `Menu.Button` should render as. |
##### Render prop object
| Prop | Type | Description |
-| ------ | ------- | -------------------------------- |
+| :----- | :------ | :------------------------------- |
| `open` | Boolean | Whether or not the menu is open. |
#### Menu.Items
@@ -704,7 +706,7 @@ function MyDropdown() {
##### Props
| Prop | Type | Default | Description |
-| --------- | ------------------- | ------- | --------------------------------------------------------------------------------- |
+| :-------- | :------------------ | :------ | :-------------------------------------------------------------------------------- |
| `as` | String \| Component | `div` | The element or component the `Menu.Items` should render as. |
| `static` | Boolean | `false` | Whether the element should ignore the internally managed open/closed state. |
| `unmount` | Boolean | `true` | Whether the element should be unmounted or hidden based on the open/closed state. |
@@ -714,7 +716,7 @@ function MyDropdown() {
##### Render prop object
| Prop | Type | Description |
-| ------ | ------- | -------------------------------- |
+| :----- | :------ | :------------------------------- |
| `open` | Boolean | Whether or not the menu is open. |
#### Menu.Item
@@ -735,14 +737,14 @@ function MyDropdown() {
##### Props
| Prop | Type | Default | Description |
-| ---------- | ------------------- | --------------------------------------- | ------------------------------------------------------------------------------------- |
+| :--------- | :------------------ | :-------------------------------------- | :------------------------------------------------------------------------------------ |
| `as` | String \| Component | `React.Fragment` _(no wrapper element)_ | The element or component the `Menu.Item` should render as. |
| `disabled` | Boolean | `false` | Whether or not the item should be disabled for keyboard navigation and ARIA purposes. |
##### Render prop object
| Prop | Type | Description |
-| ---------- | ------- | ---------------------------------------------------------------------------------- |
+| :--------- | :------ | :--------------------------------------------------------------------------------- |
| `active` | Boolean | Whether or not the item is the active/focused item in the list. |
| `disabled` | Boolean | Whether or not the item is the disabled for keyboard navigation and ARIA purposes. |
@@ -1152,16 +1154,16 @@ function MyListbox() {
##### Props
| Prop | Type | Default | Description |
-| ---------- | ------------------- | --------------------------------------- | -------------------------------------------------------- |
+| :--------- | :------------------ | :-------------------------------------- | :------------------------------------------------------- |
| `as` | String \| Component | `React.Fragment` _(no wrapper element_) | The element or component the `Listbox` should render as. |
| `disabled` | Boolean | `false` | Enable/Disable the `Listbox` component. |
-| `value` | `T` | | The selected value. |
-| `onChange` | `(value: T): void` | | The function to call when a new option is selected. |
+| `value` | `T` | - | The selected value. |
+| `onChange` | `(value: T): void` | - | The function to call when a new option is selected. |
##### Render prop object
| Prop | Type | Description |
-| ---------- | ------- | --------------------------------------- |
+| :--------- | :------ | :-------------------------------------- |
| `open` | Boolean | Whether or not the listbox is open. |
| `disabled` | Boolean | Whether or not the listbox is disabled. |
@@ -1181,13 +1183,13 @@ function MyListbox() {
##### Props
| Prop | Type | Default | Description |
-| ---- | ------------------- | -------- | --------------------------------------------------------------- |
+| :--- | :------------------ | :------- | :-------------------------------------------------------------- |
| `as` | String \| Component | `button` | The element or component the `Listbox.Button` should render as. |
##### Render prop object
| Prop | Type | Description |
-| ---------- | ------- | --------------------------------------- |
+| :--------- | :------ | :-------------------------------------- |
| `open` | Boolean | Whether or not the listbox is open. |
| `disabled` | Boolean | Whether or not the listbox is disabled. |
@@ -1200,13 +1202,13 @@ function MyListbox() {
##### Props
| Prop | Type | Default | Description |
-| ---- | ------------------- | ------- | -------------------------------------------------------------- |
+| :--- | :------------------ | :------ | :------------------------------------------------------------- |
| `as` | String \| Component | `label` | The element or component the `Listbox.Label` should render as. |
##### Render prop object
| Prop | Type | Description |
-| ---------- | ------- | --------------------------------------- |
+| :--------- | :------ | :-------------------------------------- |
| `open` | Boolean | Whether or not the listbox is open. |
| `disabled` | Boolean | Whether or not the listbox is disabled. |
@@ -1222,7 +1224,7 @@ function MyListbox() {
##### Props
| Prop | Type | Default | Description |
-| --------- | ------------------- | ------- | --------------------------------------------------------------------------------- |
+| :-------- | :------------------ | :------ | :-------------------------------------------------------------------------------- |
| `as` | String \| Component | `ul` | The element or component the `Listbox.Options` should render as. |
| `static` | Boolean | `false` | Whether the element should ignore the internally managed open/closed state. |
| `unmount` | Boolean | `true` | Whether the element should be unmounted or hidden based on the open/closed state. |
@@ -1232,7 +1234,7 @@ function MyListbox() {
##### Render prop object
| Prop | Type | Description |
-| ------ | ------- | ----------------------------------- |
+| :----- | :------ | :---------------------------------- |
| `open` | Boolean | Whether or not the listbox is open. |
#### Listbox.Option
@@ -1244,15 +1246,15 @@ function MyListbox() {
##### Props
| Prop | Type | Default | Description |
-| ---------- | ------------------- | ------- | --------------------------------------------------------------------------------------- |
+| :--------- | :------------------ | :------ | :-------------------------------------------------------------------------------------- |
| `as` | String \| Component | `li` | The element or component the `Listbox.Option` should render as. |
-| `value` | `T` | | The option value. |
+| `value` | `T` | - | The option value. |
| `disabled` | Boolean | `false` | Whether or not the option should be disabled for keyboard navigation and ARIA purposes. |
##### Render prop object
| Prop | Type | Description |
-| ---------- | ------- | ------------------------------------------------------------------------------------ |
+| :--------- | :------ | :----------------------------------------------------------------------------------- |
| `active` | Boolean | Whether or not the option is the active/focused option in the list. |
| `selected` | Boolean | Whether or not the option is the selected option in the list. |
| `disabled` | Boolean | Whether or not the option is the disabled for keyboard navigation and ARIA purposes. |
@@ -1345,15 +1347,15 @@ function NotificationsToggle() {
##### Props
| Prop | Type | Default | Description |
-| ---------- | ------------------------ | -------- | ------------------------------------------------------- |
+| :--------- | :----------------------- | :------- | :------------------------------------------------------ |
| `as` | String \| Component | `button` | The element or component the `Switch` should render as. |
-| `checked` | Boolean | | Whether or not the switch is checked. |
-| `onChange` | `(value: boolean): void` | | The function to call when the switch is toggled. |
+| `checked` | Boolean | - | Whether or not the switch is checked. |
+| `onChange` | `(value: boolean): void` | - | The function to call when the switch is toggled. |
##### Render prop object
| Prop | Type | Description |
-| --------- | ------- | ------------------------------------- |
+| :-------- | :------ | :------------------------------------ |
| `checked` | Boolean | Whether or not the switch is checked. |
#### Switch.Label
@@ -1370,9 +1372,26 @@ function NotificationsToggle() {
##### Props
| Prop | Type | Default | Description |
-| ---- | ------------------- | ------- | ------------------------------------------------------------- |
+| :--- | :------------------ | :------ | :------------------------------------------------------------ |
| `as` | String \| Component | `label` | The element or component the `Switch.Label` should render as. |
+#### Switch.Description
+
+```jsx
+
+ Enable notifications
+
+ {/* ... */}
+
+
+```
+
+##### Props
+
+| Prop | Type | Default | Description |
+| :--- | :------------------ | :------ | :------------------------------------------------------------------ |
+| `as` | String \| Component | `label` | The element or component the `Switch.Description` should render as. |
+
#### Switch.Group
```jsx
@@ -1387,5 +1406,459 @@ function NotificationsToggle() {
##### Props
| Prop | Type | Default | Description |
-| ---- | ------------------- | --------------------------------------- | ------------------------------------------------------------- |
+| :--- | :------------------ | :-------------------------------------- | :------------------------------------------------------------ |
| `as` | String \| Component | `React.Fragment` _(no wrapper element)_ | The element or component the `Switch.Group` should render as. |
+
+## Disclosure
+
+A component for showing/hiding content.
+
+- [Basic example](#basic-example-4)
+- [Component API](#component-api-4)
+
+### Basic example
+
+```jsx
+
+ Toggle
+ Contents
+
+```
+
+### Component API
+
+#### Disclosure
+
+```jsx
+
+ Toggle
+ Contents
+
+```
+
+##### Props
+
+| Prop | Type | Default | Description |
+| :--- | :------------------ | :-------------------------------------- | :---------------------------------------------------------- |
+| `as` | String \| Component | `React.Fragment` _(no wrapper element_) | The element or component the `Disclosure` should render as. |
+
+##### Render prop object
+
+| Prop | Type | Description |
+| :----- | :------ | :------------------------------------- |
+| `open` | Boolean | Whether or not the disclosure is open. |
+
+#### Disclosure.Button
+
+##### Props
+
+| Prop | Type | Default | Description |
+| :--- | :------------------ | :------- | :----------------------------------------------------------------- |
+| `as` | String \| Component | `button` | The element or component the `Disclosure.Button` should render as. |
+
+##### Render prop object
+
+| Prop | Type | Description |
+| :----- | :------ | :------------------------------------- |
+| `open` | Boolean | Whether or not the disclosure is open. |
+
+#### Disclosure.Panel
+
+##### Props
+
+| Prop | Type | Default | Description |
+| :-------- | :------------------ | :------ | :-------------------------------------------------------------------------------- |
+| `as` | String \| Component | `div` | The element or component the `Disclosure.Panel` should render as. |
+| `static` | Boolean | `false` | Whether the element should ignore the internally managed open/closed state. |
+| `unmount` | Boolean | `true` | Whether the element should be unmounted or hidden based on the open/closed state. |
+
+> **note**: `static` and `unmount` can not be used at the same time. You will get a TypeScript error if you try to do it.
+
+##### Render prop object
+
+| Prop | Type | Description |
+| :----- | :------ | :------------------------------------- |
+| `open` | Boolean | Whether or not the disclosure is open. |
+
+---
+
+## FocusTrap
+
+- [Basic example](#basic-example-5)
+- [Component API](#component-api-5)
+
+A component for making sure that you can't Tab out of the contents of this
+component.
+
+Focus strategy:
+
+- An `initialFocus` prop can be passed in, this is a `ref` object, which is a ref to the element that should receive initial focus.
+- If an input element exists with an `autoFocus` prop, it will receive initial focus.
+- If none of those exists, it will try and focus the first focusable element.
+- If that doesn't exist, it will throw an error.
+
+Once the `FocusTrap` will unmount, the focus will be restored to the element that was focused _before_ the `FocusTrap` was rendered.
+
+### Basic example
+
+```jsx
+
+
+
+```
+
+### Component API
+
+#### FocusTrap
+
+```jsx
+
+
+
+```
+
+##### Props
+
+| Prop | Type | Default | Description |
+| :------------- | :--------------------- | :---------- | :--------------------------------------------------------- |
+| `as` | String \| Component | `div` | The element or component the `FocusTrap` should render as. |
+| `initialFocus` | React.MutableRefObject | `undefined` | A ref to an element that should receive focus first. |
+
+---
+
+## Portal
+
+- [Basic example](#basic-example-6)
+- [Component API](#component-api-6)
+
+A component for rendering your contents within a Portal (at the end of `document.body`).
+
+### Basic example
+
+```jsx
+
+ This will be rendered inside a Portal, at the end of `document.body`
+
+```
+
+### Component API
+
+#### Portal
+
+```jsx
+
+ This will be rendered inside a Portal, at the end of `document.body`
+
+```
+
+##### Props
+
+| Prop | Type | Default | Description |
+| :--- | :------------------ | :-------------------------------------- | :------------------------------------------------------ |
+| `as` | String \| Component | `React.Fragment` _(no wrapper element_) | The element or component the `Portal` should render as. |
+
+##### Render prop object
+
+- None
+
+---
+
+## Dialog
+
+- [Basic example](#basic-example-7)
+- [Component API](#component-api-7)
+
+This component can be used to render content inside a Dialog/Modal. This contains a ton of features:
+
+1. Renders inside a `Portal`
+2. Controlled component
+3. Uses `FocusTrap` with its features (Focus first focusable element, `autoFocus` or `initialFocus` ref)
+4. Adds a scroll lock
+5. Prevents content jumps by faking your scrollbar width
+6. Marks other elements as `inert` (hides other elements from screen readers)
+7. Closes on `escape`
+8. Closes on click outside
+9. Once the Dialog becomes hidden (e.g.: `md:hidden`) it will also trigger the `onClose`
+
+### Basic example
+
+```jsx
+function Example() {
+ let [isOpen, setIsOpen] = useState(true)
+
+ return (
+
+
+
+ Deactivate account
+ This will permanently deactivate your account
+
+
+ Are you sure you want to deactivate your account? All of your data will be permanently
+ removed. This action cannot be undone.
+
+
+ setIsOpen(false)}>Deactivate
+ setIsOpen(false)}>Cancel
+
+ )
+}
+```
+
+### Component API
+
+#### Dialog
+
+```jsx
+function Example() {
+ let [isOpen, setIsOpen] = useState(true)
+
+ return (
+
+
+
+ Deactivate account
+ This will permanently deactivate your account
+
+
+ Are you sure you want to deactivate your account? All of your data will be permanently
+ removed. This action cannot be undone.
+
+
+ setIsOpen(false)}>Deactivate
+ setIsOpen(false)}>Cancel
+
+ )
+}
+```
+
+##### Props
+
+| Prop | Type | Default | Description |
+| :------------- | :--------------------- | :------ | :------------------------------------------------------------------------------------------------------------------------------- |
+| `open` | Boolean | / | Wether the `Dialog` is open or not. |
+| `onClose` | Function | / | Called when the `Dialog` should close. For convenience we pass in a `onClose(false)` so that you can use: `onClose={setIsOpen}`. |
+| `initialFocus` | React.MutableRefObject | / | A ref to an element that should receive focus first. |
+| `as` | String \| Component | `div` | The element or component the `Dialog` should render as. |
+| `static` | Boolean | `false` | Whether the element should ignore the internally managed open/closed state. |
+| `unmount` | Boolean | `true` | Whether the element should be unmounted or hidden based on the open/closed state. |
+
+> **note**: `static` and `unmount` can not be used at the same time. You will get a TypeScript error if you try to do it.
+
+##### Render prop object
+
+| Prop | Type | Description |
+| :----- | :------ | :--------------------------------- |
+| `open` | Boolean | Whether or not the dialog is open. |
+
+#### Dialog.Overlay
+
+This can be used to create an overlay for your Dialog component. Clicking on the overlay will close the Dialog.
+
+##### Props
+
+| Prop | Type | Default | Description |
+| :--- | :------------------ | :------ | :-------------------------------------------------------------- |
+| `as` | String \| Component | `div` | The element or component the `Dialog.Overlay` should render as. |
+
+##### Render prop object
+
+| Prop | Type | Description |
+| :----- | :------ | :------------------------------------- |
+| `open` | Boolean | Whether or not the disclosure is open. |
+
+#### Dialog.Title
+
+This is the title for your Dialog. When this is used, it will set the `aria-labelledby` on the Dialog.
+
+##### Props
+
+| Prop | Type | Default | Description |
+| :--- | :------------------ | :------ | :------------------------------------------------------------ |
+| `as` | String \| Component | `h2` | The element or component the `Dialog.Title` should render as. |
+
+##### Render prop object
+
+| Prop | Type | Description |
+| :----- | :------ | :------------------------------------- |
+| `open` | Boolean | Whether or not the disclosure is open. |
+
+#### Dialog.Description
+
+This is the description for your Dialog. When this is used, it will set the `aria-describedby` on the Dialog.
+
+##### Props
+
+| Prop | Type | Default | Description |
+| :--- | :------------------ | :------ | :------------------------------------------------------------------ |
+| `as` | String \| Component | `p` | The element or component the `Dialog.Description` should render as. |
+
+##### Render prop object
+
+| Prop | Type | Description |
+| :----- | :------ | :------------------------------------- |
+| `open` | Boolean | Whether or not the disclosure is open. |
+
+---
+
+## Popover
+
+- [Basic example](#basic-example-8)
+- [Component API](#component-api-8)
+
+This component can be used for navigation menu's, mobile menu's and flyout menu's.
+
+### Basic example
+
+```jsx
+
+
+ Solutions
+
+ Analytics
+ Engagement
+ Security
+ Integrations
+ Automations
+
+
+
+ Pricing
+ Docs
+
+
+ More
+
+ Help Center
+ Guides
+ Events
+ Security
+
+
+
+```
+
+### Component API
+
+#### Popover
+
+```jsx
+
+
+ Solutions
+
+ Analytics
+ Engagement
+ Security
+ Integrations
+ Automations
+
+
+
+ Pricing
+ Docs
+
+
+ More
+
+ Help Center
+ Guides
+ Events
+ Security
+
+
+
+```
+
+##### Props
+
+| Prop | Type | Default | Description |
+| :--- | :------------------ | :------ | :------------------------------------------------------- |
+| `as` | String \| Component | `div` | The element or component the `Popover` should render as. |
+
+##### Render prop object
+
+| Prop | Type | Description |
+| :----- | :------ | :--------------------------------- |
+| `open` | Boolean | Whether or not the dialog is open. |
+
+#### Popover.Overlay
+
+This can be used to create an overlay for your Popover component. Clicking on the overlay will close the Popover.
+
+##### Props
+
+| Prop | Type | Default | Description |
+| :--- | :------------------ | :------ | :--------------------------------------------------------------- |
+| `as` | String \| Component | `div` | The element or component the `Popover.Overlay` should render as. |
+
+##### Render prop object
+
+| Prop | Type | Description |
+| :----- | :------ | :------------------------------------- |
+| `open` | Boolean | Whether or not the disclosure is open. |
+
+#### Popover.Button
+
+This is the trigger component to open a Popover. You can also use this
+`Popover.Button` component inside a `Popover.Panel`, if you do so, then it will
+behave as a `close` button. We will also make sure to provide the correct
+`aria-*` attributes onto the button.
+
+##### Props
+
+| Prop | Type | Default | Description |
+| :--- | :------------------ | :------- | :-------------------------------------------------------------- |
+| `as` | String \| Component | `button` | The element or component the `Popover.Button` should render as. |
+
+##### Render prop object
+
+| Prop | Type | Description |
+| :----- | :------ | :------------------------------------- |
+| `open` | Boolean | Whether or not the disclosure is open. |
+
+#### Popover.Panel
+
+This component contains the contents of your Popover.
+
+##### Props
+
+| Prop | Type | Default | Description |
+| :-------- | :------------------ | :------ | :------------------------------------------------------------------------------------------------------------------------------------------ |
+| `as` | String \| Component | `div` | The element or component the `Popover.Panel` should render as. |
+| `focus` | Boolean | `false` | This will force focus inside the `Popover.Panel` when the `Popover` is open. It will also close the `Popover` if focus left this component. |
+| `static` | Boolean | `false` | Whether the element should ignore the internally managed open/closed state. |
+| `unmount` | Boolean | `true` | Whether the element should be unmounted or hidden based on the open/closed state. |
+
+> **note**: `static` and `unmount` can not be used at the same time. You will get a TypeScript error if you try to do it.
+
+##### Render prop object
+
+| Prop | Type | Description |
+| :----- | :------ | :------------------------------------- |
+| `open` | Boolean | Whether or not the disclosure is open. |
+
+#### Popover.Group
+
+This allows you to wrap multiple elements and Popover's inside a group.
+
+- When you tab out of a `Popover.Panel`, it will focus the next `Popover.Button` in line.
+- If focus left the `Popover.Group` it will close all the `Popover`'s.
+
+##### Props
+
+| Prop | Type | Default | Description |
+| :--- | :------------------ | :------ | :------------------------------------------------------------- |
+| `as` | String \| Component | `div` | The element or component the `Popover.Group` should render as. |
+
+##### Render prop object
+
+- None
diff --git a/packages/@headlessui-react/pages/dialog/dialog.tsx b/packages/@headlessui-react/pages/dialog/dialog.tsx
new file mode 100644
index 0000000000..539b62c9c6
--- /dev/null
+++ b/packages/@headlessui-react/pages/dialog/dialog.tsx
@@ -0,0 +1,110 @@
+import React, { useState, Fragment } from 'react'
+import { Dialog, Transition } from '@headlessui/react'
+
+export default function Home() {
+ let [isOpen, setIsOpen] = useState(false)
+
+ return (
+ <>
+ setIsOpen(v => !v)}
+ className="m-12 px-4 py-2 text-base font-medium leading-6 text-gray-700 transition duration-150 ease-in-out bg-white border border-gray-300 rounded-md shadow-sm hover:text-gray-500 focus:outline-none focus:border-blue-300 focus:shadow-outline-blue sm:text-sm sm:leading-5"
+ >
+ Toggle!
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* This element is to trick the browser into centering the modal contents. */}
+
+
+
+
+
+
+
+ {/* Heroicon name: exclamation */}
+
+
+
+
+
+
+ Deactivate account
+
+
+
+ Are you sure you want to deactivate your account? All of your data will
+ be permanently removed. This action cannot be undone.
+
+
+
+
+
+
+ setIsOpen(false)}
+ className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-600 text-base font-medium text-white hover:bg-red-700 focus:outline-none focus:shadow-outline-red sm:ml-3 sm:w-auto sm:text-sm"
+ >
+ Deactivate
+
+ setIsOpen(false)}
+ className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:text-gray-500 focus:outline-none focus:shadow-outline-indigo sm:mt-0 sm:w-auto sm:text-sm"
+ >
+ Cancel
+
+
+
+
+
+
+
+
+ >
+ )
+}
diff --git a/packages/@headlessui-react/pages/disclosure/disclosure.tsx b/packages/@headlessui-react/pages/disclosure/disclosure.tsx
new file mode 100644
index 0000000000..8fa9335804
--- /dev/null
+++ b/packages/@headlessui-react/pages/disclosure/disclosure.tsx
@@ -0,0 +1,32 @@
+import React from 'react'
+import { Disclosure, Transition } from '@headlessui/react'
+
+export default function Home() {
+ return (
+
+
+
+ {({ open }) => (
+ <>
+ Trigger
+
+
+
+ Content
+
+
+ >
+ )}
+
+
+
+ )
+}
diff --git a/packages/@headlessui-react/pages/popover/popover.tsx b/packages/@headlessui-react/pages/popover/popover.tsx
new file mode 100644
index 0000000000..1b2876a49c
--- /dev/null
+++ b/packages/@headlessui-react/pages/popover/popover.tsx
@@ -0,0 +1,101 @@
+import React, { forwardRef } from 'react'
+import { Popover, Portal } from '@headlessui/react'
+import { usePopper } from '../../playground-utils/hooks/use-popper'
+import { PropsOf as Props } from '../../src/types'
+
+let Button = forwardRef((props: Props<'button'>, ref) => {
+ return (
+
+ )
+})
+
+function Link(props: Props<'a'>) {
+ return (
+
+ {props.children}
+
+ )
+}
+
+export default function Home() {
+ let options = {
+ placement: 'bottom-start',
+ strategy: 'fixed',
+ modifiers: [],
+ }
+
+ let [reference1, popper1] = usePopper(options)
+ let [reference2, popper2] = usePopper(options)
+
+ let links = ['First', 'Second', 'Third', 'Fourth']
+
+ return (
+
+
Previous
+
+
+
+ Normal
+
+ {links.map((link, i) => (
+
+ Normal - {link}
+
+ ))}
+
+
+
+
+ Focus
+
+ {links.map((link, i) => (
+ Focus - {link}
+ ))}
+
+
+
+
+ Portal
+
+
+ {links.map(link => (
+ Portal - {link}
+ ))}
+
+
+
+
+
+ Focus in Portal
+
+
+ {links.map(link => (
+ Focus in Portal - {link}
+ ))}
+
+
+
+
+
+
Next
+
+ )
+}
diff --git a/packages/@headlessui-react/src/components/dialog/dialog.test.tsx b/packages/@headlessui-react/src/components/dialog/dialog.test.tsx
new file mode 100644
index 0000000000..26d47950e2
--- /dev/null
+++ b/packages/@headlessui-react/src/components/dialog/dialog.test.tsx
@@ -0,0 +1,477 @@
+import React, { createElement, useState } from 'react'
+import { render } from '@testing-library/react'
+
+import { Dialog } from './dialog'
+import { suppressConsoleLogs } from '../../test-utils/suppress-console-logs'
+import {
+ DialogState,
+ assertDialog,
+ assertDialogDescription,
+ assertDialogOverlay,
+ assertDialogTitle,
+ getDialog,
+ getDialogOverlay,
+ getByText,
+ assertActiveElement,
+} from '../../test-utils/accessibility-assertions'
+import { click, press, Keys } from '../../test-utils/interactions'
+import { Props } from '../../types'
+
+jest.mock('../../hooks/use-id')
+
+// @ts-expect-error
+global.IntersectionObserver = class FakeIntersectionObserver {
+ observe() {}
+ disconnect() {}
+}
+
+afterAll(() => jest.restoreAllMocks())
+
+function TabSentinel(props: Props<'div'>) {
+ return
+}
+
+describe('Safe guards', () => {
+ it.each([
+ ['Dialog.Overlay', Dialog.Overlay],
+ ['Dialog.Title', Dialog.Title],
+ ['Dialog.Description', Dialog.Description],
+ ])(
+ 'should error when we are using a <%s /> without a parent ',
+ suppressConsoleLogs((name, Component) => {
+ expect(() => render(createElement(Component))).toThrowError(
+ `<${name} /> is missing a parent component.`
+ )
+ expect.hasAssertions()
+ })
+ )
+
+ it(
+ 'should be possible to render a Dialog without crashing',
+ suppressConsoleLogs(async () => {
+ render(
+
+ Trigger
+
+
+ Contents
+
+
+ )
+
+ assertDialog({
+ state: DialogState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-dialog-1' },
+ })
+ })
+ )
+})
+
+describe('Rendering', () => {
+ describe('Dialog', () => {
+ it(
+ 'should complain when the `open` and `onClose` prop are missing',
+ suppressConsoleLogs(async () => {
+ // @ts-expect-error
+ expect(() => render( )).toThrowErrorMatchingInlineSnapshot(
+ `"You have to provide an \`open\` and an \`onClose\` prop to the \`Dialog\` component."`
+ )
+ expect.hasAssertions()
+ })
+ )
+
+ it(
+ 'should complain when an `open` prop is provided without an `onClose` prop',
+ suppressConsoleLogs(async () => {
+ // @ts-expect-error
+ expect(() => render( )).toThrowErrorMatchingInlineSnapshot(
+ `"You provided an \`open\` prop to the \`Dialog\`, but forgot an \`onClose\` prop."`
+ )
+ expect.hasAssertions()
+ })
+ )
+
+ it(
+ 'should complain when an `onClose` prop is provided without an `open` prop',
+ suppressConsoleLogs(async () => {
+ expect(() =>
+ // @ts-expect-error
+ render( {}} />)
+ ).toThrowErrorMatchingInlineSnapshot(
+ `"You provided an \`onClose\` prop to the \`Dialog\`, but forgot an \`open\` prop."`
+ )
+ expect.hasAssertions()
+ })
+ )
+
+ it(
+ 'should complain when an `open` prop is not a boolean',
+ suppressConsoleLogs(async () => {
+ expect(() =>
+ // @ts-expect-error
+ render( )
+ ).toThrowErrorMatchingInlineSnapshot(
+ `"You provided an \`open\` prop to the \`Dialog\`, but the value is not a boolean. Received: null"`
+ )
+ expect.hasAssertions()
+ })
+ )
+
+ it(
+ 'should complain when an `onClose` prop is not a function',
+ suppressConsoleLogs(async () => {
+ expect(() =>
+ // @ts-expect-error
+ render( )
+ ).toThrowErrorMatchingInlineSnapshot(
+ `"You provided an \`onClose\` prop to the \`Dialog\`, but the value is not a function. Received: null"`
+ )
+ expect.hasAssertions()
+ })
+ )
+
+ it(
+ 'should be possible to render a Dialog using a render prop',
+ suppressConsoleLogs(async () => {
+ function Example() {
+ let [isOpen, setIsOpen] = useState(false)
+
+ return (
+ <>
+ setIsOpen(true)}>
+ Trigger
+
+
+ {({ open }) => (
+ <>
+ Dialog is: {open ? 'open' : 'closed'}
+
+ >
+ )}
+
+ >
+ )
+ }
+ render( )
+
+ assertDialog({ state: DialogState.InvisibleUnmounted })
+
+ await click(document.getElementById('trigger'))
+
+ assertDialog({ state: DialogState.Visible, textContent: 'Dialog is: open' })
+ })
+ )
+
+ it('should be possible to always render the Dialog if we provide it a `static` prop', () => {
+ let focusCounter = jest.fn()
+ render(
+ <>
+ Trigger
+
+ Contents
+
+
+ >
+ )
+
+ // Let's verify that the Dialog is already there
+ expect(getDialog()).not.toBe(null)
+ expect(focusCounter).toHaveBeenCalledTimes(1)
+ })
+
+ it('should be possible to use a different render strategy for the Dialog', async () => {
+ let focusCounter = jest.fn()
+ function Example() {
+ let [isOpen, setIsOpen] = useState(false)
+
+ return (
+ <>
+ setIsOpen(v => !v)}>
+ Trigger
+
+
+
+
+ >
+ )
+ }
+ render( )
+
+ assertDialog({ state: DialogState.InvisibleHidden })
+ expect(focusCounter).toHaveBeenCalledTimes(0)
+
+ // Let's open the Dialog, to see if it is not hidden anymore
+ await click(document.getElementById('trigger'))
+ expect(focusCounter).toHaveBeenCalledTimes(1)
+
+ assertDialog({ state: DialogState.Visible })
+
+ // Let's close the Dialog
+ await press(Keys.Escape)
+ expect(focusCounter).toHaveBeenCalledTimes(1)
+
+ assertDialog({ state: DialogState.InvisibleHidden })
+ })
+
+ it(
+ 'should add a scroll lock to the html tag',
+ suppressConsoleLogs(async () => {
+ function Example() {
+ let [isOpen, setIsOpen] = useState(false)
+
+ return (
+ <>
+ setIsOpen(v => !v)}>
+ Trigger
+
+
+
+
+
+
+
+ >
+ )
+ }
+
+ render( )
+
+ // No overflow yet
+ expect(document.documentElement.style.overflow).toBe('')
+
+ let btn = document.getElementById('trigger')
+
+ // Open the dialog
+ await click(btn)
+
+ // Expect overflow
+ expect(document.documentElement.style.overflow).toBe('hidden')
+ })
+ )
+ })
+
+ describe('Dialog.Overlay', () => {
+ it(
+ 'should be possible to render Dialog.Overlay using a render prop',
+ suppressConsoleLogs(async () => {
+ let overlay = jest.fn().mockReturnValue(null)
+ function Example() {
+ let [isOpen, setIsOpen] = useState(false)
+ return (
+ <>
+ setIsOpen(v => !v)}>
+ Trigger
+
+
+ {overlay}
+
+
+ >
+ )
+ }
+
+ render( )
+
+ assertDialogOverlay({
+ state: DialogState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-dialog-overlay-2' },
+ })
+
+ await click(document.getElementById('trigger'))
+
+ assertDialogOverlay({
+ state: DialogState.Visible,
+ attributes: { id: 'headlessui-dialog-overlay-2' },
+ })
+ expect(overlay).toHaveBeenCalledWith({ open: true })
+ })
+ )
+ })
+
+ describe('Dialog.Title', () => {
+ it(
+ 'should be possible to render Dialog.Title using a render prop',
+ suppressConsoleLogs(async () => {
+ render(
+
+ Deactivate account
+
+
+ )
+
+ assertDialog({
+ state: DialogState.Visible,
+ attributes: { id: 'headlessui-dialog-1' },
+ })
+ assertDialogTitle({ state: DialogState.Visible })
+ })
+ )
+ })
+
+ describe('Dialog.Description', () => {
+ it(
+ 'should be possible to render Dialog.Description using a render prop',
+ suppressConsoleLogs(async () => {
+ render(
+
+ Deactivate account
+
+
+ )
+
+ assertDialog({
+ state: DialogState.Visible,
+ attributes: { id: 'headlessui-dialog-1' },
+ })
+ assertDialogDescription({ state: DialogState.Visible })
+ })
+ )
+ })
+})
+
+describe('Keyboard interactions', () => {
+ describe('`Escape` key', () => {
+ it(
+ 'should be possible to close the dialog with Escape',
+ suppressConsoleLogs(async () => {
+ function Example() {
+ let [isOpen, setIsOpen] = useState(false)
+ return (
+ <>
+ setIsOpen(v => !v)}>
+ Trigger
+
+
+ Contents
+
+
+ >
+ )
+ }
+ render( )
+
+ assertDialog({ state: DialogState.InvisibleUnmounted })
+
+ // Open dialog
+ await click(document.getElementById('trigger'))
+
+ // Verify it is open
+ assertDialog({
+ state: DialogState.Visible,
+ attributes: { id: 'headlessui-dialog-1' },
+ })
+
+ // Close dialog
+ await press(Keys.Escape)
+
+ // Verify it is close
+ assertDialog({ state: DialogState.InvisibleUnmounted })
+ })
+ )
+ })
+})
+
+describe('Mouse interactions', () => {
+ it(
+ 'should be possible to close a Dialog using a click on the Dialog.Overlay',
+ suppressConsoleLogs(async () => {
+ function Example() {
+ let [isOpen, setIsOpen] = useState(false)
+ return (
+ <>
+ setIsOpen(v => !v)}>
+ Trigger
+
+
+
+ Contents
+
+
+ >
+ )
+ }
+ render( )
+
+ // Open dialog
+ await click(document.getElementById('trigger'))
+
+ // Verify it is open
+ assertDialog({ state: DialogState.Visible })
+
+ // Click to close
+ await click(getDialogOverlay())
+
+ // Verify it is closed
+ assertDialog({ state: DialogState.InvisibleUnmounted })
+ })
+ )
+
+ it(
+ 'should be possible to close the dialog, and re-focus the button when we click outside on the body element',
+ suppressConsoleLogs(async () => {
+ function Example() {
+ let [isOpen, setIsOpen] = useState(false)
+ return (
+ <>
+ setIsOpen(v => !v)}>Trigger
+
+ Contents
+
+
+ >
+ )
+ }
+ render( )
+
+ // Open dialog
+ await click(getByText('Trigger'))
+
+ // Verify it is open
+ assertDialog({ state: DialogState.Visible })
+
+ // Click the body to close
+ await click(document.body)
+
+ // Verify it is closed
+ assertDialog({ state: DialogState.InvisibleUnmounted })
+
+ // Verify the button is focused
+ assertActiveElement(getByText('Trigger'))
+ })
+ )
+
+ it(
+ 'should be possible to close the dialog, and keep focus on the focusable element',
+ suppressConsoleLogs(async () => {
+ function Example() {
+ let [isOpen, setIsOpen] = useState(false)
+ return (
+ <>
+ Hello
+ setIsOpen(v => !v)}>Trigger
+
+ Contents
+
+
+ >
+ )
+ }
+ render( )
+
+ // Open dialog
+ await click(getByText('Trigger'))
+
+ // Verify it is open
+ assertDialog({ state: DialogState.Visible })
+
+ // Click the button to close (outside click)
+ await click(getByText('Hello'))
+
+ // Verify it is closed
+ assertDialog({ state: DialogState.InvisibleUnmounted })
+
+ // Verify the button is focused
+ assertActiveElement(getByText('Hello'))
+ })
+ )
+})
diff --git a/packages/@headlessui-react/src/components/dialog/dialog.tsx b/packages/@headlessui-react/src/components/dialog/dialog.tsx
new file mode 100644
index 0000000000..2c24c7fd39
--- /dev/null
+++ b/packages/@headlessui-react/src/components/dialog/dialog.tsx
@@ -0,0 +1,368 @@
+// WAI-ARIA: https://www.w3.org/TR/wai-aria-practices-1.2/#dialog_modal
+import React, {
+ createContext,
+ useContext,
+ useReducer,
+ useMemo,
+ useCallback,
+
+ // Types
+ ElementType,
+ Ref,
+ MouseEvent as ReactMouseEvent,
+ useEffect,
+ useRef,
+ ContextType,
+ MutableRefObject,
+} from 'react'
+
+import { Props } from '../../types'
+import { match } from '../../utils/match'
+import { forwardRefWithAs, render, Features, PropsForFeatures } from '../../utils/render'
+import { useSyncRefs } from '../../hooks/use-sync-refs'
+import { Keys } from '../keyboard'
+import { isDisabledReactIssue7711 } from '../../utils/bugs'
+import { useId } from '../../hooks/use-id'
+import { useFocusTrap } from '../../hooks/use-focus-trap'
+import { useInertOthers } from '../../hooks/use-inert-others'
+import { Portal } from '../../components/portal/portal'
+
+enum DialogStates {
+ Open,
+ Closed,
+}
+
+interface StateDefinition {
+ titleElement: HTMLElement | null
+ descriptionElement: HTMLElement | null
+}
+
+enum ActionTypes {
+ SetTitleElement,
+ SetDescriptionElement,
+}
+
+type Actions =
+ | { type: ActionTypes.SetTitleElement; element: HTMLElement | null }
+ | { type: ActionTypes.SetDescriptionElement; element: HTMLElement | null }
+
+let reducers: {
+ [P in ActionTypes]: (
+ state: StateDefinition,
+ action: Extract
+ ) => StateDefinition
+} = {
+ [ActionTypes.SetTitleElement](state, action) {
+ if (state.titleElement === action.element) return state
+ return { ...state, titleElement: action.element }
+ },
+ [ActionTypes.SetDescriptionElement](state, action) {
+ if (state.descriptionElement === action.element) return state
+ return { ...state, descriptionElement: action.element }
+ },
+}
+
+let DialogContext = createContext<
+ | [
+ {
+ dialogState: DialogStates
+ close(): void
+ setTitle(element: HTMLElement | null): void
+ setDescription(element: HTMLElement | null): void
+ },
+ StateDefinition
+ ]
+ | null
+>(null)
+DialogContext.displayName = 'DialogContext'
+
+function useDialogContext(component: string) {
+ let context = useContext(DialogContext)
+ if (context === null) {
+ let err = new Error(`<${component} /> is missing a parent <${Dialog.name} /> component.`)
+ if (Error.captureStackTrace) Error.captureStackTrace(err, useDialogContext)
+ throw err
+ }
+ return context
+}
+
+function stateReducer(state: StateDefinition, action: Actions) {
+ return match(action.type, reducers, state, action)
+}
+
+// ---
+
+let DEFAULT_DIALOG_TAG = 'div' as const
+interface DialogRenderPropArg {
+ open: boolean
+}
+type DialogPropsWeControl = 'id' | 'role' | 'aria-modal' | 'aria-describedby' | 'aria-labelledby'
+
+let DialogRenderFeatures = Features.RenderStrategy | Features.Static
+
+let DialogRoot = forwardRefWithAs(function Dialog<
+ TTag extends ElementType = typeof DEFAULT_DIALOG_TAG
+>(
+ props: Props &
+ PropsForFeatures & {
+ open: boolean
+ onClose(value: boolean): void
+ initialFocus?: MutableRefObject
+ },
+ ref: Ref
+) {
+ let { open, onClose, initialFocus, ...rest } = props
+
+ let internalDialogRef = useRef(null)
+ let dialogRef = useSyncRefs(internalDialogRef, ref)
+
+ // Validations
+ let hasOpen = props.hasOwnProperty('open')
+ let hasOnClose = props.hasOwnProperty('onClose')
+ if (!hasOpen && !hasOnClose) {
+ throw new Error(
+ `You have to provide an \`open\` and an \`onClose\` prop to the \`Dialog\` component.`
+ )
+ }
+
+ if (!hasOpen) {
+ throw new Error(
+ `You provided an \`onClose\` prop to the \`Dialog\`, but forgot an \`open\` prop.`
+ )
+ }
+
+ if (!hasOnClose) {
+ throw new Error(
+ `You provided an \`open\` prop to the \`Dialog\`, but forgot an \`onClose\` prop.`
+ )
+ }
+
+ if (typeof open !== 'boolean') {
+ throw new Error(
+ `You provided an \`open\` prop to the \`Dialog\`, but the value is not a boolean. Received: ${open}`
+ )
+ }
+
+ if (typeof onClose !== 'function') {
+ throw new Error(
+ `You provided an \`onClose\` prop to the \`Dialog\`, but the value is not a function. Received: ${onClose}`
+ )
+ }
+
+ let dialogState = open ? DialogStates.Open : DialogStates.Closed
+
+ let [state, dispatch] = useReducer(stateReducer, {
+ titleElement: null,
+ descriptionElement: null,
+ } as StateDefinition)
+
+ let close = useCallback(() => onClose(false), [onClose])
+
+ let setTitle = useCallback(
+ (element: HTMLElement | null) => dispatch({ type: ActionTypes.SetTitleElement, element }),
+ [dispatch]
+ )
+ let setDescription = useCallback(
+ (element: HTMLElement | null) => dispatch({ type: ActionTypes.SetDescriptionElement, element }),
+ [dispatch]
+ )
+
+ // Handle outside click
+ useEffect(() => {
+ function handler(event: MouseEvent) {
+ let target = event.target as HTMLElement
+
+ if (dialogState !== DialogStates.Open) return
+ if (internalDialogRef.current?.contains(target)) return
+
+ close()
+ }
+
+ window.addEventListener('mousedown', handler)
+ return () => window.removeEventListener('mousedown', handler)
+ }, [dialogState, internalDialogRef, close])
+
+ // Handle `Escape` to close
+ useEffect(() => {
+ function handler(event: KeyboardEvent) {
+ if (event.key !== Keys.Escape) return
+ if (dialogState !== DialogStates.Open) return
+ close()
+ }
+
+ window.addEventListener('keydown', handler)
+ return () => window.removeEventListener('keydown', handler)
+ }, [close, dialogState])
+
+ // Scroll lock
+ useEffect(() => {
+ if (dialogState !== DialogStates.Open) return
+
+ let overflow = document.documentElement.style.overflow
+ let paddingRight = document.documentElement.style.paddingRight
+
+ let scrollbarWidth = window.innerWidth - document.documentElement.clientWidth
+
+ document.documentElement.style.overflow = 'hidden'
+ document.documentElement.style.paddingRight = `${scrollbarWidth}px`
+ return () => {
+ document.documentElement.style.overflow = overflow
+ document.documentElement.style.paddingRight = paddingRight
+ }
+ }, [dialogState])
+
+ // Trigger close when the FocusTrap gets hidden
+ useEffect(() => {
+ if (dialogState !== DialogStates.Open) return
+ if (!internalDialogRef.current) return
+
+ let observer = new IntersectionObserver(entries => {
+ for (let entry of entries) {
+ if (
+ entry.boundingClientRect.x === 0 &&
+ entry.boundingClientRect.y === 0 &&
+ entry.boundingClientRect.width === 0 &&
+ entry.boundingClientRect.height === 0
+ ) {
+ close()
+ }
+ }
+ })
+
+ observer.observe(internalDialogRef.current)
+
+ return () => observer.disconnect()
+ }, [dialogState, internalDialogRef, close])
+
+ let enabled = props.static ? true : dialogState === DialogStates.Open
+ useFocusTrap(internalDialogRef, enabled, { initialFocus })
+ useInertOthers(internalDialogRef, enabled)
+
+ let id = `headlessui-dialog-${useId()}`
+
+ let contextBag = useMemo>(
+ () => [{ dialogState, close, setTitle, setDescription }, state],
+ [dialogState, state, close, setTitle, setDescription]
+ )
+
+ let propsBag = useMemo(() => ({ open: dialogState === DialogStates.Open }), [
+ dialogState,
+ ])
+ let propsWeControl = {
+ ref: dialogRef,
+ id,
+ role: 'dialog',
+ 'aria-modal': dialogState === DialogStates.Open ? true : undefined,
+ 'aria-labelledby': state.titleElement?.id,
+ 'aria-describedby': state.descriptionElement?.id,
+ }
+ let passthroughProps = rest
+
+ return (
+
+
+ {render(
+ { ...passthroughProps, ...propsWeControl },
+ propsBag,
+ DEFAULT_DIALOG_TAG,
+ DialogRenderFeatures,
+ dialogState === DialogStates.Open
+ )}
+
+
+ )
+})
+
+// ---
+
+let DEFAULT_OVERLAY_TAG = 'div' as const
+interface OverlayRenderPropArg {
+ open: boolean
+}
+type OverlayPropsWeControl = 'id' | 'aria-hidden' | 'onClick'
+
+let Overlay = forwardRefWithAs(function Overlay<
+ TTag extends ElementType = typeof DEFAULT_OVERLAY_TAG
+>(props: Props, ref: Ref) {
+ let [{ dialogState, close }] = useDialogContext([Dialog.name, Overlay.name].join('.'))
+ let overlayRef = useSyncRefs(ref)
+
+ let id = `headlessui-dialog-overlay-${useId()}`
+
+ let handleClick = useCallback(
+ (event: ReactMouseEvent) => {
+ if (isDisabledReactIssue7711(event.currentTarget)) return event.preventDefault()
+ close()
+ },
+ [close]
+ )
+
+ let propsBag = useMemo(
+ () => ({ open: dialogState === DialogStates.Open }),
+ [dialogState]
+ )
+ let propsWeControl = {
+ ref: overlayRef,
+ id,
+ 'aria-hidden': true,
+ onClick: handleClick,
+ }
+ let passthroughProps = props
+
+ return render({ ...passthroughProps, ...propsWeControl }, propsBag, DEFAULT_OVERLAY_TAG)
+})
+
+// ---
+
+let DEFAULT_TITLE_TAG = 'h2' as const
+interface TitleRenderPropArg {
+ open: boolean
+}
+type TitlePropsWeControl = 'id' | 'ref'
+
+function Title(
+ props: Props
+) {
+ let [{ dialogState, setTitle }] = useDialogContext([Dialog.name, Title.name].join('.'))
+
+ let id = `headlessui-dialog-title-${useId()}`
+
+ let propsBag = useMemo(() => ({ open: dialogState === DialogStates.Open }), [
+ dialogState,
+ ])
+ let propsWeControl = { ref: setTitle, id }
+ let passthroughProps = props
+
+ return render({ ...passthroughProps, ...propsWeControl }, propsBag, DEFAULT_TITLE_TAG)
+}
+
+// ---
+
+let DEFAULT_DESCRIPTION_TAG = 'p' as const
+interface DescriptionRenderPropArg {
+ open: boolean
+}
+type DescriptionPropsWeControl = 'id' | 'ref'
+
+function Description(
+ props: Props
+) {
+ let [{ dialogState, setDescription }] = useDialogContext(
+ [Dialog.name, Description.name].join('.')
+ )
+
+ let id = `headlessui-dialog-description-${useId()}`
+
+ let propsBag = useMemo(
+ () => ({ open: dialogState === DialogStates.Open }),
+ [dialogState]
+ )
+ let propsWeControl = { ref: setDescription, id }
+ let passthroughProps = props
+
+ return render({ ...passthroughProps, ...propsWeControl }, propsBag, DEFAULT_DESCRIPTION_TAG)
+}
+
+// ---
+
+export let Dialog = Object.assign(DialogRoot, { Overlay, Title, Description })
diff --git a/packages/@headlessui-react/src/components/disclosure/disclosure.test.tsx b/packages/@headlessui-react/src/components/disclosure/disclosure.test.tsx
new file mode 100644
index 0000000000..81757bb0dd
--- /dev/null
+++ b/packages/@headlessui-react/src/components/disclosure/disclosure.test.tsx
@@ -0,0 +1,531 @@
+import React, { createElement } from 'react'
+import { render } from '@testing-library/react'
+
+import { Disclosure } from './disclosure'
+import { suppressConsoleLogs } from '../../test-utils/suppress-console-logs'
+import {
+ DisclosureState,
+ assertDisclosurePanel,
+ assertDisclosureButton,
+ getDisclosureButton,
+ getDisclosurePanel,
+} from '../../test-utils/accessibility-assertions'
+import { click, press, Keys, MouseButton } from '../../test-utils/interactions'
+
+jest.mock('../../hooks/use-id')
+
+afterAll(() => jest.restoreAllMocks())
+
+describe('Safe guards', () => {
+ it.each([
+ ['Disclosure.Button', Disclosure.Button],
+ ['Disclosure.Panel', Disclosure.Panel],
+ ])(
+ 'should error when we are using a <%s /> without a parent ',
+ suppressConsoleLogs((name, Component) => {
+ expect(() => render(createElement(Component))).toThrowError(
+ `<${name} /> is missing a parent component.`
+ )
+ })
+ )
+
+ it(
+ 'should be possible to render a Disclosure without crashing',
+ suppressConsoleLogs(async () => {
+ render(
+
+ Trigger
+ Contents
+
+ )
+
+ assertDisclosureButton({
+ state: DisclosureState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-disclosure-button-1' },
+ })
+ assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted })
+ })
+ )
+})
+
+describe('Rendering', () => {
+ describe('Disclosure', () => {
+ it(
+ 'should be possible to render a Disclosure using a render prop',
+ suppressConsoleLogs(async () => {
+ render(
+
+ {({ open }) => (
+ <>
+ Trigger
+ Panel is: {open ? 'open' : 'closed'}
+ >
+ )}
+
+ )
+
+ assertDisclosureButton({
+ state: DisclosureState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-disclosure-button-1' },
+ })
+ assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted })
+
+ await click(getDisclosureButton())
+
+ assertDisclosureButton({
+ state: DisclosureState.Visible,
+ attributes: { id: 'headlessui-disclosure-button-1' },
+ })
+ assertDisclosurePanel({ state: DisclosureState.Visible, textContent: 'Panel is: open' })
+ })
+ )
+ })
+
+ describe('Disclosure.Button', () => {
+ it(
+ 'should be possible to render a Disclosure.Button using a render prop',
+ suppressConsoleLogs(async () => {
+ render(
+
+ {JSON.stringify}
+
+
+ )
+
+ assertDisclosureButton({
+ state: DisclosureState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-disclosure-button-1' },
+ textContent: JSON.stringify({ open: false }),
+ })
+ assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted })
+
+ await click(getDisclosureButton())
+
+ assertDisclosureButton({
+ state: DisclosureState.Visible,
+ attributes: { id: 'headlessui-disclosure-button-1' },
+ textContent: JSON.stringify({ open: true }),
+ })
+ assertDisclosurePanel({ state: DisclosureState.Visible })
+ })
+ )
+
+ it(
+ 'should be possible to render a Disclosure.Button using a render prop and an `as` prop',
+ suppressConsoleLogs(async () => {
+ render(
+
+
+ {JSON.stringify}
+
+
+
+ )
+
+ assertDisclosureButton({
+ state: DisclosureState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-disclosure-button-1' },
+ textContent: JSON.stringify({ open: false }),
+ })
+ assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted })
+
+ await click(getDisclosureButton())
+
+ assertDisclosureButton({
+ state: DisclosureState.Visible,
+ attributes: { id: 'headlessui-disclosure-button-1' },
+ textContent: JSON.stringify({ open: true }),
+ })
+ assertDisclosurePanel({ state: DisclosureState.Visible })
+ })
+ )
+ })
+
+ describe('Disclosure.Panel', () => {
+ it(
+ 'should be possible to render Disclosure.Panel using a render prop',
+ suppressConsoleLogs(async () => {
+ render(
+
+ Trigger
+ {JSON.stringify}
+
+ )
+
+ assertDisclosureButton({
+ state: DisclosureState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-disclosure-button-1' },
+ })
+ assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted })
+
+ await click(getDisclosureButton())
+
+ assertDisclosureButton({
+ state: DisclosureState.Visible,
+ attributes: { id: 'headlessui-disclosure-button-1' },
+ })
+ assertDisclosurePanel({
+ state: DisclosureState.Visible,
+ textContent: JSON.stringify({ open: true }),
+ })
+ })
+ )
+
+ it('should be possible to always render the Disclosure.Panel if we provide it a `static` prop', () => {
+ render(
+
+ Trigger
+ Contents
+
+ )
+
+ // Let's verify that the Disclosure is already there
+ expect(getDisclosurePanel()).not.toBe(null)
+ })
+
+ it('should be possible to use a different render strategy for the Disclosure.Panel', async () => {
+ render(
+
+ Trigger
+ Contents
+
+ )
+
+ assertDisclosureButton({ state: DisclosureState.InvisibleHidden })
+ assertDisclosurePanel({ state: DisclosureState.InvisibleHidden })
+
+ // Let's open the Disclosure, to see if it is not hidden anymore
+ await click(getDisclosureButton())
+
+ assertDisclosureButton({ state: DisclosureState.Visible })
+ assertDisclosurePanel({ state: DisclosureState.Visible })
+
+ // Let's re-click the Disclosure, to see if it is hidden again
+ await click(getDisclosureButton())
+
+ assertDisclosureButton({ state: DisclosureState.InvisibleHidden })
+ assertDisclosurePanel({ state: DisclosureState.InvisibleHidden })
+ })
+ })
+})
+
+describe('Keyboard interactions', () => {
+ describe('`Enter` key', () => {
+ it(
+ 'should be possible to open the Disclosure with Enter',
+ suppressConsoleLogs(async () => {
+ render(
+
+ Trigger
+ Contents
+
+ )
+
+ assertDisclosureButton({
+ state: DisclosureState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-disclosure-button-1' },
+ })
+ assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted })
+
+ // Focus the button
+ getDisclosureButton()?.focus()
+
+ // Open disclosure
+ await press(Keys.Enter)
+
+ // Verify it is open
+ assertDisclosureButton({ state: DisclosureState.Visible })
+ assertDisclosurePanel({
+ state: DisclosureState.Visible,
+ attributes: { id: 'headlessui-disclosure-panel-2' },
+ })
+
+ // Close disclosure
+ await press(Keys.Enter)
+ assertDisclosureButton({ state: DisclosureState.InvisibleUnmounted })
+ })
+ )
+
+ it(
+ 'should not be possible to open the disclosure with Enter when the button is disabled',
+ suppressConsoleLogs(async () => {
+ render(
+
+ Trigger
+ Content
+
+ )
+
+ assertDisclosureButton({
+ state: DisclosureState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-disclosure-button-1' },
+ })
+ assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted })
+
+ // Focus the button
+ getDisclosureButton()?.focus()
+
+ // Try to open the disclosure
+ await press(Keys.Enter)
+
+ // Verify it is still closed
+ assertDisclosureButton({
+ state: DisclosureState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-disclosure-button-1' },
+ })
+ assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted })
+ })
+ )
+
+ it(
+ 'should be possible to close the disclosure with Enter when the disclosure is open',
+ suppressConsoleLogs(async () => {
+ render(
+
+ Trigger
+ Contents
+
+ )
+
+ assertDisclosureButton({
+ state: DisclosureState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-disclosure-button-1' },
+ })
+ assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted })
+
+ // Focus the button
+ getDisclosureButton()?.focus()
+
+ // Open disclosure
+ await press(Keys.Enter)
+
+ // Verify it is open
+ assertDisclosureButton({ state: DisclosureState.Visible })
+ assertDisclosurePanel({
+ state: DisclosureState.Visible,
+ attributes: { id: 'headlessui-disclosure-panel-2' },
+ })
+
+ // Close disclosure
+ await press(Keys.Enter)
+
+ // Verify it is closed again
+ assertDisclosureButton({ state: DisclosureState.InvisibleUnmounted })
+ assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted })
+ })
+ )
+ })
+
+ describe('`Space` key', () => {
+ it(
+ 'should be possible to open the disclosure with Space',
+ suppressConsoleLogs(async () => {
+ render(
+
+ Trigger
+ Contents
+
+ )
+
+ assertDisclosureButton({
+ state: DisclosureState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-disclosure-button-1' },
+ })
+ assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted })
+
+ // Focus the button
+ getDisclosureButton()?.focus()
+
+ // Open disclosure
+ await press(Keys.Space)
+
+ // Verify it is open
+ assertDisclosureButton({ state: DisclosureState.Visible })
+ assertDisclosurePanel({
+ state: DisclosureState.Visible,
+ attributes: { id: 'headlessui-disclosure-panel-2' },
+ })
+ })
+ )
+
+ it(
+ 'should not be possible to open the disclosure with Space when the button is disabled',
+ suppressConsoleLogs(async () => {
+ render(
+
+ Trigger
+ Contents
+
+ )
+
+ assertDisclosureButton({
+ state: DisclosureState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-disclosure-button-1' },
+ })
+ assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted })
+
+ // Focus the button
+ getDisclosureButton()?.focus()
+
+ // Try to open the disclosure
+ await press(Keys.Space)
+
+ // Verify it is still closed
+ assertDisclosureButton({
+ state: DisclosureState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-disclosure-button-1' },
+ })
+ assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted })
+ })
+ )
+
+ it(
+ 'should be possible to close the disclosure with Space when the disclosure is open',
+ suppressConsoleLogs(async () => {
+ render(
+
+ Trigger
+ Contents
+
+ )
+
+ assertDisclosureButton({
+ state: DisclosureState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-disclosure-button-1' },
+ })
+ assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted })
+
+ // Focus the button
+ getDisclosureButton()?.focus()
+
+ // Open disclosure
+ await press(Keys.Space)
+
+ // Verify it is open
+ assertDisclosureButton({ state: DisclosureState.Visible })
+ assertDisclosurePanel({
+ state: DisclosureState.Visible,
+ attributes: { id: 'headlessui-disclosure-panel-2' },
+ })
+
+ // Close disclosure
+ await press(Keys.Space)
+
+ // Verify it is closed again
+ assertDisclosureButton({ state: DisclosureState.InvisibleUnmounted })
+ assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted })
+ })
+ )
+ })
+})
+
+describe('Mouse interactions', () => {
+ it(
+ 'should be possible to open a disclosure on click',
+ suppressConsoleLogs(async () => {
+ render(
+
+ Trigger
+ Contents
+
+ )
+
+ assertDisclosureButton({
+ state: DisclosureState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-disclosure-button-1' },
+ })
+ assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted })
+
+ // Open disclosure
+ await click(getDisclosureButton())
+
+ // Verify it is open
+ assertDisclosureButton({ state: DisclosureState.Visible })
+ assertDisclosurePanel({
+ state: DisclosureState.Visible,
+ attributes: { id: 'headlessui-disclosure-panel-2' },
+ })
+ })
+ )
+
+ it(
+ 'should not be possible to open a disclosure on right click',
+ suppressConsoleLogs(async () => {
+ render(
+
+ Trigger
+ Contents
+
+ )
+
+ assertDisclosureButton({
+ state: DisclosureState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-disclosure-button-1' },
+ })
+ assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted })
+
+ // Open disclosure
+ await click(getDisclosureButton(), MouseButton.Right)
+
+ // Verify it is still closed
+ assertDisclosureButton({
+ state: DisclosureState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-disclosure-button-1' },
+ })
+ assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted })
+ })
+ )
+
+ it(
+ 'should not be possible to open a disclosure on click when the button is disabled',
+ suppressConsoleLogs(async () => {
+ render(
+
+ Trigger
+ Contents
+
+ )
+
+ assertDisclosureButton({
+ state: DisclosureState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-disclosure-button-1' },
+ })
+ assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted })
+
+ // Try to open the disclosure
+ await click(getDisclosureButton())
+
+ // Verify it is still closed
+ assertDisclosureButton({
+ state: DisclosureState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-disclosure-button-1' },
+ })
+ assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted })
+ })
+ )
+
+ it(
+ 'should be possible to close a disclosure on click',
+ suppressConsoleLogs(async () => {
+ render(
+
+ Trigger
+ Contents
+
+ )
+
+ // Open disclosure
+ await click(getDisclosureButton())
+
+ // Verify it is open
+ assertDisclosureButton({ state: DisclosureState.Visible })
+
+ // Click to close
+ await click(getDisclosureButton())
+
+ // Verify it is closed
+ assertDisclosureButton({ state: DisclosureState.InvisibleUnmounted })
+ assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted })
+ })
+ )
+})
diff --git a/packages/@headlessui-react/src/components/disclosure/disclosure.tsx b/packages/@headlessui-react/src/components/disclosure/disclosure.tsx
new file mode 100644
index 0000000000..de6c1583f2
--- /dev/null
+++ b/packages/@headlessui-react/src/components/disclosure/disclosure.tsx
@@ -0,0 +1,258 @@
+// WAI-ARIA: https://www.w3.org/TR/wai-aria-practices-1.2/#disclosure
+import {
+ createContext,
+ useContext,
+ Fragment,
+ useReducer,
+ useEffect,
+ useMemo,
+ useCallback,
+
+ // Types
+ Dispatch,
+ ElementType,
+ Ref,
+ KeyboardEvent as ReactKeyboardEvent,
+ MouseEvent as ReactMouseEvent,
+} from 'react'
+
+import { Props } from '../../types'
+import { match } from '../../utils/match'
+import { forwardRefWithAs, render, Features, PropsForFeatures } from '../../utils/render'
+import { useSyncRefs } from '../../hooks/use-sync-refs'
+import { useId } from '../../hooks/use-id'
+import { Keys } from '../keyboard'
+import { isDisabledReactIssue7711 } from '../../utils/bugs'
+import React from 'react'
+
+enum DisclosureStates {
+ Open,
+ Closed,
+}
+
+interface StateDefinition {
+ disclosureState: DisclosureStates
+
+ linkedPanel: boolean
+
+ buttonId: string
+ panelId: string
+}
+
+enum ActionTypes {
+ ToggleDisclosure,
+
+ SetButtonId,
+ SetPanelId,
+
+ LinkPanel,
+ UnlinkPanel,
+}
+
+type Actions =
+ | { type: ActionTypes.ToggleDisclosure }
+ | { type: ActionTypes.SetButtonId; buttonId: string }
+ | { type: ActionTypes.SetPanelId; panelId: string }
+ | { type: ActionTypes.LinkPanel }
+ | { type: ActionTypes.UnlinkPanel }
+
+let reducers: {
+ [P in ActionTypes]: (
+ state: StateDefinition,
+ action: Extract
+ ) => StateDefinition
+} = {
+ [ActionTypes.ToggleDisclosure]: state => ({
+ ...state,
+ disclosureState: match(state.disclosureState, {
+ [DisclosureStates.Open]: DisclosureStates.Closed,
+ [DisclosureStates.Closed]: DisclosureStates.Open,
+ }),
+ }),
+ [ActionTypes.LinkPanel](state) {
+ if (state.linkedPanel === true) return state
+ return { ...state, linkedPanel: true }
+ },
+ [ActionTypes.UnlinkPanel](state) {
+ if (state.linkedPanel === false) return state
+ return { ...state, linkedPanel: false }
+ },
+ [ActionTypes.SetButtonId](state, action) {
+ if (state.buttonId === action.buttonId) return state
+ return { ...state, buttonId: action.buttonId }
+ },
+ [ActionTypes.SetPanelId](state, action) {
+ if (state.panelId === action.panelId) return state
+ return { ...state, panelId: action.panelId }
+ },
+}
+
+let DisclosureContext = createContext<[StateDefinition, Dispatch] | null>(null)
+DisclosureContext.displayName = 'DisclosureContext'
+
+function useDisclosureContext(component: string) {
+ let context = useContext(DisclosureContext)
+ if (context === null) {
+ let err = new Error(`<${component} /> is missing a parent <${Disclosure.name} /> component.`)
+ if (Error.captureStackTrace) Error.captureStackTrace(err, useDisclosureContext)
+ throw err
+ }
+ return context
+}
+
+function stateReducer(state: StateDefinition, action: Actions) {
+ return match(action.type, reducers, state, action)
+}
+
+// ---
+
+let DEFAULT_DISCLOSURE_TAG = Fragment
+interface DisclosureRenderPropArg {
+ open: boolean
+}
+
+export function Disclosure(
+ props: Props
+) {
+ let buttonId = `headlessui-disclosure-button-${useId()}`
+ let panelId = `headlessui-disclosure-panel-${useId()}`
+
+ let reducerBag = useReducer(stateReducer, {
+ disclosureState: DisclosureStates.Closed,
+ linkedPanel: false,
+ buttonId,
+ panelId,
+ } as StateDefinition)
+ let [{ disclosureState }, dispatch] = reducerBag
+
+ useEffect(() => dispatch({ type: ActionTypes.SetButtonId, buttonId }), [buttonId, dispatch])
+ useEffect(() => dispatch({ type: ActionTypes.SetPanelId, panelId }), [panelId, dispatch])
+
+ let propsBag = useMemo(
+ () => ({ open: disclosureState === DisclosureStates.Open }),
+ [disclosureState]
+ )
+
+ return (
+
+ {render(props, propsBag, DEFAULT_DISCLOSURE_TAG)}
+
+ )
+}
+
+// ---
+
+let DEFAULT_BUTTON_TAG = 'button' as const
+interface ButtonRenderPropArg {
+ open: boolean
+}
+type ButtonPropsWeControl =
+ | 'id'
+ | 'type'
+ | 'aria-expanded'
+ | 'aria-controls'
+ | 'onKeyDown'
+ | 'onClick'
+
+let Button = forwardRefWithAs(function Button(
+ props: Props,
+ ref: Ref
+) {
+ let [state, dispatch] = useDisclosureContext([Disclosure.name, Button.name].join('.'))
+ let buttonRef = useSyncRefs(ref)
+
+ let handleKeyDown = useCallback(
+ (event: ReactKeyboardEvent) => {
+ switch (event.key) {
+ case Keys.Space:
+ case Keys.Enter:
+ event.preventDefault()
+ dispatch({ type: ActionTypes.ToggleDisclosure })
+ break
+ }
+ },
+ [dispatch]
+ )
+
+ let handleClick = useCallback(
+ (event: ReactMouseEvent) => {
+ if (isDisabledReactIssue7711(event.currentTarget)) return
+ if (props.disabled) return
+ dispatch({ type: ActionTypes.ToggleDisclosure })
+ },
+ [dispatch, props.disabled]
+ )
+
+ let propsBag = useMemo(
+ () => ({ open: state.disclosureState === DisclosureStates.Open }),
+ [state]
+ )
+
+ let passthroughProps = props
+ let propsWeControl = {
+ ref: buttonRef,
+ id: state.buttonId,
+ type: 'button',
+ 'aria-expanded': state.disclosureState === DisclosureStates.Open ? true : undefined,
+ 'aria-controls': state.linkedPanel ? state.panelId : undefined,
+ onKeyDown: handleKeyDown,
+ onClick: handleClick,
+ }
+
+ return render({ ...passthroughProps, ...propsWeControl }, propsBag, DEFAULT_BUTTON_TAG)
+})
+
+// ---
+
+let DEFAULT_PANEL_TAG = 'div' as const
+interface PanelRenderPropArg {
+ open: boolean
+}
+type PanelPropsWeControl = 'id'
+
+let PanelRenderFeatures = Features.RenderStrategy | Features.Static
+
+let Panel = forwardRefWithAs(function Panel(
+ props: Props &
+ PropsForFeatures,
+ ref: Ref
+) {
+ let [state, dispatch] = useDisclosureContext([Disclosure.name, Panel.name].join('.'))
+ let panelRef = useSyncRefs(ref, () => {
+ if (state.linkedPanel) return
+ dispatch({ type: ActionTypes.LinkPanel })
+ })
+
+ // Unlink on "unmount" myself
+ useEffect(() => () => dispatch({ type: ActionTypes.UnlinkPanel }), [dispatch])
+
+ // Unlink on "unmount" children
+ useEffect(() => {
+ if (state.disclosureState === DisclosureStates.Closed && (props.unmount ?? true)) {
+ dispatch({ type: ActionTypes.UnlinkPanel })
+ }
+ }, [state.disclosureState, props.unmount, dispatch])
+
+ let propsBag = useMemo(
+ () => ({ open: state.disclosureState === DisclosureStates.Open }),
+ [state]
+ )
+ let propsWeControl = {
+ ref: panelRef,
+ id: state.panelId,
+ }
+ let passthroughProps = props
+
+ return render(
+ { ...passthroughProps, ...propsWeControl },
+ propsBag,
+ DEFAULT_PANEL_TAG,
+ PanelRenderFeatures,
+ state.disclosureState === DisclosureStates.Open
+ )
+})
+
+// ---
+
+Disclosure.Button = Button
+Disclosure.Panel = Panel
diff --git a/packages/@headlessui-react/src/components/focus-trap/focus-trap.test.tsx b/packages/@headlessui-react/src/components/focus-trap/focus-trap.test.tsx
new file mode 100644
index 0000000000..1e1f0bc6d8
--- /dev/null
+++ b/packages/@headlessui-react/src/components/focus-trap/focus-trap.test.tsx
@@ -0,0 +1,328 @@
+import React, { useState, useRef } from 'react'
+import { render } from '@testing-library/react'
+
+import { FocusTrap } from './focus-trap'
+import { assertActiveElement } from '../../test-utils/accessibility-assertions'
+import { suppressConsoleLogs } from '../../test-utils/suppress-console-logs'
+import { click, press, shift, Keys } from '../../test-utils/interactions'
+
+it('should focus the first focusable element inside the FocusTrap', () => {
+ let { getByText } = render(
+
+ Trigger
+
+ )
+
+ assertActiveElement(getByText('Trigger'))
+})
+
+it('should focus the autoFocus element inside the FocusTrap if that exists', () => {
+ render(
+
+
+
+
+
+ )
+
+ assertActiveElement(document.getElementById('b'))
+})
+
+it('should focus the initialFocus element inside the FocusTrap if that exists', () => {
+ function Example() {
+ let initialFocusRef = useRef(null)
+
+ return (
+
+
+
+
+
+ )
+ }
+ render( )
+
+ assertActiveElement(document.getElementById('c'))
+})
+
+it('should focus the initialFocus element inside the FocusTrap even if another element has autoFocus', () => {
+ function Example() {
+ let initialFocusRef = useRef(null)
+
+ return (
+
+
+
+
+
+ )
+ }
+ render( )
+
+ assertActiveElement(document.getElementById('c'))
+})
+
+it(
+ 'should error when there is no focusable element inside the FocusTrap',
+ suppressConsoleLogs(() => {
+ expect(() => {
+ render(
+
+ Nothing to see here...
+
+ )
+ }).toThrowErrorMatchingInlineSnapshot(
+ `"There are no focusable elements inside the "`
+ )
+ })
+)
+
+it(
+ 'should not be possible to programmatically escape the focus trap',
+ suppressConsoleLogs(async () => {
+ function Example() {
+ return (
+ <>
+
+
+
+
+
+
+
+ >
+ )
+ }
+
+ render( )
+
+ let [a, b, c, d] = Array.from(document.querySelectorAll('input'))
+
+ // Ensure that input-b is the active elememt
+ assertActiveElement(b)
+
+ // Tab to the next item
+ await press(Keys.Tab)
+
+ // Ensure that input-c is the active elememt
+ assertActiveElement(c)
+
+ // Try to move focus
+ a?.focus()
+
+ // Ensure that input-c is still the active element
+ assertActiveElement(c)
+
+ // Click on an element within the FocusTrap
+ await click(b)
+
+ // Ensure that input-b is the active element
+ assertActiveElement(b)
+
+ // Try to move focus again
+ a?.focus()
+
+ // Ensure that input-b is still the active element
+ assertActiveElement(b)
+
+ // Focus on an element within the FocusTrap
+ d?.focus()
+
+ // Ensure that input-d is the active element
+ assertActiveElement(d)
+
+ // Try to move focus again
+ a?.focus()
+
+ // Ensure that input-d is still the active element
+ assertActiveElement(d)
+ })
+)
+
+it('should restore the previously focused element, before entering the FocusTrap, after the FocusTrap unmounts', async () => {
+ function Example() {
+ let [visible, setVisible] = useState(false)
+
+ return (
+ <>
+
+ setVisible(true)}>
+ Open modal
+
+
+ {visible && (
+
+ setVisible(false)}>
+ Close
+
+
+ )}
+ >
+ )
+ }
+
+ render( )
+
+ // The input should have focus by default because of the autoFocus prop
+ assertActiveElement(document.getElementById('item-1'))
+
+ // Open the modal
+ await click(document.getElementById('item-2')) // This will also focus this button
+
+ // Ensure that the first item inside the focus trap is focused
+ assertActiveElement(document.getElementById('item-3'))
+
+ // Close the modal
+ await click(document.getElementById('item-3'))
+
+ // Ensure that we restored focus correctly
+ assertActiveElement(document.getElementById('item-2'))
+})
+
+it('should be possible tab to the next focusable element within the focus trap', async () => {
+ render(
+ <>
+ Before
+
+ Item A
+ Item B
+ Item C
+
+ After
+ >
+ )
+
+ // Item A should be focused because the FocusTrap will focus the first item
+ assertActiveElement(document.getElementById('item-a'))
+
+ // Next
+ await press(Keys.Tab)
+ assertActiveElement(document.getElementById('item-b'))
+
+ // Next
+ await press(Keys.Tab)
+ assertActiveElement(document.getElementById('item-c'))
+
+ // Loop around!
+ await press(Keys.Tab)
+ assertActiveElement(document.getElementById('item-a'))
+})
+
+it('should be possible shift+tab to the previous focusable element within the focus trap', async () => {
+ render(
+ <>
+ Before
+
+ Item A
+ Item B
+ Item C
+
+ After
+ >
+ )
+
+ // Item A should be focused because the FocusTrap will focus the first item
+ assertActiveElement(document.getElementById('item-a'))
+
+ // Previous (loop around!)
+ await press(shift(Keys.Tab))
+ assertActiveElement(document.getElementById('item-c'))
+
+ // Previous
+ await press(shift(Keys.Tab))
+ assertActiveElement(document.getElementById('item-b'))
+
+ // Previous
+ await press(shift(Keys.Tab))
+ assertActiveElement(document.getElementById('item-a'))
+})
+
+it('should skip the initial "hidden" elements within the focus trap', async () => {
+ render(
+ <>
+ Before
+
+
+ Item A
+
+
+ Item B
+
+ Item C
+ Item D
+
+ After
+ >
+ )
+
+ // Item C should be focused because the FocusTrap had to skip the first 2
+ assertActiveElement(document.getElementById('item-c'))
+})
+
+it('should be possible skip "hidden" elements within the focus trap', async () => {
+ render(
+ <>
+ Before
+
+ Item A
+ Item B
+
+ Item C
+
+ Item D
+
+ After
+ >
+ )
+
+ // Item A should be focused because the FocusTrap will focus the first item
+ assertActiveElement(document.getElementById('item-a'))
+
+ // Next
+ await press(Keys.Tab)
+ assertActiveElement(document.getElementById('item-b'))
+
+ // Notice that we skipped item-c
+
+ // Next
+ await press(Keys.Tab)
+ assertActiveElement(document.getElementById('item-d'))
+
+ // Loop around!
+ await press(Keys.Tab)
+ assertActiveElement(document.getElementById('item-a'))
+})
+
+it('should be possible skip disabled elements within the focus trap', async () => {
+ render(
+ <>
+ Before
+
+ Item A
+ Item B
+
+ Item C
+
+ Item D
+
+ After
+ >
+ )
+
+ // Item A should be focused because the FocusTrap will focus the first item
+ assertActiveElement(document.getElementById('item-a'))
+
+ // Next
+ await press(Keys.Tab)
+ assertActiveElement(document.getElementById('item-b'))
+
+ // Notice that we skipped item-c
+
+ // Next
+ await press(Keys.Tab)
+ assertActiveElement(document.getElementById('item-d'))
+
+ // Loop around!
+ await press(Keys.Tab)
+ assertActiveElement(document.getElementById('item-a'))
+})
diff --git a/packages/@headlessui-react/src/components/focus-trap/focus-trap.tsx b/packages/@headlessui-react/src/components/focus-trap/focus-trap.tsx
new file mode 100644
index 0000000000..69a9e37bf3
--- /dev/null
+++ b/packages/@headlessui-react/src/components/focus-trap/focus-trap.tsx
@@ -0,0 +1,26 @@
+import {
+ useRef,
+
+ // Types
+ ElementType,
+ MutableRefObject,
+} from 'react'
+
+import { Props } from '../../types'
+import { render } from '../../utils/render'
+import { useFocusTrap } from '../../hooks/use-focus-trap'
+
+let DEFAULT_FOCUS_TRAP_TAG = 'div' as const
+
+export function FocusTrap(
+ props: Props & { initialFocus?: MutableRefObject }
+) {
+ let containerRef = useRef(null)
+ let { initialFocus, ...passthroughProps } = props
+
+ useFocusTrap(containerRef, true, { initialFocus })
+
+ let propsWeControl = { ref: containerRef }
+
+ return render({ ...passthroughProps, ...propsWeControl }, {}, DEFAULT_FOCUS_TRAP_TAG)
+}
diff --git a/packages/@headlessui-react/src/components/listbox/listbox.tsx b/packages/@headlessui-react/src/components/listbox/listbox.tsx
index eacb2d78d2..3e799cdff7 100644
--- a/packages/@headlessui-react/src/components/listbox/listbox.tsx
+++ b/packages/@headlessui-react/src/components/listbox/listbox.tsx
@@ -31,6 +31,7 @@ import { Keys } from '../keyboard'
import { Focus, calculateActiveIndex } from '../../utils/calculate-active-index'
import { resolvePropValue } from '../../utils/resolve-prop-value'
import { isDisabledReactIssue7711 } from '../../utils/bugs'
+import { isFocusableElement } from '../../utils/focus-management'
enum ListboxStates {
Open,
@@ -195,7 +196,6 @@ export function Listbox dispatch({ type: ActionTypes.SetDisabled, disabled }), [disabled])
+ // Handle outside click
useEffect(() => {
function handler(event: MouseEvent) {
let target = event.target as HTMLElement
- let active = document.activeElement
if (listboxState !== ListboxStates.Open) return
+
if (buttonRef.current?.contains(target)) return
+ if (optionsRef.current?.contains(target)) return
+
+ dispatch({ type: ActionTypes.CloseListbox })
- if (!optionsRef.current?.contains(target)) dispatch({ type: ActionTypes.CloseListbox })
- if (active !== document.body && active?.contains(target)) return // Keep focus on newly clicked/focused element
- if (!event.defaultPrevented) buttonRef.current?.focus({ preventScroll: true })
+ if (!isFocusableElement(target)) {
+ event.preventDefault()
+ buttonRef.current?.focus()
+ }
}
window.addEventListener('mousedown', handler)
return () => window.removeEventListener('mousedown', handler)
- }, [listboxState, optionsRef, buttonRef, d, dispatch])
+ }, [listboxState, buttonRef, optionsRef, dispatch])
let propsBag = useMemo(
() => ({ open: listboxState === ListboxStates.Open, disabled }),
@@ -596,7 +601,11 @@ function Option<
dispatch({ type: ActionTypes.GoToOption, focus: Focus.Nothing })
}, [disabled, active, dispatch])
- let propsBag = useMemo(() => ({ active, selected, disabled }), [active, selected, disabled])
+ let propsBag = useMemo(() => ({ active, selected, disabled }), [
+ active,
+ selected,
+ disabled,
+ ])
let propsWeControl = {
id,
role: 'option',
diff --git a/packages/@headlessui-react/src/components/menu/menu.tsx b/packages/@headlessui-react/src/components/menu/menu.tsx
index ac01dda116..31421606f6 100644
--- a/packages/@headlessui-react/src/components/menu/menu.tsx
+++ b/packages/@headlessui-react/src/components/menu/menu.tsx
@@ -31,6 +31,7 @@ import { Keys } from '../keyboard'
import { Focus, calculateActiveIndex } from '../../utils/calculate-active-index'
import { resolvePropValue } from '../../utils/resolve-prop-value'
import { isDisabledReactIssue7711 } from '../../utils/bugs'
+import { isFocusableElement } from '../../utils/focus-management'
enum MenuStates {
Open,
@@ -55,7 +56,6 @@ enum ActionTypes {
GoToItem,
Search,
ClearSearch,
-
RegisterItem,
UnregisterItem,
}
@@ -168,24 +168,31 @@ export function Menu(
} as StateDefinition)
let [{ menuState, itemsRef, buttonRef }, dispatch] = reducerBag
+ // Handle outside click
useEffect(() => {
function handler(event: MouseEvent) {
let target = event.target as HTMLElement
- let active = document.activeElement
if (menuState !== MenuStates.Open) return
+
if (buttonRef.current?.contains(target)) return
+ if (itemsRef.current?.contains(target)) return
+
+ dispatch({ type: ActionTypes.CloseMenu })
- if (!itemsRef.current?.contains(target)) dispatch({ type: ActionTypes.CloseMenu })
- if (active !== document.body && active?.contains(target)) return // Keep focus on newly clicked/focused element
- if (!event.defaultPrevented) buttonRef.current?.focus({ preventScroll: true })
+ if (!isFocusableElement(target)) {
+ event.preventDefault()
+ buttonRef.current?.focus()
+ }
}
window.addEventListener('mousedown', handler)
return () => window.removeEventListener('mousedown', handler)
- }, [menuState, itemsRef, buttonRef, dispatch])
+ }, [menuState, buttonRef, itemsRef, dispatch])
- let propsBag = useMemo(() => ({ open: menuState === MenuStates.Open }), [menuState])
+ let propsBag = useMemo(() => ({ open: menuState === MenuStates.Open }), [
+ menuState,
+ ])
return (
@@ -264,7 +271,10 @@ let Button = forwardRefWithAs(function Button ({ open: state.menuState === MenuStates.Open }), [state])
+ let propsBag = useMemo(
+ () => ({ open: state.menuState === MenuStates.Open }),
+ [state]
+ )
let passthroughProps = props
let propsWeControl = {
ref: buttonRef,
@@ -387,7 +397,10 @@ let Items = forwardRefWithAs(function Items ({ open: state.menuState === MenuStates.Open }), [state])
+ let propsBag = useMemo(
+ () => ({ open: state.menuState === MenuStates.Open }),
+ [state]
+ )
let propsWeControl = {
'aria-activedescendant':
state.activeItemIndex === null ? undefined : state.items[state.activeItemIndex]?.id,
@@ -491,7 +504,7 @@ function Item(
dispatch({ type: ActionTypes.GoToItem, focus: Focus.Nothing })
}, [disabled, active, dispatch])
- let propsBag = useMemo(() => ({ active, disabled }), [active, disabled])
+ let propsBag = useMemo(() => ({ active, disabled }), [active, disabled])
let propsWeControl = {
id,
role: 'menuitem',
diff --git a/packages/@headlessui-react/src/components/popover/popover.test.tsx b/packages/@headlessui-react/src/components/popover/popover.test.tsx
new file mode 100644
index 0000000000..573d4e606d
--- /dev/null
+++ b/packages/@headlessui-react/src/components/popover/popover.test.tsx
@@ -0,0 +1,1739 @@
+import React, { createElement } from 'react'
+import { render } from '@testing-library/react'
+
+import { Popover } from './popover'
+import { suppressConsoleLogs } from '../../test-utils/suppress-console-logs'
+import {
+ PopoverState,
+ assertPopoverPanel,
+ assertPopoverButton,
+ getPopoverButton,
+ getPopoverPanel,
+ getByText,
+ assertActiveElement,
+ assertContainsActiveElement,
+ getPopoverOverlay,
+} from '../../test-utils/accessibility-assertions'
+import { click, press, Keys, MouseButton, shift } from '../../test-utils/interactions'
+import { Portal } from '../portal/portal'
+
+jest.mock('../../hooks/use-id')
+
+afterAll(() => jest.restoreAllMocks())
+
+describe('Safe guards', () => {
+ it.each([
+ ['Popover.Button', Popover.Button],
+ ['Popover.Panel', Popover.Panel],
+ ['Popover.Overlay', Popover.Overlay],
+ ])(
+ 'should error when we are using a <%s /> without a parent ',
+ suppressConsoleLogs((name, Component) => {
+ expect(() => render(createElement(Component))).toThrowError(
+ `<${name} /> is missing a parent component.`
+ )
+ })
+ )
+
+ it(
+ 'should be possible to render a Popover without crashing',
+ suppressConsoleLogs(async () => {
+ render(
+
+ Trigger
+ Contents
+
+ )
+
+ assertPopoverButton({
+ state: PopoverState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-popover-button-1' },
+ })
+ assertPopoverPanel({ state: PopoverState.InvisibleUnmounted })
+ })
+ )
+})
+
+describe('Rendering', () => {
+ describe('Popover.Group', () => {
+ it(
+ 'should be possible to render a Popover.Group with multiple Popover components',
+ suppressConsoleLogs(async () => {
+ render(
+
+
+ Trigger 1
+ Panel 1
+
+
+ Trigger 2
+ Panel 2
+
+
+ )
+
+ assertPopoverButton({ state: PopoverState.InvisibleUnmounted }, getByText('Trigger 1'))
+ assertPopoverButton({ state: PopoverState.InvisibleUnmounted }, getByText('Trigger 2'))
+
+ assertPopoverPanel({ state: PopoverState.InvisibleUnmounted }, getByText('Panel 1'))
+ assertPopoverPanel({ state: PopoverState.InvisibleUnmounted }, getByText('Panel 2'))
+
+ await click(getByText('Trigger 1'))
+
+ assertPopoverButton({ state: PopoverState.Visible }, getByText('Trigger 1'))
+ assertPopoverButton({ state: PopoverState.InvisibleUnmounted }, getByText('Trigger 2'))
+
+ assertPopoverPanel({ state: PopoverState.Visible }, getByText('Panel 1'))
+ assertPopoverPanel({ state: PopoverState.InvisibleUnmounted }, getByText('Panel 2'))
+
+ await click(getByText('Trigger 2'))
+
+ assertPopoverButton({ state: PopoverState.InvisibleUnmounted }, getByText('Trigger 1'))
+ assertPopoverButton({ state: PopoverState.Visible }, getByText('Trigger 2'))
+
+ assertPopoverPanel({ state: PopoverState.InvisibleUnmounted }, getByText('Panel 1'))
+ assertPopoverPanel({ state: PopoverState.Visible }, getByText('Panel 2'))
+ })
+ )
+ })
+
+ describe('Popover', () => {
+ it(
+ 'should be possible to render a Popover using a render prop',
+ suppressConsoleLogs(async () => {
+ render(
+
+ {({ open }) => (
+ <>
+ Trigger
+ Panel is: {open ? 'open' : 'closed'}
+ >
+ )}
+
+ )
+
+ assertPopoverButton({
+ state: PopoverState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-popover-button-1' },
+ })
+ assertPopoverPanel({ state: PopoverState.InvisibleUnmounted })
+
+ await click(getPopoverButton())
+
+ assertPopoverButton({
+ state: PopoverState.Visible,
+ attributes: { id: 'headlessui-popover-button-1' },
+ })
+ assertPopoverPanel({ state: PopoverState.Visible, textContent: 'Panel is: open' })
+ })
+ )
+ })
+
+ describe('Popover.Button', () => {
+ it(
+ 'should be possible to render a Popover.Button using a render prop',
+ suppressConsoleLogs(async () => {
+ render(
+
+ {JSON.stringify}
+
+
+ )
+
+ assertPopoverButton({
+ state: PopoverState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-popover-button-1' },
+ textContent: JSON.stringify({ open: false }),
+ })
+ assertPopoverPanel({ state: PopoverState.InvisibleUnmounted })
+
+ await click(getPopoverButton())
+
+ assertPopoverButton({
+ state: PopoverState.Visible,
+ attributes: { id: 'headlessui-popover-button-1' },
+ textContent: JSON.stringify({ open: true }),
+ })
+ assertPopoverPanel({ state: PopoverState.Visible })
+ })
+ )
+
+ it(
+ 'should be possible to render a Popover.Button using a render prop and an `as` prop',
+ suppressConsoleLogs(async () => {
+ render(
+
+
+ {JSON.stringify}
+
+
+
+ )
+
+ assertPopoverButton({
+ state: PopoverState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-popover-button-1' },
+ textContent: JSON.stringify({ open: false }),
+ })
+ assertPopoverPanel({ state: PopoverState.InvisibleUnmounted })
+
+ await click(getPopoverButton())
+
+ assertPopoverButton({
+ state: PopoverState.Visible,
+ attributes: { id: 'headlessui-popover-button-1' },
+ textContent: JSON.stringify({ open: true }),
+ })
+ assertPopoverPanel({ state: PopoverState.Visible })
+ })
+ )
+ })
+
+ describe('Popover.Panel', () => {
+ it(
+ 'should be possible to render Popover.Panel using a render prop',
+ suppressConsoleLogs(async () => {
+ render(
+
+ Trigger
+ {JSON.stringify}
+
+ )
+
+ assertPopoverButton({
+ state: PopoverState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-popover-button-1' },
+ })
+ assertPopoverPanel({ state: PopoverState.InvisibleUnmounted })
+
+ await click(getPopoverButton())
+
+ assertPopoverButton({
+ state: PopoverState.Visible,
+ attributes: { id: 'headlessui-popover-button-1' },
+ })
+ assertPopoverPanel({
+ state: PopoverState.Visible,
+ textContent: JSON.stringify({ open: true }),
+ })
+ })
+ )
+
+ it('should be possible to always render the Popover.Panel if we provide it a `static` prop', () => {
+ render(
+
+ Trigger
+ Contents
+
+ )
+
+ // Let's verify that the Popover is already there
+ expect(getPopoverPanel()).not.toBe(null)
+ })
+
+ it('should be possible to use a different render strategy for the Popover.Panel', async () => {
+ render(
+
+ Trigger
+ Contents
+
+ )
+
+ getPopoverButton()?.focus()
+
+ assertPopoverButton({ state: PopoverState.InvisibleHidden })
+ assertPopoverPanel({ state: PopoverState.InvisibleHidden })
+
+ // Let's open the Popover, to see if it is not hidden anymore
+ await click(getPopoverButton())
+
+ assertPopoverButton({ state: PopoverState.Visible })
+ assertPopoverPanel({ state: PopoverState.Visible })
+
+ // Let's re-click the Popover, to see if it is hidden again
+ await click(getPopoverButton())
+
+ assertPopoverButton({ state: PopoverState.InvisibleHidden })
+ assertPopoverPanel({ state: PopoverState.InvisibleHidden })
+ })
+
+ it(
+ 'should be possible to move the focus inside the panel to the first focusable element (very first link)',
+ suppressConsoleLogs(async () => {
+ render(
+
+ Trigger
+
+ Link 1
+
+
+ )
+
+ // Focus the button
+ getPopoverButton()?.focus()
+
+ // Ensure the button is focused
+ assertActiveElement(getPopoverButton())
+
+ // Open the popover
+ await click(getPopoverButton())
+
+ // Ensure the active element is within the Panel
+ assertContainsActiveElement(getPopoverPanel())
+ assertActiveElement(getByText('Link 1'))
+ })
+ )
+
+ it(
+ 'should close the Popover, when Popover.Panel has the focus prop and you focus the open button',
+ suppressConsoleLogs(async () => {
+ render(
+
+ Trigger
+
+ Link 1
+
+
+ )
+
+ // Focus the button
+ getPopoverButton()?.focus()
+
+ // Ensure the button is focused
+ assertActiveElement(getPopoverButton())
+
+ // Open the popover
+ await click(getPopoverButton())
+
+ // Ensure the active element is within the Panel
+ assertContainsActiveElement(getPopoverPanel())
+ assertActiveElement(getByText('Link 1'))
+
+ // Focus the button again
+ getPopoverButton()?.focus()
+
+ // Ensure the Popover is closed again
+ assertPopoverPanel({ state: PopoverState.InvisibleUnmounted })
+ })
+ )
+
+ it(
+ 'should be possible to move the focus inside the panel to the first focusable element (skip hidden link)',
+ suppressConsoleLogs(async () => {
+ render(
+
+ Trigger
+
+
+ Link 1
+
+ Link 2
+
+
+ )
+
+ // Focus the button
+ getPopoverButton()?.focus()
+
+ // Ensure the button is focused
+ assertActiveElement(getPopoverButton())
+
+ // Open the popover
+ await click(getPopoverButton())
+
+ // Ensure the active element is within the Panel
+ assertContainsActiveElement(getPopoverPanel())
+ assertActiveElement(getByText('Link 2'))
+ })
+ )
+
+ it(
+ 'should be possible to move the focus inside the panel to the first focusable element (very first link) when the hidden render strategy is used',
+ suppressConsoleLogs(async () => {
+ render(
+
+ Trigger
+
+ Link 1
+
+
+ )
+
+ // Focus the button
+ getPopoverButton()?.focus()
+
+ // Ensure the button is focused
+ assertActiveElement(getPopoverButton())
+
+ // Open the popover
+ await click(getPopoverButton())
+
+ // Ensure the active element is within the Panel
+ assertContainsActiveElement(getPopoverPanel())
+ assertActiveElement(getByText('Link 1'))
+ })
+ )
+ })
+})
+
+describe('Keyboard interactions', () => {
+ describe('`Enter` key', () => {
+ it(
+ 'should be possible to open the Popover with Enter',
+ suppressConsoleLogs(async () => {
+ render(
+
+ Trigger
+ Contents
+
+ )
+
+ assertPopoverButton({
+ state: PopoverState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-popover-button-1' },
+ })
+ assertPopoverPanel({ state: PopoverState.InvisibleUnmounted })
+
+ // Focus the button
+ getPopoverButton()?.focus()
+
+ // Open popover
+ await press(Keys.Enter)
+
+ // Verify it is open
+ assertPopoverButton({ state: PopoverState.Visible })
+ assertPopoverPanel({
+ state: PopoverState.Visible,
+ attributes: { id: 'headlessui-popover-panel-2' },
+ })
+
+ // Close popover
+ await press(Keys.Enter)
+ assertPopoverButton({ state: PopoverState.InvisibleUnmounted })
+ })
+ )
+
+ it(
+ 'should not be possible to open the popover with Enter when the button is disabled',
+ suppressConsoleLogs(async () => {
+ render(
+
+ Trigger
+ Content
+
+ )
+
+ assertPopoverButton({
+ state: PopoverState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-popover-button-1' },
+ })
+ assertPopoverPanel({ state: PopoverState.InvisibleUnmounted })
+
+ // Focus the button
+ getPopoverButton()?.focus()
+
+ // Try to open the popover
+ await press(Keys.Enter)
+
+ // Verify it is still closed
+ assertPopoverButton({
+ state: PopoverState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-popover-button-1' },
+ })
+ assertPopoverPanel({ state: PopoverState.InvisibleUnmounted })
+ })
+ )
+
+ it(
+ 'should be possible to close the popover with Enter when the popover is open',
+ suppressConsoleLogs(async () => {
+ render(
+
+ Trigger
+ Contents
+
+ )
+
+ assertPopoverButton({
+ state: PopoverState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-popover-button-1' },
+ })
+ assertPopoverPanel({ state: PopoverState.InvisibleUnmounted })
+
+ // Focus the button
+ getPopoverButton()?.focus()
+
+ // Open popover
+ await press(Keys.Enter)
+
+ // Verify it is open
+ assertPopoverButton({ state: PopoverState.Visible })
+ assertPopoverPanel({
+ state: PopoverState.Visible,
+ attributes: { id: 'headlessui-popover-panel-2' },
+ })
+
+ // Close popover
+ await press(Keys.Enter)
+
+ // Verify it is closed again
+ assertPopoverButton({ state: PopoverState.InvisibleUnmounted })
+ assertPopoverPanel({ state: PopoverState.InvisibleUnmounted })
+ })
+ )
+
+ it(
+ 'should close other popover menus when we open a new one',
+ suppressConsoleLogs(async () => {
+ render(
+
+
+ Trigger 1
+ Panel 1
+
+
+ Trigger 2
+ Panel 2
+
+
+ )
+
+ // Open the first Popover
+ await click(getByText('Trigger 1'))
+
+ // Verify the correct popovers are open
+ assertPopoverButton({ state: PopoverState.Visible }, getByText('Trigger 1'))
+ assertPopoverButton({ state: PopoverState.InvisibleUnmounted }, getByText('Trigger 2'))
+
+ // Focus trigger 2
+ getByText('Trigger 2')?.focus()
+
+ // Verify the correct popovers are open
+ assertPopoverButton({ state: PopoverState.Visible }, getByText('Trigger 1'))
+ assertPopoverButton({ state: PopoverState.InvisibleUnmounted }, getByText('Trigger 2'))
+
+ // Open the second popover
+ await press(Keys.Enter)
+
+ // Verify the correct popovers are open
+ assertPopoverButton({ state: PopoverState.InvisibleUnmounted }, getByText('Trigger 1'))
+ assertPopoverButton({ state: PopoverState.Visible }, getByText('Trigger 2'))
+ })
+ )
+
+ it(
+ 'should close the Popover by pressing `Enter` on a Popover.Button inside a Popover.Panel',
+ suppressConsoleLogs(async () => {
+ render(
+
+ Open
+
+ Close
+
+
+ )
+
+ // Open the popover
+ await click(getPopoverButton())
+
+ let closeBtn = getByText('Close')
+
+ expect(closeBtn).not.toHaveAttribute('id')
+ expect(closeBtn).not.toHaveAttribute('aria-controls')
+ expect(closeBtn).not.toHaveAttribute('aria-expanded')
+
+ // The close button should close the popover
+ await press(Keys.Enter, closeBtn)
+
+ // Verify it is closed
+ assertPopoverPanel({ state: PopoverState.InvisibleUnmounted })
+
+ // Verify we restored the Open button
+ assertActiveElement(getPopoverButton())
+ })
+ )
+ })
+
+ describe('`Escape` key', () => {
+ it(
+ 'should close the Popover menu, when pressing escape on the Popover.Button',
+ suppressConsoleLogs(async () => {
+ render(
+
+ Trigger
+ Contents
+
+ )
+
+ // Focus the button
+ getPopoverButton()?.focus()
+
+ // Verify popover is closed
+ assertPopoverButton({ state: PopoverState.InvisibleUnmounted })
+
+ // Open popover
+ await click(getPopoverButton())
+
+ // Verify popover is open
+ assertPopoverButton({ state: PopoverState.Visible })
+
+ // Close popover
+ await press(Keys.Escape)
+
+ // Verify popover is closed
+ assertPopoverButton({ state: PopoverState.InvisibleUnmounted })
+
+ // Verify button is (still) focused
+ assertActiveElement(getPopoverButton())
+ })
+ )
+
+ it(
+ 'should close the Popover menu, when pressing escape on the Popover.Panel',
+ suppressConsoleLogs(async () => {
+ render(
+
+ Trigger
+
+ Link
+
+
+ )
+
+ // Focus the button
+ getPopoverButton()?.focus()
+
+ // Verify popover is closed
+ assertPopoverButton({ state: PopoverState.InvisibleUnmounted })
+
+ // Open popover
+ await click(getPopoverButton())
+
+ // Verify popover is open
+ assertPopoverButton({ state: PopoverState.Visible })
+
+ // Tab to next focusable item
+ await press(Keys.Tab)
+
+ // Verify the active element is inside the panel
+ assertContainsActiveElement(getPopoverPanel())
+
+ // Close popover
+ await press(Keys.Escape)
+
+ // Verify popover is closed
+ assertPopoverButton({ state: PopoverState.InvisibleUnmounted })
+
+ // Verify button is focused again
+ assertActiveElement(getPopoverButton())
+ })
+ )
+
+ it(
+ 'should be possible to close a sibling Popover when pressing escape on a sibling Popover.Button',
+ suppressConsoleLogs(async () => {
+ render(
+
+
+ Trigger 1
+ Panel 1
+
+
+
+ Trigger 2
+ Panel 2
+
+
+ )
+
+ // Focus the button of the first Popover
+ getByText('Trigger 1')?.focus()
+
+ // Verify popover is closed
+ assertPopoverButton({ state: PopoverState.InvisibleUnmounted }, getByText('Trigger 1'))
+ assertPopoverButton({ state: PopoverState.InvisibleUnmounted }, getByText('Trigger 2'))
+
+ // Open popover
+ await click(getByText('Trigger 1'))
+
+ // Verify popover is open
+ assertPopoverButton({ state: PopoverState.Visible }, getByText('Trigger 1'))
+ assertPopoverButton({ state: PopoverState.InvisibleUnmounted }, getByText('Trigger 2'))
+
+ assertPopoverPanel({ state: PopoverState.Visible }, getByText('Panel 1'))
+ assertPopoverPanel({ state: PopoverState.InvisibleUnmounted }, getByText('Panel 2'))
+
+ // Focus the button of the second popover menu
+ getByText('Trigger 2')?.focus()
+
+ // Close popover
+ await press(Keys.Escape)
+
+ // Verify both popovers are closed
+ assertPopoverButton({ state: PopoverState.InvisibleUnmounted }, getByText('Trigger 1'))
+ assertPopoverButton({ state: PopoverState.InvisibleUnmounted }, getByText('Trigger 2'))
+
+ // Verify the button of the second popover is still focused
+ assertActiveElement(getByText('Trigger 2'))
+ })
+ )
+ })
+
+ describe('`Tab` key', () => {
+ it(
+ 'should be possible to Tab through the panel contents onto the next Popover.Button',
+ suppressConsoleLogs(async () => {
+ render(
+
+
+ Trigger 1
+
+ Link 1
+ Link 2
+
+
+
+
+ Trigger 2
+ Panel 2
+
+
+ )
+
+ // Focus the button of the first Popover
+ getByText('Trigger 1')?.focus()
+
+ // Open popover
+ await click(getByText('Trigger 1'))
+
+ // Verify we are focused on the first link
+ await press(Keys.Tab)
+ assertActiveElement(getByText('Link 1'))
+
+ // Verify we are focused on the second link
+ await press(Keys.Tab)
+ assertActiveElement(getByText('Link 2'))
+
+ // Let's Tab again
+ await press(Keys.Tab)
+
+ // Verify that the first Popover is still open
+ assertPopoverButton({ state: PopoverState.Visible })
+ assertPopoverPanel({ state: PopoverState.Visible })
+
+ // Verify that the second button is focused
+ assertActiveElement(getByText('Trigger 2'))
+ })
+ )
+
+ it(
+ 'should be possible to place a focusable item in the Popover.Group, and keep the Popover open when we focus the focusable element',
+ suppressConsoleLogs(async () => {
+ render(
+
+
+ Trigger 1
+
+ Link 1
+ Link 2
+
+
+
+ Link in between
+
+
+ Trigger 2
+ Panel 2
+
+
+ )
+
+ // Focus the button of the first Popover
+ getByText('Trigger 1')?.focus()
+
+ // Open popover
+ await click(getByText('Trigger 1'))
+
+ // Verify we are focused on the first link
+ await press(Keys.Tab)
+ assertActiveElement(getByText('Link 1'))
+
+ // Verify we are focused on the second link
+ await press(Keys.Tab)
+ assertActiveElement(getByText('Link 2'))
+
+ // Let's Tab to the in between link
+ await press(Keys.Tab)
+
+ // Verify that the first Popover is still open
+ assertPopoverButton({ state: PopoverState.Visible })
+ assertPopoverPanel({ state: PopoverState.Visible })
+
+ // Verify that the in between link is focused
+ assertActiveElement(getByText('Link in between'))
+ })
+ )
+
+ it(
+ 'should close the Popover menu once we Tab out of the Popover.Group',
+ suppressConsoleLogs(async () => {
+ render(
+ <>
+
+
+ Trigger 1
+
+ Link 1
+ Link 2
+
+
+
+
+ Trigger 2
+
+ Link 3
+ Link 4
+
+
+
+
+ Next
+ >
+ )
+
+ // Focus the button of the first Popover
+ getByText('Trigger 1')?.focus()
+
+ // Open popover
+ await click(getByText('Trigger 1'))
+
+ // Verify we are focused on the first link
+ await press(Keys.Tab)
+ assertActiveElement(getByText('Link 1'))
+
+ // Verify we are focused on the second link
+ await press(Keys.Tab)
+ assertActiveElement(getByText('Link 2'))
+
+ // Let's Tab again
+ await press(Keys.Tab)
+
+ // Verify that the first Popover is still open
+ assertPopoverButton({ state: PopoverState.Visible })
+ assertPopoverPanel({ state: PopoverState.Visible })
+
+ // Verify that the second button is focused
+ assertActiveElement(getByText('Trigger 2'))
+
+ // Let's Tab out of the Popover.Group
+ await press(Keys.Tab)
+
+ // Verify the next link is now focused
+ assertActiveElement(getByText('Next'))
+
+ // Verify the popover is closed
+ assertPopoverButton({ state: PopoverState.InvisibleUnmounted })
+ assertPopoverPanel({ state: PopoverState.InvisibleUnmounted })
+ })
+ )
+
+ it(
+ 'should close the Popover menu once we Tab out of the Popover',
+ suppressConsoleLogs(async () => {
+ render(
+ <>
+
+ Trigger 1
+
+ Link 1
+ Link 2
+
+
+
+ Next
+ >
+ )
+
+ // Focus the button of the first Popover
+ getByText('Trigger 1')?.focus()
+
+ // Open popover
+ await click(getByText('Trigger 1'))
+
+ // Verify we are focused on the first link
+ await press(Keys.Tab)
+ assertActiveElement(getByText('Link 1'))
+
+ // Verify we are focused on the second link
+ await press(Keys.Tab)
+ assertActiveElement(getByText('Link 2'))
+
+ // Let's Tab out of the Popover
+ await press(Keys.Tab)
+
+ // Verify the next link is now focused
+ assertActiveElement(getByText('Next'))
+
+ // Verify the popover is closed
+ assertPopoverButton({ state: PopoverState.InvisibleUnmounted })
+ assertPopoverPanel({ state: PopoverState.InvisibleUnmounted })
+ })
+ )
+
+ it(
+ 'should close the Popover when the Popover.Panel has a focus prop',
+ suppressConsoleLogs(async () => {
+ render(
+ <>
+ Previous
+
+ Trigger
+
+ Link 1
+ Link 2
+
+
+ Next
+ >
+ )
+
+ // Open the popover
+ await click(getPopoverButton())
+
+ // Focus should be within the panel
+ assertContainsActiveElement(getPopoverPanel())
+
+ // Tab out of the component
+ await press(Keys.Tab) // Tab to link 1
+ await press(Keys.Tab) // Tab out
+
+ // The popover should be closed
+ assertPopoverPanel({ state: PopoverState.InvisibleUnmounted })
+
+ // The active element should be the Next link outside of the popover
+ assertActiveElement(getByText('Next'))
+ })
+ )
+
+ it(
+ 'should close the Popover when the Popover.Panel has a focus prop (Popover.Panel uses a Portal)',
+ suppressConsoleLogs(async () => {
+ render(
+ <>
+ Previous
+
+ Trigger
+
+
+ Link 1
+ Link 2
+
+
+
+ Next
+ >
+ )
+
+ // Open the popover
+ await click(getPopoverButton())
+
+ // Focus should be within the panel
+ assertContainsActiveElement(getPopoverPanel())
+
+ // The focus should be on the first link
+ assertActiveElement(getByText('Link 1'))
+
+ // Tab to the next link
+ await press(Keys.Tab)
+
+ // The focus should be on the second link
+ assertActiveElement(getByText('Link 2'))
+
+ // Tab out of the component
+ await press(Keys.Tab)
+
+ // The popover should be closed
+ assertPopoverPanel({ state: PopoverState.InvisibleUnmounted })
+
+ // The active element should be the Next link outside of the popover
+ assertActiveElement(getByText('Next'))
+ })
+ )
+
+ it(
+ 'should close the Popover when the Popover.Panel has a focus prop (Popover.Panel uses a Portal), and focus the next focusable item in line',
+ suppressConsoleLogs(async () => {
+ render(
+ <>
+ Previous
+
+ Trigger
+
+
+ Link 1
+ Link 2
+
+
+
+ >
+ )
+
+ // Open the popover
+ await click(getPopoverButton())
+
+ // Focus should be within the panel
+ assertContainsActiveElement(getPopoverPanel())
+
+ // The focus should be on the first link
+ assertActiveElement(getByText('Link 1'))
+
+ // Tab to the next link
+ await press(Keys.Tab)
+
+ // The focus should be on the second link
+ assertActiveElement(getByText('Link 2'))
+
+ // Tab out of the component
+ await press(Keys.Tab)
+
+ // The popover should be closed
+ assertPopoverPanel({ state: PopoverState.InvisibleUnmounted })
+
+ // The active element should be the Previous link outside of the popover, this is the next one in line
+ assertActiveElement(getByText('Previous'))
+ })
+ )
+ })
+
+ describe('`Shift+Tab` key', () => {
+ it(
+ 'should close the Popover menu once we Tab out of the Popover.Group',
+ suppressConsoleLogs(async () => {
+ render(
+ <>
+ Previous
+
+
+
+ Trigger 1
+
+ Link 1
+ Link 2
+
+
+
+
+ Trigger 2
+
+ Link 3
+ Link 4
+
+
+
+ >
+ )
+
+ // Focus the button of the second Popover
+ getByText('Trigger 2')?.focus()
+
+ // Open popover
+ await click(getByText('Trigger 2'))
+
+ // Verify we can tab to Trigger 1
+ await press(shift(Keys.Tab))
+ assertActiveElement(getByText('Trigger 1'))
+
+ // Let's Tab out of the Popover.Group
+ await press(shift(Keys.Tab))
+
+ // Verify the previous link is now focused
+ assertActiveElement(getByText('Previous'))
+
+ // Verify the popover is closed
+ assertPopoverButton({ state: PopoverState.InvisibleUnmounted })
+ assertPopoverPanel({ state: PopoverState.InvisibleUnmounted })
+ })
+ )
+
+ it(
+ 'should close the Popover menu once we Tab out of the Popover',
+ suppressConsoleLogs(async () => {
+ render(
+ <>
+ Previous
+
+
+ Trigger 1
+
+ Link 1
+ Link 2
+
+
+ >
+ )
+
+ // Focus the button of the Popover
+ getPopoverButton()?.focus()
+
+ // Open popover
+ await click(getPopoverButton())
+
+ // Let's Tab out of the Popover
+ await press(shift(Keys.Tab))
+
+ // Verify the previous link is now focused
+ assertActiveElement(getByText('Previous'))
+
+ // Verify the popover is closed
+ assertPopoverButton({ state: PopoverState.InvisibleUnmounted })
+ assertPopoverPanel({ state: PopoverState.InvisibleUnmounted })
+ })
+ )
+
+ it(
+ 'should focus the previous Popover.Button when Shift+Tab on the second Popover.Button',
+ suppressConsoleLogs(async () => {
+ render(
+
+
+ Trigger 1
+
+ Link 1
+ Link 2
+
+
+
+
+ Trigger 2
+
+ Link 3
+ Link 4
+
+
+
+ )
+
+ // Open the second popover
+ await click(getByText('Trigger 2'))
+
+ // Ensure the second popover is open
+ assertPopoverButton({ state: PopoverState.Visible }, getByText('Trigger 2'))
+
+ // Close the popover
+ await press(Keys.Escape)
+
+ // Ensure the popover is now closed
+ assertPopoverButton({ state: PopoverState.InvisibleUnmounted }, getByText('Trigger 2'))
+
+ // Ensure the second Popover.Button is focused
+ assertActiveElement(getByText('Trigger 2'))
+
+ // Tab backwards
+ await press(shift(Keys.Tab))
+
+ // Ensure the first Popover.Button is open
+ assertActiveElement(getByText('Trigger 1'))
+ })
+ )
+
+ it(
+ 'should focus the Popover.Button when pressing Shift+Tab when we focus inside the Popover.Panel',
+ suppressConsoleLogs(async () => {
+ render(
+
+ Trigger 1
+
+ Link 1
+ Link 2
+
+
+ )
+
+ // Open the popover
+ await click(getPopoverButton())
+
+ // Ensure the popover is open
+ assertPopoverButton({ state: PopoverState.Visible })
+
+ // Ensure the Link 1 is focused
+ assertActiveElement(getByText('Link 1'))
+
+ // Tab out of the Panel
+ await press(shift(Keys.Tab))
+
+ // Ensure the Popover.Button is focused again
+ assertActiveElement(getPopoverButton())
+
+ // Ensure the Popover is closed
+ assertPopoverButton({ state: PopoverState.InvisibleUnmounted })
+ assertPopoverPanel({ state: PopoverState.InvisibleUnmounted })
+ })
+ )
+
+ it(
+ 'should focus the Popover.Button when pressing Shift+Tab when we focus inside the Popover.Panel (inside a Portal)',
+ suppressConsoleLogs(async () => {
+ render(
+
+ Trigger 1
+
+
+ Link 1
+ Link 2
+
+
+
+ )
+
+ // Open the popover
+ await click(getPopoverButton())
+
+ // Ensure the popover is open
+ assertPopoverButton({ state: PopoverState.Visible })
+
+ // Ensure the Link 1 is focused
+ assertActiveElement(getByText('Link 1'))
+
+ // Tab out of the Panel
+ await press(shift(Keys.Tab))
+
+ // Ensure the Popover.Button is focused again
+ assertActiveElement(getPopoverButton())
+
+ // Ensure the Popover is closed
+ assertPopoverButton({ state: PopoverState.InvisibleUnmounted })
+ assertPopoverPanel({ state: PopoverState.InvisibleUnmounted })
+ })
+ )
+
+ it(
+ 'should be possible to focus the last item in the Popover.Panel when pressing Shift+Tab on the next Popover.Button',
+ suppressConsoleLogs(async () => {
+ render(
+
+
+ Trigger 1
+
+ Link 1
+ Link 2
+
+
+
+
+ Trigger 2
+
+ Link 3
+ Link 4
+
+
+
+ )
+
+ // Open the popover
+ await click(getByText('Trigger 1'))
+
+ // Ensure the popover is open
+ assertPopoverButton({ state: PopoverState.Visible })
+
+ // Focus the second button
+ getByText('Trigger 2')?.focus()
+
+ // Verify the second button is focused
+ assertActiveElement(getByText('Trigger 2'))
+
+ // Ensure the first Popover is still open
+ assertPopoverButton({ state: PopoverState.Visible })
+ assertPopoverPanel({ state: PopoverState.Visible })
+
+ // Press shift+tab, to move focus to the last item in the Popover.Panel
+ await press(shift(Keys.Tab), getByText('Trigger 2'))
+
+ // Verify we are focusing the last link of the first Popover
+ assertActiveElement(getByText('Link 2'))
+ })
+ )
+
+ it(
+ "should be possible to focus the last item in the Popover.Panel when pressing Shift+Tab on the next Popover.Button (using Portal's)",
+ suppressConsoleLogs(async () => {
+ render(
+
+
+ Trigger 1
+
+
+ Link 1
+ Link 2
+
+
+
+
+
+ Trigger 2
+
+
+ Link 3
+ Link 4
+
+
+
+
+ )
+
+ // Open the popover
+ await click(getByText('Trigger 1'))
+
+ // Ensure the popover is open
+ assertPopoverButton({ state: PopoverState.Visible })
+
+ // Focus the second button
+ getByText('Trigger 2')?.focus()
+
+ // Verify the second button is focused
+ assertActiveElement(getByText('Trigger 2'))
+
+ // Ensure the first Popover is still open
+ assertPopoverButton({ state: PopoverState.Visible })
+ assertPopoverPanel({ state: PopoverState.Visible })
+
+ // Press shift+tab, to move focus to the last item in the Popover.Panel
+ await press(shift(Keys.Tab), getByText('Trigger 2'))
+
+ // Verify we are focusing the last link of the first Popover
+ assertActiveElement(getByText('Link 2'))
+ })
+ )
+ })
+
+ describe('`Space` key', () => {
+ it(
+ 'should be possible to open the popover with Space',
+ suppressConsoleLogs(async () => {
+ render(
+
+ Trigger
+ Contents
+
+ )
+
+ assertPopoverButton({
+ state: PopoverState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-popover-button-1' },
+ })
+ assertPopoverPanel({ state: PopoverState.InvisibleUnmounted })
+
+ // Focus the button
+ getPopoverButton()?.focus()
+
+ // Open popover
+ await press(Keys.Space)
+
+ // Verify it is open
+ assertPopoverButton({ state: PopoverState.Visible })
+ assertPopoverPanel({
+ state: PopoverState.Visible,
+ attributes: { id: 'headlessui-popover-panel-2' },
+ })
+ })
+ )
+
+ it(
+ 'should not be possible to open the popover with Space when the button is disabled',
+ suppressConsoleLogs(async () => {
+ render(
+
+ Trigger
+ Contents
+
+ )
+
+ assertPopoverButton({
+ state: PopoverState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-popover-button-1' },
+ })
+ assertPopoverPanel({ state: PopoverState.InvisibleUnmounted })
+
+ // Focus the button
+ getPopoverButton()?.focus()
+
+ // Try to open the popover
+ await press(Keys.Space)
+
+ // Verify it is still closed
+ assertPopoverButton({
+ state: PopoverState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-popover-button-1' },
+ })
+ assertPopoverPanel({ state: PopoverState.InvisibleUnmounted })
+ })
+ )
+
+ it(
+ 'should be possible to close the popover with Space when the popover is open',
+ suppressConsoleLogs(async () => {
+ render(
+
+ Trigger
+ Contents
+
+ )
+
+ assertPopoverButton({
+ state: PopoverState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-popover-button-1' },
+ })
+ assertPopoverPanel({ state: PopoverState.InvisibleUnmounted })
+
+ // Focus the button
+ getPopoverButton()?.focus()
+
+ // Open popover
+ await press(Keys.Space)
+
+ // Verify it is open
+ assertPopoverButton({ state: PopoverState.Visible })
+ assertPopoverPanel({
+ state: PopoverState.Visible,
+ attributes: { id: 'headlessui-popover-panel-2' },
+ })
+
+ // Close popover
+ await press(Keys.Space)
+
+ // Verify it is closed again
+ assertPopoverButton({ state: PopoverState.InvisibleUnmounted })
+ assertPopoverPanel({ state: PopoverState.InvisibleUnmounted })
+ })
+ )
+
+ it(
+ 'should close other popover menus when we open a new one',
+ suppressConsoleLogs(async () => {
+ render(
+
+
+ Trigger 1
+ Panel 1
+
+
+ Trigger 2
+ Panel 2
+
+
+ )
+
+ // Open the first Popover
+ await click(getByText('Trigger 1'))
+
+ // Verify the correct popovers are open
+ assertPopoverButton({ state: PopoverState.Visible }, getByText('Trigger 1'))
+ assertPopoverButton({ state: PopoverState.InvisibleUnmounted }, getByText('Trigger 2'))
+
+ // Focus trigger 2
+ getByText('Trigger 2')?.focus()
+
+ // Verify the correct popovers are open
+ assertPopoverButton({ state: PopoverState.Visible }, getByText('Trigger 1'))
+ assertPopoverButton({ state: PopoverState.InvisibleUnmounted }, getByText('Trigger 2'))
+
+ // Open the second popover
+ await press(Keys.Space)
+
+ // Verify the correct popovers are open
+ assertPopoverButton({ state: PopoverState.InvisibleUnmounted }, getByText('Trigger 1'))
+ assertPopoverButton({ state: PopoverState.Visible }, getByText('Trigger 2'))
+ })
+ )
+
+ it(
+ 'should close the Popover by pressing `Space` on a Popover.Button inside a Popover.Panel',
+ suppressConsoleLogs(async () => {
+ render(
+
+ Open
+
+ Close
+
+
+ )
+
+ // Open the popover
+ await click(getPopoverButton())
+
+ let closeBtn = getByText('Close')
+
+ expect(closeBtn).not.toHaveAttribute('id')
+ expect(closeBtn).not.toHaveAttribute('aria-controls')
+ expect(closeBtn).not.toHaveAttribute('aria-expanded')
+
+ // The close button should close the popover
+ await press(Keys.Space, closeBtn)
+
+ // Verify it is closed
+ assertPopoverPanel({ state: PopoverState.InvisibleUnmounted })
+
+ // Verify we restored the Open button
+ assertActiveElement(getPopoverButton())
+ })
+ )
+ })
+})
+
+describe('Mouse interactions', () => {
+ it(
+ 'should be possible to open a popover on click',
+ suppressConsoleLogs(async () => {
+ render(
+
+ Trigger
+ Contents
+
+ )
+
+ assertPopoverButton({
+ state: PopoverState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-popover-button-1' },
+ })
+ assertPopoverPanel({ state: PopoverState.InvisibleUnmounted })
+
+ // Open popover
+ await click(getPopoverButton())
+
+ // Verify it is open
+ assertPopoverButton({ state: PopoverState.Visible })
+ assertPopoverPanel({
+ state: PopoverState.Visible,
+ attributes: { id: 'headlessui-popover-panel-2' },
+ })
+ })
+ )
+
+ it(
+ 'should not be possible to open a popover on right click',
+ suppressConsoleLogs(async () => {
+ render(
+
+ Trigger
+ Contents
+
+ )
+
+ assertPopoverButton({
+ state: PopoverState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-popover-button-1' },
+ })
+ assertPopoverPanel({ state: PopoverState.InvisibleUnmounted })
+
+ // Open popover
+ await click(getPopoverButton(), MouseButton.Right)
+
+ // Verify it is still closed
+ assertPopoverButton({
+ state: PopoverState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-popover-button-1' },
+ })
+ assertPopoverPanel({ state: PopoverState.InvisibleUnmounted })
+ })
+ )
+
+ it(
+ 'should not be possible to open a popover on click when the button is disabled',
+ suppressConsoleLogs(async () => {
+ render(
+
+ Trigger
+ Contents
+
+ )
+
+ assertPopoverButton({
+ state: PopoverState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-popover-button-1' },
+ })
+ assertPopoverPanel({ state: PopoverState.InvisibleUnmounted })
+
+ // Try to open the popover
+ await click(getPopoverButton())
+
+ // Verify it is still closed
+ assertPopoverButton({
+ state: PopoverState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-popover-button-1' },
+ })
+ assertPopoverPanel({ state: PopoverState.InvisibleUnmounted })
+ })
+ )
+
+ it(
+ 'should be possible to close a popover on click',
+ suppressConsoleLogs(async () => {
+ render(
+
+ Trigger
+ Contents
+
+ )
+
+ getPopoverButton()?.focus()
+
+ // Open popover
+ await click(getPopoverButton())
+
+ // Verify it is open
+ assertPopoverButton({ state: PopoverState.Visible })
+
+ // Click to close
+ await click(getPopoverButton())
+
+ // Verify it is closed
+ assertPopoverButton({ state: PopoverState.InvisibleUnmounted })
+ assertPopoverPanel({ state: PopoverState.InvisibleUnmounted })
+ })
+ )
+
+ it(
+ 'should be possible to close a Popover using a click on the Popover.Overlay',
+ suppressConsoleLogs(async () => {
+ render(
+
+ Trigger
+ Contents
+
+
+ )
+
+ // Open popover
+ await click(getPopoverButton())
+
+ // Verify it is open
+ assertPopoverButton({ state: PopoverState.Visible })
+
+ // Click the overlay to close
+ await click(getPopoverOverlay())
+
+ // Verify it is open
+ assertPopoverButton({ state: PopoverState.InvisibleUnmounted })
+ })
+ )
+
+ it(
+ 'should be possible to close the popover, and re-focus the button when we click outside on the body element',
+ suppressConsoleLogs(async () => {
+ render(
+
+ Trigger
+ Contents
+
+ )
+
+ // Open popover
+ await click(getPopoverButton())
+
+ // Verify it is open
+ assertPopoverButton({ state: PopoverState.Visible })
+
+ // Click the body to close
+ await click(document.body)
+
+ // Verify it is closed
+ assertPopoverButton({ state: PopoverState.InvisibleUnmounted })
+
+ // Verify the button is focused
+ assertActiveElement(getPopoverButton())
+ })
+ )
+
+ it(
+ 'should be possible to close the popover, and re-focus the button when we click outside on a non-focusable element',
+ suppressConsoleLogs(async () => {
+ render(
+ <>
+
+ Trigger
+ Contents
+
+
+ I am just text
+ >
+ )
+
+ // Open popover
+ await click(getPopoverButton())
+
+ // Verify it is open
+ assertPopoverButton({ state: PopoverState.Visible })
+
+ // Click the span to close
+ await click(getByText('I am just text'))
+
+ // Verify it is closed
+ assertPopoverButton({ state: PopoverState.InvisibleUnmounted })
+
+ // Verify the button is focused
+ assertActiveElement(getPopoverButton())
+ })
+ )
+
+ it(
+ 'should be possible to close the popover, by clicking outside the popover on another focusable element',
+ suppressConsoleLogs(async () => {
+ render(
+ <>
+
+ Trigger
+ Contents
+
+
+ Different button
+ >
+ )
+
+ // Open popover
+ await click(getPopoverButton())
+
+ // Verify it is open
+ assertPopoverButton({ state: PopoverState.Visible })
+
+ // Click the extra button to close
+ await click(getByText('Different button'))
+
+ // Verify it is closed
+ assertPopoverButton({ state: PopoverState.InvisibleUnmounted })
+
+ // Verify the other button is focused
+ assertActiveElement(getByText('Different button'))
+ })
+ )
+
+ it(
+ 'should be possible to close the Popover by clicking on a Popover.Button inside a Popover.Panel',
+ suppressConsoleLogs(async () => {
+ render(
+
+ Open
+
+ Close
+
+
+ )
+
+ // Open the popover
+ await click(getPopoverButton())
+
+ let closeBtn = getByText('Close')
+
+ expect(closeBtn).not.toHaveAttribute('id')
+ expect(closeBtn).not.toHaveAttribute('aria-controls')
+ expect(closeBtn).not.toHaveAttribute('aria-expanded')
+
+ // The close button should close the popover
+ await click(closeBtn)
+
+ // Verify it is closed
+ assertPopoverPanel({ state: PopoverState.InvisibleUnmounted })
+
+ // Verify we restored the Open button
+ assertActiveElement(getPopoverButton())
+ })
+ )
+})
diff --git a/packages/@headlessui-react/src/components/popover/popover.tsx b/packages/@headlessui-react/src/components/popover/popover.tsx
new file mode 100644
index 0000000000..b34e2dac1b
--- /dev/null
+++ b/packages/@headlessui-react/src/components/popover/popover.tsx
@@ -0,0 +1,706 @@
+import React, {
+ createContext,
+ useContext,
+ useReducer,
+ useEffect,
+ useMemo,
+ useCallback,
+
+ // Types
+ Dispatch,
+ ElementType,
+ Ref,
+ KeyboardEvent as ReactKeyboardEvent,
+ MouseEvent as ReactMouseEvent,
+ useRef,
+ useState,
+ ContextType,
+} from 'react'
+
+import { Props } from '../../types'
+import { match } from '../../utils/match'
+import { forwardRefWithAs, render, Features, PropsForFeatures } from '../../utils/render'
+import { useSyncRefs } from '../../hooks/use-sync-refs'
+import { useId } from '../../hooks/use-id'
+import { Keys } from '../keyboard'
+import { isDisabledReactIssue7711 } from '../../utils/bugs'
+import {
+ getFocusableElements,
+ Focus,
+ focusIn,
+ FocusResult,
+ isFocusableElement,
+} from '../../utils/focus-management'
+
+enum PopoverStates {
+ Open,
+ Closed,
+}
+
+interface StateDefinition {
+ popoverState: PopoverStates
+
+ button: HTMLElement | null
+ buttonId: string
+ panel: HTMLElement | null
+ panelId: string
+}
+
+enum ActionTypes {
+ TogglePopover,
+ ClosePopover,
+
+ SetButton,
+ SetButtonId,
+ SetPanel,
+ SetPanelId,
+}
+
+type Actions =
+ | { type: ActionTypes.TogglePopover }
+ | { type: ActionTypes.ClosePopover }
+ | { type: ActionTypes.SetButton; button: HTMLElement | null }
+ | { type: ActionTypes.SetButtonId; buttonId: string }
+ | { type: ActionTypes.SetPanel; panel: HTMLElement | null }
+ | { type: ActionTypes.SetPanelId; panelId: string }
+
+let reducers: {
+ [P in ActionTypes]: (
+ state: StateDefinition,
+ action: Extract
+ ) => StateDefinition
+} = {
+ [ActionTypes.TogglePopover]: state => ({
+ ...state,
+ popoverState: match(state.popoverState, {
+ [PopoverStates.Open]: PopoverStates.Closed,
+ [PopoverStates.Closed]: PopoverStates.Open,
+ }),
+ }),
+ [ActionTypes.ClosePopover](state) {
+ if (state.popoverState === PopoverStates.Closed) return state
+ return { ...state, popoverState: PopoverStates.Closed }
+ },
+ [ActionTypes.SetButton](state, action) {
+ if (state.button === action.button) return state
+ return { ...state, button: action.button }
+ },
+ [ActionTypes.SetButtonId](state, action) {
+ if (state.buttonId === action.buttonId) return state
+ return { ...state, buttonId: action.buttonId }
+ },
+ [ActionTypes.SetPanel](state, action) {
+ if (state.panel === action.panel) return state
+ return { ...state, panel: action.panel }
+ },
+ [ActionTypes.SetPanelId](state, action) {
+ if (state.panelId === action.panelId) return state
+ return { ...state, panelId: action.panelId }
+ },
+}
+
+let PopoverContext = createContext<[StateDefinition, Dispatch] | null>(null)
+PopoverContext.displayName = 'PopoverContext'
+
+function usePopoverContext(component: string) {
+ let context = useContext(PopoverContext)
+ if (context === null) {
+ let err = new Error(`<${component} /> is missing a parent <${Popover.name} /> component.`)
+ if (Error.captureStackTrace) Error.captureStackTrace(err, usePopoverContext)
+ throw err
+ }
+ return context
+}
+
+let PopoverGroupContext = createContext<{
+ registerPopover(registerbag: PopoverRegisterBag): void
+ unregisterPopover(registerbag: PopoverRegisterBag): void
+ isFocusWithinPopoverGroup(): boolean
+ closeOthers(buttonId: string): void
+} | null>(null)
+PopoverGroupContext.displayName = 'PopoverGroupContext'
+
+function usePopoverGroupContext() {
+ return useContext(PopoverGroupContext)
+}
+
+let PopoverPanelContext = createContext(null)
+PopoverPanelContext.displayName = 'PopoverPanelContext'
+
+function usePopoverPanelContext() {
+ return useContext(PopoverPanelContext)
+}
+
+interface PopoverRegisterBag {
+ buttonId: string
+ panelId: string
+ close(): void
+}
+function stateReducer(state: StateDefinition, action: Actions) {
+ return match(action.type, reducers, state, action)
+}
+
+// ---
+
+let DEFAULT_FLYOUT_TAG = 'div' as const
+interface PopoverRenderPropArg {
+ open: boolean
+}
+
+export function Popover(
+ props: Props
+) {
+ let buttonId = `headlessui-popover-button-${useId()}`
+ let panelId = `headlessui-popover-panel-${useId()}`
+
+ let reducerBag = useReducer(stateReducer, {
+ popoverState: PopoverStates.Closed,
+ linkedPanel: false,
+ button: null,
+ buttonId,
+ panel: null,
+ panelId,
+ } as StateDefinition)
+ let [{ popoverState, button, panel }, dispatch] = reducerBag
+
+ useEffect(() => dispatch({ type: ActionTypes.SetButtonId, buttonId }), [buttonId, dispatch])
+ useEffect(() => dispatch({ type: ActionTypes.SetPanelId, panelId }), [panelId, dispatch])
+
+ let registerBag = useMemo(
+ () => ({ buttonId, panelId, close: () => dispatch({ type: ActionTypes.ClosePopover }) }),
+ [buttonId, panelId, dispatch]
+ )
+
+ let groupContext = usePopoverGroupContext()
+ let registerPopover = groupContext?.registerPopover
+ let isFocusWithinPopoverGroup = useCallback(() => {
+ return (
+ groupContext?.isFocusWithinPopoverGroup() ??
+ (button?.contains(document.activeElement) || panel?.contains(document.activeElement))
+ )
+ }, [groupContext, button, panel])
+
+ useEffect(() => registerPopover?.(registerBag), [registerPopover, registerBag])
+
+ // Handle focus out
+ useEffect(() => {
+ if (popoverState !== PopoverStates.Open) return
+
+ function handler() {
+ if (isFocusWithinPopoverGroup()) return
+ if (!button) return
+ if (!panel) return
+
+ dispatch({ type: ActionTypes.ClosePopover })
+ }
+
+ window.addEventListener('focus', handler, true)
+ return () => window.removeEventListener('focus', handler, true)
+ }, [popoverState, isFocusWithinPopoverGroup, groupContext, button, panel, dispatch])
+
+ // Handle outside click
+ useEffect(() => {
+ function handler(event: MouseEvent) {
+ let target = event.target as HTMLElement
+
+ if (popoverState !== PopoverStates.Open) return
+
+ if (button?.contains(target)) return
+ if (panel?.contains(target)) return
+
+ dispatch({ type: ActionTypes.ClosePopover })
+
+ if (!isFocusableElement(target)) {
+ event.preventDefault()
+ button?.focus()
+ }
+ }
+
+ window.addEventListener('mousedown', handler)
+ return () => window.removeEventListener('mousedown', handler)
+ }, [popoverState, button, panel, dispatch])
+
+ let propsBag = useMemo(
+ () => ({ open: popoverState === PopoverStates.Open }),
+ [popoverState]
+ )
+
+ return (
+
+ {render(props, propsBag, DEFAULT_FLYOUT_TAG)}
+
+ )
+}
+
+// ---
+
+let DEFAULT_BUTTON_TAG = 'button' as const
+interface ButtonRenderPropArg {
+ open: boolean
+}
+type ButtonPropsWeControl =
+ | 'id'
+ | 'type'
+ | 'aria-expanded'
+ | 'aria-controls'
+ | 'onKeyDown'
+ | 'onClick'
+
+let Button = forwardRefWithAs(function Button(
+ props: Props,
+ ref: Ref
+) {
+ let [state, dispatch] = usePopoverContext([Popover.name, Button.name].join('.'))
+ let internalButtonRef = useRef(null)
+
+ let groupContext = usePopoverGroupContext()
+ let closeOthers = groupContext?.closeOthers
+
+ let panelContext = usePopoverPanelContext()
+ let isWithinPanel = panelContext === null ? false : panelContext === state.panelId
+
+ let buttonRef = useSyncRefs(
+ internalButtonRef,
+ ref,
+ isWithinPanel ? null : button => dispatch({ type: ActionTypes.SetButton, button })
+ )
+
+ // TODO: Revisit when handling Tab/Shift+Tab when using Portal's
+ let activeElementRef = useRef(null)
+ let previousActiveElementRef = useRef(
+ typeof window === 'undefined' ? null : document.activeElement
+ )
+ useEffect(() => {
+ function handler() {
+ previousActiveElementRef.current = activeElementRef.current
+ activeElementRef.current = document.activeElement
+ }
+
+ window.addEventListener('focus', handler, true)
+ return () => window.removeEventListener('focus', handler, true)
+ }, [previousActiveElementRef, activeElementRef])
+
+ let handleKeyDown = useCallback(
+ (event: ReactKeyboardEvent) => {
+ if (isWithinPanel) {
+ if (state.popoverState === PopoverStates.Closed) return
+ switch (event.key) {
+ case Keys.Space:
+ case Keys.Enter:
+ event.preventDefault() // Prevent triggering a *click* event
+ dispatch({ type: ActionTypes.ClosePopover })
+ state.button?.focus() // Re-focus the original opening Button
+ break
+ }
+ } else {
+ switch (event.key) {
+ case Keys.Space:
+ case Keys.Enter:
+ event.preventDefault() // Prevent triggering a *click* event
+ if (state.popoverState === PopoverStates.Closed) closeOthers?.(state.buttonId)
+ dispatch({ type: ActionTypes.TogglePopover })
+ break
+
+ case Keys.Escape:
+ if (state.popoverState !== PopoverStates.Open) return closeOthers?.(state.buttonId)
+ if (!internalButtonRef.current) return
+ if (!internalButtonRef.current.contains(document.activeElement)) return
+ dispatch({ type: ActionTypes.ClosePopover })
+ break
+
+ case Keys.Tab:
+ if (state.popoverState !== PopoverStates.Open) return
+ if (!state.panel) return
+ if (!state.button) return
+
+ // TODO: Revisit when handling Tab/Shift+Tab when using Portal's
+ if (event.shiftKey) {
+ // Check if the last focused element exists, and check that it is not inside button or panel itself
+ if (!previousActiveElementRef.current) return
+ if (state.button?.contains(previousActiveElementRef.current)) return
+ if (state.panel.contains(previousActiveElementRef.current)) return
+
+ // Check if the last focused element is *after* the button in the DOM
+ let focusableElements = getFocusableElements()
+ let previousIdx = focusableElements.indexOf(
+ previousActiveElementRef.current as HTMLElement
+ )
+ let buttonIdx = focusableElements.indexOf(state.button)
+ if (buttonIdx > previousIdx) return
+
+ event.preventDefault()
+ event.stopPropagation()
+
+ focusIn(state.panel, Focus.Last)
+ } else {
+ event.preventDefault()
+ event.stopPropagation()
+
+ focusIn(state.panel, Focus.First)
+ }
+
+ break
+ }
+ }
+ },
+ [
+ dispatch,
+ state.popoverState,
+ state.buttonId,
+ state.button,
+ state.panel,
+ internalButtonRef,
+ closeOthers,
+ isWithinPanel,
+ ]
+ )
+
+ let handleKeyUp = useCallback(
+ (event: ReactKeyboardEvent) => {
+ if (isWithinPanel) return
+ if (state.popoverState !== PopoverStates.Open) return
+ if (!state.panel) return
+ if (!state.button) return
+
+ // TODO: Revisit when handling Tab/Shift+Tab when using Portal's
+ switch (event.key) {
+ case Keys.Tab:
+ // Check if the last focused element exists, and check that it is not inside button or panel itself
+ if (!previousActiveElementRef.current) return
+ if (state.button?.contains(previousActiveElementRef.current)) return
+ if (state.panel.contains(previousActiveElementRef.current)) return
+
+ // Check if the last focused element is *after* the button in the DOM
+ let focusableElements = getFocusableElements()
+ let previousIdx = focusableElements.indexOf(
+ previousActiveElementRef.current as HTMLElement
+ )
+ let buttonIdx = focusableElements.indexOf(state.button)
+ if (buttonIdx > previousIdx) return
+
+ event.preventDefault()
+ focusIn(state.panel, Focus.Last)
+ break
+ }
+ },
+ [state.popoverState, state.panel, state.button, isWithinPanel]
+ )
+
+ let handleClick = useCallback(
+ (event: ReactMouseEvent) => {
+ if (isDisabledReactIssue7711(event.currentTarget)) return
+ if (props.disabled) return
+ if (isWithinPanel) {
+ dispatch({ type: ActionTypes.ClosePopover })
+ state.button?.focus() // Re-focus the original opening Button
+ } else {
+ if (state.popoverState === PopoverStates.Closed) closeOthers?.(state.buttonId)
+ dispatch({ type: ActionTypes.TogglePopover })
+ }
+ },
+ [
+ dispatch,
+ state.button,
+ state.popoverState,
+ state.buttonId,
+ props.disabled,
+ closeOthers,
+ isWithinPanel,
+ ]
+ )
+
+ let propsBag = useMemo(
+ () => ({ open: state.popoverState === PopoverStates.Open }),
+ [state]
+ )
+
+ let passthroughProps = props
+ let propsWeControl = isWithinPanel
+ ? {
+ type: 'button',
+ onKeyDown: handleKeyDown,
+ onClick: handleClick,
+ }
+ : {
+ ref: buttonRef,
+ id: state.buttonId,
+ type: 'button',
+ 'aria-expanded': state.popoverState === PopoverStates.Open ? true : undefined,
+ 'aria-controls': state.panel ? state.panelId : undefined,
+ onKeyDown: handleKeyDown,
+ onKeyUp: handleKeyUp,
+ onClick: handleClick,
+ }
+
+ return render({ ...passthroughProps, ...propsWeControl }, propsBag, DEFAULT_BUTTON_TAG)
+})
+
+// ---
+
+let DEFAULT_OVERLAY_TAG = 'div' as const
+interface OverlayRenderPropArg {
+ open: boolean
+}
+type OverlayPropsWeControl = 'id' | 'aria-hidden' | 'onClick'
+
+let Overlay = forwardRefWithAs(function Overlay<
+ TTag extends ElementType = typeof DEFAULT_OVERLAY_TAG
+>(props: Props, ref: Ref) {
+ let [{ popoverState }, dispatch] = usePopoverContext([Popover.name, Overlay.name].join('.'))
+ let overlayRef = useSyncRefs(ref)
+
+ let id = `headlessui-popover-overlay-${useId()}`
+
+ let handleClick = useCallback(
+ (event: ReactMouseEvent) => {
+ if (isDisabledReactIssue7711(event.currentTarget)) return event.preventDefault()
+ dispatch({ type: ActionTypes.ClosePopover })
+ },
+ [dispatch]
+ )
+
+ let propsBag = useMemo(
+ () => ({ open: popoverState === PopoverStates.Open }),
+ [popoverState]
+ )
+ let propsWeControl = {
+ ref: overlayRef,
+ id,
+ 'aria-hidden': true,
+ onClick: handleClick,
+ }
+ let passthroughProps = props
+
+ return render({ ...passthroughProps, ...propsWeControl }, propsBag, DEFAULT_OVERLAY_TAG)
+})
+
+// ---
+
+let DEFAULT_PANEL_TAG = 'div' as const
+interface PanelRenderPropArg {
+ open: boolean
+}
+type PanelPropsWeControl = 'id' | 'onKeyDown'
+
+let PanelRenderFeatures = Features.RenderStrategy | Features.Static
+
+let Panel = forwardRefWithAs(function Panel(
+ props: Props &
+ PropsForFeatures & { focus?: boolean },
+ ref: Ref
+) {
+ let { focus = false, ...passthroughProps } = props
+
+ let [state, dispatch] = usePopoverContext([Popover.name, Panel.name].join('.'))
+ let internalPanelRef = useRef(null)
+ let panelRef = useSyncRefs(internalPanelRef, ref, panel => {
+ dispatch({ type: ActionTypes.SetPanel, panel })
+ })
+
+ let handleKeyDown = useCallback(
+ (event: KeyboardEvent) => {
+ switch (event.key) {
+ case Keys.Escape:
+ if (state.popoverState !== PopoverStates.Open) return
+ if (!internalPanelRef.current) return
+ if (!internalPanelRef.current.contains(document.activeElement)) return
+ event.preventDefault()
+ dispatch({ type: ActionTypes.ClosePopover })
+ state.button?.focus()
+ break
+ }
+ },
+ [state, internalPanelRef, dispatch]
+ )
+
+ // Unlink on "unmount" myself
+ useEffect(() => () => dispatch({ type: ActionTypes.SetPanel, panel: null }), [dispatch])
+
+ // Unlink on "unmount" children
+ useEffect(() => {
+ if (state.popoverState === PopoverStates.Closed && (props.unmount ?? true)) {
+ dispatch({ type: ActionTypes.SetPanel, panel: null })
+ }
+ }, [state.popoverState, props.unmount, dispatch])
+
+ // Move focus within panel
+ useEffect(() => {
+ if (!focus) return
+ if (state.popoverState !== PopoverStates.Open) return
+ if (!internalPanelRef.current) return
+
+ let activeElement = document.activeElement as HTMLElement
+ if (internalPanelRef.current.contains(activeElement)) return // Already focused within Dialog
+
+ focusIn(internalPanelRef.current, Focus.First)
+ }, [focus, internalPanelRef, state.popoverState])
+
+ // Handle Tab / Shift+Tab focus positioning
+ useEffect(() => {
+ if (state.popoverState !== PopoverStates.Open) return
+ if (!internalPanelRef.current) return
+
+ function handler(event: KeyboardEvent) {
+ if (event.key !== Keys.Tab) return
+ if (!document.activeElement) return
+ if (!internalPanelRef.current) return
+ if (!internalPanelRef.current.contains(document.activeElement)) return
+
+ // We will take-over the default tab behaviour so that we have a bit
+ // control over what is focused next. It will behave exactly the same,
+ // but it will also "fix" some issues based on wether you are using a
+ // Portal or not.
+ event.preventDefault()
+
+ let result = focusIn(internalPanelRef.current, event.shiftKey ? Focus.Previous : Focus.Next)
+
+ if (result === FocusResult.Underflow) {
+ return state.button?.focus()
+ } else if (result === FocusResult.Overflow) {
+ if (!state.button) return
+
+ let elements = getFocusableElements()
+ let buttonIdx = elements.indexOf(state.button)
+
+ let nextElements = elements
+ .splice(buttonIdx + 1) // Elements after button
+ .filter(element => !internalPanelRef.current?.contains(element)) // Ignore items in panel
+
+ // Try to focus the next element, however it could fail if we are in a
+ // Portal that happens to be the very last one in the DOM. In that
+ // case we would Error (because nothing after the button is
+ // focusable). Therefore we will try and focus the very first item in
+ // the document.body.
+ if (focusIn(nextElements, Focus.First) === FocusResult.Error) {
+ focusIn(document.body, Focus.First)
+ }
+ }
+ }
+
+ window.addEventListener('keydown', handler)
+ return () => window.removeEventListener('keydown', handler)
+ }, [focus, internalPanelRef, state.popoverState, state.button])
+
+ // Handle focus out when we are in special "focus" mode
+ useEffect(() => {
+ if (!focus) return
+ if (state.popoverState !== PopoverStates.Open) return
+ if (!internalPanelRef.current) return
+
+ function handler() {
+ if (internalPanelRef.current?.contains(document.activeElement as HTMLElement)) return
+ dispatch({ type: ActionTypes.ClosePopover })
+ }
+
+ window.addEventListener('focus', handler, true)
+ return () => window.removeEventListener('focus', handler, true)
+ }, [focus, state.popoverState, dispatch])
+
+ let propsBag = useMemo(
+ () => ({ open: state.popoverState === PopoverStates.Open }),
+ [state]
+ )
+ let propsWeControl = {
+ ref: panelRef,
+ id: state.panelId,
+ onKeyDown: handleKeyDown,
+ }
+
+ return (
+
+ {render(
+ { ...passthroughProps, ...propsWeControl },
+ propsBag,
+ DEFAULT_PANEL_TAG,
+ PanelRenderFeatures,
+ state.popoverState === PopoverStates.Open
+ )}
+
+ )
+})
+
+// ---
+
+let DEFAULT_GROUP_TAG = 'div' as const
+interface GroupRenderPropArg {}
+type GroupPropsWeControl = 'id'
+
+function Group(
+ props: Props
+) {
+ let groupRef = useRef(null)
+ let [popovers, setPopovers] = useState([])
+
+ let unregisterPopover = useCallback(
+ (registerbag: PopoverRegisterBag) => {
+ setPopovers(existing => {
+ let idx = existing.indexOf(registerbag)
+ if (idx !== -1) {
+ let clone = existing.slice()
+ clone.splice(idx, 1)
+ return clone
+ }
+ return existing
+ })
+ },
+ [setPopovers]
+ )
+
+ let registerPopover = useCallback(
+ (registerbag: PopoverRegisterBag) => {
+ setPopovers(existing => [...existing, registerbag])
+ return () => unregisterPopover(registerbag)
+ },
+ [setPopovers, unregisterPopover]
+ )
+
+ let isFocusWithinPopoverGroup = useCallback(() => {
+ let element = document.activeElement as HTMLElement
+
+ if (groupRef.current?.contains(element)) return true
+
+ // Check if the focus is in one of the button or panel elements. This is important in case you are rendering inside a Portal.
+ return popovers.some(bag => {
+ return (
+ document.getElementById(bag.buttonId)?.contains(element) ||
+ document.getElementById(bag.panelId)?.contains(element)
+ )
+ })
+ }, [groupRef, popovers])
+
+ let closeOthers = useCallback(
+ (buttonId: string) => {
+ for (let popover of popovers) {
+ if (popover.buttonId !== buttonId) popover.close()
+ }
+ },
+ [popovers]
+ )
+
+ let contextBag = useMemo>(
+ () => ({
+ registerPopover: registerPopover,
+ unregisterPopover: unregisterPopover,
+ isFocusWithinPopoverGroup,
+ closeOthers,
+ }),
+ [registerPopover, unregisterPopover, isFocusWithinPopoverGroup, closeOthers]
+ )
+
+ let propsBag = useMemo(() => ({}), [])
+ let propsWeControl = { ref: groupRef }
+ let passthroughProps = props
+
+ return (
+
+ {render({ ...passthroughProps, ...propsWeControl }, propsBag, DEFAULT_GROUP_TAG)}
+
+ )
+}
+
+// ---
+
+Popover.Button = Button
+Popover.Overlay = Overlay
+Popover.Panel = Panel
+Popover.Group = Group
diff --git a/packages/@headlessui-react/src/components/portal/portal.test.tsx b/packages/@headlessui-react/src/components/portal/portal.test.tsx
new file mode 100644
index 0000000000..e504bd05db
--- /dev/null
+++ b/packages/@headlessui-react/src/components/portal/portal.test.tsx
@@ -0,0 +1,142 @@
+import React, { useState } from 'react'
+import { render } from '@testing-library/react'
+
+import { Portal } from './portal'
+
+import { click } from '../../test-utils/interactions'
+
+function getPortalRoot() {
+ return document.getElementById('headlessui-portal-root')
+}
+
+beforeEach(() => {
+ document.body.innerHTML = ''
+})
+
+it('should be possible to use a Portal', () => {
+ expect(getPortalRoot()).toBe(null)
+
+ render(
+
+
+ Contents...
+
+
+ )
+
+ let parent = document.getElementById('parent')
+ let content = document.getElementById('content')
+
+ expect(getPortalRoot()).not.toBe(null)
+
+ // Ensure the content is not part of the parent
+ expect(parent).not.toContain(content)
+
+ // Ensure the content does exist
+ expect(content).not.toBe(null)
+ expect(content).toHaveTextContent('Contents...')
+})
+
+it('should be possible to use multiple Portal elements', () => {
+ expect(getPortalRoot()).toBe(null)
+
+ render(
+
+
+ Contents 1 ...
+
+
+
+ Contents 2 ...
+
+
+ )
+
+ let parent = document.getElementById('parent')
+ let content1 = document.getElementById('content1')
+ let content2 = document.getElementById('content2')
+
+ expect(getPortalRoot()).not.toBe(null)
+
+ // Ensure the content1 is not part of the parent
+ expect(parent).not.toContain(content1)
+
+ // Ensure the content2 is not part of the parent
+ expect(parent).not.toContain(content2)
+
+ // Ensure the content does exist
+ expect(content1).not.toBe(null)
+ expect(content1).toHaveTextContent('Contents 1 ...')
+
+ // Ensure the content does exist
+ expect(content2).not.toBe(null)
+ expect(content2).toHaveTextContent('Contents 2 ...')
+})
+
+it('should cleanup the Portal root when the last Portal is unmounted', async () => {
+ expect(getPortalRoot()).toBe(null)
+
+ function Example() {
+ let [renderA, setRenderA] = useState(false)
+ let [renderB, setRenderB] = useState(false)
+
+ return (
+
+ setRenderA(v => !v)}>
+ Toggle A
+
+ setRenderB(v => !v)}>
+ Toggle B
+
+
+ {renderA && (
+
+ Contents 1 ...
+
+ )}
+
+ {renderB && (
+
+ Contents 2 ...
+
+ )}
+
+ )
+ }
+
+ render( )
+
+ let a = document.getElementById('a')
+ let b = document.getElementById('b')
+
+ expect(getPortalRoot()).toBe(null)
+
+ // Let's render the first Portal
+ await click(a)
+
+ expect(getPortalRoot()).not.toBe(null)
+ expect(getPortalRoot().childNodes).toHaveLength(1)
+
+ // Let's render the second Portal
+ await click(b)
+
+ expect(getPortalRoot()).not.toBe(null)
+ expect(getPortalRoot().childNodes).toHaveLength(2)
+
+ // Let's remove the first portal
+ await click(a)
+
+ expect(getPortalRoot()).not.toBe(null)
+ expect(getPortalRoot().childNodes).toHaveLength(1)
+
+ // Let's remove the second Portal
+ await click(b)
+
+ expect(getPortalRoot()).toBe(null)
+
+ // Let's render the first Portal again
+ await click(a)
+
+ expect(getPortalRoot()).not.toBe(null)
+ expect(getPortalRoot().childNodes).toHaveLength(1)
+})
diff --git a/packages/@headlessui-react/src/components/portal/portal.tsx b/packages/@headlessui-react/src/components/portal/portal.tsx
new file mode 100644
index 0000000000..01497c2ae0
--- /dev/null
+++ b/packages/@headlessui-react/src/components/portal/portal.tsx
@@ -0,0 +1,52 @@
+// WAI-ARIA: https://www.w3.org/TR/wai-aria-practices-1.2/#dialog_modal
+import {
+ Fragment,
+
+ // Types
+ ElementType,
+ useState,
+} from 'react'
+
+import { Props } from '../../types'
+import { render } from '../../utils/render'
+import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect'
+import { createPortal } from 'react-dom'
+
+// ---
+
+let DEFAULT_PORTAL_TAG = Fragment
+interface PortalRenderPropArg {}
+
+export function Portal(
+ props: Props
+) {
+ let [target] = useState(() => {
+ if (typeof window === 'undefined') return null
+ let existingRoot = document.getElementById('headlessui-portal-root')
+ if (existingRoot) return existingRoot
+
+ let root = document.createElement('div')
+ root.setAttribute('id', 'headlessui-portal-root')
+ return document.body.appendChild(root)
+ })
+ let [element] = useState(() =>
+ typeof window === 'undefined' ? null : document.createElement('div')
+ )
+
+ useIsoMorphicEffect(() => {
+ if (!target) return
+ if (!element) return
+
+ target.appendChild(element)
+
+ return () => {
+ if (!target) return
+ if (!element) return
+
+ target.removeChild(element)
+ if (target.childNodes.length <= 0) document.body.removeChild(target)
+ }
+ }, [target])
+
+ return !target || !element ? null : createPortal(render(props, {}, DEFAULT_PORTAL_TAG), element)
+}
diff --git a/packages/@headlessui-react/src/components/switch/switch.test.tsx b/packages/@headlessui-react/src/components/switch/switch.test.tsx
index 66742cd563..24664d3542 100644
--- a/packages/@headlessui-react/src/components/switch/switch.test.tsx
+++ b/packages/@headlessui-react/src/components/switch/switch.test.tsx
@@ -15,7 +15,10 @@ import { suppressConsoleLogs } from '../../test-utils/suppress-console-logs'
jest.mock('../../hooks/use-id')
describe('Safe guards', () => {
- it.each([['Switch.Label', Switch.Label]])(
+ it.each([
+ ['Switch.Label', Switch.Label],
+ ['Switch.Description', Switch.Description],
+ ])(
'should error when we are using a <%s /> without a parent ',
suppressConsoleLogs((name, Component) => {
expect(() => render(createElement(Component))).toThrowError(
@@ -115,6 +118,44 @@ describe('Render composition', () => {
// Thus: Label A should not be part of the "label" in this case
assertSwitch({ state: SwitchState.Off, label: 'Label B' })
})
+
+ it('should be possible to render a Switch.Group, Switch and Switch.Description (before the Switch)', () => {
+ render(
+
+ This is an important feature
+
+
+ )
+
+ assertSwitch({ state: SwitchState.Off, description: 'This is an important feature' })
+ })
+
+ it('should be possible to render a Switch.Group, Switch and Switch.Description (after the Switch)', () => {
+ render(
+
+
+ This is an important feature
+
+ )
+
+ assertSwitch({ state: SwitchState.Off, description: 'This is an important feature' })
+ })
+
+ it('should be possible to render a Switch.Group, Switch, Switch.Label and Switch.Description', () => {
+ render(
+
+ Label A
+
+ This is an important feature
+
+ )
+
+ assertSwitch({
+ state: SwitchState.Off,
+ label: 'Label A',
+ description: 'This is an important feature',
+ })
+ })
})
describe('Keyboard interactions', () => {
diff --git a/packages/@headlessui-react/src/components/switch/switch.tsx b/packages/@headlessui-react/src/components/switch/switch.tsx
index 621bd2e2bb..ddc6f5311e 100644
--- a/packages/@headlessui-react/src/components/switch/switch.tsx
+++ b/packages/@headlessui-react/src/components/switch/switch.tsx
@@ -22,9 +22,11 @@ import { isDisabledReactIssue7711 } from '../../utils/bugs'
interface StateDefinition {
switch: HTMLButtonElement | null
label: HTMLLabelElement | null
+ description: HTMLParagraphElement | null
setSwitch(element: HTMLButtonElement): void
setLabel(element: HTMLLabelElement): void
+ setDescription(element: HTMLParagraphElement): void
}
let GroupContext = createContext(null)
@@ -47,15 +49,25 @@ let DEFAULT_GROUP_TAG = Fragment
function Group(props: Props) {
let [switchElement, setSwitchElement] = useState(null)
let [labelElement, setLabelElement] = useState(null)
+ let [descriptionElement, setDescriptionElement] = useState(null)
let context = useMemo(
() => ({
switch: switchElement,
- label: labelElement,
setSwitch: setSwitchElement,
+ label: labelElement,
setLabel: setLabelElement,
+ description: descriptionElement,
+ setDescription: setDescriptionElement,
}),
- [switchElement, setSwitchElement, labelElement, setLabelElement]
+ [
+ switchElement,
+ setSwitchElement,
+ labelElement,
+ setLabelElement,
+ descriptionElement,
+ setDescriptionElement,
+ ]
)
return (
@@ -76,6 +88,8 @@ type SwitchPropsWeControl =
| 'role'
| 'tabIndex'
| 'aria-checked'
+ | 'aria-labelledby'
+ | 'aria-describedby'
| 'onClick'
| 'onKeyUp'
| 'onKeyPress'
@@ -129,6 +143,7 @@ export function Switch(
className: resolvePropValue(className, propsBag),
'aria-checked': checked,
'aria-labelledby': groupContext?.label?.id,
+ 'aria-describedby': groupContext?.description?.id,
onClick: handleClick,
onKeyUp: handleKeyUp,
onKeyPress: handleKeyPress,
@@ -165,5 +180,22 @@ function Label(
// ---
+let DEFAULT_DESCRIPTIONL_TAG = 'p' as const
+interface DescriptionRenderPropArg {}
+type DescriptionPropsWeControl = 'id' | 'ref'
+
+function Description(
+ props: Props
+) {
+ let state = useGroupContext([Switch.name, Description.name].join('.'))
+ let id = `headlessui-switch-description-${useId()}`
+
+ let propsWeControl = { ref: state.setDescription, id }
+ return render({ ...props, ...propsWeControl }, {}, DEFAULT_DESCRIPTIONL_TAG)
+}
+
+// ---
+
Switch.Group = Group
Switch.Label = Label
+Switch.Description = Description
diff --git a/packages/@headlessui-react/src/components/transitions/transition.tsx b/packages/@headlessui-react/src/components/transitions/transition.tsx
index a9778ff940..a39b481dc6 100644
--- a/packages/@headlessui-react/src/components/transitions/transition.tsx
+++ b/packages/@headlessui-react/src/components/transitions/transition.tsx
@@ -155,17 +155,13 @@ function useNesting(done?: () => void) {
}
function noop() {}
-let eventNames: (keyof TransitionEvents)[] = [
- 'beforeEnter',
- 'afterEnter',
- 'beforeLeave',
- 'afterLeave',
-]
+let eventNames = ['beforeEnter', 'afterEnter', 'beforeLeave', 'afterLeave'] as const
function ensureEventHooksExist(events: TransitionEvents) {
- return eventNames.reduce((all, eventName) => {
- all[eventName] = events[eventName] || noop
- return all
- }, {} as Record void>)
+ let result = {} as Record void>
+ for (let name of eventNames) {
+ result[name] = events[name] ?? noop
+ }
+ return result
}
function useEvents(events: TransitionEvents) {
diff --git a/packages/@headlessui-react/src/hooks/use-focus-trap.ts b/packages/@headlessui-react/src/hooks/use-focus-trap.ts
new file mode 100644
index 0000000000..2b5f42ced3
--- /dev/null
+++ b/packages/@headlessui-react/src/hooks/use-focus-trap.ts
@@ -0,0 +1,117 @@
+import {
+ useRef,
+ // Types
+ MutableRefObject,
+} from 'react'
+
+import { Keys } from '../components/keyboard'
+import { useIsoMorphicEffect } from './use-iso-morphic-effect'
+import { focusElement, focusIn, Focus, FocusResult } from '../utils/focus-management'
+
+export function useFocusTrap(
+ container: MutableRefObject,
+ enabled: boolean = true,
+ options: { initialFocus?: MutableRefObject } = {}
+) {
+ let restoreElement = useRef(
+ typeof window !== 'undefined' ? (document.activeElement as HTMLElement) : null
+ )
+ let previousActiveElement = useRef(null)
+ let mounted = useRef(false)
+
+ // Handle initial focus
+ useIsoMorphicEffect(() => {
+ if (!enabled) return
+ if (!container.current) return
+
+ mounted.current = true
+
+ let activeElement = document.activeElement as HTMLElement
+
+ if (options.initialFocus?.current) {
+ if (options.initialFocus?.current === activeElement) {
+ return // Initial focus ref is already the active element
+ }
+ } else if (container.current.contains(activeElement)) {
+ return // Already focused within Dialog
+ }
+
+ restoreElement.current = activeElement
+
+ // Try to focus the initialFocus ref
+ if (options.initialFocus?.current) {
+ focusElement(options.initialFocus.current)
+ } else {
+ let result = focusIn(container.current, Focus.First)
+ if (result === FocusResult.Error) {
+ throw new Error('There are no focusable elements inside the ')
+ }
+ }
+
+ previousActiveElement.current = document.activeElement as HTMLElement
+
+ return () => {
+ mounted.current = false
+ focusElement(restoreElement.current)
+ restoreElement.current = null
+ previousActiveElement.current = null
+ }
+ }, [enabled, container, mounted, options.initialFocus])
+
+ // Handle Tab & Shift+Tab keyboard events
+ useIsoMorphicEffect(() => {
+ if (!enabled) return
+
+ function handler(event: KeyboardEvent) {
+ if (event.key !== Keys.Tab) return
+ if (!document.activeElement) return
+ if (!container.current) return
+
+ event.preventDefault()
+
+ let result = focusIn(
+ container.current,
+ (event.shiftKey ? Focus.Previous : Focus.Next) | Focus.WrapAround
+ )
+
+ if (result === FocusResult.Success) {
+ previousActiveElement.current = document.activeElement as HTMLElement
+ }
+ }
+
+ window.addEventListener('keydown', handler)
+ return () => window.removeEventListener('keydown', handler)
+ }, [enabled])
+
+ // Prevent programmatically escaping
+ useIsoMorphicEffect(() => {
+ if (!enabled) return
+ if (!container.current) return
+
+ let element = container.current
+
+ function handler(event: FocusEvent) {
+ let previous = previousActiveElement.current
+ if (!previous) return
+ if (!mounted.current) return
+
+ let toElement = event.target as HTMLElement | null
+
+ if (toElement && toElement instanceof HTMLElement) {
+ if (!element.contains(toElement)) {
+ event.preventDefault()
+ event.stopPropagation()
+ focusElement(previous)
+ } else {
+ previousActiveElement.current = toElement
+ focusElement(toElement)
+ }
+ } else {
+ focusElement(previousActiveElement.current)
+ }
+ }
+
+ window.addEventListener('focus', handler, true)
+ return () => window.removeEventListener('focus', handler, true)
+ }, [enabled, mounted, container])
+}
diff --git a/packages/@headlessui-react/src/hooks/use-inert-others.ts b/packages/@headlessui-react/src/hooks/use-inert-others.ts
new file mode 100644
index 0000000000..f2c1291e4c
--- /dev/null
+++ b/packages/@headlessui-react/src/hooks/use-inert-others.ts
@@ -0,0 +1,66 @@
+import { MutableRefObject } from 'react'
+import { useIsoMorphicEffect } from './use-iso-morphic-effect'
+
+function* getAllSiblings(element: HTMLElement) {
+ if (!element.parentElement) return
+ let node = element.parentElement.firstChild
+
+ while (node) {
+ if (node !== element && node instanceof HTMLElement) yield node
+ node = node.nextSibling
+ }
+}
+
+export function useInertOthers(
+ container: MutableRefObject,
+ enabled: boolean = true
+) {
+ useIsoMorphicEffect(() => {
+ if (!enabled) return
+ if (!container.current) return
+
+ let element = container.current
+ let elements = new Map()
+
+ // Collect my direct siblings
+ for (let sibling of getAllSiblings(element)) {
+ elements.set(sibling, {
+ 'aria-hidden': sibling.getAttribute('aria-hidden'),
+ // @ts-expect-error `inert` does not exist on HTMLElement (yet!)
+ inert: sibling.inert,
+ })
+ }
+
+ // Collect direct children of the body
+ document.querySelectorAll('body > *').forEach(directChild => {
+ if (directChild === element) return // Skip myself
+ if (!(directChild instanceof HTMLElement)) return // Skip non-HTMLElements
+ if (directChild.contains(element)) return // Skip my parent
+
+ elements.set(directChild, {
+ 'aria-hidden': directChild.getAttribute('aria-hidden'),
+ // @ts-expect-error `inert` does not exist on HTMLElement (yet!)
+ inert: directChild.inert,
+ })
+ })
+
+ // MUTATE ALL THE ELEMENTS
+ for (let element of elements.keys()) {
+ element.setAttribute('aria-hidden', 'true')
+ // @ts-expect-error `inert` does not exist on HTMLElement (yet!)
+ element.inert = true
+ }
+
+ return () => {
+ for (let [element, bag] of elements.entries()) {
+ if (element === null) continue
+ else if (bag['aria-hidden'] === null) element.removeAttribute('aria-hidden')
+ else element.setAttribute('aria-hidden', bag['aria-hidden'])
+ // @ts-expect-error `inert` does not exist on HTMLElement (yet!)
+ element.inert = bag.inert
+ }
+
+ elements.clear()
+ }
+ }, [enabled])
+}
diff --git a/packages/@headlessui-react/src/hooks/use-sync-refs.ts b/packages/@headlessui-react/src/hooks/use-sync-refs.ts
index a90a4b71d0..5a072c4f01 100644
--- a/packages/@headlessui-react/src/hooks/use-sync-refs.ts
+++ b/packages/@headlessui-react/src/hooks/use-sync-refs.ts
@@ -1,16 +1,22 @@
-import { useCallback } from 'react'
+import { useRef, useEffect, useCallback } from 'react'
export function useSyncRefs(
...refs: (React.MutableRefObject | ((instance: TType) => void) | null)[]
) {
+ let cache = useRef(refs)
+
+ useEffect(() => {
+ cache.current = refs
+ }, [refs])
+
return useCallback(
(value: TType) => {
- refs.forEach(ref => {
- if (ref === null) return
- if (typeof ref === 'function') return ref(value)
- ref.current = value
- })
+ for (let ref of cache.current) {
+ if (ref == null) continue
+ if (typeof ref === 'function') ref(value)
+ else ref.current = value
+ }
},
- [refs]
+ [cache]
)
}
diff --git a/packages/@headlessui-react/src/index.test.ts b/packages/@headlessui-react/src/index.test.ts
index e5fb10ea78..9f4d602b57 100644
--- a/packages/@headlessui-react/src/index.test.ts
+++ b/packages/@headlessui-react/src/index.test.ts
@@ -5,5 +5,15 @@ import * as HeadlessUI from './index'
* the outside world that we didn't want!
*/
it('should expose the correct components', () => {
- expect(Object.keys(HeadlessUI)).toEqual(['Transition', 'Menu', 'Listbox', 'Switch'])
+ expect(Object.keys(HeadlessUI)).toEqual([
+ 'Dialog',
+ 'Disclosure',
+ 'FocusTrap',
+ 'Listbox',
+ 'Menu',
+ 'Popover',
+ 'Portal',
+ 'Switch',
+ 'Transition',
+ ])
})
diff --git a/packages/@headlessui-react/src/index.ts b/packages/@headlessui-react/src/index.ts
index 5feddccd1d..7bca5342c6 100644
--- a/packages/@headlessui-react/src/index.ts
+++ b/packages/@headlessui-react/src/index.ts
@@ -1,4 +1,9 @@
-export * from './components/transitions/transition'
-export * from './components/menu/menu'
+export * from './components/dialog/dialog'
+export * from './components/disclosure/disclosure'
+export * from './components/focus-trap/focus-trap'
export * from './components/listbox/listbox'
+export * from './components/menu/menu'
+export * from './components/popover/popover'
+export * from './components/portal/portal'
export * from './components/switch/switch'
+export * from './components/transitions/transition'
diff --git a/packages/@headlessui-react/src/test-utils/accessibility-assertions.ts b/packages/@headlessui-react/src/test-utils/accessibility-assertions.ts
index 7957ff29b3..0e5673d818 100644
--- a/packages/@headlessui-react/src/test-utils/accessibility-assertions.ts
+++ b/packages/@headlessui-react/src/test-utils/accessibility-assertions.ts
@@ -535,6 +535,7 @@ export function assertSwitch(
tag?: string
textContent?: string
label?: string
+ description?: string
},
switchElement = getSwitch()
) {
@@ -556,6 +557,10 @@ export function assertSwitch(
assertLabelValue(switchElement, options.label)
}
+ if (options.description) {
+ assertDescriptionValue(switchElement, options.description)
+ }
+
switch (options.state) {
case SwitchState.On:
expect(switchElement).toHaveAttribute('aria-checked', 'true')
@@ -576,6 +581,250 @@ export function assertSwitch(
// ---
+export function getDisclosureButton(): HTMLElement | null {
+ return document.querySelector('[id^="headlessui-disclosure-button-"]')
+}
+
+export function getDisclosurePanel(): HTMLElement | null {
+ return document.querySelector('[id^="headlessui-disclosure-panel-"]')
+}
+
+// ---
+
+export enum DisclosureState {
+ /** The disclosure is visible to the user. */
+ Visible,
+
+ /** The disclosure is **not** visible to the user. It's still in the DOM, but it is hidden. */
+ InvisibleHidden,
+
+ /** The disclosure is **not** visible to the user. It's not in the DOM, it is unmounted. */
+ InvisibleUnmounted,
+}
+
+// ---
+
+export function assertDisclosureButton(
+ options: {
+ attributes?: Record
+ textContent?: string
+ state: DisclosureState
+ },
+ button = getDisclosureButton()
+) {
+ try {
+ if (button === null) return expect(button).not.toBe(null)
+
+ // Ensure disclosure button have these properties
+ expect(button).toHaveAttribute('id')
+
+ switch (options.state) {
+ case DisclosureState.Visible:
+ expect(button).toHaveAttribute('aria-controls')
+ expect(button).toHaveAttribute('aria-expanded', 'true')
+ break
+
+ case DisclosureState.InvisibleHidden:
+ expect(button).toHaveAttribute('aria-controls')
+ expect(button).not.toHaveAttribute('aria-expanded')
+ break
+
+ case DisclosureState.InvisibleUnmounted:
+ expect(button).not.toHaveAttribute('aria-controls')
+ expect(button).not.toHaveAttribute('aria-expanded')
+ break
+
+ default:
+ assertNever(options.state)
+ }
+
+ if (options.textContent) {
+ expect(button).toHaveTextContent(options.textContent)
+ }
+
+ // Ensure disclosure button has the following attributes
+ for (let attributeName in options.attributes) {
+ expect(button).toHaveAttribute(attributeName, options.attributes[attributeName])
+ }
+ } catch (err) {
+ Error.captureStackTrace(err, assertDisclosureButton)
+ throw err
+ }
+}
+
+export function assertDisclosurePanel(
+ options: {
+ attributes?: Record
+ textContent?: string
+ state: DisclosureState
+ },
+ panel = getDisclosurePanel()
+) {
+ try {
+ switch (options.state) {
+ case DisclosureState.InvisibleHidden:
+ if (panel === null) return expect(panel).not.toBe(null)
+
+ assertHidden(panel)
+
+ if (options.textContent) expect(panel).toHaveTextContent(options.textContent)
+
+ for (let attributeName in options.attributes) {
+ expect(panel).toHaveAttribute(attributeName, options.attributes[attributeName])
+ }
+ break
+
+ case DisclosureState.Visible:
+ if (panel === null) return expect(panel).not.toBe(null)
+
+ assertVisible(panel)
+
+ if (options.textContent) expect(panel).toHaveTextContent(options.textContent)
+
+ for (let attributeName in options.attributes) {
+ expect(panel).toHaveAttribute(attributeName, options.attributes[attributeName])
+ }
+ break
+
+ case DisclosureState.InvisibleUnmounted:
+ expect(panel).toBe(null)
+ break
+
+ default:
+ assertNever(options.state)
+ }
+ } catch (err) {
+ Error.captureStackTrace(err, assertDisclosurePanel)
+ throw err
+ }
+}
+
+// ---
+
+export function getPopoverButton(): HTMLElement | null {
+ return document.querySelector('[id^="headlessui-popover-button-"]')
+}
+
+export function getPopoverPanel(): HTMLElement | null {
+ return document.querySelector('[id^="headlessui-popover-panel-"]')
+}
+
+export function getPopoverOverlay(): HTMLElement | null {
+ return document.querySelector('[id^="headlessui-popover-overlay-"]')
+}
+
+// ---
+
+export enum PopoverState {
+ /** The popover is visible to the user. */
+ Visible,
+
+ /** The popover is **not** visible to the user. It's still in the DOM, but it is hidden. */
+ InvisibleHidden,
+
+ /** The popover is **not** visible to the user. It's not in the DOM, it is unmounted. */
+ InvisibleUnmounted,
+}
+
+// ---
+
+export function assertPopoverButton(
+ options: {
+ attributes?: Record
+ textContent?: string
+ state: PopoverState
+ },
+ button = getPopoverButton()
+) {
+ try {
+ if (button === null) return expect(button).not.toBe(null)
+
+ // Ensure popover button have these properties
+ expect(button).toHaveAttribute('id')
+
+ switch (options.state) {
+ case PopoverState.Visible:
+ expect(button).toHaveAttribute('aria-controls')
+ expect(button).toHaveAttribute('aria-expanded', 'true')
+ break
+
+ case PopoverState.InvisibleHidden:
+ expect(button).toHaveAttribute('aria-controls')
+ expect(button).not.toHaveAttribute('aria-expanded')
+ break
+
+ case PopoverState.InvisibleUnmounted:
+ expect(button).not.toHaveAttribute('aria-controls')
+ expect(button).not.toHaveAttribute('aria-expanded')
+ break
+
+ default:
+ assertNever(options.state)
+ }
+
+ if (options.textContent) {
+ expect(button).toHaveTextContent(options.textContent)
+ }
+
+ // Ensure popover button has the following attributes
+ for (let attributeName in options.attributes) {
+ expect(button).toHaveAttribute(attributeName, options.attributes[attributeName])
+ }
+ } catch (err) {
+ Error.captureStackTrace(err, assertPopoverButton)
+ throw err
+ }
+}
+
+export function assertPopoverPanel(
+ options: {
+ attributes?: Record
+ textContent?: string
+ state: PopoverState
+ },
+ panel = getPopoverPanel()
+) {
+ try {
+ switch (options.state) {
+ case PopoverState.InvisibleHidden:
+ if (panel === null) return expect(panel).not.toBe(null)
+
+ assertHidden(panel)
+
+ if (options.textContent) expect(panel).toHaveTextContent(options.textContent)
+
+ for (let attributeName in options.attributes) {
+ expect(panel).toHaveAttribute(attributeName, options.attributes[attributeName])
+ }
+ break
+
+ case PopoverState.Visible:
+ if (panel === null) return expect(panel).not.toBe(null)
+
+ assertVisible(panel)
+
+ if (options.textContent) expect(panel).toHaveTextContent(options.textContent)
+
+ for (let attributeName in options.attributes) {
+ expect(panel).toHaveAttribute(attributeName, options.attributes[attributeName])
+ }
+ break
+
+ case PopoverState.InvisibleUnmounted:
+ expect(panel).toBe(null)
+ break
+
+ default:
+ assertNever(options.state)
+ }
+ } catch (err) {
+ Error.captureStackTrace(err, assertPopoverPanel)
+ throw err
+ }
+}
+
+// ---
+
export function assertLabelValue(element: HTMLElement | null, value: string) {
if (element === null) return expect(element).not.toBe(null)
@@ -600,16 +849,288 @@ export function assertLabelValue(element: HTMLElement | null, value: string) {
// ---
+export function assertDescriptionValue(element: HTMLElement | null, value: string) {
+ if (element === null) return expect(element).not.toBe(null)
+
+ let id = element.getAttribute('aria-describedby')!
+ expect(document.getElementById(id)?.textContent).toEqual(value)
+}
+
+// ---
+
+export function getDialog(): HTMLElement | null {
+ return document.querySelector('[role="dialog"]')
+}
+
+export function getDialogTitle(): HTMLElement | null {
+ return document.querySelector('[id^="headlessui-dialog-title-"]')
+}
+
+export function getDialogDescription(): HTMLElement | null {
+ return document.querySelector('[id^="headlessui-dialog-description-"]')
+}
+
+export function getDialogOverlay(): HTMLElement | null {
+ return document.querySelector('[id^="headlessui-dialog-overlay-"]')
+}
+
+// ---
+
+export enum DialogState {
+ /** The dialog is visible to the user. */
+ Visible,
+
+ /** The dialog is **not** visible to the user. It's still in the DOM, but it is hidden. */
+ InvisibleHidden,
+
+ /** The dialog is **not** visible to the user. It's not in the DOM, it is unmounted. */
+ InvisibleUnmounted,
+}
+
+// ---
+
+export function assertDialog(
+ options: {
+ attributes?: Record
+ textContent?: string
+ state: DialogState
+ },
+ dialog = getDialog()
+) {
+ try {
+ switch (options.state) {
+ case DialogState.InvisibleHidden:
+ if (dialog === null) return expect(dialog).not.toBe(null)
+
+ assertHidden(dialog)
+
+ expect(dialog).toHaveAttribute('role', 'dialog')
+ expect(dialog).not.toHaveAttribute('aria-modal', 'true')
+
+ if (options.textContent) expect(dialog).toHaveTextContent(options.textContent)
+
+ for (let attributeName in options.attributes) {
+ expect(dialog).toHaveAttribute(attributeName, options.attributes[attributeName])
+ }
+ break
+
+ case DialogState.Visible:
+ if (dialog === null) return expect(dialog).not.toBe(null)
+
+ assertVisible(dialog)
+
+ expect(dialog).toHaveAttribute('role', 'dialog')
+ expect(dialog).toHaveAttribute('aria-modal', 'true')
+
+ if (options.textContent) expect(dialog).toHaveTextContent(options.textContent)
+
+ for (let attributeName in options.attributes) {
+ expect(dialog).toHaveAttribute(attributeName, options.attributes[attributeName])
+ }
+ break
+
+ case DialogState.InvisibleUnmounted:
+ expect(dialog).toBe(null)
+ break
+
+ default:
+ assertNever(options.state)
+ }
+ } catch (err) {
+ Error.captureStackTrace(err, assertDialog)
+ throw err
+ }
+}
+
+export function assertDialogTitle(
+ options: {
+ attributes?: Record
+ textContent?: string
+ state: DialogState
+ },
+ title = getDialogTitle(),
+ dialog = getDialog()
+) {
+ try {
+ switch (options.state) {
+ case DialogState.InvisibleHidden:
+ if (title === null) return expect(title).not.toBe(null)
+ if (dialog === null) return expect(dialog).not.toBe(null)
+
+ assertHidden(title)
+
+ expect(title).toHaveAttribute('id')
+ expect(dialog).toHaveAttribute('aria-labelledby', title.id)
+
+ if (options.textContent) expect(title).toHaveTextContent(options.textContent)
+
+ for (let attributeName in options.attributes) {
+ expect(title).toHaveAttribute(attributeName, options.attributes[attributeName])
+ }
+ break
+
+ case DialogState.Visible:
+ if (title === null) return expect(title).not.toBe(null)
+ if (dialog === null) return expect(dialog).not.toBe(null)
+
+ assertVisible(title)
+
+ expect(title).toHaveAttribute('id')
+ expect(dialog).toHaveAttribute('aria-labelledby', title.id)
+
+ if (options.textContent) expect(title).toHaveTextContent(options.textContent)
+
+ for (let attributeName in options.attributes) {
+ expect(title).toHaveAttribute(attributeName, options.attributes[attributeName])
+ }
+ break
+
+ case DialogState.InvisibleUnmounted:
+ expect(title).toBe(null)
+ break
+
+ default:
+ assertNever(options.state)
+ }
+ } catch (err) {
+ Error.captureStackTrace(err, assertDialogTitle)
+ throw err
+ }
+}
+
+export function assertDialogDescription(
+ options: {
+ attributes?: Record
+ textContent?: string
+ state: DialogState
+ },
+ description = getDialogDescription(),
+ dialog = getDialog()
+) {
+ try {
+ switch (options.state) {
+ case DialogState.InvisibleHidden:
+ if (description === null) return expect(description).not.toBe(null)
+ if (dialog === null) return expect(dialog).not.toBe(null)
+
+ assertHidden(description)
+
+ expect(description).toHaveAttribute('id')
+ expect(dialog).toHaveAttribute('aria-describedby', description.id)
+
+ if (options.textContent) expect(description).toHaveTextContent(options.textContent)
+
+ for (let attributeName in options.attributes) {
+ expect(description).toHaveAttribute(attributeName, options.attributes[attributeName])
+ }
+ break
+
+ case DialogState.Visible:
+ if (description === null) return expect(description).not.toBe(null)
+ if (dialog === null) return expect(dialog).not.toBe(null)
+
+ assertVisible(description)
+
+ expect(description).toHaveAttribute('id')
+ expect(dialog).toHaveAttribute('aria-describedby', description.id)
+
+ if (options.textContent) expect(description).toHaveTextContent(options.textContent)
+
+ for (let attributeName in options.attributes) {
+ expect(description).toHaveAttribute(attributeName, options.attributes[attributeName])
+ }
+ break
+
+ case DialogState.InvisibleUnmounted:
+ expect(description).toBe(null)
+ break
+
+ default:
+ assertNever(options.state)
+ }
+ } catch (err) {
+ Error.captureStackTrace(err, assertDialogDescription)
+ throw err
+ }
+}
+
+export function assertDialogOverlay(
+ options: {
+ attributes?: Record
+ textContent?: string
+ state: DialogState
+ },
+ overlay = getDialogOverlay()
+) {
+ try {
+ switch (options.state) {
+ case DialogState.InvisibleHidden:
+ if (overlay === null) return expect(overlay).not.toBe(null)
+
+ assertHidden(overlay)
+
+ if (options.textContent) expect(overlay).toHaveTextContent(options.textContent)
+
+ for (let attributeName in options.attributes) {
+ expect(overlay).toHaveAttribute(attributeName, options.attributes[attributeName])
+ }
+ break
+
+ case DialogState.Visible:
+ if (overlay === null) return expect(overlay).not.toBe(null)
+
+ assertVisible(overlay)
+
+ if (options.textContent) expect(overlay).toHaveTextContent(options.textContent)
+
+ for (let attributeName in options.attributes) {
+ expect(overlay).toHaveAttribute(attributeName, options.attributes[attributeName])
+ }
+ break
+
+ case DialogState.InvisibleUnmounted:
+ expect(overlay).toBe(null)
+ break
+
+ default:
+ assertNever(options.state)
+ }
+ } catch (err) {
+ Error.captureStackTrace(err, assertDialogOverlay)
+ throw err
+ }
+}
+
+// ---
+
export function assertActiveElement(element: HTMLElement | null) {
try {
if (element === null) return expect(element).not.toBe(null)
- expect(document.activeElement).toBe(element)
+ try {
+ // Jest has a weird bug:
+ // "Cannot assign to read only property 'Symbol(impl)' of object '[object DOMImplementation]'"
+ // when this assertion fails.
+ // Therefore we will catch it when something goes wrong, and just look at the outerHTML string.
+ expect(document.activeElement).toBe(element)
+ } catch (err) {
+ expect(document.activeElement?.outerHTML).toBe(element.outerHTML)
+ }
} catch (err) {
Error.captureStackTrace(err, assertActiveElement)
throw err
}
}
+export function assertContainsActiveElement(element: HTMLElement | null) {
+ try {
+ if (element === null) return expect(element).not.toBe(null)
+ expect(element.contains(document.activeElement)).toBe(true)
+ } catch (err) {
+ Error.captureStackTrace(err, assertContainsActiveElement)
+ throw err
+ }
+}
+
// ---
export function assertHidden(element: HTMLElement | null) {
@@ -635,3 +1156,20 @@ export function assertVisible(element: HTMLElement | null) {
throw err
}
}
+
+// ---
+
+export function getByText(text: string): HTMLElement | null {
+ let walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT, {
+ acceptNode(node: HTMLElement) {
+ if (node.children.length > 0) return NodeFilter.FILTER_SKIP
+ return NodeFilter.FILTER_ACCEPT
+ },
+ })
+
+ while (walker.nextNode()) {
+ if (walker.currentNode.textContent === text) return walker.currentNode as HTMLElement
+ }
+
+ return null
+}
diff --git a/packages/@headlessui-react/src/test-utils/interactions.ts b/packages/@headlessui-react/src/test-utils/interactions.ts
index de387dadc4..b7a2c6e332 100644
--- a/packages/@headlessui-react/src/test-utils/interactions.ts
+++ b/packages/@headlessui-react/src/test-utils/interactions.ts
@@ -190,6 +190,12 @@ export async function click(
if (!cancelled) {
fireEvent.mouseDown(element, options)
}
+
+ // Ensure to trigger a `focus` event if the element is focusable
+ if ((element as HTMLElement)?.matches(focusableSelector)) {
+ ;(element as HTMLElement).focus()
+ }
+
fireEvent.pointerUp(element, options)
if (!cancelled) {
fireEvent.mouseUp(element, options)
@@ -306,7 +312,14 @@ let focusableSelector = [
'select:not([disabled])',
'textarea:not([disabled])',
]
- .map(selector => `${selector}:not([tabindex='-1'])`)
+ .map(
+ process.env.NODE_ENV === 'test'
+ ? // TODO: Remove this once JSDOM fixes the issue where an element that is
+ // "hidden" can be the document.activeElement, because this is not possible
+ // in real browsers.
+ selector => `${selector}:not([tabindex='-1']):not([style*='display: none'])`
+ : selector => `${selector}:not([tabindex='-1'])`
+ )
.join(',')
function getFocusableElements(container = document.body) {
diff --git a/packages/@headlessui-react/src/utils/focus-management.ts b/packages/@headlessui-react/src/utils/focus-management.ts
new file mode 100644
index 0000000000..d95d20b579
--- /dev/null
+++ b/packages/@headlessui-react/src/utils/focus-management.ts
@@ -0,0 +1,117 @@
+// Credit:
+// - https://stackoverflow.com/a/30753870
+let focusableSelector = [
+ '[contentEditable=true]',
+ '[tabindex]',
+ 'a[href]',
+ 'area[href]',
+ 'button:not([disabled])',
+ 'iframe',
+ 'input:not([disabled])',
+ 'select:not([disabled])',
+ 'textarea:not([disabled])',
+]
+ .map(
+ process.env.NODE_ENV === 'test'
+ ? // TODO: Remove this once JSDOM fixes the issue where an element that is
+ // "hidden" can be the document.activeElement, because this is not possible
+ // in real browsers.
+ selector => `${selector}:not([tabindex='-1']):not([style*='display: none'])`
+ : selector => `${selector}:not([tabindex='-1'])`
+ )
+ .join(',')
+
+export enum Focus {
+ /* Focus the first non-disabled element */
+ First = 1 << 0,
+
+ /* Focus the previous non-disabled element */
+ Previous = 1 << 1,
+
+ /* Focus the next non-disabled element */
+ Next = 1 << 2,
+
+ /* Focus the last non-disabled element */
+ Last = 1 << 3,
+
+ /* Wrap tab around */
+ WrapAround = 1 << 4,
+
+ /* Prevent scrolling the focusable elements into view */
+ NoScroll = 1 << 5,
+}
+
+export enum FocusResult {
+ Error,
+ Overflow,
+ Success,
+ Underflow,
+}
+
+enum Direction {
+ Previous = -1,
+ Next = 1,
+}
+
+export function getFocusableElements(container: HTMLElement | null = document.body) {
+ if (container == null) return []
+ return Array.from(container.querySelectorAll(focusableSelector))
+}
+
+export function isFocusableElement(element: HTMLElement) {
+ return element.matches(focusableSelector)
+}
+
+export function focusElement(element: HTMLElement | null) {
+ element?.focus({ preventScroll: true })
+}
+
+export function focusIn(container: HTMLElement | HTMLElement[], focus: Focus) {
+ let elements = Array.isArray(container) ? container : getFocusableElements(container)
+ let active = document.activeElement as HTMLElement
+
+ let direction = (() => {
+ if (focus & (Focus.First | Focus.Next)) return Direction.Next
+ if (focus & (Focus.Previous | Focus.Last)) return Direction.Previous
+
+ throw new Error('Missing Focus.First, Focus.Previous, Focus.Next or Focus.Last')
+ })()
+
+ let startIndex = (() => {
+ if (focus & Focus.First) return 0
+ if (focus & Focus.Previous) return Math.max(0, elements.indexOf(active)) - 1
+ if (focus & Focus.Next) return Math.max(0, elements.indexOf(active)) + 1
+ if (focus & Focus.Last) return elements.length - 1
+
+ throw new Error('Missing Focus.First, Focus.Previous, Focus.Next or Focus.Last')
+ })()
+
+ let focusOptions = focus & Focus.NoScroll ? { preventScroll: true } : {}
+
+ let offset = 0
+ let total = elements.length
+ let next = undefined
+ do {
+ // Guard against infinite loops
+ if (offset >= total || offset + total <= 0) return FocusResult.Error
+
+ let nextIdx = startIndex + offset
+
+ if (focus & Focus.WrapAround) {
+ nextIdx = (nextIdx + total) % total
+ } else {
+ if (nextIdx < 0) return FocusResult.Underflow
+ if (nextIdx >= total) return FocusResult.Overflow
+ }
+
+ next = elements[nextIdx]
+
+ // Try the focus the next element, might not work if it is "hidden" to the user.
+ next?.focus(focusOptions)
+
+ // Try the next one in line
+ offset += direction
+ } while (next !== document.activeElement)
+
+ return FocusResult.Success
+}
diff --git a/packages/@headlessui-react/src/utils/render.test.tsx b/packages/@headlessui-react/src/utils/render.test.tsx
index 1af1e78d19..c2cb5dcb8a 100644
--- a/packages/@headlessui-react/src/utils/render.test.tsx
+++ b/packages/@headlessui-react/src/utils/render.test.tsx
@@ -3,7 +3,7 @@ import { render as testRender, prettyDOM, getByTestId } from '@testing-library/r
import { suppressConsoleLogs } from '../test-utils/suppress-console-logs'
import { render, Features, PropsForFeatures } from './render'
-import { Props } from '../types'
+import { Props, Expand } from '../types'
function contents() {
return prettyDOM(getByTestId(document.body, 'wrapper'), undefined, {
@@ -273,7 +273,7 @@ describe('Features.Static', () => {
let bag = {}
let EnabledFeatures = Features.Static
function Dummy(
- props: Props & { show: boolean } & PropsForFeatures
+ props: Expand & { show: boolean } & PropsForFeatures>
) {
let { show, ...rest } = props
return {render(rest, bag, 'div', EnabledFeatures, show)}
@@ -367,7 +367,7 @@ describe('Features.RenderStrategy', () => {
let bag = {}
let EnabledFeatures = Features.RenderStrategy
function Dummy(
- props: Props & { show: boolean } & PropsForFeatures
+ props: Expand & { show: boolean } & PropsForFeatures>
) {
let { show, ...rest } = props
return {render(rest, bag, 'div', EnabledFeatures, show)}
@@ -383,7 +383,7 @@ describe('Features.Static | Features.RenderStrategy', () => {
let bag = {}
let EnabledFeatures = Features.Static | Features.RenderStrategy
function Dummy(
- props: Props & { show: boolean } & PropsForFeatures
+ props: Expand & { show: boolean } & PropsForFeatures>
) {
let { show, ...rest } = props
return {render(rest, bag, 'div', EnabledFeatures, show)}
diff --git a/packages/@headlessui-react/src/utils/render.ts b/packages/@headlessui-react/src/utils/render.ts
index bcc6541132..c40027d409 100644
--- a/packages/@headlessui-react/src/utils/render.ts
+++ b/packages/@headlessui-react/src/utils/render.ts
@@ -182,8 +182,8 @@ function mergeEventFunctions(
* This is a hack, but basically we want to keep the full 'API' of the component, but we do want to
* wrap it in a forwardRef so that we _can_ passthrough the ref
*/
-export function forwardRefWithAs(component: T): T {
- return forwardRef((component as unknown) as any) as any
+export function forwardRefWithAs(component: T): T {
+ return Object.assign(forwardRef((component as unknown) as any) as any, { name: component.name })
}
function compact>(object: T) {
diff --git a/packages/@headlessui-vue/README.md b/packages/@headlessui-vue/README.md
index 027162c62e..b7d420a8a0 100644
--- a/packages/@headlessui-vue/README.md
+++ b/packages/@headlessui-vue/README.md
@@ -297,13 +297,13 @@ To tell an element to render its children directly with no wrapper element, use
##### Props
| Prop | Type | Default | Description |
-| ---- | ------------------- | --------------------------------- | ----------------------------------------------------- |
+| :--- | :------------------ | :-------------------------------- | :---------------------------------------------------- |
| `as` | String \| Component | `template` _(no wrapper element_) | The element or component the `Menu` should render as. |
##### Slot props
| Prop | Type | Description |
-| ------ | ------- | -------------------------------- |
+| :----- | :------ | :------------------------------- |
| `open` | Boolean | Whether or not the menu is open. |
#### MenuButton
@@ -318,13 +318,13 @@ To tell an element to render its children directly with no wrapper element, use
##### Props
| Prop | Type | Default | Description |
-| ---- | ------------------- | -------- | ----------------------------------------------------------- |
+| :--- | :------------------ | :------- | :---------------------------------------------------------- |
| `as` | String \| Component | `button` | The element or component the `MenuButton` should render as. |
##### Slot props
| Prop | Type | Description |
-| ------ | ------- | -------------------------------- |
+| :----- | :------ | :------------------------------- |
| `open` | Boolean | Whether or not the menu is open. |
#### MenuItems
@@ -339,7 +339,7 @@ To tell an element to render its children directly with no wrapper element, use
##### Props
| Prop | Type | Default | Description |
-| --------- | ------------------- | ------- | --------------------------------------------------------------------------------- |
+| :-------- | :------------------ | :------ | :-------------------------------------------------------------------------------- |
| `as` | String \| Component | `div` | The element or component the `MenuItems` should render as. |
| `static` | Boolean | `false` | Whether the element should ignore the internally managed open/closed state. |
| `unmount` | Boolean | `true` | Whether the element should be unmounted or hidden based on the open/closed state. |
@@ -347,7 +347,7 @@ To tell an element to render its children directly with no wrapper element, use
##### Slot props
| Prop | Type | Description |
-| ------ | ------- | -------------------------------- |
+| :----- | :------ | :------------------------------- |
| `open` | Boolean | Whether or not the menu is open. |
#### MenuItem
@@ -363,14 +363,14 @@ To tell an element to render its children directly with no wrapper element, use
##### Props
| Prop | Type | Default | Description |
-| ---------- | ------------------- | --------------------------------- | ------------------------------------------------------------------------------------- |
+| :--------- | :------------------ | :-------------------------------- | :------------------------------------------------------------------------------------ |
| `as` | String \| Component | `template` _(no wrapper element)_ | The element or component the `MenuItem` should render as. |
| `disabled` | Boolean | `false` | Whether or not the item should be disabled for keyboard navigation and ARIA purposes. |
##### Slot props
| Prop | Type | Description |
-| ---------- | ------- | ---------------------------------------------------------------------------------- |
+| :--------- | :------ | :--------------------------------------------------------------------------------- |
| `active` | Boolean | Whether or not the item is the active/focused item in the list. |
| `disabled` | Boolean | Whether or not the item is the disabled for keyboard navigation and ARIA purposes. |
@@ -976,15 +976,15 @@ export default {
##### Props
| Prop | Type | Default | Description |
-| ---------- | ------------------- | --------------------------------- | -------------------------------------------------------- |
+| :--------- | :------------------ | :-------------------------------- | :------------------------------------------------------- |
| `as` | String \| Component | `template` _(no wrapper element_) | The element or component the `Listbox` should render as. |
-| `v-model` | `T` | | The selected value. |
+| `v-model` | `T` | - | The selected value. |
| `disabled` | Boolean | `false` | Enable/Disable the `Listbox` component. |
##### Slot props
| Prop | Type | Description |
-| ---------- | ------- | --------------------------------------- |
+| :--------- | :------ | :-------------------------------------- |
| `open` | Boolean | Whether or not the listbox is open. |
| `disabled` | Boolean | Whether or not the listbox is disabled. |
@@ -1000,13 +1000,13 @@ export default {
##### Props
| Prop | Type | Default | Description |
-| ---- | ------------------- | -------- | -------------------------------------------------------------- |
+| :--- | :------------------ | :------- | :------------------------------------------------------------- |
| `as` | String \| Component | `button` | The element or component the `ListboxButton` should render as. |
##### Slot props
| Prop | Type | Description |
-| ---------- | ------- | --------------------------------------- |
+| :--------- | :------ | :-------------------------------------- |
| `open` | Boolean | Whether or not the listbox is open. |
| `disabled` | Boolean | Whether or not the listbox is disabled. |
@@ -1019,13 +1019,13 @@ export default {
##### Props
| Prop | Type | Default | Description |
-| ---- | ------------------- | ------- | ------------------------------------------------------------- |
+| :--- | :------------------ | :------ | :------------------------------------------------------------ |
| `as` | String \| Component | `label` | The element or component the `ListboxLabel` should render as. |
##### Slot props
| Prop | Type | Description |
-| ---------- | ------- | --------------------------------------- |
+| :--------- | :------ | :-------------------------------------- |
| `open` | Boolean | Whether or not the listbox is open. |
| `disabled` | Boolean | Whether or not the listbox is disabled. |
@@ -1041,7 +1041,7 @@ export default {
##### Props
| Prop | Type | Default | Description |
-| --------- | ------------------- | ------- | --------------------------------------------------------------------------------- |
+| :-------- | :------------------ | :------ | :-------------------------------------------------------------------------------- |
| `as` | String \| Component | `ul` | The element or component the `ListboxOptions` should render as. |
| `static` | Boolean | `false` | Whether the element should ignore the internally managed open/closed state. |
| `unmount` | Boolean | `true` | Whether the element should be unmounted or hidden based on the open/closed state. |
@@ -1049,7 +1049,7 @@ export default {
##### Slot props
| Prop | Type | Description |
-| ------ | ------- | ----------------------------------- |
+| :----- | :------ | :---------------------------------- |
| `open` | Boolean | Whether or not the listbox is open. |
#### ListboxOption
@@ -1061,15 +1061,15 @@ export default {
##### Props
| Prop | Type | Default | Description |
-| ---------- | ------------------- | ------- | --------------------------------------------------------------------------------------- |
+| :--------- | :------------------ | :------ | :-------------------------------------------------------------------------------------- |
| `as` | String \| Component | `li` | The element or component the `ListboxOption` should render as. |
-| `value` | `T` | | The option value. |
+| `value` | `T` | - | The option value. |
| `disabled` | Boolean | `false` | Whether or not the option should be disabled for keyboard navigation and ARIA purposes. |
##### Slot props
| Prop | Type | Description |
-| ---------- | ------- | ------------------------------------------------------------------------------------ |
+| :--------- | :------ | :----------------------------------------------------------------------------------- |
| `active` | Boolean | Whether or not the option is the active/focused option in the list. |
| `selected` | Boolean | Whether or not the option is the selected option in the list. |
| `disabled` | Boolean | Whether or not the option is the disabled for keyboard navigation and ARIA purposes. |
@@ -1086,7 +1086,7 @@ The `Switch` component and related child components are used to quickly build cu
### Basic example
-Switches are built using the `Switch` component. Optionally you can also use the `SwitchGroup` and `SwitchLabel` components.
+Switches are built using the `Switch` component. Optionally you can also use the `SwitchGroup`, `SwitchLabel` and `SwitchDescription` components.
```vue
@@ -1106,13 +1106,14 @@ Switches are built using the `Switch` component. Optionally you can also use the