From ea31c57a2823f6db86780f76b1d436831d01f60b Mon Sep 17 00:00:00 2001
From: Robin Malfait <malfait.robin@gmail.com>
Date: Tue, 2 Apr 2024 19:00:19 +0200
Subject: [PATCH 1/4] add `useOnDisappear` hook

This hook allows us to trigger a callback if the element becomes
"hidden". We use the bounding client rect and check the dimensions to
know wether we are "hidden" or not.
---
 .../src/hooks/use-on-disappear.ts             | 41 +++++++++++++++++++
 1 file changed, 41 insertions(+)
 create mode 100644 packages/@headlessui-react/src/hooks/use-on-disappear.ts

diff --git a/packages/@headlessui-react/src/hooks/use-on-disappear.ts b/packages/@headlessui-react/src/hooks/use-on-disappear.ts
new file mode 100644
index 0000000000..c126fb3380
--- /dev/null
+++ b/packages/@headlessui-react/src/hooks/use-on-disappear.ts
@@ -0,0 +1,41 @@
+import { useEffect, type MutableRefObject } from 'react'
+import { disposables } from '../utils/disposables'
+import { useLatestValue } from './use-latest-value'
+
+export function useOnDisappear(
+  ref: MutableRefObject<HTMLElement | null> | HTMLElement | null,
+  cb: () => void,
+  enabled = true
+) {
+  let listenerRef = useLatestValue((element: HTMLElement) => {
+    let rect = element.getBoundingClientRect()
+    if (rect.x === 0 && rect.y === 0 && rect.width === 0 && rect.height === 0) {
+      cb()
+    }
+  })
+
+  useEffect(() => {
+    if (!enabled) return
+
+    let element = ref === null ? null : ref instanceof HTMLElement ? ref : ref.current
+    if (!element) return
+
+    let d = disposables()
+
+    // Try using ResizeObserver
+    if (typeof ResizeObserver !== 'undefined') {
+      let observer = new ResizeObserver(() => listenerRef.current(element))
+      observer.observe(element)
+      d.add(() => observer.disconnect())
+    }
+
+    // Try using IntersectionObserver
+    if (typeof IntersectionObserver !== 'undefined') {
+      let observer = new IntersectionObserver(() => listenerRef.current(element))
+      observer.observe(element)
+      d.add(() => observer.disconnect())
+    }
+
+    return () => d.dispose()
+  }, [ref, listenerRef, enabled])
+}

From d475cac2f2801a285ea31b417b005da63dc8bbce Mon Sep 17 00:00:00 2001
From: Robin Malfait <malfait.robin@gmail.com>
Date: Tue, 2 Apr 2024 19:01:17 +0200
Subject: [PATCH 2/4] use new `useOnDisappear` hook in components with the
 `anchor` prop

---
 .../src/components/combobox/combobox.tsx      |  5 +++++
 .../src/components/dialog/dialog.tsx          | 21 +++----------------
 .../src/components/listbox/listbox.tsx        |  4 ++++
 .../src/components/menu/menu.tsx              |  4 ++++
 .../src/components/popover/popover.tsx        |  4 ++++
 5 files changed, 20 insertions(+), 18 deletions(-)

diff --git a/packages/@headlessui-react/src/components/combobox/combobox.tsx b/packages/@headlessui-react/src/components/combobox/combobox.tsx
index d31d66ed16..249858a9ec 100644
--- a/packages/@headlessui-react/src/components/combobox/combobox.tsx
+++ b/packages/@headlessui-react/src/components/combobox/combobox.tsx
@@ -31,6 +31,7 @@ import { useFrameDebounce } from '../../hooks/use-frame-debounce'
 import { useId } from '../../hooks/use-id'
 import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect'
 import { useLatestValue } from '../../hooks/use-latest-value'
+import { useOnDisappear } from '../../hooks/use-on-disappear'
 import { useOutsideClick } from '../../hooks/use-outside-click'
 import { useOwnerDocument } from '../../hooks/use-owner'
 import { useRefocusableInput } from '../../hooks/use-refocusable-input'
@@ -1548,6 +1549,7 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
     ...theirProps
   } = props
   let data = useData('Combobox.Options')
+  let actions = useActions('Combobox.Options')
 
   let [floatingRef, style] = useFloatingPanel(anchor)
   let getFloatingPanelProps = useFloatingPanelProps()
@@ -1562,6 +1564,9 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
     return data.comboboxState === ComboboxState.Open
   })()
 
+  // Ensure we close the combobox as soon as the input becomes hidden
+  useOnDisappear(data.inputRef, actions.closeCombobox, visible)
+
   useIsoMorphicEffect(() => {
     data.optionsPropsRef.current.static = props.static ?? false
   }, [data.optionsPropsRef, props.static])
diff --git a/packages/@headlessui-react/src/components/dialog/dialog.tsx b/packages/@headlessui-react/src/components/dialog/dialog.tsx
index d8f44721f8..6b573c27f5 100644
--- a/packages/@headlessui-react/src/components/dialog/dialog.tsx
+++ b/packages/@headlessui-react/src/components/dialog/dialog.tsx
@@ -24,6 +24,7 @@ import { useEventListener } from '../../hooks/use-event-listener'
 import { useId } from '../../hooks/use-id'
 import { useInert } from '../../hooks/use-inert'
 import { useIsTouchDevice } from '../../hooks/use-is-touch-device'
+import { useOnDisappear } from '../../hooks/use-on-disappear'
 import { useOutsideClick } from '../../hooks/use-outside-click'
 import { useOwnerDocument } from '../../hooks/use-owner'
 import { useRootContainers } from '../../hooks/use-root-containers'
@@ -338,24 +339,8 @@ function DialogFn<TTag extends ElementType = typeof DEFAULT_DIALOG_TAG>(
   })()
   useScrollLock(ownerDocument, scrollLockEnabled, resolveRootContainers)
 
-  // Trigger close when the FocusTrap gets hidden
-  useEffect(() => {
-    if (dialogState !== DialogStates.Open) return
-    if (!internalDialogRef.current) return
-
-    let observer = new ResizeObserver((entries) => {
-      for (let entry of entries) {
-        let rect = entry.target.getBoundingClientRect()
-        if (rect.x === 0 && rect.y === 0 && rect.width === 0 && rect.height === 0) {
-          close()
-        }
-      }
-    })
-
-    observer.observe(internalDialogRef.current)
-
-    return () => observer.disconnect()
-  }, [dialogState, internalDialogRef, close])
+  // Ensure we close the dialog as soon as the dialog itself becomes hidden
+  useOnDisappear(internalDialogRef, close, dialogState === DialogStates.Open)
 
   let [describedby, DescriptionProvider] = useDescriptions()
 
diff --git a/packages/@headlessui-react/src/components/listbox/listbox.tsx b/packages/@headlessui-react/src/components/listbox/listbox.tsx
index d9d5ed6c8e..852a3eca91 100644
--- a/packages/@headlessui-react/src/components/listbox/listbox.tsx
+++ b/packages/@headlessui-react/src/components/listbox/listbox.tsx
@@ -31,6 +31,7 @@ import { useEvent } from '../../hooks/use-event'
 import { useId } from '../../hooks/use-id'
 import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect'
 import { useLatestValue } from '../../hooks/use-latest-value'
+import { useOnDisappear } from '../../hooks/use-on-disappear'
 import { useOutsideClick } from '../../hooks/use-outside-click'
 import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
 import { useSyncRefs } from '../../hooks/use-sync-refs'
@@ -898,6 +899,9 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
     return data.listboxState === ListboxStates.Open
   })()
 
+  // Ensure we close the listbox as soon as the button becomes hidden
+  useOnDisappear(data.buttonRef, actions.closeListbox, visible)
+
   let initialOption = useRef<number | null>(null)
 
   useEffect(() => {
diff --git a/packages/@headlessui-react/src/components/menu/menu.tsx b/packages/@headlessui-react/src/components/menu/menu.tsx
index 9cf528ec38..0aa6dcd00f 100644
--- a/packages/@headlessui-react/src/components/menu/menu.tsx
+++ b/packages/@headlessui-react/src/components/menu/menu.tsx
@@ -27,6 +27,7 @@ import { useElementSize } from '../../hooks/use-element-size'
 import { useEvent } from '../../hooks/use-event'
 import { useId } from '../../hooks/use-id'
 import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect'
+import { useOnDisappear } from '../../hooks/use-on-disappear'
 import { useOutsideClick } from '../../hooks/use-outside-click'
 import { useOwnerDocument } from '../../hooks/use-owner'
 import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
@@ -611,6 +612,9 @@ function ItemsFn<TTag extends ElementType = typeof DEFAULT_ITEMS_TAG>(
     return state.menuState === MenuStates.Open
   })()
 
+  // Ensure we close the menu as soon as the button becomes hidden
+  useOnDisappear(state.buttonRef, () => dispatch({ type: ActionTypes.CloseMenu }), visible)
+
   // We keep track whether the button moved or not, we only check this when the menu state becomes
   // closed. If the button moved, then we want to cancel pending transitions to prevent that the
   // attached `MenuItems` is still transitioning while the button moved away.
diff --git a/packages/@headlessui-react/src/components/popover/popover.tsx b/packages/@headlessui-react/src/components/popover/popover.tsx
index 9ff7687306..e9554e3c9f 100644
--- a/packages/@headlessui-react/src/components/popover/popover.tsx
+++ b/packages/@headlessui-react/src/components/popover/popover.tsx
@@ -29,6 +29,7 @@ import { useEventListener } from '../../hooks/use-event-listener'
 import { useId } from '../../hooks/use-id'
 import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect'
 import { useLatestValue } from '../../hooks/use-latest-value'
+import { useOnDisappear } from '../../hooks/use-on-disappear'
 import { useOutsideClick } from '../../hooks/use-outside-click'
 import { useOwnerDocument } from '../../hooks/use-owner'
 import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
@@ -854,6 +855,9 @@ function PanelFn<TTag extends ElementType = typeof DEFAULT_PANEL_TAG>(
     return state.popoverState === PopoverStates.Open
   })()
 
+  // Ensure we close the popover as soon as the button becomes hidden
+  useOnDisappear(state.button, () => dispatch({ type: ActionTypes.ClosePopover }), visible)
+
   let handleKeyDown = useEvent((event: ReactKeyboardEvent<HTMLButtonElement>) => {
     switch (event.key) {
       case Keys.Escape:

From 67243b98588b01824ef4ca82778f1fbde4ae4e01 Mon Sep 17 00:00:00 2001
From: Robin Malfait <malfait.robin@gmail.com>
Date: Tue, 2 Apr 2024 19:07:05 +0200
Subject: [PATCH 3/4] update changelog

---
 packages/@headlessui-react/CHANGELOG.md | 1 +
 1 file changed, 1 insertion(+)

diff --git a/packages/@headlessui-react/CHANGELOG.md b/packages/@headlessui-react/CHANGELOG.md
index b592bc329b..56148639ea 100644
--- a/packages/@headlessui-react/CHANGELOG.md
+++ b/packages/@headlessui-react/CHANGELOG.md
@@ -34,6 +34,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 - Accept optional `strategy` for the `anchor` prop ([#3034](https://github.com/tailwindlabs/headlessui/pull/3034))
 - Expose `--input-width` and `--button-width` CSS variables on the `ComboboxOptions` component ([#3057](https://github.com/tailwindlabs/headlessui/pull/3057))
 - Expose the `--button-width` CSS variable on the `PopoverPanel` component ([#3058](https://github.com/tailwindlabs/headlessui/pull/3058))
+- Close the `Combobox`, `Dialog`, `Listbox`, `Menu` and `Popover` components when the trigger disappears ([#3075](https://github.com/tailwindlabs/headlessui/pull/3075))
 
 ## [2.0.0-alpha.4] - 2024-01-03
 

From a923f0c71746bcca56c6acc4f929b1c8d1958b5e Mon Sep 17 00:00:00 2001
From: Robin Malfait <malfait.robin@gmail.com>
Date: Wed, 3 Apr 2024 15:08:46 +0200
Subject: [PATCH 4/4] document `useOnDisappear`

---
 packages/@headlessui-react/src/hooks/use-on-disappear.ts | 7 +++++++
 1 file changed, 7 insertions(+)

diff --git a/packages/@headlessui-react/src/hooks/use-on-disappear.ts b/packages/@headlessui-react/src/hooks/use-on-disappear.ts
index c126fb3380..c79720a205 100644
--- a/packages/@headlessui-react/src/hooks/use-on-disappear.ts
+++ b/packages/@headlessui-react/src/hooks/use-on-disappear.ts
@@ -2,6 +2,13 @@ import { useEffect, type MutableRefObject } from 'react'
 import { disposables } from '../utils/disposables'
 import { useLatestValue } from './use-latest-value'
 
+/**
+ * A hook to ensure that a callback is called when the element has disappeared
+ * from the screen.
+ *
+ * This can happen if you use Tailwind classes like: `hidden md:block`, once the
+ * viewport is smaller than `md` the element will disappear.
+ */
 export function useOnDisappear(
   ref: MutableRefObject<HTMLElement | null> | HTMLElement | null,
   cb: () => void,