Files
headlessui/packages/@headlessui-react/src/components/popover/popover.tsx
T
Robin Malfait c759016fa3 Fix invalid warning when using multiple Popover.Button components inside a Popover.Panel (#2333)
* add a bunch of tests to ensure we won't regress on this again

* fix incorrect warning when using multiple `Popover.Button` inside `Popover.Panel`

* update changelog
2023-03-03 14:59:10 +01:00

1049 lines
32 KiB
TypeScript

import React, {
createContext,
createRef,
useContext,
useEffect,
useMemo,
useReducer,
useRef,
useState,
// Types
ContextType,
Dispatch,
ElementType,
FocusEvent as ReactFocusEvent,
KeyboardEvent as ReactKeyboardEvent,
MouseEvent as ReactMouseEvent,
MutableRefObject,
Ref,
MouseEventHandler,
} from 'react'
import { Props } from '../../types'
import { match } from '../../utils/match'
import {
forwardRefWithAs,
render,
Features,
PropsForFeatures,
HasDisplayName,
RefProp,
} from '../../utils/render'
import { optionalRef, useSyncRefs } from '../../hooks/use-sync-refs'
import { useId } from '../../hooks/use-id'
import { Keys } from '../keyboard'
import { isDisabledReactIssue7711 } from '../../utils/bugs'
import {
getFocusableElements,
Focus,
focusIn,
isFocusableElement,
FocusableMode,
FocusResult,
} from '../../utils/focus-management'
import { OpenClosedProvider, State, useOpenClosed } from '../../internal/open-closed'
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
import { useOutsideClick } from '../../hooks/use-outside-click'
import { getOwnerDocument } from '../../utils/owner'
import { useOwnerDocument } from '../../hooks/use-owner'
import { useEventListener } from '../../hooks/use-event-listener'
import { Hidden, Features as HiddenFeatures } from '../../internal/hidden'
import { useEvent } from '../../hooks/use-event'
import { useTabDirection, Direction as TabDirection } from '../../hooks/use-tab-direction'
import { microTask } from '../../utils/micro-task'
import { useLatestValue } from '../../hooks/use-latest-value'
import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect'
type MouseEvent<T> = Parameters<MouseEventHandler<T>>[0]
enum PopoverStates {
Open,
Closed,
}
interface StateDefinition {
popoverState: PopoverStates
buttons: MutableRefObject<Symbol[]>
button: HTMLElement | null
buttonId: string | null
panel: HTMLElement | null
panelId: string | null
beforePanelSentinel: MutableRefObject<HTMLButtonElement | null>
afterPanelSentinel: MutableRefObject<HTMLButtonElement | null>
}
enum ActionTypes {
TogglePopover,
ClosePopover,
SetButton,
SetButtonId,
SetPanel,
SetPanelId,
}
type Actions =
| { type: ActionTypes.TogglePopover }
| { type: ActionTypes.ClosePopover }
| { type: ActionTypes.SetButton; button: HTMLElement | null }
| { type: ActionTypes.SetButtonId; buttonId: string | null }
| { type: ActionTypes.SetPanel; panel: HTMLElement | null }
| { type: ActionTypes.SetPanelId; panelId: string | null }
let reducers: {
[P in ActionTypes]: (
state: StateDefinition,
action: Extract<Actions, { type: P }>
) => StateDefinition
} = {
[ActionTypes.TogglePopover]: (state) => ({
...state,
popoverState: match(state.popoverState, {
[PopoverStates.Open]: PopoverStates.Closed,
[PopoverStates.Closed]: PopoverStates.Open,
}),
}),
[ActionTypes.ClosePopover](state) {
if (state.popoverState === PopoverStates.Closed) return state
return { ...state, popoverState: PopoverStates.Closed }
},
[ActionTypes.SetButton](state, action) {
if (state.button === action.button) return state
return { ...state, button: action.button }
},
[ActionTypes.SetButtonId](state, action) {
if (state.buttonId === action.buttonId) return state
return { ...state, buttonId: action.buttonId }
},
[ActionTypes.SetPanel](state, action) {
if (state.panel === action.panel) return state
return { ...state, panel: action.panel }
},
[ActionTypes.SetPanelId](state, action) {
if (state.panelId === action.panelId) return state
return { ...state, panelId: action.panelId }
},
}
let PopoverContext = createContext<[StateDefinition, Dispatch<Actions>] | null>(null)
PopoverContext.displayName = 'PopoverContext'
function usePopoverContext(component: string) {
let context = useContext(PopoverContext)
if (context === null) {
let err = new Error(`<${component} /> is missing a parent <Popover /> component.`)
if (Error.captureStackTrace) Error.captureStackTrace(err, usePopoverContext)
throw err
}
return context
}
let PopoverAPIContext = createContext<{
close(
focusableElement?: HTMLElement | MutableRefObject<HTMLElement | null> | MouseEvent<HTMLElement>
): void
isPortalled: boolean
} | null>(null)
PopoverAPIContext.displayName = 'PopoverAPIContext'
function usePopoverAPIContext(component: string) {
let context = useContext(PopoverAPIContext)
if (context === null) {
let err = new Error(`<${component} /> is missing a parent <Popover /> component.`)
if (Error.captureStackTrace) Error.captureStackTrace(err, usePopoverAPIContext)
throw err
}
return context
}
let PopoverGroupContext = createContext<{
registerPopover(registerbag: PopoverRegisterBag): void
unregisterPopover(registerbag: PopoverRegisterBag): void
isFocusWithinPopoverGroup(): boolean
closeOthers(buttonId: string): void
} | null>(null)
PopoverGroupContext.displayName = 'PopoverGroupContext'
function usePopoverGroupContext() {
return useContext(PopoverGroupContext)
}
let PopoverPanelContext = createContext<string | null>(null)
PopoverPanelContext.displayName = 'PopoverPanelContext'
function usePopoverPanelContext() {
return useContext(PopoverPanelContext)
}
interface PopoverRegisterBag {
buttonId: MutableRefObject<string | null>
panelId: MutableRefObject<string | null>
close(): void
}
function stateReducer(state: StateDefinition, action: Actions) {
return match(action.type, reducers, state, action)
}
// ---
let DEFAULT_POPOVER_TAG = 'div' as const
interface PopoverRenderPropArg {
open: boolean
close(
focusableElement?: HTMLElement | MutableRefObject<HTMLElement | null> | MouseEvent<HTMLElement>
): void
}
export type PopoverProps<TTag extends ElementType> = Props<TTag, PopoverRenderPropArg>
function PopoverFn<TTag extends ElementType = typeof DEFAULT_POPOVER_TAG>(
props: PopoverProps<TTag>,
ref: Ref<HTMLElement>
) {
let internalPopoverRef = useRef<HTMLElement | null>(null)
let popoverRef = useSyncRefs(
ref,
optionalRef((ref) => {
internalPopoverRef.current = ref
})
)
let buttons = useRef([])
let reducerBag = useReducer(stateReducer, {
popoverState: PopoverStates.Closed,
buttons,
button: null,
buttonId: null,
panel: null,
panelId: null,
beforePanelSentinel: createRef(),
afterPanelSentinel: createRef(),
} as StateDefinition)
let [
{ popoverState, button, buttonId, panel, panelId, beforePanelSentinel, afterPanelSentinel },
dispatch,
] = reducerBag
let ownerDocument = useOwnerDocument(internalPopoverRef.current ?? button)
let isPortalled = useMemo(() => {
if (!button) return false
if (!panel) return false
// We are part of a different "root" tree, so therefore we can consider it portalled. This is a
// heuristic because 3rd party tools could use some form of portal, typically rendered at the
// end of the body but we don't have an actual reference to that.
for (let root of document.querySelectorAll('body > *')) {
if (Number(root?.contains(button)) ^ Number(root?.contains(panel))) {
return true
}
}
// Use another heuristic to try and calculate wether or not the focusable elements are near
// eachother (aka, following the default focus/tab order from the browser). If they are then it
// doesn't really matter if they are portalled or not because we can follow the default tab
// order. But if they are not, then we can consider it being portalled so that we can ensure
// that tab and shift+tab (hopefully) go to the correct spot.
let elements = getFocusableElements()
let buttonIdx = elements.indexOf(button)
let beforeIdx = (buttonIdx + elements.length - 1) % elements.length
let afterIdx = (buttonIdx + 1) % elements.length
let beforeElement = elements[beforeIdx]
let afterElement = elements[afterIdx]
if (!panel.contains(beforeElement) && !panel.contains(afterElement)) {
return true
}
// It may or may not be portalled, but we don't really know.
return false
}, [button, panel])
let buttonIdRef = useLatestValue(buttonId)
let panelIdRef = useLatestValue(panelId)
let registerBag = useMemo(
() => ({
buttonId: buttonIdRef,
panelId: panelIdRef,
close: () => dispatch({ type: ActionTypes.ClosePopover }),
}),
[buttonIdRef, panelIdRef, dispatch]
)
let groupContext = usePopoverGroupContext()
let registerPopover = groupContext?.registerPopover
let isFocusWithinPopoverGroup = useEvent(() => {
return (
groupContext?.isFocusWithinPopoverGroup() ??
(ownerDocument?.activeElement &&
(button?.contains(ownerDocument.activeElement) ||
panel?.contains(ownerDocument.activeElement)))
)
})
useEffect(() => registerPopover?.(registerBag), [registerPopover, registerBag])
// Handle focus out
useEventListener(
ownerDocument?.defaultView,
'focus',
(event) => {
if (popoverState !== PopoverStates.Open) return
if (isFocusWithinPopoverGroup()) return
if (!button) return
if (!panel) return
if (event.target === window) return
if (beforePanelSentinel.current?.contains?.(event.target as HTMLElement)) return
if (afterPanelSentinel.current?.contains?.(event.target as HTMLElement)) return
dispatch({ type: ActionTypes.ClosePopover })
},
true
)
// Handle outside click
useOutsideClick(
[button, panel],
(event, target) => {
dispatch({ type: ActionTypes.ClosePopover })
if (!isFocusableElement(target, FocusableMode.Loose)) {
event.preventDefault()
button?.focus()
}
},
popoverState === PopoverStates.Open
)
let close = useEvent(
(
focusableElement?:
| HTMLElement
| MutableRefObject<HTMLElement | null>
| MouseEvent<HTMLElement>
) => {
dispatch({ type: ActionTypes.ClosePopover })
let restoreElement = (() => {
if (!focusableElement) return button
if (focusableElement instanceof HTMLElement) return focusableElement
if ('current' in focusableElement && focusableElement.current instanceof HTMLElement)
return focusableElement.current
return button
})()
restoreElement?.focus()
}
)
let api = useMemo<ContextType<typeof PopoverAPIContext>>(
() => ({ close, isPortalled }),
[close, isPortalled]
)
let slot = useMemo<PopoverRenderPropArg>(
() => ({ open: popoverState === PopoverStates.Open, close }),
[popoverState, close]
)
let theirProps = props
let ourProps = { ref: popoverRef }
return (
<PopoverPanelContext.Provider value={null}>
<PopoverContext.Provider value={reducerBag}>
<PopoverAPIContext.Provider value={api}>
<OpenClosedProvider
value={match(popoverState, {
[PopoverStates.Open]: State.Open,
[PopoverStates.Closed]: State.Closed,
})}
>
{render({
ourProps,
theirProps,
slot,
defaultTag: DEFAULT_POPOVER_TAG,
name: 'Popover',
})}
</OpenClosedProvider>
</PopoverAPIContext.Provider>
</PopoverContext.Provider>
</PopoverPanelContext.Provider>
)
}
// ---
let DEFAULT_BUTTON_TAG = 'button' as const
interface ButtonRenderPropArg {
open: boolean
}
type ButtonPropsWeControl = 'aria-controls' | 'aria-expanded'
export type PopoverButtonProps<TTag extends ElementType> = Props<
TTag,
ButtonRenderPropArg,
ButtonPropsWeControl,
{
disabled?: boolean
}
>
function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
props: PopoverButtonProps<TTag>,
ref: Ref<HTMLButtonElement>
) {
let internalId = useId()
let { id = `headlessui-popover-button-${internalId}`, ...theirProps } = props
let [state, dispatch] = usePopoverContext('Popover.Button')
let { isPortalled } = usePopoverAPIContext('Popover.Button')
let internalButtonRef = useRef<HTMLButtonElement | null>(null)
let sentinelId = `headlessui-focus-sentinel-${useId()}`
let groupContext = usePopoverGroupContext()
let closeOthers = groupContext?.closeOthers
let panelContext = usePopoverPanelContext()
// A button inside a panel will just have "close" functionality, no "open" functionality. However,
// if a `Popover.Button` is rendered inside a `Popover` which in turn is rendered inside a
// `Popover.Panel` (aka nested popovers), then we need to make sure that the button is able to
// open the nested popover.
//
// The `Popover` itself will also render a `PopoverPanelContext` but with a value of `null`. That
// way we don't need to keep track of _which_ `Popover.Panel` (if at all) we are in, we can just
// check if we are in a `Popover.Panel` or not since this will always point to the nearest one and
// won't pierce through `Popover` components themselves.
let isWithinPanel = panelContext !== null
useEffect(() => {
if (isWithinPanel) return
dispatch({ type: ActionTypes.SetButtonId, buttonId: id })
return () => {
dispatch({ type: ActionTypes.SetButtonId, buttonId: null })
}
}, [isWithinPanel, id, dispatch])
// This is a little bit different compared to the `id` we already have. The goal is to have a very
// unique identifier for this specific component. This can be achieved with the `id` from above.
//
// However, the difference is for React 17 and lower where the `useId` hook doesn't exist yet.
// There we will generate a unique ID based on a simple counter, but for SSR this will result in
// `undefined` first, later it is patched to be a unique ID. The problem is that this patching
// happens after the component is rendered and therefore there is a moment in time where multiple
// buttons have the exact same ID and the `state.buttons` would result in something like:
//
// ```js
// ['headlessui-popover-button-undefined', 'headlessui-popover-button-1']
// ```
//
// With this approach we guarantee that there is a unique value for each button.
let [uniqueIdentifier] = useState(() => Symbol())
let buttonRef = useSyncRefs(
internalButtonRef,
ref,
isWithinPanel
? null
: (button) => {
if (button) {
state.buttons.current.push(uniqueIdentifier)
} else {
let idx = state.buttons.current.indexOf(uniqueIdentifier)
if (idx !== -1) state.buttons.current.splice(idx, 1)
}
if (state.buttons.current.length > 1) {
console.warn(
'You are already using a <Popover.Button /> but only 1 <Popover.Button /> is supported.'
)
}
button && dispatch({ type: ActionTypes.SetButton, button })
}
)
let withinPanelButtonRef = useSyncRefs(internalButtonRef, ref)
let ownerDocument = useOwnerDocument(internalButtonRef)
let handleKeyDown = useEvent((event: ReactKeyboardEvent<HTMLButtonElement>) => {
if (isWithinPanel) {
if (state.popoverState === PopoverStates.Closed) return
switch (event.key) {
case Keys.Space:
case Keys.Enter:
event.preventDefault() // Prevent triggering a *click* event
// @ts-expect-error
event.target.click?.()
dispatch({ type: ActionTypes.ClosePopover })
state.button?.focus() // Re-focus the original opening Button
break
}
} else {
switch (event.key) {
case Keys.Space:
case Keys.Enter:
event.preventDefault() // Prevent triggering a *click* event
event.stopPropagation()
if (state.popoverState === PopoverStates.Closed) closeOthers?.(state.buttonId!)
dispatch({ type: ActionTypes.TogglePopover })
break
case Keys.Escape:
if (state.popoverState !== PopoverStates.Open) return closeOthers?.(state.buttonId!)
if (!internalButtonRef.current) return
if (
ownerDocument?.activeElement &&
!internalButtonRef.current.contains(ownerDocument.activeElement)
) {
return
}
event.preventDefault()
event.stopPropagation()
dispatch({ type: ActionTypes.ClosePopover })
break
}
}
})
let handleKeyUp = useEvent((event: ReactKeyboardEvent<HTMLButtonElement>) => {
if (isWithinPanel) return
if (event.key === Keys.Space) {
// Required for firefox, event.preventDefault() in handleKeyDown for
// the Space key doesn't cancel the handleKeyUp, which in turn
// triggers a *click*.
event.preventDefault()
}
})
let handleClick = useEvent((event: ReactMouseEvent) => {
if (isDisabledReactIssue7711(event.currentTarget)) return
if (props.disabled) return
if (isWithinPanel) {
dispatch({ type: ActionTypes.ClosePopover })
state.button?.focus() // Re-focus the original opening Button
} else {
event.preventDefault()
event.stopPropagation()
if (state.popoverState === PopoverStates.Closed) closeOthers?.(state.buttonId!)
dispatch({ type: ActionTypes.TogglePopover })
state.button?.focus()
}
})
let handleMouseDown = useEvent((event: ReactMouseEvent) => {
event.preventDefault()
event.stopPropagation()
})
let visible = state.popoverState === PopoverStates.Open
let slot = useMemo<ButtonRenderPropArg>(() => ({ open: visible }), [visible])
let type = useResolveButtonType(props, internalButtonRef)
let ourProps = isWithinPanel
? {
ref: withinPanelButtonRef,
type,
onKeyDown: handleKeyDown,
onClick: handleClick,
}
: {
ref: buttonRef,
id: state.buttonId,
type,
'aria-expanded': props.disabled ? undefined : state.popoverState === PopoverStates.Open,
'aria-controls': state.panel ? state.panelId : undefined,
onKeyDown: handleKeyDown,
onKeyUp: handleKeyUp,
onClick: handleClick,
onMouseDown: handleMouseDown,
}
let direction = useTabDirection()
let handleFocus = useEvent(() => {
let el = state.panel as HTMLElement
if (!el) return
function run() {
let result = match(direction.current, {
[TabDirection.Forwards]: () => focusIn(el, Focus.First),
[TabDirection.Backwards]: () => focusIn(el, Focus.Last),
})
if (result === FocusResult.Error) {
focusIn(
getFocusableElements().filter((el) => el.dataset.headlessuiFocusGuard !== 'true'),
match(direction.current, {
[TabDirection.Forwards]: Focus.Next,
[TabDirection.Backwards]: Focus.Previous,
}),
{ relativeTo: state.button }
)
}
}
// TODO: Cleanup once we are using real browser tests
if (process.env.NODE_ENV === 'test') {
microTask(run)
} else {
run()
}
})
return (
<>
{render({
ourProps,
theirProps,
slot,
defaultTag: DEFAULT_BUTTON_TAG,
name: 'Popover.Button',
})}
{visible && !isWithinPanel && isPortalled && (
<Hidden
id={sentinelId}
features={HiddenFeatures.Focusable}
data-headlessui-focus-guard
as="button"
type="button"
onFocus={handleFocus}
/>
)}
</>
)
}
// ---
let DEFAULT_OVERLAY_TAG = 'div' as const
interface OverlayRenderPropArg {
open: boolean
}
type OverlayPropsWeControl = 'aria-hidden'
let OverlayRenderFeatures = Features.RenderStrategy | Features.Static
export type PopoverOverlayProps<TTag extends ElementType> = Props<
TTag,
OverlayRenderPropArg,
OverlayPropsWeControl
> &
PropsForFeatures<typeof OverlayRenderFeatures>
function OverlayFn<TTag extends ElementType = typeof DEFAULT_OVERLAY_TAG>(
props: PopoverOverlayProps<TTag>,
ref: Ref<HTMLDivElement>
) {
let internalId = useId()
let { id = `headlessui-popover-overlay-${internalId}`, ...theirProps } = props
let [{ popoverState }, dispatch] = usePopoverContext('Popover.Overlay')
let overlayRef = useSyncRefs(ref)
let usesOpenClosedState = useOpenClosed()
let visible = (() => {
if (usesOpenClosedState !== null) {
return (usesOpenClosedState & State.Open) === State.Open
}
return popoverState === PopoverStates.Open
})()
let handleClick = useEvent((event: ReactMouseEvent) => {
if (isDisabledReactIssue7711(event.currentTarget)) return event.preventDefault()
dispatch({ type: ActionTypes.ClosePopover })
})
let slot = useMemo<OverlayRenderPropArg>(
() => ({ open: popoverState === PopoverStates.Open }),
[popoverState]
)
let ourProps = {
ref: overlayRef,
id,
'aria-hidden': true,
onClick: handleClick,
}
return render({
ourProps,
theirProps,
slot,
defaultTag: DEFAULT_OVERLAY_TAG,
features: OverlayRenderFeatures,
visible,
name: 'Popover.Overlay',
})
}
// ---
let DEFAULT_PANEL_TAG = 'div' as const
interface PanelRenderPropArg {
open: boolean
close: (focusableElement?: HTMLElement | MutableRefObject<HTMLElement | null>) => void
}
let PanelRenderFeatures = Features.RenderStrategy | Features.Static
type PanelPropsWeControl = 'tabIndex'
export type PopoverPanelProps<TTag extends ElementType> = Props<
TTag,
PanelRenderPropArg,
PanelPropsWeControl,
PropsForFeatures<typeof PanelRenderFeatures> & {
focus?: boolean
}
>
function PanelFn<TTag extends ElementType = typeof DEFAULT_PANEL_TAG>(
props: PopoverPanelProps<TTag>,
ref: Ref<HTMLDivElement>
) {
let internalId = useId()
let { id = `headlessui-popover-panel-${internalId}`, focus = false, ...theirProps } = props
let [state, dispatch] = usePopoverContext('Popover.Panel')
let { close, isPortalled } = usePopoverAPIContext('Popover.Panel')
let beforePanelSentinelId = `headlessui-focus-sentinel-before-${useId()}`
let afterPanelSentinelId = `headlessui-focus-sentinel-after-${useId()}`
let internalPanelRef = useRef<HTMLDivElement | null>(null)
let panelRef = useSyncRefs(internalPanelRef, ref, (panel) => {
dispatch({ type: ActionTypes.SetPanel, panel })
})
let ownerDocument = useOwnerDocument(internalPanelRef)
useIsoMorphicEffect(() => {
dispatch({ type: ActionTypes.SetPanelId, panelId: id })
return () => {
dispatch({ type: ActionTypes.SetPanelId, panelId: null })
}
}, [id, dispatch])
let usesOpenClosedState = useOpenClosed()
let visible = (() => {
if (usesOpenClosedState !== null) {
return (usesOpenClosedState & State.Open) === State.Open
}
return state.popoverState === PopoverStates.Open
})()
let handleKeyDown = useEvent((event: KeyboardEvent) => {
switch (event.key) {
case Keys.Escape:
if (state.popoverState !== PopoverStates.Open) return
if (!internalPanelRef.current) return
if (
ownerDocument?.activeElement &&
!internalPanelRef.current.contains(ownerDocument.activeElement)
) {
return
}
event.preventDefault()
event.stopPropagation()
dispatch({ type: ActionTypes.ClosePopover })
state.button?.focus()
break
}
})
// Unlink on "unmount" children
useEffect(() => {
if (props.static) return
if (state.popoverState === PopoverStates.Closed && (props.unmount ?? true)) {
dispatch({ type: ActionTypes.SetPanel, panel: null })
}
}, [state.popoverState, props.unmount, props.static, dispatch])
// Move focus within panel
useEffect(() => {
if (!focus) return
if (state.popoverState !== PopoverStates.Open) return
if (!internalPanelRef.current) return
let activeElement = ownerDocument?.activeElement as HTMLElement
if (internalPanelRef.current.contains(activeElement)) return // Already focused within Dialog
focusIn(internalPanelRef.current, Focus.First)
}, [focus, internalPanelRef, state.popoverState])
let slot = useMemo<PanelRenderPropArg>(
() => ({ open: state.popoverState === PopoverStates.Open, close }),
[state, close]
)
let ourProps = {
ref: panelRef,
id,
onKeyDown: handleKeyDown,
onBlur:
focus && state.popoverState === PopoverStates.Open
? (event: ReactFocusEvent) => {
let el = event.relatedTarget as HTMLElement
if (!el) return
if (!internalPanelRef.current) return
if (internalPanelRef.current?.contains(el)) return
dispatch({ type: ActionTypes.ClosePopover })
if (
state.beforePanelSentinel.current?.contains?.(el) ||
state.afterPanelSentinel.current?.contains?.(el)
) {
el.focus({ preventScroll: true })
}
}
: undefined,
tabIndex: -1,
}
let direction = useTabDirection()
let handleBeforeFocus = useEvent(() => {
let el = internalPanelRef.current as HTMLElement
if (!el) return
function run() {
match(direction.current, {
[TabDirection.Forwards]: () => {
// Try to focus the first thing in the panel. But if that fails (e.g.: there are no
// focusable elements, then we can move outside of the panel)
let result = focusIn(el, Focus.First)
if (result === FocusResult.Error) {
state.afterPanelSentinel.current?.focus()
}
},
[TabDirection.Backwards]: () => {
// Coming from the Popover.Panel (which is portalled to somewhere else). Let's redirect
// the focus to the Popover.Button again.
state.button?.focus({ preventScroll: true })
},
})
}
// TODO: Cleanup once we are using real browser tests
if (process.env.NODE_ENV === 'test') {
microTask(run)
} else {
run()
}
})
let handleAfterFocus = useEvent(() => {
let el = internalPanelRef.current as HTMLElement
if (!el) return
function run() {
match(direction.current, {
[TabDirection.Forwards]: () => {
if (!state.button) return
let elements = getFocusableElements()
let idx = elements.indexOf(state.button)
let before = elements.slice(0, idx + 1)
let after = elements.slice(idx + 1)
let combined = [...after, ...before]
// Ignore sentinel buttons and items inside the panel
for (let element of combined.slice()) {
if (element.dataset.headlessuiFocusGuard === 'true' || state.panel?.contains(element)) {
let idx = combined.indexOf(element)
if (idx !== -1) combined.splice(idx, 1)
}
}
focusIn(combined, Focus.First, { sorted: false })
},
[TabDirection.Backwards]: () => {
// Try to focus the first thing in the panel. But if that fails (e.g.: there are no
// focusable elements, then we can move outside of the panel)
let result = focusIn(el, Focus.Previous)
if (result === FocusResult.Error) {
state.button?.focus()
}
},
})
}
// TODO: Cleanup once we are using real browser tests
if (process.env.NODE_ENV === 'test') {
microTask(run)
} else {
run()
}
})
return (
<PopoverPanelContext.Provider value={id}>
{visible && isPortalled && (
<Hidden
id={beforePanelSentinelId}
ref={state.beforePanelSentinel}
features={HiddenFeatures.Focusable}
data-headlessui-focus-guard
as="button"
type="button"
onFocus={handleBeforeFocus}
/>
)}
{render({
ourProps,
theirProps,
slot,
defaultTag: DEFAULT_PANEL_TAG,
features: PanelRenderFeatures,
visible,
name: 'Popover.Panel',
})}
{visible && isPortalled && (
<Hidden
id={afterPanelSentinelId}
ref={state.afterPanelSentinel}
features={HiddenFeatures.Focusable}
data-headlessui-focus-guard
as="button"
type="button"
onFocus={handleAfterFocus}
/>
)}
</PopoverPanelContext.Provider>
)
}
// ---
let DEFAULT_GROUP_TAG = 'div' as const
interface GroupRenderPropArg {}
export type PopoverGroupProps<TTag extends ElementType> = Props<TTag, GroupRenderPropArg>
function GroupFn<TTag extends ElementType = typeof DEFAULT_PANEL_TAG>(
props: PopoverGroupProps<TTag>,
ref: Ref<HTMLElement>
) {
let internalGroupRef = useRef<HTMLElement | null>(null)
let groupRef = useSyncRefs(internalGroupRef, ref)
let [popovers, setPopovers] = useState<PopoverRegisterBag[]>([])
let unregisterPopover = useEvent((registerbag: PopoverRegisterBag) => {
setPopovers((existing) => {
let idx = existing.indexOf(registerbag)
if (idx !== -1) {
let clone = existing.slice()
clone.splice(idx, 1)
return clone
}
return existing
})
})
let registerPopover = useEvent((registerbag: PopoverRegisterBag) => {
setPopovers((existing) => [...existing, registerbag])
return () => unregisterPopover(registerbag)
})
let isFocusWithinPopoverGroup = useEvent(() => {
let ownerDocument = getOwnerDocument(internalGroupRef)
if (!ownerDocument) return false
let element = ownerDocument.activeElement
if (internalGroupRef.current?.contains(element)) return true
// Check if the focus is in one of the button or panel elements. This is important in case you are rendering inside a Portal.
return popovers.some((bag) => {
return (
ownerDocument!.getElementById(bag.buttonId.current!)?.contains(element) ||
ownerDocument!.getElementById(bag.panelId.current!)?.contains(element)
)
})
})
let closeOthers = useEvent((buttonId: string) => {
for (let popover of popovers) {
if (popover.buttonId.current !== buttonId) popover.close()
}
})
let contextBag = useMemo<ContextType<typeof PopoverGroupContext>>(
() => ({
registerPopover: registerPopover,
unregisterPopover: unregisterPopover,
isFocusWithinPopoverGroup,
closeOthers,
}),
[registerPopover, unregisterPopover, isFocusWithinPopoverGroup, closeOthers]
)
let slot = useMemo<GroupRenderPropArg>(() => ({}), [])
let theirProps = props
let ourProps = { ref: groupRef }
return (
<PopoverGroupContext.Provider value={contextBag}>
{render({
ourProps,
theirProps,
slot,
defaultTag: DEFAULT_GROUP_TAG,
name: 'Popover.Group',
})}
</PopoverGroupContext.Provider>
)
}
// ---
interface ComponentPopover extends HasDisplayName {
<TTag extends ElementType = typeof DEFAULT_POPOVER_TAG>(
props: PopoverProps<TTag> & RefProp<typeof PopoverFn>
): JSX.Element
}
interface ComponentPopoverButton extends HasDisplayName {
<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
props: PopoverButtonProps<TTag> & RefProp<typeof ButtonFn>
): JSX.Element
}
interface ComponentPopoverOverlay extends HasDisplayName {
<TTag extends ElementType = typeof DEFAULT_OVERLAY_TAG>(
props: PopoverOverlayProps<TTag> & RefProp<typeof OverlayFn>
): JSX.Element
}
interface ComponentPopoverPanel extends HasDisplayName {
<TTag extends ElementType = typeof DEFAULT_PANEL_TAG>(
props: PopoverPanelProps<TTag> & RefProp<typeof PanelFn>
): JSX.Element
}
interface ComponentPopoverGroup extends HasDisplayName {
<TTag extends ElementType = typeof DEFAULT_GROUP_TAG>(
props: PopoverGroupProps<TTag> & RefProp<typeof GroupFn>
): JSX.Element
}
let PopoverRoot = forwardRefWithAs(PopoverFn) as unknown as ComponentPopover
let Button = forwardRefWithAs(ButtonFn) as unknown as ComponentPopoverButton
let Overlay = forwardRefWithAs(OverlayFn) as unknown as ComponentPopoverOverlay
let Panel = forwardRefWithAs(PanelFn) as unknown as ComponentPopoverPanel
let Group = forwardRefWithAs(GroupFn) as unknown as ComponentPopoverGroup
export let Popover = Object.assign(PopoverRoot, { Button, Overlay, Panel, Group })