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:

```
<Dialog>
  <Menu />
</Dialog>
```

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
This commit is contained in:
Robin Malfait
2025-05-12 23:45:59 +02:00
committed by GitHub
parent afc04bc288
commit ad7300b076
19 changed files with 273 additions and 103 deletions
+1
View File
@@ -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 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)) - 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)) - 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 ## [2.2.2] - 2025-04-17
@@ -1,4 +1,5 @@
import { createContext, useContext, useMemo } from 'react' import { createContext, useContext, useMemo } from 'react'
import { useOnUnmount } from '../../hooks/use-on-unmount'
import { ComboboxMachine } from './combobox-machine' import { ComboboxMachine } from './combobox-machine'
export const ComboboxContext = createContext<ComboboxMachine<unknown> | null>(null) export const ComboboxContext = createContext<ComboboxMachine<unknown> | null>(null)
@@ -13,8 +14,11 @@ export function useComboboxMachineContext<T>(component: string) {
} }
export function useComboboxMachine({ export function useComboboxMachine({
id,
virtual = null, virtual = null,
__demoMode = false, __demoMode = false,
}: Parameters<typeof ComboboxMachine.new>[0] = {}) { }: Parameters<typeof ComboboxMachine.new>[0]) {
return useMemo(() => ComboboxMachine.new({ virtual, __demoMode }), []) let machine = useMemo(() => ComboboxMachine.new({ id, virtual, __demoMode }), [])
useOnUnmount(() => machine.dispose())
return machine
} }
@@ -1,4 +1,5 @@
import { Machine } from '../../machine' import { Machine } from '../../machine'
import { ActionTypes as StackActionTypes, stackMachines } from '../../machines/stack-machine'
import type { EnsureArray } from '../../types' import type { EnsureArray } from '../../types'
import { Focus, calculateActiveIndex } from '../../utils/calculate-active-index' import { Focus, calculateActiveIndex } from '../../utils/calculate-active-index'
import { sortByDomNode } from '../../utils/focus-management' import { sortByDomNode } from '../../utils/focus-management'
@@ -32,6 +33,8 @@ export type ComboboxOptionDataRef<T> = MutableRefObject<{
}> }>
export interface State<T> { export interface State<T> {
id: string
dataRef: MutableRefObject<{ dataRef: MutableRefObject<{
value: unknown value: unknown
defaultValue: unknown defaultValue: unknown
@@ -405,9 +408,11 @@ let reducers: {
export class ComboboxMachine<T> extends Machine<State<T>, Actions<T>> { export class ComboboxMachine<T> extends Machine<State<T>, Actions<T>> {
static new<T, TMultiple extends boolean | undefined>({ static new<T, TMultiple extends boolean | undefined>({
id,
virtual = null, virtual = null,
__demoMode = false, __demoMode = false,
}: { }: {
id: string
virtual?: { virtual?: {
options: TMultiple extends true ? EnsureArray<NoInfer<T>> : NoInfer<T>[] options: TMultiple extends true ? EnsureArray<NoInfer<T>> : NoInfer<T>[]
disabled?: ( disabled?: (
@@ -415,8 +420,9 @@ export class ComboboxMachine<T> extends Machine<State<T>, Actions<T>> {
) => boolean ) => boolean
} | null } | null
__demoMode?: boolean __demoMode?: boolean
} = {}) { }) {
return new ComboboxMachine({ return new ComboboxMachine({
id,
// @ts-expect-error TODO: Re-structure such that we don't need to ignore this // @ts-expect-error TODO: Re-structure such that we don't need to ignore this
dataRef: { current: {} }, dataRef: { current: {} },
comboboxState: __demoMode ? ComboboxState.Open : ComboboxState.Closed, comboboxState: __demoMode ? ComboboxState.Open : ComboboxState.Closed,
@@ -435,6 +441,31 @@ export class ComboboxMachine<T> extends Machine<State<T>, Actions<T>> {
}) })
} }
constructor(initialState: State<T>) {
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 = { actions = {
onChange: (newValue: T) => { onChange: (newValue: T) => {
let { onChange, compare, mode, value } = this.state.dataRef.current let { onChange, compare, mode, value } = this.state.dataRef.current
@@ -57,6 +57,7 @@ import { FormFields } from '../../internal/form-fields'
import { Frozen, useFrozenData } from '../../internal/frozen' import { Frozen, useFrozenData } from '../../internal/frozen'
import { useProvidedId } from '../../internal/id' import { useProvidedId } from '../../internal/id'
import { OpenClosedProvider, State, useOpenClosed } from '../../internal/open-closed' import { OpenClosedProvider, State, useOpenClosed } from '../../internal/open-closed'
import { stackMachines } from '../../machines/stack-machine'
import { useSlice } from '../../react-glue' import { useSlice } from '../../react-glue'
import type { EnsureArray, Props } from '../../types' import type { EnsureArray, Props } from '../../types'
import { history } from '../../utils/active-element-history' import { history } from '../../utils/active-element-history'
@@ -288,6 +289,8 @@ function ComboboxFn<TValue, TTag extends ElementType = typeof DEFAULT_COMBOBOX_T
props: ComboboxProps<TValue, boolean | undefined, TTag>, props: ComboboxProps<TValue, boolean | undefined, TTag>,
ref: Ref<HTMLElement> ref: Ref<HTMLElement>
) { ) {
let id = useId()
let providedDisabled = useDisabled() let providedDisabled = useDisabled()
let { let {
value: controlledValue, value: controlledValue,
@@ -315,7 +318,7 @@ function ComboboxFn<TValue, TTag extends ElementType = typeof DEFAULT_COMBOBOX_T
defaultValue defaultValue
) )
let machine = useComboboxMachine({ virtual, __demoMode }) let machine = useComboboxMachine({ id, virtual, __demoMode })
let optionsPropsRef = useRef<_Data['optionsPropsRef']['current']>({ static: false, hold: false }) let optionsPropsRef = useRef<_Data['optionsPropsRef']['current']>({ static: false, hold: false })
@@ -401,9 +404,14 @@ function ComboboxFn<TValue, TTag extends ElementType = typeof DEFAULT_COMBOBOX_T
state.optionsElement, state.optionsElement,
]) ])
let stackMachine = stackMachines.get(null)
let isTopLayer = useSlice(
stackMachine,
useCallback((state) => stackMachine.selectors.isTop(state, id), [stackMachine, id])
)
// Handle outside click // Handle outside click
let outsideClickEnabled = comboboxState === ComboboxState.Open useOutsideClick(isTopLayer, [buttonElement, inputElement, optionsElement], () =>
useOutsideClick(outsideClickEnabled, [buttonElement, inputElement, optionsElement], () =>
machine.actions.closeCombobox() machine.actions.closeCombobox()
) )
@@ -1082,7 +1090,7 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
}) })
let handlePointerDown = useEvent((event: ReactPointerEvent<HTMLButtonElement>) => { let handlePointerDown = useEvent((event: ReactPointerEvent<HTMLButtonElement>) => {
// 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 // event, allowing us to cancel the event before focus is moved from the
// `ComboboxInput` to the `ComboboxButton`. This keeps the input focused, // `ComboboxInput` to the `ComboboxButton`. This keeps the input focused,
// preserving the cursor position and any text selection. // preserving the cursor position and any text selection.
@@ -5,6 +5,7 @@ import React, {
Fragment, Fragment,
createContext, createContext,
createRef, createRef,
useCallback,
useContext, useContext,
useEffect, useEffect,
useMemo, useMemo,
@@ -22,6 +23,7 @@ import { useEvent } from '../../hooks/use-event'
import { useId } from '../../hooks/use-id' import { useId } from '../../hooks/use-id'
import { useInertOthers } from '../../hooks/use-inert-others' import { useInertOthers } from '../../hooks/use-inert-others'
import { useIsTouchDevice } from '../../hooks/use-is-touch-device' import { useIsTouchDevice } from '../../hooks/use-is-touch-device'
import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect'
import { useOnDisappear } from '../../hooks/use-on-disappear' import { useOnDisappear } from '../../hooks/use-on-disappear'
import { useOutsideClick } from '../../hooks/use-outside-click' import { useOutsideClick } from '../../hooks/use-outside-click'
import { useOwnerDocument } from '../../hooks/use-owner' import { useOwnerDocument } from '../../hooks/use-owner'
@@ -36,6 +38,8 @@ import { useSyncRefs } from '../../hooks/use-sync-refs'
import { CloseProvider } from '../../internal/close-provider' import { CloseProvider } from '../../internal/close-provider'
import { ResetOpenClosedProvider, State, useOpenClosed } from '../../internal/open-closed' import { ResetOpenClosedProvider, State, useOpenClosed } from '../../internal/open-closed'
import { ForcePortalRoot } from '../../internal/portal-force-root' import { ForcePortalRoot } from '../../internal/portal-force-root'
import { stackMachines } from '../../machines/stack-machine'
import { useSlice } from '../../react-glue'
import type { Props } from '../../types' import type { Props } from '../../types'
import { match } from '../../utils/match' import { match } from '../../utils/match'
import { 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 // Close Dialog on outside click
useOutsideClick(enabled, resolveRootContainers, (event) => { useOutsideClick(isTopLayer, resolveRootContainers, (event) => {
event.preventDefault() event.preventDefault()
close() close()
}) })
// Handle `Escape` to close // Handle `Escape` to close
useEscape(enabled, ownerDocument?.defaultView, (event) => { useEscape(isTopLayer, ownerDocument?.defaultView, (event) => {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
@@ -1,4 +1,5 @@
import { createContext, useContext, useMemo } from 'react' import { createContext, useContext, useMemo } from 'react'
import { useOnUnmount } from '../../hooks/use-on-unmount'
import { ListboxMachine } from './listbox-machine' import { ListboxMachine } from './listbox-machine'
export const ListboxContext = createContext<ListboxMachine<unknown> | null>(null) export const ListboxContext = createContext<ListboxMachine<unknown> | null>(null)
@@ -12,6 +13,14 @@ export function useListboxMachineContext<T>(component: string) {
return context as ListboxMachine<T> return context as ListboxMachine<T>
} }
export function useListboxMachine({ __demoMode = false } = {}) { export function useListboxMachine({
return useMemo(() => ListboxMachine.new({ __demoMode }), []) id,
__demoMode = false,
}: {
id: string
__demoMode?: boolean
}) {
let machine = useMemo(() => ListboxMachine.new({ id, __demoMode }), [])
useOnUnmount(() => machine.dispose())
return machine
} }
@@ -1,4 +1,5 @@
import { Machine, batch } from '../../machine' import { Machine, batch } from '../../machine'
import { ActionTypes as StackActionTypes, stackMachines } from '../../machines/stack-machine'
import { Focus, calculateActiveIndex } from '../../utils/calculate-active-index' import { Focus, calculateActiveIndex } from '../../utils/calculate-active-index'
import { sortByDomNode } from '../../utils/focus-management' import { sortByDomNode } from '../../utils/focus-management'
import { match } from '../../utils/match' import { match } from '../../utils/match'
@@ -30,6 +31,8 @@ type ListboxOptionDataRef<T> = MutableRefObject<{
}> }>
interface State<T> { interface State<T> {
id: string
__demoMode: boolean __demoMode: boolean
dataRef: MutableRefObject<{ dataRef: MutableRefObject<{
@@ -394,8 +397,9 @@ let reducers: {
} }
export class ListboxMachine<T> extends Machine<State<T>, Actions<T>> { export class ListboxMachine<T> extends Machine<State<T>, Actions<T>> {
static new({ __demoMode = false } = {}) { static new({ id, __demoMode = false }: { id: string; __demoMode?: boolean }) {
return new ListboxMachine({ return new ListboxMachine({
id,
// @ts-expect-error TODO: Re-structure such that we don't need to ignore this // @ts-expect-error TODO: Re-structure such that we don't need to ignore this
dataRef: { current: {} }, dataRef: { current: {} },
listboxState: __demoMode ? ListboxStates.Open : ListboxStates.Closed, listboxState: __demoMode ? ListboxStates.Open : ListboxStates.Closed,
@@ -422,6 +426,27 @@ export class ListboxMachine<T> extends Machine<State<T>, Actions<T>> {
this.send({ type: ActionTypes.SortOptions }) 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 = { actions = {
@@ -55,6 +55,7 @@ import { FormFields } from '../../internal/form-fields'
import { useFrozenData } from '../../internal/frozen' import { useFrozenData } from '../../internal/frozen'
import { useProvidedId } from '../../internal/id' import { useProvidedId } from '../../internal/id'
import { OpenClosedProvider, State, useOpenClosed } from '../../internal/open-closed' import { OpenClosedProvider, State, useOpenClosed } from '../../internal/open-closed'
import { stackMachines } from '../../machines/stack-machine'
import { useSlice } from '../../react-glue' import { useSlice } from '../../react-glue'
import type { EnsureArray, Props } from '../../types' import type { EnsureArray, Props } from '../../types'
import { isDisabledReactIssue7711 } from '../../utils/bugs' import { isDisabledReactIssue7711 } from '../../utils/bugs'
@@ -162,6 +163,8 @@ function ListboxFn<
TType = string, TType = string,
TActualType = TType extends (infer U)[] ? U : TType, TActualType = TType extends (infer U)[] ? U : TType,
>(props: ListboxProps<TTag, TType, TActualType>, ref: Ref<HTMLElement>) { >(props: ListboxProps<TTag, TType, TActualType>, ref: Ref<HTMLElement>) {
let id = useId()
let providedDisabled = useDisabled() let providedDisabled = useDisabled()
let { let {
value: controlledValue, value: controlledValue,
@@ -188,7 +191,7 @@ function ListboxFn<
defaultValue defaultValue
) )
let machine = useListboxMachine({ __demoMode }) let machine = useListboxMachine({ id, __demoMode })
let optionsPropsRef = useRef<_Data['optionsPropsRef']['current']>({ static: false, hold: false }) let optionsPropsRef = useRef<_Data['optionsPropsRef']['current']>({ static: false, hold: false })
let listRef = useRef<_Data['listRef']['current']>(new Map()) let listRef = useRef<_Data['listRef']['current']>(new Map())
@@ -241,13 +244,19 @@ function ListboxFn<
let listboxState = useSlice(machine, (state) => state.listboxState) let listboxState = useSlice(machine, (state) => state.listboxState)
// Handle outside click let stackMachine = stackMachines.get(null)
let outsideClickEnabled = listboxState === ListboxStates.Open let isTopLayer = useSlice(
stackMachine,
useCallback((state) => stackMachine.selectors.isTop(state, id), [stackMachine, id])
)
let [buttonElement, optionsElement] = useSlice(machine, (state) => [ let [buttonElement, optionsElement] = useSlice(machine, (state) => [
state.buttonElement, state.buttonElement,
state.optionsElement, state.optionsElement,
]) ])
useOutsideClick(outsideClickEnabled, [buttonElement, optionsElement], (event, target) => {
// Handle outside click
useOutsideClick(isTopLayer, [buttonElement, optionsElement], (event, target) => {
machine.send({ type: ActionTypes.CloseListbox }) machine.send({ type: ActionTypes.CloseListbox })
if (!isFocusableElement(target, FocusableMode.Loose)) { if (!isFocusableElement(target, FocusableMode.Loose)) {
@@ -1,4 +1,5 @@
import { createContext, useContext, useMemo } from 'react' import { createContext, useContext, useMemo } from 'react'
import { useOnUnmount } from '../../hooks/use-on-unmount'
import { MenuMachine } from './menu-machine' import { MenuMachine } from './menu-machine'
export const MenuContext = createContext<MenuMachine | null>(null) export const MenuContext = createContext<MenuMachine | null>(null)
@@ -12,6 +13,8 @@ export function useMenuMachineContext(component: string) {
return context return context
} }
export function useMenuMachine({ __demoMode = false } = {}) { export function useMenuMachine({ id, __demoMode = false }: { id: string; __demoMode?: boolean }) {
return useMemo(() => MenuMachine.new({ __demoMode }), []) let machine = useMemo(() => MenuMachine.new({ id, __demoMode }), [])
useOnUnmount(() => machine.dispose())
return machine
} }
@@ -1,4 +1,5 @@
import { Machine, batch } from '../../machine' import { Machine, batch } from '../../machine'
import { ActionTypes as StackActionTypes, stackMachines } from '../../machines/stack-machine'
import { Focus, calculateActiveIndex } from '../../utils/calculate-active-index' import { Focus, calculateActiveIndex } from '../../utils/calculate-active-index'
import { sortByDomNode } from '../../utils/focus-management' import { sortByDomNode } from '../../utils/focus-management'
import { match } from '../../utils/match' import { match } from '../../utils/match'
@@ -22,6 +23,8 @@ export type MenuItemDataRef = {
} }
export interface State { export interface State {
id: string
__demoMode: boolean __demoMode: boolean
menuState: MenuState menuState: MenuState
@@ -114,6 +117,7 @@ let reducers: {
}, },
[ActionTypes.OpenMenu](state, action) { [ActionTypes.OpenMenu](state, action) {
if (state.menuState === MenuState.Open) return state if (state.menuState === MenuState.Open) return state
return { return {
...state, ...state,
/* We can turn off demo mode once we re-open the `Menu` */ /* We can turn off demo mode once we re-open the `Menu` */
@@ -330,8 +334,9 @@ let reducers: {
} }
export class MenuMachine extends Machine<State, Actions> { export class MenuMachine extends Machine<State, Actions> {
static new({ __demoMode = false } = {}) { static new({ id, __demoMode = false }: { id: string; __demoMode?: boolean }) {
return new MenuMachine({ return new MenuMachine({
id,
__demoMode, __demoMode,
menuState: __demoMode ? MenuState.Open : MenuState.Closed, menuState: __demoMode ? MenuState.Open : MenuState.Closed,
buttonElement: null, buttonElement: null,
@@ -352,10 +357,28 @@ export class MenuMachine extends Machine<State, Actions> {
// Schedule a sort of the items when the DOM is ready. This doesn't // 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 // change anything rendering wise, but the sorted items are used when
// using arrow keys so we can jump to previous / next items. // using arrow keys so we can jump to previous / next items.
requestAnimationFrame(() => { this.disposables.requestAnimationFrame(() => {
this.send({ type: ActionTypes.SortItems }) 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<State>, action: Actions): State { reduce(state: Readonly<State>, action: Actions): State {
@@ -46,6 +46,7 @@ import {
type AnchorProps, type AnchorProps,
} from '../../internal/floating' } from '../../internal/floating'
import { OpenClosedProvider, State, useOpenClosed } from '../../internal/open-closed' import { OpenClosedProvider, State, useOpenClosed } from '../../internal/open-closed'
import { stackMachines } from '../../machines/stack-machine'
import { useSlice } from '../../react-glue' import { useSlice } from '../../react-glue'
import type { Props } from '../../types' import type { Props } from '../../types'
import { isDisabledReactIssue7711 } from '../../utils/bugs' import { isDisabledReactIssue7711 } from '../../utils/bugs'
@@ -95,8 +96,10 @@ function MenuFn<TTag extends ElementType = typeof DEFAULT_MENU_TAG>(
props: MenuProps<TTag>, props: MenuProps<TTag>,
ref: Ref<HTMLElement> ref: Ref<HTMLElement>
) { ) {
let id = useId()
let { __demoMode = false, ...theirProps } = props let { __demoMode = false, ...theirProps } = props
let machine = useMenuMachine({ __demoMode }) let machine = useMenuMachine({ id, __demoMode })
let [menuState, itemsElement, buttonElement] = useSlice(machine, (state) => [ let [menuState, itemsElement, buttonElement] = useSlice(machine, (state) => [
state.menuState, state.menuState,
@@ -105,9 +108,13 @@ function MenuFn<TTag extends ElementType = typeof DEFAULT_MENU_TAG>(
]) ])
let menuRef = useSyncRefs(ref) let menuRef = useSyncRefs(ref)
// Handle outside click let stackMachine = stackMachines.get(null)
let outsideClickEnabled = menuState === MenuState.Open let isTopLayer = useSlice(
useOutsideClick(outsideClickEnabled, [buttonElement, itemsElement], (event, target) => { stackMachine,
useCallback((state) => stackMachine.selectors.isTop(state, id), [stackMachine, id])
)
useOutsideClick(isTopLayer, [buttonElement, itemsElement], (event, target) => {
machine.send({ type: ActionTypes.CloseMenu }) machine.send({ type: ActionTypes.CloseMenu })
if (!isFocusableElement(target, FocusableMode.Loose)) { if (!isFocusableElement(target, FocusableMode.Loose)) {
@@ -1,4 +1,5 @@
import { createContext, useContext, useMemo } from 'react' import { createContext, useContext, useMemo } from 'react'
import { useOnUnmount } from '../../hooks/use-on-unmount'
import { PopoverMachine } from './popover-machine' import { PopoverMachine } from './popover-machine'
export const PopoverContext = createContext<PopoverMachine | null>(null) export const PopoverContext = createContext<PopoverMachine | null>(null)
@@ -12,6 +13,14 @@ export function usePopoverMachineContext(component: string) {
return context return context
} }
export function usePopoverMachine({ __demoMode = false } = {}) { export function usePopoverMachine({
return useMemo(() => PopoverMachine.new({ __demoMode }), []) id,
__demoMode = false,
}: {
id: string
__demoMode?: boolean
}) {
let machine = useMemo(() => PopoverMachine.new({ id, __demoMode }), [])
useOnUnmount(() => machine.dispose())
return machine
} }
@@ -1,5 +1,6 @@
import { type MouseEventHandler } from 'react' import { type MouseEventHandler } from 'react'
import { Machine } from '../../machine' import { Machine } from '../../machine'
import { stackMachines } from '../../machines/stack-machine'
import * as DOM from '../../utils/dom' import * as DOM from '../../utils/dom'
import { getFocusableElements } from '../../utils/focus-management' import { getFocusableElements } from '../../utils/focus-management'
import { match } from '../../utils/match' import { match } from '../../utils/match'
@@ -12,6 +13,8 @@ export enum PopoverStates {
} }
interface State { interface State {
id: string
popoverState: PopoverStates popoverState: PopoverStates
buttons: { current: Symbol[] } buttons: { current: Symbol[] }
@@ -76,8 +79,9 @@ let reducers: {
} }
export class PopoverMachine extends Machine<State, Actions> { export class PopoverMachine extends Machine<State, Actions> {
static new({ __demoMode = false } = {}) { static new({ id, __demoMode = false }: { id: string; __demoMode?: boolean }) {
return new PopoverMachine({ return new PopoverMachine({
id,
__demoMode, __demoMode,
popoverState: __demoMode ? PopoverStates.Open : PopoverStates.Closed, popoverState: __demoMode ? PopoverStates.Open : PopoverStates.Closed,
buttons: { current: [] }, buttons: { current: [] },
@@ -91,6 +95,18 @@ export class PopoverMachine extends Machine<State, Actions> {
}) })
} }
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<State>, action: Actions): State { reduce(state: Readonly<State>, action: Actions): State {
return match(action.type, reducers, state, action) return match(action.type, reducers, state, action)
} }
@@ -135,8 +135,10 @@ function PopoverFn<TTag extends ElementType = typeof DEFAULT_POPOVER_TAG>(
props: PopoverProps<TTag>, props: PopoverProps<TTag>,
ref: Ref<HTMLElement> ref: Ref<HTMLElement>
) { ) {
let id = useId()
let { __demoMode = false, ...theirProps } = props let { __demoMode = false, ...theirProps } = props
let machine = usePopoverMachine({ __demoMode }) let machine = usePopoverMachine({ id, __demoMode })
let internalPopoverRef = useRef<HTMLElement | null>(null) let internalPopoverRef = useRef<HTMLElement | null>(null)
let popoverRef = useSyncRefs( let popoverRef = useSyncRefs(
@@ -3,7 +3,6 @@ import * as DOM from '../utils/dom'
import { FocusableMode, isFocusableElement } from '../utils/focus-management' import { FocusableMode, isFocusableElement } from '../utils/focus-management'
import { isMobile } from '../utils/platform' import { isMobile } from '../utils/platform'
import { useDocumentEvent } from './use-document-event' import { useDocumentEvent } from './use-document-event'
import { useIsTopLayer } from './use-is-top-layer'
import { useLatestValue } from './use-latest-value' import { useLatestValue } from './use-latest-value'
import { useWindowEvent } from './use-window-event' import { useWindowEvent } from './use-window-event'
@@ -27,7 +26,6 @@ export function useOutsideClick(
target: HTMLOrSVGElement & Element target: HTMLOrSVGElement & Element
) => void ) => void
) { ) {
let isTopLayer = useIsTopLayer(enabled, 'outside-click')
let cbRef = useLatestValue(cb) let cbRef = useLatestValue(cb)
let handleOutsideClick = useCallback( let handleOutsideClick = useCallback(
@@ -41,11 +39,9 @@ export function useOutsideClick(
// not the Dialog (yet) // not the Dialog (yet)
if (event.defaultPrevented) return if (event.defaultPrevented) return
// Resolve the new target
let target = resolveTarget(event) let target = resolveTarget(event)
if (target === null) return
if (target === null) {
return
}
// Ignore if the target doesn't exist in the DOM anymore // Ignore if the target doesn't exist in the DOM anymore
if (!target.getRootNode().contains(target)) return if (!target.getRootNode().contains(target)) return
@@ -107,43 +103,30 @@ export function useOutsideClick(
[cbRef, containers] [cbRef, containers]
) )
let initialClickTarget = useRef<EventTarget | null>(null) let initialClickTarget = useRef<HTMLElement | null>(null)
useDocumentEvent( useDocumentEvent(
isTopLayer, enabled,
'pointerdown', 'pointerdown',
(event) => { (event) => {
initialClickTarget.current = event.composedPath?.()?.[0] || event.target if (isMobile()) return
initialClickTarget.current = (event.composedPath?.()?.[0] || event.target) as HTMLElement
}, },
true true
) )
useDocumentEvent( useDocumentEvent(
isTopLayer, enabled,
'mousedown', 'pointerup',
(event) => { (event) => {
initialClickTarget.current = event.composedPath?.()?.[0] || event.target if (isMobile()) return
}, if (!initialClickTarget.current) return
true
)
useDocumentEvent(
isTopLayer,
'click',
(event) => {
if (isMobile()) {
return
}
if (!initialClickTarget.current) {
return
}
handleOutsideClick(event, () => {
return initialClickTarget.current as HTMLElement
})
let target = initialClickTarget.current
initialClickTarget.current = null initialClickTarget.current = null
return handleOutsideClick(event, () => target)
}, },
// We will use the `capture` phase so that layers in between with `event.stopPropagation()` // 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 }) let startPosition = useRef({ x: 0, y: 0 })
useDocumentEvent( useDocumentEvent(
isTopLayer, enabled,
'touchstart', 'touchstart',
(event) => { (event) => {
startPosition.current.x = event.touches[0].clientX startPosition.current.x = event.touches[0].clientX
@@ -165,7 +148,7 @@ export function useOutsideClick(
) )
useDocumentEvent( useDocumentEvent(
isTopLayer, enabled,
'touchend', 'touchend',
(event) => { (event) => {
// If the user moves their finger by ${MOVE_THRESHOLD_PX} pixels or more, // 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 // If so this was because of a click, focus, or other interaction with the child iframe
// and we can consider it an "outside click" // and we can consider it an "outside click"
useWindowEvent( useWindowEvent(
isTopLayer, enabled,
'blur', 'blur',
(event) => { (event) => {
return handleOutsideClick(event, () => { return handleOutsideClick(event, () => {
@@ -4,7 +4,7 @@ import { useIsTopLayer } from './use-is-top-layer'
export function useScrollLock( export function useScrollLock(
enabled: boolean, enabled: boolean,
ownerDocument: Document | null, ownerDocument: Document | null,
resolveAllowedContainers: () => HTMLElement[] = () => [document.body] resolveAllowedContainers: () => Element[] = () => [document.body]
) { ) {
let isTopLayer = useIsTopLayer(enabled, 'scroll-lock') let isTopLayer = useIsTopLayer(enabled, 'scroll-lock')
+46 -39
View File
@@ -6,47 +6,54 @@ import { classNames } from '../../utils/class-names'
export default function Home() { export default function Home() {
return ( return (
<div className="flex h-full w-screen justify-center bg-gray-50 p-12"> <div className="flex h-full w-screen justify-center bg-gray-50 p-12">
<div className="relative inline-block text-left"> <ExampleMenu />
<Menu> </div>
<span className="shadow-xs rounded-md"> )
<Menu.Button as={Button}> }
<span>Options</span>
<svg
className="-mr-1 ml-2 h-5 w-5 transition-transform duration-150"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</Menu.Button>
</span>
<Menu.Items className="outline-hidden absolute right-0 mt-2 w-56 origin-top-right divide-y divide-gray-100 rounded-md border border-gray-200 bg-white shadow-lg"> export function ExampleMenu() {
<div className="px-4 py-3"> return (
<p className="text-sm leading-5">Signed in as</p> <div className="relative inline-block text-left">
<p className="truncate text-sm font-medium leading-5 text-gray-900"> <Menu>
tom@example.com <span className="shadow-xs rounded-md">
</p> <Menu.Button as={Button}>
</div> <span>Options</span>
<svg
className="-mr-1 ml-2 h-5 w-5 transition-transform duration-150"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</Menu.Button>
</span>
<div className="py-1"> <Menu.Items
<CustomMenuItem href="#account-settings">Account settings</CustomMenuItem> anchor="bottom start"
<CustomMenuItem href="#support">Support</CustomMenuItem> className="outline-hidden z-50 w-56 divide-y divide-gray-100 rounded-md border border-gray-200 bg-white shadow-lg [--anchor-gap:--spacing(1)]"
<CustomMenuItem disabled href="#new-feature"> >
New feature (soon) <div className="px-4 py-3">
</CustomMenuItem> <p className="text-sm leading-5">Signed in as</p>
<CustomMenuItem href="#license">License</CustomMenuItem> <p className="truncate text-sm font-medium leading-5 text-gray-900">tom@example.com</p>
</div> </div>
<div className="py-1">
<CustomMenuItem href="#sign-out">Sign out</CustomMenuItem> <div className="py-1">
</div> <CustomMenuItem href="#account-settings">Account settings</CustomMenuItem>
</Menu.Items> <CustomMenuItem href="#support">Support</CustomMenuItem>
</Menu> <CustomMenuItem disabled href="#new-feature">
</div> New feature (soon)
</CustomMenuItem>
<CustomMenuItem href="#license">License</CustomMenuItem>
</div>
<div className="py-1">
<CustomMenuItem href="#sign-out">Sign out</CustomMenuItem>
</div>
</Menu.Items>
</Menu>
</div> </div>
) )
} }
@@ -46,7 +46,10 @@ function Dropdown() {
</Menu.Button> </Menu.Button>
</span> </span>
<Menu.Items className="outline-hidden absolute right-0 mt-2 w-56 origin-top-right divide-y divide-gray-100 rounded-md border border-gray-200 bg-white shadow-lg"> <Menu.Items
modal={false}
className="outline-hidden absolute right-0 mt-2 w-56 origin-top-right divide-y divide-gray-100 rounded-md border border-gray-200 bg-white shadow-lg"
>
<div className="px-4 py-3"> <div className="px-4 py-3">
<p className="text-sm leading-5">Signed in as</p> <p className="text-sm leading-5">Signed in as</p>
<p className="truncate text-sm font-medium leading-5 text-gray-900">tom@example.com</p> <p className="truncate text-sm font-medium leading-5 text-gray-900">tom@example.com</p>
@@ -1,5 +1,6 @@
import { Popover, Transition } from '@headlessui/react' import { Popover, Transition } from '@headlessui/react'
import React, { forwardRef } from 'react' import React, { forwardRef } from 'react'
import { ExampleMenu } from '../menu/menu'
let Button = forwardRef( let Button = forwardRef(
(props: React.ComponentProps<'button'>, ref: React.MutableRefObject<HTMLButtonElement>) => { (props: React.ComponentProps<'button'>, ref: React.MutableRefObject<HTMLButtonElement>) => {
@@ -42,6 +43,9 @@ export default function Home() {
Normal - {item} Normal - {item}
</Button> </Button>
))} ))}
<div className="p-2">
<ExampleMenu />
</div>
</Popover.Panel> </Popover.Panel>
</Popover> </Popover>
@@ -66,6 +70,9 @@ export default function Home() {
{items.map((item) => ( {items.map((item) => (
<Button key={item}>Portal - {item}</Button> <Button key={item}>Portal - {item}</Button>
))} ))}
<div className="p-2">
<ExampleMenu />
</div>
</Popover.Panel> </Popover.Panel>
</Popover> </Popover>