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:
@@ -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')
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user