Skip to content

Commit

Permalink
Close the Combobox, Dialog, Listbox, Menu and Popover compo…
Browse files Browse the repository at this point in the history
…nents when the trigger disappears (#3075)

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

* use new `useOnDisappear` hook in components with the `anchor` prop

* update changelog

* document `useOnDisappear`
  • Loading branch information
RobinMalfait committed Apr 3, 2024
1 parent 4ed69f6 commit c1d3b7e
Show file tree
Hide file tree
Showing 7 changed files with 69 additions and 18 deletions.
1 change: 1 addition & 0 deletions packages/@headlessui-react/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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()
Expand All @@ -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])
Expand Down
21 changes: 3 additions & 18 deletions packages/@headlessui-react/src/components/dialog/dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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()

Expand Down
4 changes: 4 additions & 0 deletions packages/@headlessui-react/src/components/listbox/listbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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(() => {
Expand Down
4 changes: 4 additions & 0 deletions packages/@headlessui-react/src/components/menu/menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions packages/@headlessui-react/src/components/popover/popover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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:
Expand Down
48 changes: 48 additions & 0 deletions packages/@headlessui-react/src/hooks/use-on-disappear.ts
Original file line number Diff line number Diff line change
@@ -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> | 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])
}

0 comments on commit c1d3b7e

Please sign in to comment.