Files
headlessui/packages/@headlessui-react/src/components/dialog/dialog.tsx
T
Robin Malfait 962528c216 Improve scroll locking on iOS (#2100)
* improve types for addEventListener inside disposables

* improve scroll locking

Instead of using the "simple" hack with the `position: fixed;` we now
went back to the `touchmove` implementation.

The `position: fixed;` causes some annoying issues. For starters, on iOS
you will now get a strange gap (due to safe areas). Some applications
also saw "blank" screens based on how the page was implemented.

We also saw some issues internally, where clicking changing the scroll
position on the main page from within the Dialog.

Think about something along the lines of:
```html
<a href="#interesting-link-on-the-current-page">Interesting link on the page</a>
```

This doesn't work becauase the page is now fixed, and there is nothing
to scroll...

Instead, we now use the `touchmove` again. The problem with this last
time was that this disabled _all_ touch move events. This is obviously
not good.

Luckily, we already have a concept of "safe containers". This is what we
use for the `outside click` behaviour as well. Basically in a Dialog,
your `Dialog.Panel` is the safe container. But also third party DOM
elements that are rendered inside that Panel (or as a sibling of the
Dialog, but not your main app).

We can re-use this knowledge of "safe containers", and only cancel the
`touchmove` behaviour if this didn't happen in any of the safe
containers.

* update changelog
2022-12-15 16:09:33 +01:00

583 lines
17 KiB
TypeScript

// 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<HTMLDivElement | 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 /> 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(() => window.scrollTo(0, 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<TTag, DialogRenderPropArg, DialogPropsWeControl> &
PropsForFeatures<typeof DialogRenderFeatures> & {
open?: boolean
onClose(value: boolean): void
initialFocus?: MutableRefObject<HTMLElement | null>
__demoMode?: boolean
},
ref: Ref<HTMLDivElement>
) {
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<Set<MutableRefObject<HTMLElement | null>>>(new Set())
let internalDialogRef = useRef<HTMLDivElement | null>(null)
let dialogRef = useSyncRefs(internalDialogRef, ref)
// Reference to a node in the "main" tree, not in the portalled Dialog tree.
let mainTreeNode = useRef<HTMLDivElement | null>(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<ContextType<typeof DialogContext>>(
() => [{ dialogState, close, setTitleId }, state],
[dialogState, state, close, setTitleId]
)
let slot = useMemo<DialogRenderPropArg>(
() => ({ 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 (
<StackProvider
type="Dialog"
enabled={dialogState === DialogStates.Open}
element={internalDialogRef}
onUpdate={useEvent((message, type, element) => {
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)
},
})
})}
>
<ForcePortalRoot force={true}>
<Portal>
<DialogContext.Provider value={contextBag}>
<Portal.Group target={internalDialogRef}>
<ForcePortalRoot force={false}>
<DescriptionProvider slot={slot} name="Dialog.Description">
<FocusTrap
initialFocus={initialFocus}
containers={containers}
features={
enabled
? match(position, {
parent: FocusTrap.features.RestoreFocus,
leaf: FocusTrap.features.All & ~FocusTrap.features.FocusLock,
})
: FocusTrap.features.None
}
>
{render({
ourProps,
theirProps,
slot,
defaultTag: DEFAULT_DIALOG_TAG,
features: DialogRenderFeatures,
visible: dialogState === DialogStates.Open,
name: 'Dialog',
})}
</FocusTrap>
</DescriptionProvider>
</ForcePortalRoot>
</Portal.Group>
</DialogContext.Provider>
</Portal>
</ForcePortalRoot>
<Hidden features={HiddenFeatures.Hidden} ref={mainTreeNode} />
</StackProvider>
)
})
// ---
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<TTag, OverlayRenderPropArg, OverlayPropsWeControl>, ref: Ref<HTMLDivElement>) {
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<OverlayRenderPropArg>(
() => ({ 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<TTag, BackdropRenderPropArg, BackdropPropsWeControl>, ref: Ref<HTMLDivElement>) {
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 <Dialog.Backdrop /> component is being used, but a <Dialog.Panel /> component is missing.`
)
}
}, [state.panelRef])
let slot = useMemo<BackdropRenderPropArg>(
() => ({ open: dialogState === DialogStates.Open }),
[dialogState]
)
let ourProps = {
ref: backdropRef,
id,
'aria-hidden': true,
}
return (
<ForcePortalRoot force>
<Portal>
{render({
ourProps,
theirProps,
slot,
defaultTag: DEFAULT_BACKDROP_TAG,
name: 'Dialog.Backdrop',
})}
</Portal>
</ForcePortalRoot>
)
})
// ---
let DEFAULT_PANEL_TAG = 'div' as const
interface PanelRenderPropArg {
open: boolean
}
let Panel = forwardRefWithAs(function Panel<TTag extends ElementType = typeof DEFAULT_PANEL_TAG>(
props: Props<TTag, PanelRenderPropArg>,
ref: Ref<HTMLDivElement>
) {
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<PanelRenderPropArg>(
() => ({ open: dialogState === DialogStates.Open }),
[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,
}
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<TTag extends ElementType = typeof DEFAULT_TITLE_TAG>(
props: Props<TTag, TitleRenderPropArg>,
ref: Ref<HTMLHeadingElement>
) {
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<TitleRenderPropArg>(
() => ({ 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 })