Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Close the Combobox, Dialog, Listbox, Menu and Popover components when the trigger disappears #3075

Merged
merged 4 commits into from
Apr 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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])
}
Loading