diff --git a/packages/@headlessui-react/CHANGELOG.md b/packages/@headlessui-react/CHANGELOG.md index 82d2298..d1e74cc 100644 --- a/packages/@headlessui-react/CHANGELOG.md +++ b/packages/@headlessui-react/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fix focus not returned to SVG Element ([#3704](https://github.com/tailwindlabs/headlessui/pull/3704)) - Fix `Listbox` not focusing first or last option on ArrowUp / ArrowDown ([#3721](https://github.com/tailwindlabs/headlessui/pull/3721)) - Performance improvement: only re-render top-level component when nesting components e.g.: `Menu` inside a `Dialog` ([#3722](https://github.com/tailwindlabs/headlessui/pull/3722)) +- Fix closing `Menu` when other `Menu` is opened ([#3726](https://github.com/tailwindlabs/headlessui/pull/3726)) ## [2.2.2] - 2025-04-17 diff --git a/packages/@headlessui-react/src/components/combobox/combobox-machine-glue.tsx b/packages/@headlessui-react/src/components/combobox/combobox-machine-glue.tsx index 4121fbe..153bfa2 100644 --- a/packages/@headlessui-react/src/components/combobox/combobox-machine-glue.tsx +++ b/packages/@headlessui-react/src/components/combobox/combobox-machine-glue.tsx @@ -1,4 +1,5 @@ import { createContext, useContext, useMemo } from 'react' +import { useOnUnmount } from '../../hooks/use-on-unmount' import { ComboboxMachine } from './combobox-machine' export const ComboboxContext = createContext | null>(null) @@ -13,8 +14,11 @@ export function useComboboxMachineContext(component: string) { } export function useComboboxMachine({ + id, virtual = null, __demoMode = false, -}: Parameters[0] = {}) { - return useMemo(() => ComboboxMachine.new({ virtual, __demoMode }), []) +}: Parameters[0]) { + let machine = useMemo(() => ComboboxMachine.new({ id, virtual, __demoMode }), []) + useOnUnmount(() => machine.dispose()) + return machine } diff --git a/packages/@headlessui-react/src/components/combobox/combobox-machine.ts b/packages/@headlessui-react/src/components/combobox/combobox-machine.ts index aa6353c..ca4fea7 100644 --- a/packages/@headlessui-react/src/components/combobox/combobox-machine.ts +++ b/packages/@headlessui-react/src/components/combobox/combobox-machine.ts @@ -1,4 +1,5 @@ import { Machine } from '../../machine' +import { ActionTypes as StackActionTypes, stackMachines } from '../../machines/stack-machine' import type { EnsureArray } from '../../types' import { Focus, calculateActiveIndex } from '../../utils/calculate-active-index' import { sortByDomNode } from '../../utils/focus-management' @@ -32,6 +33,8 @@ export type ComboboxOptionDataRef = MutableRefObject<{ }> export interface State { + id: string + dataRef: MutableRefObject<{ value: unknown defaultValue: unknown @@ -405,9 +408,11 @@ let reducers: { export class ComboboxMachine extends Machine, Actions> { static new({ + id, virtual = null, __demoMode = false, }: { + id: string virtual?: { options: TMultiple extends true ? EnsureArray> : NoInfer[] disabled?: ( @@ -415,8 +420,9 @@ export class ComboboxMachine extends Machine, Actions> { ) => boolean } | null __demoMode?: boolean - } = {}) { + }) { return new ComboboxMachine({ + id, // @ts-expect-error TODO: Re-structure such that we don't need to ignore this dataRef: { current: {} }, comboboxState: __demoMode ? ComboboxState.Open : ComboboxState.Closed, @@ -435,6 +441,31 @@ export class ComboboxMachine extends Machine, Actions> { }) } + constructor(initialState: State) { + super(initialState) + + // When the combobox is open, and it's not on the top of the hierarchy, we + // should close it again. + { + let id = this.state.id + let stackMachine = stackMachines.get(null) + + this.disposables.add( + stackMachine.on(StackActionTypes.Push, (state) => { + if ( + !stackMachine.selectors.isTop(state, id) && + this.state.comboboxState === ComboboxState.Open + ) { + this.actions.closeCombobox() + } + }) + ) + + this.on(ActionTypes.OpenCombobox, () => stackMachine.actions.push(id)) + this.on(ActionTypes.CloseCombobox, () => stackMachine.actions.pop(id)) + } + } + actions = { onChange: (newValue: T) => { let { onChange, compare, mode, value } = this.state.dataRef.current diff --git a/packages/@headlessui-react/src/components/combobox/combobox.tsx b/packages/@headlessui-react/src/components/combobox/combobox.tsx index 70f63ae..f14d220 100644 --- a/packages/@headlessui-react/src/components/combobox/combobox.tsx +++ b/packages/@headlessui-react/src/components/combobox/combobox.tsx @@ -57,6 +57,7 @@ import { FormFields } from '../../internal/form-fields' import { Frozen, useFrozenData } from '../../internal/frozen' import { useProvidedId } from '../../internal/id' import { OpenClosedProvider, State, useOpenClosed } from '../../internal/open-closed' +import { stackMachines } from '../../machines/stack-machine' import { useSlice } from '../../react-glue' import type { EnsureArray, Props } from '../../types' import { history } from '../../utils/active-element-history' @@ -288,6 +289,8 @@ function ComboboxFn, ref: Ref ) { + let id = useId() + let providedDisabled = useDisabled() let { value: controlledValue, @@ -315,7 +318,7 @@ function ComboboxFn({ static: false, hold: false }) @@ -401,9 +404,14 @@ function ComboboxFn stackMachine.selectors.isTop(state, id), [stackMachine, id]) + ) + // Handle outside click - let outsideClickEnabled = comboboxState === ComboboxState.Open - useOutsideClick(outsideClickEnabled, [buttonElement, inputElement, optionsElement], () => + useOutsideClick(isTopLayer, [buttonElement, inputElement, optionsElement], () => machine.actions.closeCombobox() ) @@ -1082,7 +1090,7 @@ function ButtonFn( }) let handlePointerDown = useEvent((event: ReactPointerEvent) => { - // We use the `poitnerdown` event here since it fires before the focus + // We use the `pointerdown` event here since it fires before the focus // event, allowing us to cancel the event before focus is moved from the // `ComboboxInput` to the `ComboboxButton`. This keeps the input focused, // preserving the cursor position and any text selection. diff --git a/packages/@headlessui-react/src/components/dialog/dialog.tsx b/packages/@headlessui-react/src/components/dialog/dialog.tsx index 590660e..2a64e1d 100644 --- a/packages/@headlessui-react/src/components/dialog/dialog.tsx +++ b/packages/@headlessui-react/src/components/dialog/dialog.tsx @@ -5,6 +5,7 @@ import React, { Fragment, createContext, createRef, + useCallback, useContext, useEffect, useMemo, @@ -22,6 +23,7 @@ import { useEvent } from '../../hooks/use-event' import { useId } from '../../hooks/use-id' import { useInertOthers } from '../../hooks/use-inert-others' import { useIsTouchDevice } from '../../hooks/use-is-touch-device' +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' @@ -36,6 +38,8 @@ import { useSyncRefs } from '../../hooks/use-sync-refs' import { CloseProvider } from '../../internal/close-provider' import { ResetOpenClosedProvider, State, useOpenClosed } from '../../internal/open-closed' import { ForcePortalRoot } from '../../internal/portal-force-root' +import { stackMachines } from '../../machines/stack-machine' +import { useSlice } from '../../react-glue' import type { Props } from '../../types' import { match } from '../../utils/match' import { @@ -212,14 +216,33 @@ let InternalDialog = forwardRefWithAs(function InternalDialog< ]), }) + // Ensure that the Dialog is the top layer when it is opened. + // + // In a perfect world this is pushed / popped when we open / close the Dialog + // for within an event listener. But since the state is controlled by the + // user, this is the next best thing to do. + let stackMachine = stackMachines.get(null) + useIsoMorphicEffect(() => { + if (!enabled) return + + stackMachine.actions.push(id) + return () => stackMachine.actions.pop(id) + }, [stackMachine, id, enabled]) + + // Check if the dialog is the current top layer + let isTopLayer = useSlice( + stackMachine, + useCallback((state) => stackMachine.selectors.isTop(state, id), [stackMachine, id]) + ) + // Close Dialog on outside click - useOutsideClick(enabled, resolveRootContainers, (event) => { + useOutsideClick(isTopLayer, resolveRootContainers, (event) => { event.preventDefault() close() }) // Handle `Escape` to close - useEscape(enabled, ownerDocument?.defaultView, (event) => { + useEscape(isTopLayer, ownerDocument?.defaultView, (event) => { event.preventDefault() event.stopPropagation() diff --git a/packages/@headlessui-react/src/components/listbox/listbox-machine-glue.tsx b/packages/@headlessui-react/src/components/listbox/listbox-machine-glue.tsx index 42a271c..1f8d1da 100644 --- a/packages/@headlessui-react/src/components/listbox/listbox-machine-glue.tsx +++ b/packages/@headlessui-react/src/components/listbox/listbox-machine-glue.tsx @@ -1,4 +1,5 @@ import { createContext, useContext, useMemo } from 'react' +import { useOnUnmount } from '../../hooks/use-on-unmount' import { ListboxMachine } from './listbox-machine' export const ListboxContext = createContext | null>(null) @@ -12,6 +13,14 @@ export function useListboxMachineContext(component: string) { return context as ListboxMachine } -export function useListboxMachine({ __demoMode = false } = {}) { - return useMemo(() => ListboxMachine.new({ __demoMode }), []) +export function useListboxMachine({ + id, + __demoMode = false, +}: { + id: string + __demoMode?: boolean +}) { + let machine = useMemo(() => ListboxMachine.new({ id, __demoMode }), []) + useOnUnmount(() => machine.dispose()) + return machine } diff --git a/packages/@headlessui-react/src/components/listbox/listbox-machine.ts b/packages/@headlessui-react/src/components/listbox/listbox-machine.ts index e9de917..c205e21 100644 --- a/packages/@headlessui-react/src/components/listbox/listbox-machine.ts +++ b/packages/@headlessui-react/src/components/listbox/listbox-machine.ts @@ -1,4 +1,5 @@ import { Machine, batch } from '../../machine' +import { ActionTypes as StackActionTypes, stackMachines } from '../../machines/stack-machine' import { Focus, calculateActiveIndex } from '../../utils/calculate-active-index' import { sortByDomNode } from '../../utils/focus-management' import { match } from '../../utils/match' @@ -30,6 +31,8 @@ type ListboxOptionDataRef = MutableRefObject<{ }> interface State { + id: string + __demoMode: boolean dataRef: MutableRefObject<{ @@ -394,8 +397,9 @@ let reducers: { } export class ListboxMachine extends Machine, Actions> { - static new({ __demoMode = false } = {}) { + static new({ id, __demoMode = false }: { id: string; __demoMode?: boolean }) { return new ListboxMachine({ + id, // @ts-expect-error TODO: Re-structure such that we don't need to ignore this dataRef: { current: {} }, listboxState: __demoMode ? ListboxStates.Open : ListboxStates.Closed, @@ -422,6 +426,27 @@ export class ListboxMachine extends Machine, Actions> { this.send({ type: ActionTypes.SortOptions }) }) }) + + // When the listbox is open, and it's not on the top of the hierarchy, we + // should close it again. + { + let id = this.state.id + let stackMachine = stackMachines.get(null) + + this.disposables.add( + stackMachine.on(StackActionTypes.Push, (state) => { + if ( + !stackMachine.selectors.isTop(state, id) && + this.state.listboxState === ListboxStates.Open + ) { + this.actions.closeListbox() + } + }) + ) + + this.on(ActionTypes.OpenListbox, () => stackMachine.actions.push(id)) + this.on(ActionTypes.CloseListbox, () => stackMachine.actions.pop(id)) + } } actions = { diff --git a/packages/@headlessui-react/src/components/listbox/listbox.tsx b/packages/@headlessui-react/src/components/listbox/listbox.tsx index cc4f05a..aa59f26 100644 --- a/packages/@headlessui-react/src/components/listbox/listbox.tsx +++ b/packages/@headlessui-react/src/components/listbox/listbox.tsx @@ -55,6 +55,7 @@ import { FormFields } from '../../internal/form-fields' import { useFrozenData } from '../../internal/frozen' import { useProvidedId } from '../../internal/id' import { OpenClosedProvider, State, useOpenClosed } from '../../internal/open-closed' +import { stackMachines } from '../../machines/stack-machine' import { useSlice } from '../../react-glue' import type { EnsureArray, Props } from '../../types' import { isDisabledReactIssue7711 } from '../../utils/bugs' @@ -162,6 +163,8 @@ function ListboxFn< TType = string, TActualType = TType extends (infer U)[] ? U : TType, >(props: ListboxProps, ref: Ref) { + let id = useId() + let providedDisabled = useDisabled() let { value: controlledValue, @@ -188,7 +191,7 @@ function ListboxFn< defaultValue ) - let machine = useListboxMachine({ __demoMode }) + let machine = useListboxMachine({ id, __demoMode }) let optionsPropsRef = useRef<_Data['optionsPropsRef']['current']>({ static: false, hold: false }) let listRef = useRef<_Data['listRef']['current']>(new Map()) @@ -241,13 +244,19 @@ function ListboxFn< let listboxState = useSlice(machine, (state) => state.listboxState) - // Handle outside click - let outsideClickEnabled = listboxState === ListboxStates.Open + let stackMachine = stackMachines.get(null) + let isTopLayer = useSlice( + stackMachine, + useCallback((state) => stackMachine.selectors.isTop(state, id), [stackMachine, id]) + ) + let [buttonElement, optionsElement] = useSlice(machine, (state) => [ state.buttonElement, state.optionsElement, ]) - useOutsideClick(outsideClickEnabled, [buttonElement, optionsElement], (event, target) => { + + // Handle outside click + useOutsideClick(isTopLayer, [buttonElement, optionsElement], (event, target) => { machine.send({ type: ActionTypes.CloseListbox }) if (!isFocusableElement(target, FocusableMode.Loose)) { diff --git a/packages/@headlessui-react/src/components/menu/menu-machine-glue.tsx b/packages/@headlessui-react/src/components/menu/menu-machine-glue.tsx index 324d3db..d9c711f 100644 --- a/packages/@headlessui-react/src/components/menu/menu-machine-glue.tsx +++ b/packages/@headlessui-react/src/components/menu/menu-machine-glue.tsx @@ -1,4 +1,5 @@ import { createContext, useContext, useMemo } from 'react' +import { useOnUnmount } from '../../hooks/use-on-unmount' import { MenuMachine } from './menu-machine' export const MenuContext = createContext(null) @@ -12,6 +13,8 @@ export function useMenuMachineContext(component: string) { return context } -export function useMenuMachine({ __demoMode = false } = {}) { - return useMemo(() => MenuMachine.new({ __demoMode }), []) +export function useMenuMachine({ id, __demoMode = false }: { id: string; __demoMode?: boolean }) { + let machine = useMemo(() => MenuMachine.new({ id, __demoMode }), []) + useOnUnmount(() => machine.dispose()) + return machine } diff --git a/packages/@headlessui-react/src/components/menu/menu-machine.ts b/packages/@headlessui-react/src/components/menu/menu-machine.ts index b888690..7f593a7 100644 --- a/packages/@headlessui-react/src/components/menu/menu-machine.ts +++ b/packages/@headlessui-react/src/components/menu/menu-machine.ts @@ -1,4 +1,5 @@ import { Machine, batch } from '../../machine' +import { ActionTypes as StackActionTypes, stackMachines } from '../../machines/stack-machine' import { Focus, calculateActiveIndex } from '../../utils/calculate-active-index' import { sortByDomNode } from '../../utils/focus-management' import { match } from '../../utils/match' @@ -22,6 +23,8 @@ export type MenuItemDataRef = { } export interface State { + id: string + __demoMode: boolean menuState: MenuState @@ -114,6 +117,7 @@ let reducers: { }, [ActionTypes.OpenMenu](state, action) { if (state.menuState === MenuState.Open) return state + return { ...state, /* We can turn off demo mode once we re-open the `Menu` */ @@ -330,8 +334,9 @@ let reducers: { } export class MenuMachine extends Machine { - static new({ __demoMode = false } = {}) { + static new({ id, __demoMode = false }: { id: string; __demoMode?: boolean }) { return new MenuMachine({ + id, __demoMode, menuState: __demoMode ? MenuState.Open : MenuState.Closed, buttonElement: null, @@ -352,10 +357,28 @@ export class MenuMachine extends Machine { // Schedule a sort of the items when the DOM is ready. This doesn't // change anything rendering wise, but the sorted items are used when // using arrow keys so we can jump to previous / next items. - requestAnimationFrame(() => { + this.disposables.requestAnimationFrame(() => { this.send({ type: ActionTypes.SortItems }) }) }) + + // When the menu is open, and it's not on the top of the hierarchy, we + // should close it again. + { + let id = this.state.id + let stackMachine = stackMachines.get(null) + + this.disposables.add( + stackMachine.on(StackActionTypes.Push, (state) => { + if (!stackMachine.selectors.isTop(state, id) && this.state.menuState === MenuState.Open) { + this.send({ type: ActionTypes.CloseMenu }) + } + }) + ) + + this.on(ActionTypes.OpenMenu, () => stackMachine.actions.push(id)) + this.on(ActionTypes.CloseMenu, () => stackMachine.actions.pop(id)) + } } reduce(state: Readonly, action: Actions): State { diff --git a/packages/@headlessui-react/src/components/menu/menu.tsx b/packages/@headlessui-react/src/components/menu/menu.tsx index da1c262..1971dd0 100644 --- a/packages/@headlessui-react/src/components/menu/menu.tsx +++ b/packages/@headlessui-react/src/components/menu/menu.tsx @@ -46,6 +46,7 @@ import { type AnchorProps, } from '../../internal/floating' import { OpenClosedProvider, State, useOpenClosed } from '../../internal/open-closed' +import { stackMachines } from '../../machines/stack-machine' import { useSlice } from '../../react-glue' import type { Props } from '../../types' import { isDisabledReactIssue7711 } from '../../utils/bugs' @@ -95,8 +96,10 @@ function MenuFn( props: MenuProps, ref: Ref ) { + let id = useId() + let { __demoMode = false, ...theirProps } = props - let machine = useMenuMachine({ __demoMode }) + let machine = useMenuMachine({ id, __demoMode }) let [menuState, itemsElement, buttonElement] = useSlice(machine, (state) => [ state.menuState, @@ -105,9 +108,13 @@ function MenuFn( ]) let menuRef = useSyncRefs(ref) - // Handle outside click - let outsideClickEnabled = menuState === MenuState.Open - useOutsideClick(outsideClickEnabled, [buttonElement, itemsElement], (event, target) => { + let stackMachine = stackMachines.get(null) + let isTopLayer = useSlice( + stackMachine, + useCallback((state) => stackMachine.selectors.isTop(state, id), [stackMachine, id]) + ) + + useOutsideClick(isTopLayer, [buttonElement, itemsElement], (event, target) => { machine.send({ type: ActionTypes.CloseMenu }) if (!isFocusableElement(target, FocusableMode.Loose)) { diff --git a/packages/@headlessui-react/src/components/popover/popover-machine-glue.tsx b/packages/@headlessui-react/src/components/popover/popover-machine-glue.tsx index defbded..95f6145 100644 --- a/packages/@headlessui-react/src/components/popover/popover-machine-glue.tsx +++ b/packages/@headlessui-react/src/components/popover/popover-machine-glue.tsx @@ -1,4 +1,5 @@ import { createContext, useContext, useMemo } from 'react' +import { useOnUnmount } from '../../hooks/use-on-unmount' import { PopoverMachine } from './popover-machine' export const PopoverContext = createContext(null) @@ -12,6 +13,14 @@ export function usePopoverMachineContext(component: string) { return context } -export function usePopoverMachine({ __demoMode = false } = {}) { - return useMemo(() => PopoverMachine.new({ __demoMode }), []) +export function usePopoverMachine({ + id, + __demoMode = false, +}: { + id: string + __demoMode?: boolean +}) { + let machine = useMemo(() => PopoverMachine.new({ id, __demoMode }), []) + useOnUnmount(() => machine.dispose()) + return machine } diff --git a/packages/@headlessui-react/src/components/popover/popover-machine.ts b/packages/@headlessui-react/src/components/popover/popover-machine.ts index 3b4bb84..631cd19 100644 --- a/packages/@headlessui-react/src/components/popover/popover-machine.ts +++ b/packages/@headlessui-react/src/components/popover/popover-machine.ts @@ -1,5 +1,6 @@ import { type MouseEventHandler } from 'react' import { Machine } from '../../machine' +import { stackMachines } from '../../machines/stack-machine' import * as DOM from '../../utils/dom' import { getFocusableElements } from '../../utils/focus-management' import { match } from '../../utils/match' @@ -12,6 +13,8 @@ export enum PopoverStates { } interface State { + id: string + popoverState: PopoverStates buttons: { current: Symbol[] } @@ -76,8 +79,9 @@ let reducers: { } export class PopoverMachine extends Machine { - static new({ __demoMode = false } = {}) { + static new({ id, __demoMode = false }: { id: string; __demoMode?: boolean }) { return new PopoverMachine({ + id, __demoMode, popoverState: __demoMode ? PopoverStates.Open : PopoverStates.Closed, buttons: { current: [] }, @@ -91,6 +95,18 @@ export class PopoverMachine extends Machine { }) } + constructor(initialState: State) { + super(initialState) + + { + let id = this.state.id + let stackMachine = stackMachines.get(null) + + this.on(ActionTypes.OpenPopover, () => stackMachine.actions.push(id)) + this.on(ActionTypes.ClosePopover, () => stackMachine.actions.pop(id)) + } + } + reduce(state: Readonly, action: Actions): State { return match(action.type, reducers, state, action) } diff --git a/packages/@headlessui-react/src/components/popover/popover.tsx b/packages/@headlessui-react/src/components/popover/popover.tsx index 0519f9e..5ab8bfc 100644 --- a/packages/@headlessui-react/src/components/popover/popover.tsx +++ b/packages/@headlessui-react/src/components/popover/popover.tsx @@ -135,8 +135,10 @@ function PopoverFn( props: PopoverProps, ref: Ref ) { + let id = useId() + let { __demoMode = false, ...theirProps } = props - let machine = usePopoverMachine({ __demoMode }) + let machine = usePopoverMachine({ id, __demoMode }) let internalPopoverRef = useRef(null) let popoverRef = useSyncRefs( diff --git a/packages/@headlessui-react/src/hooks/use-outside-click.ts b/packages/@headlessui-react/src/hooks/use-outside-click.ts index 3d15705..62515d5 100644 --- a/packages/@headlessui-react/src/hooks/use-outside-click.ts +++ b/packages/@headlessui-react/src/hooks/use-outside-click.ts @@ -3,7 +3,6 @@ import * as DOM from '../utils/dom' import { FocusableMode, isFocusableElement } from '../utils/focus-management' import { isMobile } from '../utils/platform' import { useDocumentEvent } from './use-document-event' -import { useIsTopLayer } from './use-is-top-layer' import { useLatestValue } from './use-latest-value' import { useWindowEvent } from './use-window-event' @@ -27,7 +26,6 @@ export function useOutsideClick( target: HTMLOrSVGElement & Element ) => void ) { - let isTopLayer = useIsTopLayer(enabled, 'outside-click') let cbRef = useLatestValue(cb) let handleOutsideClick = useCallback( @@ -41,11 +39,9 @@ export function useOutsideClick( // not the Dialog (yet) if (event.defaultPrevented) return + // Resolve the new target let target = resolveTarget(event) - - if (target === null) { - return - } + if (target === null) return // Ignore if the target doesn't exist in the DOM anymore if (!target.getRootNode().contains(target)) return @@ -107,43 +103,30 @@ export function useOutsideClick( [cbRef, containers] ) - let initialClickTarget = useRef(null) + let initialClickTarget = useRef(null) useDocumentEvent( - isTopLayer, + enabled, 'pointerdown', (event) => { - initialClickTarget.current = event.composedPath?.()?.[0] || event.target + if (isMobile()) return + + initialClickTarget.current = (event.composedPath?.()?.[0] || event.target) as HTMLElement }, true ) useDocumentEvent( - isTopLayer, - 'mousedown', + enabled, + 'pointerup', (event) => { - initialClickTarget.current = event.composedPath?.()?.[0] || event.target - }, - true - ) - - useDocumentEvent( - isTopLayer, - 'click', - (event) => { - if (isMobile()) { - return - } - - if (!initialClickTarget.current) { - return - } - - handleOutsideClick(event, () => { - return initialClickTarget.current as HTMLElement - }) + if (isMobile()) return + if (!initialClickTarget.current) return + let target = initialClickTarget.current initialClickTarget.current = null + + return handleOutsideClick(event, () => target) }, // We will use the `capture` phase so that layers in between with `event.stopPropagation()` @@ -155,7 +138,7 @@ export function useOutsideClick( let startPosition = useRef({ x: 0, y: 0 }) useDocumentEvent( - isTopLayer, + enabled, 'touchstart', (event) => { startPosition.current.x = event.touches[0].clientX @@ -165,7 +148,7 @@ export function useOutsideClick( ) useDocumentEvent( - isTopLayer, + enabled, 'touchend', (event) => { // If the user moves their finger by ${MOVE_THRESHOLD_PX} pixels or more, @@ -201,7 +184,7 @@ export function useOutsideClick( // If so this was because of a click, focus, or other interaction with the child iframe // and we can consider it an "outside click" useWindowEvent( - isTopLayer, + enabled, 'blur', (event) => { return handleOutsideClick(event, () => { diff --git a/packages/@headlessui-react/src/hooks/use-scroll-lock.ts b/packages/@headlessui-react/src/hooks/use-scroll-lock.ts index a07630c..031f554 100644 --- a/packages/@headlessui-react/src/hooks/use-scroll-lock.ts +++ b/packages/@headlessui-react/src/hooks/use-scroll-lock.ts @@ -4,7 +4,7 @@ import { useIsTopLayer } from './use-is-top-layer' export function useScrollLock( enabled: boolean, ownerDocument: Document | null, - resolveAllowedContainers: () => HTMLElement[] = () => [document.body] + resolveAllowedContainers: () => Element[] = () => [document.body] ) { let isTopLayer = useIsTopLayer(enabled, 'scroll-lock') diff --git a/playgrounds/react/pages/menu/menu.tsx b/playgrounds/react/pages/menu/menu.tsx index 2f5fb23..78a0a42 100644 --- a/playgrounds/react/pages/menu/menu.tsx +++ b/playgrounds/react/pages/menu/menu.tsx @@ -6,47 +6,54 @@ import { classNames } from '../../utils/class-names' export default function Home() { return (
-
- - - - Options - - - - - + +
+ ) +} - -
-

Signed in as

-

- tom@example.com -

-
+export function ExampleMenu() { + return ( +
+ + + + Options + + + + + -
- Account settings - Support - - New feature (soon) - - License -
-
- Sign out -
- -
-
+ +
+

Signed in as

+

tom@example.com

+
+ +
+ Account settings + Support + + New feature (soon) + + License +
+
+ Sign out +
+
+
) } diff --git a/playgrounds/react/pages/menu/multiple-elements.tsx b/playgrounds/react/pages/menu/multiple-elements.tsx index 3b370e1..7694ebb 100644 --- a/playgrounds/react/pages/menu/multiple-elements.tsx +++ b/playgrounds/react/pages/menu/multiple-elements.tsx @@ -46,7 +46,10 @@ function Dropdown() { - +

Signed in as

tom@example.com

diff --git a/playgrounds/react/pages/popover/popover.tsx b/playgrounds/react/pages/popover/popover.tsx index 875500f..5e72155 100644 --- a/playgrounds/react/pages/popover/popover.tsx +++ b/playgrounds/react/pages/popover/popover.tsx @@ -1,5 +1,6 @@ import { Popover, Transition } from '@headlessui/react' import React, { forwardRef } from 'react' +import { ExampleMenu } from '../menu/menu' let Button = forwardRef( (props: React.ComponentProps<'button'>, ref: React.MutableRefObject) => { @@ -42,6 +43,9 @@ export default function Home() { Normal - {item} ))} +
+ +
@@ -66,6 +70,9 @@ export default function Home() { {items.map((item) => ( ))} +
+ +