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