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 `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
@@ -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<ComboboxMachine<unknown> | null>(null)
@@ -13,8 +14,11 @@ export function useComboboxMachineContext<T>(component: string) {
}
export function useComboboxMachine({
id,
virtual = null,
__demoMode = false,
}: Parameters<typeof ComboboxMachine.new>[0] = {}) {
return useMemo(() => ComboboxMachine.new({ virtual, __demoMode }), [])
}: Parameters<typeof ComboboxMachine.new>[0]) {
let machine = useMemo(() => ComboboxMachine.new({ id, virtual, __demoMode }), [])
useOnUnmount(() => machine.dispose())
return machine
}
@@ -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<T> = MutableRefObject<{
}>
export interface State<T> {
id: string
dataRef: MutableRefObject<{
value: unknown
defaultValue: unknown
@@ -405,9 +408,11 @@ let reducers: {
export class ComboboxMachine<T> extends Machine<State<T>, Actions<T>> {
static new<T, TMultiple extends boolean | undefined>({
id,
virtual = null,
__demoMode = false,
}: {
id: string
virtual?: {
options: TMultiple extends true ? EnsureArray<NoInfer<T>> : NoInfer<T>[]
disabled?: (
@@ -415,8 +420,9 @@ export class ComboboxMachine<T> extends Machine<State<T>, Actions<T>> {
) => 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<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 = {
onChange: (newValue: T) => {
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 { 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<TValue, TTag extends ElementType = typeof DEFAULT_COMBOBOX_T
props: ComboboxProps<TValue, boolean | undefined, TTag>,
ref: Ref<HTMLElement>
) {
let id = useId()
let providedDisabled = useDisabled()
let {
value: controlledValue,
@@ -315,7 +318,7 @@ function ComboboxFn<TValue, TTag extends ElementType = typeof DEFAULT_COMBOBOX_T
defaultValue
)
let machine = useComboboxMachine({ virtual, __demoMode })
let machine = useComboboxMachine({ id, virtual, __demoMode })
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,
])
let stackMachine = stackMachines.get(null)
let isTopLayer = useSlice(
stackMachine,
useCallback((state) => 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<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
})
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
// `ComboboxInput` to the `ComboboxButton`. This keeps the input focused,
// preserving the cursor position and any text selection.
@@ -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()
@@ -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<ListboxMachine<unknown> | null>(null)
@@ -12,6 +13,14 @@ export function useListboxMachineContext<T>(component: string) {
return context as ListboxMachine<T>
}
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
}
@@ -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<T> = MutableRefObject<{
}>
interface State<T> {
id: string
__demoMode: boolean
dataRef: MutableRefObject<{
@@ -394,8 +397,9 @@ let reducers: {
}
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({
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<T> extends Machine<State<T>, Actions<T>> {
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 = {
@@ -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<TTag, TType, TActualType>, ref: Ref<HTMLElement>) {
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)) {
@@ -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<MenuMachine | null>(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
}
@@ -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<State, Actions> {
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<State, Actions> {
// 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<State>, action: Actions): State {
@@ -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<TTag extends ElementType = typeof DEFAULT_MENU_TAG>(
props: MenuProps<TTag>,
ref: Ref<HTMLElement>
) {
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<TTag extends ElementType = typeof DEFAULT_MENU_TAG>(
])
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)) {
@@ -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<PopoverMachine | null>(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
}
@@ -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<State, Actions> {
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<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 {
return match(action.type, reducers, state, action)
}
@@ -135,8 +135,10 @@ function PopoverFn<TTag extends ElementType = typeof DEFAULT_POPOVER_TAG>(
props: PopoverProps<TTag>,
ref: Ref<HTMLElement>
) {
let id = useId()
let { __demoMode = false, ...theirProps } = props
let machine = usePopoverMachine({ __demoMode })
let machine = usePopoverMachine({ id, __demoMode })
let internalPopoverRef = useRef<HTMLElement | null>(null)
let popoverRef = useSyncRefs(
@@ -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<EventTarget | null>(null)
let initialClickTarget = useRef<HTMLElement | null>(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, () => {
@@ -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')
+46 -39
View File
@@ -6,47 +6,54 @@ import { classNames } from '../../utils/class-names'
export default function Home() {
return (
<div className="flex h-full w-screen justify-center bg-gray-50 p-12">
<div className="relative inline-block text-left">
<Menu>
<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>
<ExampleMenu />
</div>
)
}
<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">
<div className="px-4 py-3">
<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>
</div>
export function ExampleMenu() {
return (
<div className="relative inline-block text-left">
<Menu>
<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>
<div className="py-1">
<CustomMenuItem href="#account-settings">Account settings</CustomMenuItem>
<CustomMenuItem href="#support">Support</CustomMenuItem>
<CustomMenuItem disabled href="#new-feature">
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>
<Menu.Items
anchor="bottom start"
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)]"
>
<div className="px-4 py-3">
<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>
</div>
<div className="py-1">
<CustomMenuItem href="#account-settings">Account settings</CustomMenuItem>
<CustomMenuItem href="#support">Support</CustomMenuItem>
<CustomMenuItem disabled href="#new-feature">
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>
)
}
@@ -46,7 +46,10 @@ function Dropdown() {
</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">
<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">
<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>
@@ -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<HTMLButtonElement>) => {
@@ -42,6 +43,9 @@ export default function Home() {
Normal - {item}
</Button>
))}
<div className="p-2">
<ExampleMenu />
</div>
</Popover.Panel>
</Popover>
@@ -66,6 +70,9 @@ export default function Home() {
{items.map((item) => (
<Button key={item}>Portal - {item}</Button>
))}
<div className="p-2">
<ExampleMenu />
</div>
</Popover.Panel>
</Popover>