// WAI-ARIA: https://www.w3.org/TR/wai-aria-practices-1.2/#dialog_modal import React, { createContext, createRef, useContext, useEffect, useMemo, useReducer, useRef, useState, // Types ContextType, ElementType, MouseEvent as ReactMouseEvent, MutableRefObject, Ref, } from 'react' import { Props } from '../../types' import { match } from '../../utils/match' import { forwardRefWithAs, render, Features, PropsForFeatures } from '../../utils/render' import { useSyncRefs } from '../../hooks/use-sync-refs' import { Keys } from '../keyboard' import { isDisabledReactIssue7711 } from '../../utils/bugs' import { useId } from '../../hooks/use-id' import { FocusTrap } from '../../components/focus-trap/focus-trap' import { useInertOthers } from '../../hooks/use-inert-others' import { Portal } from '../../components/portal/portal' import { ForcePortalRoot } from '../../internal/portal-force-root' import { Description, useDescriptions } from '../description/description' import { useOpenClosed, State } from '../../internal/open-closed' import { useServerHandoffComplete } from '../../hooks/use-server-handoff-complete' import { StackProvider, StackMessage } from '../../internal/stack-context' import { useOutsideClick } from '../../hooks/use-outside-click' 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 { disposables } from '../../utils/disposables' import { isIOS } from '../../utils/platform' enum DialogStates { Open, Closed, } interface StateDefinition { titleId: string | null panelRef: MutableRefObject } enum ActionTypes { SetTitleId, } type Actions = { type: ActionTypes.SetTitleId; id: string | null } let reducers: { [P in ActionTypes]: ( state: StateDefinition, action: Extract ) => StateDefinition } = { [ActionTypes.SetTitleId](state, action) { if (state.titleId === action.id) return state return { ...state, titleId: action.id } }, } let DialogContext = createContext< | [ { dialogState: DialogStates 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 component.`) if (Error.captureStackTrace) Error.captureStackTrace(err, useDialogContext) throw err } return context } function useScrollLock( ownerDocument: Document | null, enabled: boolean, resolveAllowedContainers: () => HTMLElement[] = () => [document.body] ) { useEffect(() => { if (!enabled) return if (!ownerDocument) return let d = disposables() function style(node: HTMLElement, property: string, value: string) { let previous = node.style.getPropertyValue(property) Object.assign(node.style, { [property]: value }) return d.add(() => { Object.assign(node.style, { [property]: previous }) }) } let documentElement = ownerDocument.documentElement let ownerWindow = ownerDocument.defaultView ?? window let scrollbarWidthBefore = ownerWindow.innerWidth - documentElement.clientWidth style(documentElement, 'overflow', 'hidden') if (scrollbarWidthBefore > 0) { let scrollbarWidthAfter = documentElement.clientWidth - documentElement.offsetWidth let scrollbarWidth = scrollbarWidthBefore - scrollbarWidthAfter style(documentElement, 'paddingRight', `${scrollbarWidth}px`) } if (isIOS()) { let scrollPosition = window.pageYOffset style(document.body, 'marginTop', `-${scrollPosition}px`) window.scrollTo(0, 0) d.addEventListener( ownerDocument, 'touchmove', (e) => { // Check if we are scrolling inside any of the allowed containers, if not let's cancel the event! if ( e.target instanceof HTMLElement && !resolveAllowedContainers().some((container) => container.contains(e.target as HTMLElement) ) ) { e.preventDefault() } }, { passive: false } ) // Restore scroll position d.add(() => { // Before opening the Dialog, we capture the current pageYOffset, and offset the page with // this value so that we can also scroll to `(0, 0)`. // // If we want to restore a few things can happen: // // 1. The window.pageYOffset is still at 0, this means nothing happened, and we can safely // restore to the captured value earlier. // 2. The window.pageYOffset is **not** at 0. This means that something happened (e.g.: a // link was scrolled into view in the background). Ideally we want to restore to this _new_ // position. To do this, we can take the new value into account with the captured value from // before. // // (Since the value of window.pageYOffset is 0 in the first case, we should be able to // always sum these values) window.scrollTo(0, window.pageYOffset + scrollPosition) }) } return d.dispose }, [ownerDocument, enabled]) } function stateReducer(state: StateDefinition, action: Actions) { return match(action.type, reducers, state, action) } // --- let DEFAULT_DIALOG_TAG = 'div' as const interface DialogRenderPropArg { open: boolean } type DialogPropsWeControl = 'role' | 'aria-modal' | 'aria-describedby' | 'aria-labelledby' let DialogRenderFeatures = Features.RenderStrategy | Features.Static let DialogRoot = forwardRefWithAs(function Dialog< TTag extends ElementType = typeof DEFAULT_DIALOG_TAG >( props: Props & PropsForFeatures & { open?: boolean onClose(value: boolean): void initialFocus?: MutableRefObject __demoMode?: boolean }, ref: Ref ) { let internalId = useId() let { id = `headlessui-dialog-${internalId}`, open, onClose, initialFocus, __demoMode = false, ...theirProps } = props let [nestedDialogCount, setNestedDialogCount] = useState(0) let usesOpenClosedState = useOpenClosed() if (open === undefined && usesOpenClosedState !== null) { // Update the `open` prop based on the open closed state open = match(usesOpenClosedState, { [State.Open]: true, [State.Closed]: false, }) } let containers = useRef>>(new Set()) let internalDialogRef = useRef(null) let dialogRef = useSyncRefs(internalDialogRef, ref) // Reference to a node in the "main" tree, not in the portalled Dialog tree. let mainTreeNode = useRef(null) let ownerDocument = useOwnerDocument(internalDialogRef) // Validations 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 (typeof open !== 'boolean') { throw new Error( `You provided an \`open\` prop to the \`Dialog\`, but the value is not a boolean. Received: ${open}` ) } if (typeof onClose !== 'function') { throw new Error( `You provided an \`onClose\` prop to the \`Dialog\`, but the value is not a function. Received: ${onClose}` ) } 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 ? (__demoMode ? false : dialogState === DialogStates.Open) : false let hasNestedDialogs = nestedDialogCount > 1 // 1 is the current dialog let hasParentDialog = useContext(DialogContext) !== null // If there are multiple dialogs, then you can be the root, the leaf or one // in between. We only care abou whether you are the top most one or not. let position = !hasNestedDialogs ? 'leaf' : 'parent' // Ensure other elements can't be interacted with useInertOthers(internalDialogRef, hasNestedDialogs ? enabled : false) let resolveContainers = useEvent(() => { // Third party roots let rootContainers = Array.from( ownerDocument?.querySelectorAll('body > *, [data-headlessui-portal]') ?? [] ).filter((container) => { if (!(container instanceof HTMLElement)) return false // Skip non-HTMLElements if (container.contains(mainTreeNode.current)) return false // Skip if it is the main app if (state.panelRef.current && container.contains(state.panelRef.current)) return false return true // Keep }) return [...rootContainers, state.panelRef.current ?? internalDialogRef.current] as HTMLElement[] }) // Close Dialog on outside click useOutsideClick(() => resolveContainers(), close, enabled && !hasNestedDialogs) // Handle `Escape` to close useEventListener(ownerDocument?.defaultView, 'keydown', (event) => { if (event.defaultPrevented) return if (event.key !== Keys.Escape) return if (dialogState !== DialogStates.Open) return if (hasNestedDialogs) return event.preventDefault() event.stopPropagation() close() }) // Scroll lock useScrollLock( ownerDocument, dialogState === DialogStates.Open && !hasParentDialog, resolveContainers ) // Trigger close when the FocusTrap gets hidden useEffect(() => { if (dialogState !== DialogStates.Open) return if (!internalDialogRef.current) return let observer = new IntersectionObserver((entries) => { for (let entry of entries) { if ( entry.boundingClientRect.x === 0 && entry.boundingClientRect.y === 0 && entry.boundingClientRect.width === 0 && entry.boundingClientRect.height === 0 ) { close() } } }) observer.observe(internalDialogRef.current) return () => observer.disconnect() }, [dialogState, internalDialogRef, close]) let [describedby, DescriptionProvider] = useDescriptions() let contextBag = useMemo>( () => [{ dialogState, close, setTitleId }, state], [dialogState, state, close, setTitleId] ) let slot = useMemo( () => ({ open: dialogState === DialogStates.Open }), [dialogState] ) let ourProps = { ref: dialogRef, id, role: 'dialog', 'aria-modal': dialogState === DialogStates.Open ? true : undefined, 'aria-labelledby': state.titleId, 'aria-describedby': describedby, } return ( { if (type !== 'Dialog') return match(message, { [StackMessage.Add]() { containers.current.add(element) setNestedDialogCount((count) => count + 1) }, [StackMessage.Remove]() { containers.current.add(element) setNestedDialogCount((count) => count - 1) }, }) })} > {render({ ourProps, theirProps, slot, defaultTag: DEFAULT_DIALOG_TAG, features: DialogRenderFeatures, visible: dialogState === DialogStates.Open, name: 'Dialog', })} ) }) // --- let DEFAULT_OVERLAY_TAG = 'div' as const interface OverlayRenderPropArg { open: boolean } type OverlayPropsWeControl = 'aria-hidden' | 'onClick' let Overlay = forwardRefWithAs(function Overlay< TTag extends ElementType = typeof DEFAULT_OVERLAY_TAG >(props: Props, ref: Ref) { let internalId = useId() let { id = `headlessui-dialog-overlay-${internalId}`, ...theirProps } = props let [{ dialogState, close }] = useDialogContext('Dialog.Overlay') let overlayRef = useSyncRefs(ref) let handleClick = useEvent((event: ReactMouseEvent) => { if (event.target !== event.currentTarget) return if (isDisabledReactIssue7711(event.currentTarget)) return event.preventDefault() event.preventDefault() event.stopPropagation() close() }) let slot = useMemo( () => ({ open: dialogState === DialogStates.Open }), [dialogState] ) let ourProps = { ref: overlayRef, id, 'aria-hidden': true, onClick: handleClick, } return render({ ourProps, theirProps, slot, defaultTag: DEFAULT_OVERLAY_TAG, name: 'Dialog.Overlay', }) }) // --- let DEFAULT_BACKDROP_TAG = 'div' as const interface BackdropRenderPropArg { open: boolean } type BackdropPropsWeControl = 'aria-hidden' | 'onClick' let Backdrop = forwardRefWithAs(function Backdrop< TTag extends ElementType = typeof DEFAULT_BACKDROP_TAG >(props: Props, ref: Ref) { let internalId = useId() let { id = `headlessui-dialog-backdrop-${internalId}`, ...theirProps } = props let [{ dialogState }, state] = useDialogContext('Dialog.Backdrop') let backdropRef = useSyncRefs(ref) useEffect(() => { if (state.panelRef.current === null) { throw new Error( `A component is being used, but a component is missing.` ) } }, [state.panelRef]) let slot = useMemo( () => ({ open: dialogState === DialogStates.Open }), [dialogState] ) let ourProps = { ref: backdropRef, id, 'aria-hidden': true, } return ( {render({ ourProps, theirProps, slot, defaultTag: DEFAULT_BACKDROP_TAG, name: 'Dialog.Backdrop', })} ) }) // --- let DEFAULT_PANEL_TAG = 'div' as const interface PanelRenderPropArg { open: boolean } let Panel = forwardRefWithAs(function Panel( props: Props, ref: Ref ) { let internalId = useId() let { id = `headlessui-dialog-panel-${internalId}`, ...theirProps } = props let [{ dialogState }, state] = useDialogContext('Dialog.Panel') let panelRef = useSyncRefs(ref, state.panelRef) let slot = useMemo( () => ({ open: dialogState === DialogStates.Open }), [dialogState] ) // Prevent the click events inside the Dialog.Panel from bubbling through the React Tree which // could submit wrapping
elements even if we portalled the Dialog. let handleClick = useEvent((event: ReactMouseEvent) => { event.stopPropagation() }) let ourProps = { ref: panelRef, id, onClick: handleClick, } return render({ ourProps, theirProps, slot, defaultTag: DEFAULT_PANEL_TAG, name: 'Dialog.Panel', }) }) // --- let DEFAULT_TITLE_TAG = 'h2' as const interface TitleRenderPropArg { open: boolean } let Title = forwardRefWithAs(function Title( props: Props, ref: Ref ) { 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 }), [dialogState] ) let ourProps = { ref: titleRef, id } return render({ ourProps, theirProps, slot, defaultTag: DEFAULT_TITLE_TAG, name: 'Dialog.Title', }) }) // --- export let Dialog = Object.assign(DialogRoot, { Backdrop, Panel, Overlay, Title, Description })