Close the Combobox, Dialog, Listbox, Menu and Popover components 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`
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
Reference in New Issue
Block a user