From 2b37043622d50fde8f63d36fdcc74fd7edb55b05 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 18 Feb 2021 18:27:18 +0100 Subject: [PATCH] Multiple new components (#220) * add Disclosure component * expose the Disclosure component * add Disclosure example component page * temporary fix selector because of JSDOM bug * add useFocusTrap hook * add FocusTrap component * expose FocusTrap * add Dialog component * add Dialog example component page * expose Dialog * random cleanup * make TypeScript a bit more happy * add Switch.Description component for React * add Switch.Description component for Vue * ensure focus event is triggered on click when element is focusable * remove Dialog.Button and Dialog.Panel from accessibility assertions * add Portal component * expose Portal * always render Dialog in a Portal * add useInertOthers hook This will allow us to mark everything but the current ref as "inert". This is important for screenreaders, to ensure that screenreaders and assistive technology can't interact with other content but the current ref. This implementation is not ideal yet. It doesn't take into account that you can use the hook in 2 different components. For now this is fine, since we only use it in a Dialog and you should also probably only have a single Dialog open at a time. Will improve this in the future! * use the useInertOthers hook * add scroll lock to the dialog * ensure we respect autoFocus on form elements within the Dialog If we have an autoFocus on an input, that input will receive focus. Once we try to focus the first focusable element in the Dialog this could be lead to unwanted behaviour. Therefore we check if the focus already is within the Dialog, if it is, keep it like that. * only mark aria-modal when Dialog is open * add initialFocus option to Dialog, FocusTrap & useFocusTrap * add tests and a few fixes for the initialFocusRef functionality * forward ref to underlying Dialog component * close Dialog when it becomes hidden Could happen when this is in md:hidden for example * prevent infinite loop When we `Tab` in a FocusTrap it will try and focus the Next element. If we are in a state where none of the elements inside the FocusTrap can be focused, then we keep trying to focus the next one in line. This results in an infinite loop... To mitigate this issue, we check if we looped around, if we did, it means that we tried all the other focusable elements, therefore we can stop. * isIntersecting doesn't work in every scenario When page is scrollable, when dialog is translated of the page. Now just checking for sizes, which should be enough for md:hiden cases * render Portal contents in a div Otherwise you can't use multiple Portal components if you render multiple children inside each Portal * ensure the props bag is typed * add getByText and assertContainsActiveElement helpers * add Popover component * expose Popover * add Popover example component page * add quick checks to prevent useless renders * drop incorrect close function * update Changelog * make test error more readable when comparing DOM nodes * actually call .focus() on the element This ensures that the document.activeElement becomes the focused element. * improve useSyncRefs, because ...refs is *always* different * add dedicated focus management utilities * refactor useFocusTrap, use focus management utilities * fix regression while using outside click There might be a chance that you didn't even notice this *bug*. The idea is that when you click outside, that the Menu or Listbox closes. However there is another step that happens: 1. When you click on a focusable item, keep the focus on that item. 2. When you click on a non-focusable item, move focus back to the Menu.Button or Listbox.Button We broke part 2, we never returned to the Menu.Button or Listbox.Button. This is (might) be important for screenreaders so that they don't "get lost", because if you click on a non-focusable item, the document.body becomes the active element. Confusing. * add outside-click to Dialog itself * update docs --- CHANGELOG.md | 9 +- packages/@headlessui-react/README.md | 603 +++++- .../@headlessui-react/pages/dialog/dialog.tsx | 110 ++ .../pages/disclosure/disclosure.tsx | 32 + .../pages/popover/popover.tsx | 101 + .../src/components/dialog/dialog.test.tsx | 477 +++++ .../src/components/dialog/dialog.tsx | 368 ++++ .../components/disclosure/disclosure.test.tsx | 531 +++++ .../src/components/disclosure/disclosure.tsx | 258 +++ .../components/focus-trap/focus-trap.test.tsx | 328 ++++ .../src/components/focus-trap/focus-trap.tsx | 26 + .../src/components/listbox/listbox.tsx | 23 +- .../src/components/menu/menu.tsx | 33 +- .../src/components/popover/popover.test.tsx | 1739 +++++++++++++++++ .../src/components/popover/popover.tsx | 706 +++++++ .../src/components/portal/portal.test.tsx | 142 ++ .../src/components/portal/portal.tsx | 52 + .../src/components/switch/switch.test.tsx | 43 +- .../src/components/switch/switch.tsx | 36 +- .../src/components/transitions/transition.tsx | 16 +- .../src/hooks/use-focus-trap.ts | 117 ++ .../src/hooks/use-inert-others.ts | 66 + .../src/hooks/use-sync-refs.ts | 20 +- packages/@headlessui-react/src/index.test.ts | 12 +- packages/@headlessui-react/src/index.ts | 9 +- .../test-utils/accessibility-assertions.ts | 540 ++++- .../src/test-utils/interactions.ts | 15 +- .../src/utils/focus-management.ts | 117 ++ .../src/utils/render.test.tsx | 8 +- .../@headlessui-react/src/utils/render.ts | 4 +- packages/@headlessui-vue/README.md | 72 +- .../src/components/switch/switch.test.tsx | 62 +- .../src/components/switch/switch.ts | 35 +- packages/@headlessui-vue/src/index.test.ts | 1 + .../test-utils/accessibility-assertions.ts | 14 + .../src/test-utils/interactions.ts | 13 +- 36 files changed, 6592 insertions(+), 146 deletions(-) create mode 100644 packages/@headlessui-react/pages/dialog/dialog.tsx create mode 100644 packages/@headlessui-react/pages/disclosure/disclosure.tsx create mode 100644 packages/@headlessui-react/pages/popover/popover.tsx create mode 100644 packages/@headlessui-react/src/components/dialog/dialog.test.tsx create mode 100644 packages/@headlessui-react/src/components/dialog/dialog.tsx create mode 100644 packages/@headlessui-react/src/components/disclosure/disclosure.test.tsx create mode 100644 packages/@headlessui-react/src/components/disclosure/disclosure.tsx create mode 100644 packages/@headlessui-react/src/components/focus-trap/focus-trap.test.tsx create mode 100644 packages/@headlessui-react/src/components/focus-trap/focus-trap.tsx create mode 100644 packages/@headlessui-react/src/components/popover/popover.test.tsx create mode 100644 packages/@headlessui-react/src/components/popover/popover.tsx create mode 100644 packages/@headlessui-react/src/components/portal/portal.test.tsx create mode 100644 packages/@headlessui-react/src/components/portal/portal.tsx create mode 100644 packages/@headlessui-react/src/hooks/use-focus-trap.ts create mode 100644 packages/@headlessui-react/src/hooks/use-inert-others.ts create mode 100644 packages/@headlessui-react/src/utils/focus-management.ts 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