Skip to content

Commit

Permalink
Improve outside click support (#1175)
Browse files Browse the repository at this point in the history
* improve outside click support

We used to use `pointerdown`, but some older devices with iOS 12 didn't
have support for that. Instead we used `mousedown`. But now it turns out
that some devices only properly use `pointerdown` and not the `mousedown` event.

Instead, we will listen to both, but make sure to only handle the event
once.

* update changelog
  • Loading branch information
RobinMalfait authored Mar 1, 2022
1 parent 1b3837b commit cefb899
Show file tree
Hide file tree
Showing 13 changed files with 150 additions and 60 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Fix `hover` scroll ([#1161](https://github.com/tailwindlabs/headlessui/pull/1161))
- Guarantee DOM sort order when performing actions ([#1168](https://github.com/tailwindlabs/headlessui/pull/1168))
- Fix `<Transition>` flickering issue ([#1118](https://github.com/tailwindlabs/headlessui/pull/1118))
- Improve outside click support ([#1175](https://github.com/tailwindlabs/headlessui/pull/1175))

## [Unreleased - @headlessui/vue]

Expand All @@ -26,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Ensure links are triggered inside `Popover Panel` components ([#1153](https://github.com/tailwindlabs/headlessui/pull/1153))
- Fix `hover` scroll ([#1161](https://github.com/tailwindlabs/headlessui/pull/1161))
- Guarantee DOM sort order when performing actions ([#1168](https://github.com/tailwindlabs/headlessui/pull/1168))
- Improve outside click support ([#1175](https://github.com/tailwindlabs/headlessui/pull/1175))

## [@headlessui/react@v1.5.0] - 2022-02-17

Expand Down
10 changes: 2 additions & 8 deletions packages/@headlessui-react/src/components/combobox/combobox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import { disposables } from '../../utils/disposables'
import { Keys } from '../keyboard'
import { Focus, calculateActiveIndex } from '../../utils/calculate-active-index'
import { isDisabledReactIssue7711 } from '../../utils/bugs'
import { useWindowEvent } from '../../hooks/use-window-event'
import { useOutsideClick } from '../../hooks/use-outside-click'
import { useOpenClosed, State, OpenClosedProvider } from '../../internal/open-closed'
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
import { useLatestValue } from '../../hooks/use-latest-value'
Expand Down Expand Up @@ -302,15 +302,9 @@ let ComboboxRoot = forwardRefWithAs(function Combobox<
useIsoMorphicEffect(() => dispatch({ type: ActionTypes.SetDisabled, disabled }), [disabled])

// Handle outside click
useWindowEvent('mousedown', (event) => {
let target = event.target as HTMLElement

useOutsideClick([buttonRef, inputRef, optionsRef], () => {
if (comboboxState !== ComboboxStates.Open) return

if (buttonRef.current?.contains(target)) return
if (inputRef.current?.contains(target)) return
if (optionsRef.current?.contains(target)) return

dispatch({ type: ActionTypes.CloseCombobox })
})

Expand Down
6 changes: 2 additions & 4 deletions packages/@headlessui-react/src/components/dialog/dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { useWindowEvent } from '../../hooks/use-window-event'
import { useOpenClosed, State } from '../../internal/open-closed'
import { useServerHandoffComplete } from '../../hooks/use-server-handoff-complete'
import { StackProvider, StackMessage } from '../../internal/stack-context'
import { useOutsideClick } from '../../hooks/use-outside-click'

enum DialogStates {
Open,
Expand Down Expand Up @@ -207,12 +208,9 @@ let DialogRoot = forwardRefWithAs(function Dialog<
useInertOthers(internalDialogRef, hasNestedDialogs ? enabled : false)

// Handle outside click
useWindowEvent('mousedown', (event) => {
let target = event.target as HTMLElement

useOutsideClick(internalDialogRef, () => {
if (dialogState !== DialogStates.Open) return
if (hasNestedDialogs) return
if (internalDialogRef.current?.contains(target)) return

close()
})
Expand Down
9 changes: 2 additions & 7 deletions packages/@headlessui-react/src/components/listbox/listbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,9 @@ import { Keys } from '../keyboard'
import { Focus, calculateActiveIndex } from '../../utils/calculate-active-index'
import { isDisabledReactIssue7711 } from '../../utils/bugs'
import { isFocusableElement, FocusableMode, sortByDomNode } from '../../utils/focus-management'
import { useWindowEvent } from '../../hooks/use-window-event'
import { useOpenClosed, State, OpenClosedProvider } from '../../internal/open-closed'
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
import { useOutsideClick } from '../../hooks/use-outside-click'

enum ListboxStates {
Open,
Expand Down Expand Up @@ -281,14 +281,9 @@ let ListboxRoot = forwardRefWithAs(function Listbox<
)

// Handle outside click
useWindowEvent('mousedown', (event) => {
let target = event.target as HTMLElement

useOutsideClick([buttonRef, optionsRef], (event, target) => {
if (listboxState !== ListboxStates.Open) return

if (buttonRef.current?.contains(target)) return
if (optionsRef.current?.contains(target)) return

dispatch({ type: ActionTypes.CloseListbox })

if (!isFocusableElement(target, FocusableMode.Loose)) {
Expand Down
9 changes: 2 additions & 7 deletions packages/@headlessui-react/src/components/menu/menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import { Keys } from '../keyboard'
import { Focus, calculateActiveIndex } from '../../utils/calculate-active-index'
import { isDisabledReactIssue7711 } from '../../utils/bugs'
import { isFocusableElement, FocusableMode, sortByDomNode } from '../../utils/focus-management'
import { useWindowEvent } from '../../hooks/use-window-event'
import { useOutsideClick } from '../../hooks/use-outside-click'
import { useTreeWalker } from '../../hooks/use-tree-walker'
import { useOpenClosed, State, OpenClosedProvider } from '../../internal/open-closed'
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
Expand Down Expand Up @@ -219,14 +219,9 @@ let MenuRoot = forwardRefWithAs(function Menu<TTag extends ElementType = typeof
let menuRef = useSyncRefs(ref)

// Handle outside click
useWindowEvent('mousedown', (event) => {
let target = event.target as HTMLElement

useOutsideClick([buttonRef, itemsRef], (event, target) => {
if (menuState !== MenuStates.Open) return

if (buttonRef.current?.contains(target)) return
if (itemsRef.current?.contains(target)) return

dispatch({ type: ActionTypes.CloseMenu })

if (!isFocusableElement(target, FocusableMode.Loose)) {
Expand Down
8 changes: 2 additions & 6 deletions packages/@headlessui-react/src/components/popover/popover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
import { useWindowEvent } from '../../hooks/use-window-event'
import { OpenClosedProvider, State, useOpenClosed } from '../../internal/open-closed'
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
import { useOutsideClick } from '../../hooks/use-outside-click'

enum PopoverStates {
Open,
Expand Down Expand Up @@ -218,14 +219,9 @@ let PopoverRoot = forwardRefWithAs(function Popover<
)

// Handle outside click
useWindowEvent('mousedown', (event) => {
let target = event.target as HTMLElement

useOutsideClick([button, panel], (event, target) => {
if (popoverState !== PopoverStates.Open) return

if (button?.contains(target)) return
if (panel?.contains(target)) return

dispatch({ type: ActionTypes.ClosePopover })

if (!isFocusableElement(target, FocusableMode.Loose)) {
Expand Down
63 changes: 63 additions & 0 deletions packages/@headlessui-react/src/hooks/use-outside-click.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { MutableRefObject, useMemo, useRef } from 'react'
import { useLatestValue } from './use-latest-value'
import { useWindowEvent } from './use-window-event'

// Polyfill
function microTask(cb: () => void) {
if (typeof queueMicrotask === 'function') {
queueMicrotask(cb)
} else {
Promise.resolve()
.then(cb)
.catch((e) =>
setTimeout(() => {
throw e
})
)
}
}

export function useOutsideClick(
containers:
| HTMLElement
| MutableRefObject<HTMLElement | null>
| (MutableRefObject<HTMLElement | null> | HTMLElement | null)[]
| Set<HTMLElement>,
cb: (event: MouseEvent | PointerEvent, target: HTMLElement) => void
) {
let _containers = useMemo(() => {
if (Array.isArray(containers)) {
return containers
}

if (containers instanceof Set) {
return containers
}

return [containers]
}, [containers])

let called = useRef(false)
let handler = useLatestValue((event: MouseEvent | PointerEvent) => {
if (called.current) return
called.current = true
microTask(() => {
called.current = false
})

let target = event.target as HTMLElement

for (let container of _containers) {
if (container === null) continue
let domNode = container instanceof HTMLElement ? container : container.current
if (domNode?.contains(target)) {
return
}
}

return cb(event, target)
})

useWindowEvent('pointerdown', (...args) => handler.current(...args))
useWindowEvent('mousedown', (...args) => handler.current(...args))
}
12 changes: 3 additions & 9 deletions packages/@headlessui-vue/src/components/combobox/combobox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,12 @@ import { useId } from '../../hooks/use-id'
import { Keys } from '../../keyboard'
import { calculateActiveIndex, Focus } from '../../utils/calculate-active-index'
import { dom } from '../../utils/dom'
import { useWindowEvent } from '../../hooks/use-window-event'
import { useOpenClosed, State, useOpenClosedProvider } from '../../internal/open-closed'
import { match } from '../../utils/match'
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
import { useTreeWalker } from '../../hooks/use-tree-walker'
import { sortByDomNode } from '../../utils/focus-management'
import { useOutsideClick } from '../../hooks/use-outside-click'

enum ComboboxStates {
Open,
Expand Down Expand Up @@ -229,15 +229,9 @@ export let Combobox = defineComponent({
},
}

useWindowEvent('mousedown', (event) => {
let target = event.target as HTMLElement

// Handle outside click
useOutsideClick([inputRef, buttonRef, optionsRef], () => {
if (comboboxState.value !== ComboboxStates.Open) return

if (dom(inputRef)?.contains(target)) return
if (dom(buttonRef)?.contains(target)) return
if (dom(optionsRef)?.contains(target)) return

api.closeCombobox()
})

Expand Down
7 changes: 2 additions & 5 deletions packages/@headlessui-vue/src/components/dialog/dialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ import { Keys } from '../../keyboard'
import { useId } from '../../hooks/use-id'
import { useFocusTrap } from '../../hooks/use-focus-trap'
import { useInertOthers } from '../../hooks/use-inert-others'
import { contains } from '../../internal/dom-containers'
import { useWindowEvent } from '../../hooks/use-window-event'
import { Portal, PortalGroup } from '../portal/portal'
import { StackMessage, useStackProvider } from '../../internal/stack-context'
Expand All @@ -32,6 +31,7 @@ import { ForcePortalRoot } from '../../internal/portal-force-root'
import { Description, useDescriptions } from '../description/description'
import { dom } from '../../utils/dom'
import { useOpenClosed, State } from '../../internal/open-closed'
import { useOutsideClick } from '../../hooks/use-outside-click'

enum DialogStates {
Open,
Expand Down Expand Up @@ -158,12 +158,9 @@ export let Dialog = defineComponent({
provide(DialogContext, api)

// Handle outside click
useWindowEvent('mousedown', (event) => {
let target = event.target as HTMLElement

useOutsideClick(containers.value, (_event, target) => {
if (dialogState.value !== DialogStates.Open) return
if (containers.value.size !== 1) return
if (contains(containers.value, target)) return

api.close()
nextTick(() => target?.focus())
Expand Down
7 changes: 3 additions & 4 deletions packages/@headlessui-vue/src/components/listbox/listbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,11 @@ import { useId } from '../../hooks/use-id'
import { Keys } from '../../keyboard'
import { calculateActiveIndex, Focus } from '../../utils/calculate-active-index'
import { dom } from '../../utils/dom'
import { useWindowEvent } from '../../hooks/use-window-event'
import { useOpenClosed, State, useOpenClosedProvider } from '../../internal/open-closed'
import { match } from '../../utils/match'
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
import { sortByDomNode } from '../../utils/focus-management'
import { useOutsideClick } from '../../hooks/use-outside-click'

enum ListboxStates {
Open,
Expand Down Expand Up @@ -219,12 +219,11 @@ export let Listbox = defineComponent({
},
}

useWindowEvent('mousedown', (event) => {
let target = event.target as HTMLElement
// Handle outside click
useOutsideClick(buttonRef, (event, target) => {
let active = document.activeElement

if (listboxState.value !== ListboxStates.Open) return
if (dom(buttonRef)?.contains(target)) return

if (!dom(optionsRef)?.contains(target)) api.closeListbox()
if (active !== document.body && active?.contains(target)) return // Keep focus on newly clicked/focused element
Expand Down
7 changes: 3 additions & 4 deletions packages/@headlessui-vue/src/components/menu/menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,12 @@ import { useId } from '../../hooks/use-id'
import { Keys } from '../../keyboard'
import { Focus, calculateActiveIndex } from '../../utils/calculate-active-index'
import { dom } from '../../utils/dom'
import { useWindowEvent } from '../../hooks/use-window-event'
import { useTreeWalker } from '../../hooks/use-tree-walker'
import { useOpenClosedProvider, State, useOpenClosed } from '../../internal/open-closed'
import { match } from '../../utils/match'
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
import { sortByDomNode } from '../../utils/focus-management'
import { useOutsideClick } from '../../hooks/use-outside-click'

enum MenuStates {
Open,
Expand Down Expand Up @@ -170,12 +170,11 @@ export let Menu = defineComponent({
},
}

useWindowEvent('mousedown', (event) => {
let target = event.target as HTMLElement
// Handle outside click
useOutsideClick(buttonRef, (event, target) => {
let active = document.activeElement

if (menuState.value !== MenuStates.Open) return
if (dom(buttonRef)?.contains(target)) return

if (!dom(itemsRef)?.contains(target)) api.closeMenu()
if (active !== document.body && active?.contains(target)) return // Keep focus on newly clicked/focused element
Expand Down
8 changes: 2 additions & 6 deletions packages/@headlessui-vue/src/components/popover/popover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { dom } from '../../utils/dom'
import { useWindowEvent } from '../../hooks/use-window-event'
import { useOpenClosedProvider, State, useOpenClosed } from '../../internal/open-closed'
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
import { useOutsideClick } from '../../hooks/use-outside-click'

enum PopoverStates {
Open,
Expand Down Expand Up @@ -175,14 +176,9 @@ export let Popover = defineComponent({
)

// Handle outside click
useWindowEvent('mousedown', (event: MouseEvent) => {
let target = event.target as HTMLElement

useOutsideClick([button, panel], (event, target) => {
if (popoverState.value !== PopoverStates.Open) return

if (dom(button)?.contains(target)) return
if (dom(panel)?.contains(target)) return

api.closePopover()

if (!isFocusableElement(target, FocusableMode.Loose)) {
Expand Down
Loading

0 comments on commit cefb899

Please sign in to comment.