Files
headlessui/packages/@headlessui-react/src/components/dialog/dialog.tsx
T
Robin Malfait c13e6b7752 Improve dialog and SSR (#477)
* delay initialization of Dialog

We were using a useLayoutEffect, now let's use a useEffect instead. It
still moves focus to the correct element, but that process is now a bit
delayed. This means that users will less-likely be urged to "hack"
around the issue by using fake focusable elements which will result in
worse accessibility.

* add hook to deal with server handoff

This will allow us to delay certain features. For example we can delay
the focus trapping until it is fully hydrated. We can also delay
rendering the Portal to ensure hydration works correctly.

* use server handoff complete hook

* update changelog
2021-05-04 12:18:53 +02:00

399 lines
11 KiB
TypeScript

// WAI-ARIA: https://www.w3.org/TR/wai-aria-practices-1.2/#dialog_modal
import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useReducer,
useRef,
// Types
ContextType,
ElementType,
MouseEvent as ReactMouseEvent,
KeyboardEvent as ReactKeyboardEvent,
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 { useFocusTrap } from '../../hooks/use-focus-trap'
import { useInertOthers } from '../../hooks/use-inert-others'
import { Portal } from '../../components/portal/portal'
import { StackProvider, StackMessage } from '../../internal/stack-context'
import { ForcePortalRoot } from '../../internal/portal-force-root'
import { contains } from '../../internal/dom-containers'
import { Description, useDescriptions } from '../description/description'
import { useWindowEvent } from '../../hooks/use-window-event'
import { useOpenClosed, State } from '../../internal/open-closed'
import { useServerHandoffComplete } from '../../hooks/use-server-handoff-complete'
enum DialogStates {
Open,
Closed,
}
interface StateDefinition {
titleId: string | 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
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.displayName} /> 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 DEFAULT_DIALOG_TAG = 'div' as const
interface DialogRenderPropArg {
open: boolean
}
type DialogPropsWeControl =
| 'id'
| 'role'
| 'aria-modal'
| 'aria-describedby'
| 'aria-labelledby'
| 'onClick'
let DialogRenderFeatures = Features.RenderStrategy | Features.Static
let DialogRoot = forwardRefWithAs(function Dialog<
TTag extends ElementType = typeof DEFAULT_DIALOG_TAG
>(
props: Props<TTag, DialogRenderPropArg, DialogPropsWeControl> &
PropsForFeatures<typeof DialogRenderFeatures> & {
open?: boolean
onClose(value: boolean): void
initialFocus?: MutableRefObject<HTMLElement | null>
},
ref: Ref<HTMLDivElement>
) {
let { open, onClose, initialFocus, ...rest } = props
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<Set<HTMLElement>>(new Set())
let internalDialogRef = useRef<HTMLDivElement | null>(null)
let dialogRef = useSyncRefs(internalDialogRef, ref)
// 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 visible = (() => {
if (usesOpenClosedState !== null) {
return usesOpenClosedState === State.Open
}
return dialogState === DialogStates.Open
})()
let [state, dispatch] = useReducer(stateReducer, {
titleId: null,
descriptionId: null,
} as StateDefinition)
let close = useCallback(() => onClose(false), [onClose])
let setTitleId = useCallback(
(id: string | null) => dispatch({ type: ActionTypes.SetTitleId, id }),
[dispatch]
)
// Handle outside click
useWindowEvent('mousedown', event => {
let target = event.target as HTMLElement
if (dialogState !== DialogStates.Open) return
if (containers.current.size !== 1) return
if (contains(containers.current, target)) return
close()
})
// Scroll lock
useEffect(() => {
if (dialogState !== DialogStates.Open) return
let overflow = document.documentElement.style.overflow
let paddingRight = document.documentElement.style.paddingRight
let scrollbarWidth = window.innerWidth - document.documentElement.clientWidth
document.documentElement.style.overflow = 'hidden'
document.documentElement.style.paddingRight = `${scrollbarWidth}px`
return () => {
document.documentElement.style.overflow = overflow
document.documentElement.style.paddingRight = paddingRight
}
}, [dialogState])
// 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 ready = useServerHandoffComplete()
let enabled = ready && dialogState === DialogStates.Open
useFocusTrap(containers, enabled, { initialFocus })
useInertOthers(internalDialogRef, enabled)
let [describedby, DescriptionProvider] = useDescriptions()
let id = `headlessui-dialog-${useId()}`
let contextBag = useMemo<ContextType<typeof DialogContext>>(
() => [{ dialogState, close, setTitleId }, state],
[dialogState, state, close, setTitleId]
)
let slot = useMemo<DialogRenderPropArg>(() => ({ open: dialogState === DialogStates.Open }), [
dialogState,
])
let propsWeControl = {
ref: dialogRef,
id,
role: 'dialog',
'aria-modal': dialogState === DialogStates.Open ? true : undefined,
'aria-labelledby': state.titleId,
'aria-describedby': describedby,
onClick(event: ReactMouseEvent) {
event.stopPropagation()
},
// Handle `Escape` to close
onKeyDown(event: ReactKeyboardEvent) {
if (event.key !== Keys.Escape) return
if (dialogState !== DialogStates.Open) return
if (containers.current.size > 1) return // 1 is myself, otherwise other elements in the Stack
event.preventDefault()
event.stopPropagation()
close()
},
}
let passthroughProps = rest
return (
<StackProvider
onUpdate={(message, element) => {
return match(message, {
[StackMessage.AddElement]() {
containers.current.add(element)
},
[StackMessage.RemoveElement]() {
containers.current.delete(element)
},
})
}}
>
<ForcePortalRoot force={true}>
<Portal>
<DialogContext.Provider value={contextBag}>
<Portal.Group target={internalDialogRef}>
<ForcePortalRoot force={false}>
<DescriptionProvider slot={slot} name="Dialog.Description">
{render({
props: { ...passthroughProps, ...propsWeControl },
slot,
defaultTag: DEFAULT_DIALOG_TAG,
features: DialogRenderFeatures,
visible,
name: 'Dialog',
})}
</DescriptionProvider>
</ForcePortalRoot>
</Portal.Group>
</DialogContext.Provider>
</Portal>
</ForcePortalRoot>
</StackProvider>
)
})
// ---
let DEFAULT_OVERLAY_TAG = 'div' as const
interface OverlayRenderPropArg {
open: boolean
}
type OverlayPropsWeControl = 'id' | 'aria-hidden' | 'onClick'
let Overlay = forwardRefWithAs(function Overlay<
TTag extends ElementType = typeof DEFAULT_OVERLAY_TAG
>(props: Props<TTag, OverlayRenderPropArg, OverlayPropsWeControl>, ref: Ref<HTMLDivElement>) {
let [{ dialogState, close }] = useDialogContext([Dialog.displayName, Overlay.name].join('.'))
let overlayRef = useSyncRefs(ref)
let id = `headlessui-dialog-overlay-${useId()}`
let handleClick = useCallback(
(event: ReactMouseEvent) => {
if (isDisabledReactIssue7711(event.currentTarget)) return event.preventDefault()
event.preventDefault()
event.stopPropagation()
close()
},
[close]
)
let slot = useMemo<OverlayRenderPropArg>(() => ({ open: dialogState === DialogStates.Open }), [
dialogState,
])
let propsWeControl = {
ref: overlayRef,
id,
'aria-hidden': true,
onClick: handleClick,
}
let passthroughProps = props
return render({
props: { ...passthroughProps, ...propsWeControl },
slot,
defaultTag: DEFAULT_OVERLAY_TAG,
name: 'Dialog.Overlay',
})
})
// ---
let DEFAULT_TITLE_TAG = 'h2' as const
interface TitleRenderPropArg {
open: boolean
}
type TitlePropsWeControl = 'id'
function Title<TTag extends ElementType = typeof DEFAULT_TITLE_TAG>(
props: Props<TTag, TitleRenderPropArg, TitlePropsWeControl>
) {
let [{ dialogState, setTitleId }] = useDialogContext([Dialog.displayName, Title.name].join('.'))
let id = `headlessui-dialog-title-${useId()}`
useEffect(() => {
setTitleId(id)
return () => setTitleId(null)
}, [id, setTitleId])
let slot = useMemo<TitleRenderPropArg>(() => ({ open: dialogState === DialogStates.Open }), [
dialogState,
])
let propsWeControl = { id }
let passthroughProps = props
return render({
props: { ...passthroughProps, ...propsWeControl },
slot,
defaultTag: DEFAULT_TITLE_TAG,
name: 'Dialog.Title',
})
}
// ---
export let Dialog = Object.assign(DialogRoot, { Overlay, Title, Description })