From ad7300b07618934cdc7a8d5e793f8adac177e90b Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Mon, 12 May 2025 23:45:59 +0200 Subject: [PATCH] Fix closing `Menu` when other `Menu` is opened (#3726) Fixes: #3701 This PR fixes an issue where an open `Menu` is not closed when opening a new `Menu`. This is also fixed for `Listbox` and `Combobox` that used the same techniques. This happened because we recently shipped an improvement where the `Menu` opens on `pointerdown` instead of on `click`. This means that the `useOutsideClick` hook was not correct anymore because it relies on `click`. We could try and figure out that we should already close on `pointerdown` but this might not be expected for other components. Instead we want to simplify things a bit and ideally not even worry about what event caused a specific state change. Instead of trying to fight timing issues when certain events happen, this PR takes a slightly different approach. We already had the concept of a "top-layer" similar to the browser's `#top-layer` (when using native `dialog`). This essentially lets us know which component sits on top of the hierarchy. This top-layer is important because when you have the following structure: ``` ``` Assuming that both the `Dialog` and `Menu` are open, clicking outside or pressing escape should _only_ close the `Menu`. Once the `Menu` is closed, we should close the `Dialog`. In this case, we can enable/disable the `useOutsideClick` hook based on whether the current component is the top-layer or not. Some components like the `Menu`, `Listbox` and `Combobox` should immediately close when they are not the top-layer anymore. A `Dialog` can stay open, because you can have interactable elements like the example above in the `Dialog`. Luckily, these components that should immediately close already use their own state machine. This allows us to listen to the `OpenMenu` (or `OpenListbox`, `OpenCombobox`) event, and if that happens, we can push the current component on the shared stack machine. This now means that it doesn't matter _how_ the `Menu` is opened, but the moment a user event (click, enter, ...) opens the `Menu`, we now that we are on top of the stack. All other components could listen to push events on the stack. Once those happen, we can close the current component immediately. This has the nice side effect that we don't have to use a `useEffect` to check for state changes. We can just act immediately when an event happens. The `useOutsideClick` hooks is still used and useful in situations where you literally just clicked somewhere else. But in case you are opening another `Menu` or another `Listbox`, we can immediately close the one that was open before. ## Test plan Before: https://github.com/user-attachments/assets/f2efd94b-9aa2-404c-ad54-c8747b4d46ac After: https://github.com/user-attachments/assets/25c78fc4-c1da-4e51-89b6-4270f2804ab0 --- packages/@headlessui-react/CHANGELOG.md | 1 + .../combobox/combobox-machine-glue.tsx | 8 +- .../components/combobox/combobox-machine.ts | 33 ++++++- .../src/components/combobox/combobox.tsx | 16 +++- .../src/components/dialog/dialog.tsx | 27 +++++- .../listbox/listbox-machine-glue.tsx | 13 ++- .../src/components/listbox/listbox-machine.ts | 27 +++++- .../src/components/listbox/listbox.tsx | 17 +++- .../src/components/menu/menu-machine-glue.tsx | 7 +- .../src/components/menu/menu-machine.ts | 27 +++++- .../src/components/menu/menu.tsx | 15 +++- .../popover/popover-machine-glue.tsx | 13 ++- .../src/components/popover/popover-machine.ts | 18 +++- .../src/components/popover/popover.tsx | 4 +- .../src/hooks/use-outside-click.ts | 51 ++++------- .../src/hooks/use-scroll-lock.ts | 2 +- playgrounds/react/pages/menu/menu.tsx | 85 ++++++++++--------- .../react/pages/menu/multiple-elements.tsx | 5 +- playgrounds/react/pages/popover/popover.tsx | 7 ++ 19 files changed, 273 insertions(+), 103 deletions(-) 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) => ( ))} +
+ +