Files
headlessui/packages/@headlessui-react/src/components/dialog/dialog.tsx
T
BrandonGoren 5ca68a9c95 Update react types to avoid unbound method lint errors (#3480)
Using @headlessui close methods/types in a project with
eslint-typescript currently causes "UnboundMethod" errors because we're
using class member syntax to define the functions.

I tweaked the declarations here to use arrow syntax in few places. The
behavior should be unchanged, but we are no longer implying the
existence of a "this"
2024-09-23 13:40:18 +02:00

596 lines
17 KiB
TypeScript

'use client'
// WAI-ARIA: https://www.w3.org/WAI/ARIA/apg/patterns/dialogmodal/
import React, {
Fragment,
createContext,
createRef,
useContext,
useEffect,
useMemo,
useReducer,
useRef,
type ContextType,
type ElementType,
type MutableRefObject,
type MouseEvent as ReactMouseEvent,
type Ref,
type RefObject,
} from 'react'
import { useEscape } from '../../hooks/use-escape'
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 { useOnDisappear } from '../../hooks/use-on-disappear'
import { useOutsideClick } from '../../hooks/use-outside-click'
import { useOwnerDocument } from '../../hooks/use-owner'
import {
MainTreeProvider,
useMainTreeNode,
useRootContainers,
} from '../../hooks/use-root-containers'
import { useScrollLock } from '../../hooks/use-scroll-lock'
import { useServerHandoffComplete } from '../../hooks/use-server-handoff-complete'
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 type { Props } from '../../types'
import { match } from '../../utils/match'
import {
RenderFeatures,
forwardRefWithAs,
render,
type HasDisplayName,
type PropsForFeatures,
type RefProp,
} from '../../utils/render'
import {
Description,
useDescriptions,
type _internal_ComponentDescription,
} from '../description/description'
import { FocusTrap, FocusTrapFeatures } from '../focus-trap/focus-trap'
import { Portal, PortalGroup, useNestedPortals } from '../portal/portal'
import { Transition, TransitionChild } from '../transition/transition'
enum DialogStates {
Open,
Closed,
}
interface StateDefinition {
titleId: string | null
panelRef: MutableRefObject<HTMLElement | null>
}
enum ActionTypes {
SetTitleId,
}
type Actions = { type: ActionTypes.SetTitleId; id: string | null }
let reducers: {
[P in ActionTypes]: (
state: StateDefinition,
action: Extract<Actions, { type: P }>
) => StateDefinition
} = {
[ActionTypes.SetTitleId](state, action) {
if (state.titleId === action.id) return state
return { ...state, titleId: action.id }
},
}
let DialogContext = createContext<
| [
{
dialogState: DialogStates
unmount: boolean
close: () => void
setTitleId: (id: string | null) => void
},
StateDefinition,
]
| null
>(null)
DialogContext.displayName = 'DialogContext'
function useDialogContext(component: string) {
let context = useContext(DialogContext)
if (context === null) {
let err = new Error(`<${component} /> is missing a parent <Dialog /> component.`)
if (Error.captureStackTrace) Error.captureStackTrace(err, useDialogContext)
throw err
}
return context
}
function stateReducer(state: StateDefinition, action: Actions) {
return match(action.type, reducers, state, action)
}
// ---
let InternalDialog = forwardRefWithAs(function InternalDialog<
TTag extends ElementType = typeof DEFAULT_DIALOG_TAG,
>(props: DialogProps<TTag>, ref: Ref<HTMLElement>) {
let internalId = useId()
let {
id = `headlessui-dialog-${internalId}`,
open,
onClose,
initialFocus,
role = 'dialog',
autoFocus = true,
__demoMode = false,
unmount = false,
...theirProps
} = props
let didWarnOnRole = useRef(false)
role = (function () {
if (role === 'dialog' || role === 'alertdialog') {
return role
}
if (!didWarnOnRole.current) {
didWarnOnRole.current = true
console.warn(
`Invalid role [${role}] passed to <Dialog />. Only \`dialog\` and and \`alertdialog\` are supported. Using \`dialog\` instead.`
)
}
return 'dialog'
})()
let usesOpenClosedState = useOpenClosed()
if (open === undefined && usesOpenClosedState !== null) {
// Update the `open` prop based on the open closed state
open = (usesOpenClosedState & State.Open) === State.Open
}
let internalDialogRef = useRef<HTMLElement | null>(null)
let dialogRef = useSyncRefs(internalDialogRef, ref)
let ownerDocument = useOwnerDocument(internalDialogRef)
let dialogState = open ? DialogStates.Open : DialogStates.Closed
let [state, dispatch] = useReducer(stateReducer, {
titleId: null,
descriptionId: null,
panelRef: createRef(),
} as StateDefinition)
let close = useEvent(() => onClose(false))
let setTitleId = useEvent((id: string | null) => dispatch({ type: ActionTypes.SetTitleId, id }))
let ready = useServerHandoffComplete()
let enabled = ready ? dialogState === DialogStates.Open : false
let [portals, PortalWrapper] = useNestedPortals()
// We use this because reading these values during initial render(s)
// can result in `null` rather then the actual elements
// This doesn't happen when using certain components like a
// `<Dialog.Title>` because they cause the parent to re-render
let defaultContainer: RefObject<HTMLElement> = {
get current() {
return state.panelRef.current ?? internalDialogRef.current
},
}
let mainTreeNode = useMainTreeNode()
let { resolveContainers: resolveRootContainers } = useRootContainers({
mainTreeNode,
portals,
defaultContainers: [defaultContainer],
})
// When the `Dialog` is wrapped in a `Transition` (or another Headless UI component that exposes
// the OpenClosed state) then we get some information via context about its state. When the
// `Transition` is about to close, then the `State.Closing` state will be exposed. This allows us
// to enable/disable certain functionality in the `Dialog` upfront instead of waiting until the
// `Transition` is done transitioning.
let isClosing =
usesOpenClosedState !== null ? (usesOpenClosedState & State.Closing) === State.Closing : false
// Ensure other elements can't be interacted with
let inertOthersEnabled = __demoMode ? false : isClosing ? false : enabled
useInertOthers(inertOthersEnabled, {
allowed: useEvent(() => [
// Allow the headlessui-portal of the Dialog to be interactive. This
// contains the current dialog and the necessary focus guard elements.
internalDialogRef.current?.closest<HTMLElement>('[data-headlessui-portal]') ?? null,
]),
disallowed: useEvent(() => [
// Disallow the "main" tree root node
mainTreeNode?.closest<HTMLElement>('body > *:not(#headlessui-portal-root)') ?? null,
]),
})
// Close Dialog on outside click
useOutsideClick(enabled, resolveRootContainers, (event) => {
event.preventDefault()
close()
})
// Handle `Escape` to close
useEscape(enabled, ownerDocument?.defaultView, (event) => {
event.preventDefault()
event.stopPropagation()
// Ensure that we blur the current activeElement to prevent maintaining
// focus and potentially scrolling the page to the end (because the Dialog
// is rendered in a Portal at the end of the document.body and the browser
// tries to keep the focused element in view)
//
// Typically only happens in Safari.
if (
document.activeElement &&
'blur' in document.activeElement &&
typeof document.activeElement.blur === 'function'
) {
document.activeElement.blur()
}
close()
})
// Scroll lock
let scrollLockEnabled = __demoMode ? false : isClosing ? false : enabled
useScrollLock(scrollLockEnabled, ownerDocument, resolveRootContainers)
// Ensure we close the dialog as soon as the dialog itself becomes hidden
useOnDisappear(enabled, internalDialogRef, close)
let [describedby, DescriptionProvider] = useDescriptions()
let contextBag = useMemo<ContextType<typeof DialogContext>>(
() => [{ dialogState, close, setTitleId, unmount }, state],
[dialogState, state, close, setTitleId, unmount]
)
let slot = useMemo(
() => ({ open: dialogState === DialogStates.Open }) satisfies DialogRenderPropArg,
[dialogState]
)
let ourProps = {
ref: dialogRef,
id,
role,
tabIndex: -1,
'aria-modal': __demoMode ? undefined : dialogState === DialogStates.Open ? true : undefined,
'aria-labelledby': state.titleId,
'aria-describedby': describedby,
unmount,
}
let shouldMoveFocusInside = !useIsTouchDevice()
let focusTrapFeatures = FocusTrapFeatures.None
if (enabled && !__demoMode) {
focusTrapFeatures |= FocusTrapFeatures.RestoreFocus
focusTrapFeatures |= FocusTrapFeatures.TabLock
if (autoFocus) {
focusTrapFeatures |= FocusTrapFeatures.AutoFocus
}
if (shouldMoveFocusInside) {
focusTrapFeatures |= FocusTrapFeatures.InitialFocus
}
}
return (
<ResetOpenClosedProvider>
<ForcePortalRoot force={true}>
<Portal>
<DialogContext.Provider value={contextBag}>
<PortalGroup target={internalDialogRef}>
<ForcePortalRoot force={false}>
<DescriptionProvider slot={slot}>
<PortalWrapper>
<FocusTrap
initialFocus={initialFocus}
initialFocusFallback={internalDialogRef}
containers={resolveRootContainers}
features={focusTrapFeatures}
>
<CloseProvider value={close}>
{render({
ourProps,
theirProps,
slot,
defaultTag: DEFAULT_DIALOG_TAG,
features: DialogRenderFeatures,
visible: dialogState === DialogStates.Open,
name: 'Dialog',
})}
</CloseProvider>
</FocusTrap>
</PortalWrapper>
</DescriptionProvider>
</ForcePortalRoot>
</PortalGroup>
</DialogContext.Provider>
</Portal>
</ForcePortalRoot>
</ResetOpenClosedProvider>
)
})
// ---
let DEFAULT_DIALOG_TAG = 'div' as const
type DialogRenderPropArg = {
open: boolean
}
type DialogPropsWeControl = 'aria-describedby' | 'aria-labelledby' | 'aria-modal'
let DialogRenderFeatures = RenderFeatures.RenderStrategy | RenderFeatures.Static
export type DialogProps<TTag extends ElementType = typeof DEFAULT_DIALOG_TAG> = Props<
TTag,
DialogRenderPropArg,
DialogPropsWeControl,
PropsForFeatures<typeof DialogRenderFeatures> & {
open?: boolean
onClose: (value: boolean) => void
initialFocus?: MutableRefObject<HTMLElement | null>
role?: 'dialog' | 'alertdialog'
autoFocus?: boolean
transition?: boolean
__demoMode?: boolean
}
>
function DialogFn<TTag extends ElementType = typeof DEFAULT_DIALOG_TAG>(
props: DialogProps<TTag>,
ref: Ref<HTMLElement>
) {
let { transition = false, open, ...rest } = props
// Validations
let usesOpenClosedState = useOpenClosed()
let hasOpen = props.hasOwnProperty('open') || usesOpenClosedState !== null
let hasOnClose = props.hasOwnProperty('onClose')
if (!hasOpen && !hasOnClose) {
throw new Error(
`You have to provide an \`open\` and an \`onClose\` prop to the \`Dialog\` component.`
)
}
if (!hasOpen) {
throw new Error(
`You provided an \`onClose\` prop to the \`Dialog\`, but forgot an \`open\` prop.`
)
}
if (!hasOnClose) {
throw new Error(
`You provided an \`open\` prop to the \`Dialog\`, but forgot an \`onClose\` prop.`
)
}
if (!usesOpenClosedState && typeof props.open !== 'boolean') {
throw new Error(
`You provided an \`open\` prop to the \`Dialog\`, but the value is not a boolean. Received: ${props.open}`
)
}
if (typeof props.onClose !== 'function') {
throw new Error(
`You provided an \`onClose\` prop to the \`Dialog\`, but the value is not a function. Received: ${props.onClose}`
)
}
if ((open !== undefined || transition) && !rest.static) {
return (
<MainTreeProvider>
<Transition show={open} transition={transition} unmount={rest.unmount}>
<InternalDialog ref={ref} {...rest} />
</Transition>
</MainTreeProvider>
)
}
return (
<MainTreeProvider>
<InternalDialog ref={ref} open={open} {...rest} />
</MainTreeProvider>
)
}
// ---
let DEFAULT_PANEL_TAG = 'div' as const
type PanelRenderPropArg = {
open: boolean
}
export type DialogPanelProps<TTag extends ElementType = typeof DEFAULT_PANEL_TAG> = Props<
TTag,
PanelRenderPropArg,
never,
{ transition?: boolean }
>
function PanelFn<TTag extends ElementType = typeof DEFAULT_PANEL_TAG>(
props: DialogPanelProps<TTag>,
ref: Ref<HTMLElement>
) {
let internalId = useId()
let { id = `headlessui-dialog-panel-${internalId}`, transition = false, ...theirProps } = props
let [{ dialogState, unmount }, state] = useDialogContext('Dialog.Panel')
let panelRef = useSyncRefs(ref, state.panelRef)
let slot = useMemo(
() => ({ open: dialogState === DialogStates.Open }) satisfies PanelRenderPropArg,
[dialogState]
)
// Prevent the click events inside the Dialog.Panel from bubbling through the React Tree which
// could submit wrapping <form> elements even if we portalled the Dialog.
let handleClick = useEvent((event: ReactMouseEvent) => {
event.stopPropagation()
})
let ourProps = {
ref: panelRef,
id,
onClick: handleClick,
}
let Wrapper = transition ? TransitionChild : Fragment
let wrapperProps = transition ? { unmount } : {}
return (
<Wrapper {...wrapperProps}>
{render({
ourProps,
theirProps,
slot,
defaultTag: DEFAULT_PANEL_TAG,
name: 'Dialog.Panel',
})}
</Wrapper>
)
}
// ---
let DEFAULT_BACKDROP_TAG = 'div' as const
type BackdropRenderPropArg = {
open: boolean
}
export type DialogBackdropProps<TTag extends ElementType = typeof DEFAULT_BACKDROP_TAG> = Props<
TTag,
BackdropRenderPropArg,
never,
{ transition?: boolean }
>
function BackdropFn<TTag extends ElementType = typeof DEFAULT_BACKDROP_TAG>(
props: DialogBackdropProps<TTag>,
ref: Ref<HTMLElement>
) {
let { transition = false, ...theirProps } = props
let [{ dialogState, unmount }] = useDialogContext('Dialog.Backdrop')
let slot = useMemo(
() => ({ open: dialogState === DialogStates.Open }) satisfies BackdropRenderPropArg,
[dialogState]
)
let ourProps = { ref, 'aria-hidden': true }
let Wrapper = transition ? TransitionChild : Fragment
let wrapperProps = transition ? { unmount } : {}
return (
<Wrapper {...wrapperProps}>
{render({
ourProps,
theirProps,
slot,
defaultTag: DEFAULT_BACKDROP_TAG,
name: 'Dialog.Backdrop',
})}
</Wrapper>
)
}
// ---
let DEFAULT_TITLE_TAG = 'h2' as const
type TitleRenderPropArg = {
open: boolean
}
export type DialogTitleProps<TTag extends ElementType = typeof DEFAULT_TITLE_TAG> = Props<
TTag,
TitleRenderPropArg
>
function TitleFn<TTag extends ElementType = typeof DEFAULT_TITLE_TAG>(
props: DialogTitleProps<TTag>,
ref: Ref<HTMLElement>
) {
let internalId = useId()
let { id = `headlessui-dialog-title-${internalId}`, ...theirProps } = props
let [{ dialogState, setTitleId }] = useDialogContext('Dialog.Title')
let titleRef = useSyncRefs(ref)
useEffect(() => {
setTitleId(id)
return () => setTitleId(null)
}, [id, setTitleId])
let slot = useMemo(
() => ({ open: dialogState === DialogStates.Open }) satisfies TitleRenderPropArg,
[dialogState]
)
let ourProps = { ref: titleRef, id }
return render({
ourProps,
theirProps,
slot,
defaultTag: DEFAULT_TITLE_TAG,
name: 'Dialog.Title',
})
}
// ---
export interface _internal_ComponentDialog extends HasDisplayName {
<TTag extends ElementType = typeof DEFAULT_DIALOG_TAG>(
props: DialogProps<TTag> & RefProp<typeof DialogFn>
): JSX.Element
}
export interface _internal_ComponentDialogPanel extends HasDisplayName {
<TTag extends ElementType = typeof DEFAULT_PANEL_TAG>(
props: DialogPanelProps<TTag> & RefProp<typeof PanelFn>
): JSX.Element
}
export interface _internal_ComponentDialogBackdrop extends HasDisplayName {
<TTag extends ElementType = typeof DEFAULT_BACKDROP_TAG>(
props: DialogBackdropProps<TTag> & RefProp<typeof BackdropFn>
): JSX.Element
}
export interface _internal_ComponentDialogTitle extends HasDisplayName {
<TTag extends ElementType = typeof DEFAULT_TITLE_TAG>(
props: DialogTitleProps<TTag> & RefProp<typeof TitleFn>
): JSX.Element
}
export interface _internal_ComponentDialogDescription extends _internal_ComponentDescription {}
let DialogRoot = forwardRefWithAs(DialogFn) as _internal_ComponentDialog
export let DialogPanel = forwardRefWithAs(PanelFn) as _internal_ComponentDialogPanel
export let DialogBackdrop = forwardRefWithAs(BackdropFn) as _internal_ComponentDialogBackdrop
export let DialogTitle = forwardRefWithAs(TitleFn) as _internal_ComponentDialogTitle
/** @deprecated use `<Description>` instead of `<DialogDescription>` */
export let DialogDescription = Description as _internal_ComponentDialogDescription
export let Dialog = Object.assign(DialogRoot, {
/** @deprecated use `<DialogPanel>` instead of `<Dialog.Panel>` */
Panel: DialogPanel,
/** @deprecated use `<DialogTitle>` instead of `<Dialog.Title>` */
Title: DialogTitle,
/** @deprecated use `<Description>` instead of `<Dialog.Description>` */
Description: Description as _internal_ComponentDialogDescription,
})