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 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( ...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( 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( })() 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( 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(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( 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( 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) => { switch (event.key) { case Keys.Escape: 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..c79720a205 --- /dev/null +++ b/packages/@headlessui-react/src/hooks/use-on-disappear.ts @@ -0,0 +1,48 @@ +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, + 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]) +}