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. +

+ + + +
+ ) +} +``` + +### 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. +

+ + + +
+ ) +} +``` + +##### 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 ( + <> + + + + +
+
+ + +
+
+
+ + + {/* 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. +

+
+
+
+
+
+ + +
+
+
+
+
+
+
+ + ) +} 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 ( +
+ + + + + + + {links.map((link, i) => ( + + Normal - {link} + + ))} + + + + + + + {links.map((link, i) => ( + Focus - {link} + ))} + + + + + + + + {links.map(link => ( + Portal - {link} + ))} + + + + + + + + + {links.map(link => ( + Focus in Portal - {link} + ))} + + + + + + +
+ ) +} 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( + + + + +

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 ( + <> + + + {({ 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( + <> + + +

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 ( + <> + + + + + + ) + } + 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 ( + <> + + + + + + + + + ) + } + + 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 ( + <> + + + {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 ( + <> + + + 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 ( + <> + + + + 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 ( + <> + + + 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 ( + <> + + + + 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( + + + + ) + + 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 ( + <> + + + + {visible && ( + + + + )} + + ) + } + + 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( + <> + + + + + + + + + ) + + // 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( + <> + + + + + + + + + ) + + // 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( + <> + + + + + + + + + + ) + + // 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( + <> + + + + + + + + + + ) + + // 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( + <> + + + + + + + + + + ) + + // 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 + + + + + ) + + // 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 ( +
+ + + + {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