Add transition prop to DialogPanel and DialogBackdrop components (#3309)

* add internal `ResetOpenClosedProvider`

This will allow us to reset the `OpenClosedProvider` and reset the
"boundary". This is important when we want to wrap a `Dialog` inside of
a `Transition` that exists in another component that is wrapped in a
transition itself.

This will be used in let's say a `DisclosurePanel`:

```tsx
<Disclosure>              // OpenClosedProvider
  <Transition>
    <DisclosurePanel>     // ResetOpenClosedProvider
      <Dialog />          // Can safely wrap `<Dialog />` in `<Transition />`
    </DisclosurePanel>
  </Transition>
</Disclosure>
```

* use `ResetOpenClosedProvider` in `PopoverPanel` and `DisclosurePanel`

* add `transition` prop to `<Transition>` component

This prop allows us to enabled / disable the `Transition` functionality.
E.g.: expose the underlying data attributes.

But it will still setup a `Transition` boundary for coordinating the
`TransitionChild` components.

* always wrap `Dialog` in a `Transition` component

+ add `transition` props to the `Dialog`, `DialogPanel` and `DialogBackdrop`

This will allow us individually control the transition on each element,
but also setup the transition boundary on the `Dialog` for coordination
purposes.

* improve dialog playground example

* update built in transition playground example to use individual transition props

* speedup example transitions

* Add validations to DialogFn

This technically means most or all of them can be removed from InternalDialog but we can do that later

* Pass `unmount={false}` from the Dialog to the wrapping transition

* Only wrap Dialog in a Transition if it’s not `static`

I’m not 100% sure this is right but it seems like it might be given that `static` implies it’s always rendered.

* remove validations from `InternalDialog`

Already validated by `Dialog` itself

* use existing `usesOpenClosedState`

* reword comment

* remove flawed test

The reason this test is flawed and why it's safe to delete it:

This test opened the dialog, then clicked on an element outside of the
dialog to close it and prove that we correctly focused that new element
instead of going to the button that opened the dialog in the first
place.

This test used to work before marked the rest of the page as `inert`.
Right now we mark the rest of the page as `inert`, so running this in a
real browser means that we can't click or focus an element outside of
the `dialog` simply because the rest of the page is inert.

The reason it fails all of a sudden is that the introduction of
`<Transition>` around the `<Dialog>` by default purely delays the
mounting just enough to record different elements to try and restore
focus to.

That said, this test clicked outside of a dialog and focused that
element which can't work in a real browser because the element can't be
interacted with at all.

* update changelog

---------

Co-authored-by: Jordan Pittman <jordan@cryptica.me>
This commit is contained in:
Robin Malfait
2024-06-21 00:44:12 +02:00
committed by GitHub
parent 7a40af6b55
commit 07ba551e63
9 changed files with 213 additions and 197 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Add ability to render multiple `Dialog` components at once (without nesting them) ([#3242](https://github.com/tailwindlabs/headlessui/pull/3242))
- Add CSS based transitions using `data-*` attributes ([#3273](https://github.com/tailwindlabs/headlessui/pull/3273), [#3285](https://github.com/tailwindlabs/headlessui/pull/3285))
- Add `transition` prop to `Dialog` component ([#3307](https://github.com/tailwindlabs/headlessui/pull/3307))
- Add `transition` prop to `Dialog`, `DialogBackdrop` and `DialogPanel` components ([#3307](https://github.com/tailwindlabs/headlessui/pull/3307), [#3309](https://github.com/tailwindlabs/headlessui/pull/3309))
- Add `DialogBackdrop` component ([#3307](https://github.com/tailwindlabs/headlessui/pull/3307))
- Add `PopoverBackdrop` component to replace `PopoverOverlay` ([#3308](https://github.com/tailwindlabs/headlessui/pull/3308))
@@ -1019,41 +1019,6 @@ describe('Mouse interactions', () => {
})
)
it(
'should be possible to close the dialog, and keep focus on the focusable element',
suppressConsoleLogs(async () => {
function Example() {
let [isOpen, setIsOpen] = useState(false)
return (
<>
<button>Hello</button>
<button onClick={() => setIsOpen((v) => !v)}>Trigger</button>
<Dialog autoFocus={false} open={isOpen} onClose={setIsOpen}>
Contents
<TabSentinel />
</Dialog>
</>
)
}
render(<Example />)
// Open dialog
await click(getByText('Trigger'))
// Verify it is open
assertDialog({ state: DialogState.Visible })
// Click the button to close (outside click)
await click(getByText('Hello'))
// Verify it is closed
assertDialog({ state: DialogState.InvisibleUnmounted })
// Verify the button is focused
assertActiveElement(getByText('Hello'))
})
)
it(
'should be possible to submit a form inside a Dialog',
suppressConsoleLogs(async () => {
@@ -31,7 +31,7 @@ import { useServerHandoffComplete } from '../../hooks/use-server-handoff-complet
import { useSyncRefs } from '../../hooks/use-sync-refs'
import { CloseProvider } from '../../internal/close-provider'
import { HoistFormFields } from '../../internal/form-fields'
import { State, useOpenClosed } from '../../internal/open-closed'
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'
@@ -52,8 +52,6 @@ import { FocusTrap, FocusTrapFeatures } from '../focus-trap/focus-trap'
import { Portal, PortalGroup, useNestedPortals } from '../portal/portal'
import { Transition, TransitionChild } from '../transition/transition'
let WithTransitionWrapper = createContext(false)
enum DialogStates {
Open,
Closed,
@@ -111,33 +109,9 @@ function stateReducer(state: StateDefinition, action: Actions) {
// ---
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
__demoMode?: boolean
transition?: boolean
}
>
function DialogFn<TTag extends ElementType = typeof DEFAULT_DIALOG_TAG>(
props: DialogProps<TTag>,
ref: Ref<HTMLElement>
) {
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}`,
@@ -146,7 +120,6 @@ function DialogFn<TTag extends ElementType = typeof DEFAULT_DIALOG_TAG>(
initialFocus,
role = 'dialog',
autoFocus = true,
transition = false,
__demoMode = false,
...theirProps
} = props
@@ -179,39 +152,6 @@ function DialogFn<TTag extends ElementType = typeof DEFAULT_DIALOG_TAG>(
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, {
@@ -343,19 +283,8 @@ function DialogFn<TTag extends ElementType = typeof DEFAULT_DIALOG_TAG>(
}
}
if (transition) {
let { transition: _transition, open, ...rest } = props
return (
<WithTransitionWrapper.Provider value={true}>
<Transition show={open}>
<Dialog ref={ref} {...rest} />
</Transition>
</WithTransitionWrapper.Provider>
)
}
return (
<>
<ResetOpenClosedProvider>
<ForcePortalRoot force={true}>
<Portal>
<DialogContext.Provider value={contextBag}>
@@ -391,8 +320,86 @@ function DialogFn<TTag extends ElementType = typeof DEFAULT_DIALOG_TAG>(
<HoistFormFields>
<MainTreeNode />
</HoistFormFields>
</>
</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}`
)
}
let inTransitionComponent = usesOpenClosedState !== null
if (!inTransitionComponent && open !== undefined && !rest.static) {
return (
<Transition show={open} transition={transition} unmount={rest.unmount}>
<InternalDialog ref={ref} {...rest} />
</Transition>
)
}
return <InternalDialog ref={ref} open={open} {...rest} />
}
// ---
@@ -404,7 +411,9 @@ type PanelRenderPropArg = {
export type DialogPanelProps<TTag extends ElementType = typeof DEFAULT_PANEL_TAG> = Props<
TTag,
PanelRenderPropArg
PanelRenderPropArg,
never,
{ transition?: boolean }
>
function PanelFn<TTag extends ElementType = typeof DEFAULT_PANEL_TAG>(
@@ -412,7 +421,7 @@ function PanelFn<TTag extends ElementType = typeof DEFAULT_PANEL_TAG>(
ref: Ref<HTMLElement>
) {
let internalId = useId()
let { id = `headlessui-dialog-panel-${internalId}`, ...theirProps } = props
let { id = `headlessui-dialog-panel-${internalId}`, transition = false, ...theirProps } = props
let [{ dialogState }, state] = useDialogContext('Dialog.Panel')
let panelRef = useSyncRefs(ref, state.panelRef)
@@ -433,20 +442,18 @@ function PanelFn<TTag extends ElementType = typeof DEFAULT_PANEL_TAG>(
onClick: handleClick,
}
let Wrapper = useContext(WithTransitionWrapper) ? TransitionChild : Fragment
let Wrapper = transition ? TransitionChild : Fragment
return (
<WithTransitionWrapper.Provider value={false}>
<Wrapper>
{render({
ourProps,
theirProps,
slot,
defaultTag: DEFAULT_PANEL_TAG,
name: 'Dialog.Panel',
})}
</Wrapper>
</WithTransitionWrapper.Provider>
<Wrapper>
{render({
ourProps,
theirProps,
slot,
defaultTag: DEFAULT_PANEL_TAG,
name: 'Dialog.Panel',
})}
</Wrapper>
)
}
@@ -459,14 +466,16 @@ type BackdropRenderPropArg = {
export type DialogBackdropProps<TTag extends ElementType = typeof DEFAULT_BACKDROP_TAG> = Props<
TTag,
BackdropRenderPropArg
BackdropRenderPropArg,
never,
{ transition?: boolean }
>
function BackdropFn<TTag extends ElementType = typeof DEFAULT_BACKDROP_TAG>(
props: DialogBackdropProps<TTag>,
ref: Ref<HTMLElement>
) {
let theirProps = props
let { transition = false, ...theirProps } = props
let [{ dialogState }] = useDialogContext('Dialog.Backdrop')
let slot = useMemo(
@@ -476,7 +485,7 @@ function BackdropFn<TTag extends ElementType = typeof DEFAULT_BACKDROP_TAG>(
let ourProps = { ref }
let Wrapper = useContext(WithTransitionWrapper) ? TransitionChild : Fragment
let Wrapper = transition ? TransitionChild : Fragment
return (
<Wrapper>
@@ -26,7 +26,12 @@ import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
import { optionalRef, useSyncRefs } from '../../hooks/use-sync-refs'
import { useTransition, type TransitionData } from '../../hooks/use-transition'
import { CloseProvider } from '../../internal/close-provider'
import { OpenClosedProvider, State, useOpenClosed } from '../../internal/open-closed'
import {
OpenClosedProvider,
ResetOpenClosedProvider,
State,
useOpenClosed,
} from '../../internal/open-closed'
import type { Props } from '../../types'
import { isDisabledReactIssue7711 } from '../../utils/bugs'
import { match } from '../../utils/match'
@@ -480,18 +485,20 @@ function PanelFn<TTag extends ElementType = typeof DEFAULT_PANEL_TAG>(
}
return (
<DisclosurePanelContext.Provider value={state.panelId}>
{render({
mergeRefs,
ourProps,
theirProps,
slot,
defaultTag: DEFAULT_PANEL_TAG,
features: PanelRenderFeatures,
visible,
name: 'Disclosure.Panel',
})}
</DisclosurePanelContext.Provider>
<ResetOpenClosedProvider>
<DisclosurePanelContext.Provider value={state.panelId}>
{render({
mergeRefs,
ourProps,
theirProps,
slot,
defaultTag: DEFAULT_PANEL_TAG,
features: PanelRenderFeatures,
visible,
name: 'Disclosure.Panel',
})}
</DisclosurePanelContext.Provider>
</ResetOpenClosedProvider>
)
}
@@ -47,7 +47,12 @@ import {
type AnchorProps,
} from '../../internal/floating'
import { Hidden, HiddenFeatures } from '../../internal/hidden'
import { OpenClosedProvider, State, useOpenClosed } from '../../internal/open-closed'
import {
OpenClosedProvider,
ResetOpenClosedProvider,
State,
useOpenClosed,
} from '../../internal/open-closed'
import type { Props } from '../../types'
import { isDisabledReactIssue7711 } from '../../utils/bugs'
import {
@@ -1043,44 +1048,46 @@ function PanelFn<TTag extends ElementType = typeof DEFAULT_PANEL_TAG>(
})
return (
<PopoverPanelContext.Provider value={id}>
<PopoverAPIContext.Provider value={{ close, isPortalled }}>
<Portal enabled={portal ? props.static || visible : false}>
{visible && isPortalled && (
<Hidden
id={beforePanelSentinelId}
ref={state.beforePanelSentinel}
features={HiddenFeatures.Focusable}
data-headlessui-focus-guard
as="button"
type="button"
onFocus={handleBeforeFocus}
/>
)}
{render({
mergeRefs,
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}
/>
)}
</Portal>
</PopoverAPIContext.Provider>
</PopoverPanelContext.Provider>
<ResetOpenClosedProvider>
<PopoverPanelContext.Provider value={id}>
<PopoverAPIContext.Provider value={{ close, isPortalled }}>
<Portal enabled={portal ? props.static || visible : false}>
{visible && isPortalled && (
<Hidden
id={beforePanelSentinelId}
ref={state.beforePanelSentinel}
features={HiddenFeatures.Focusable}
data-headlessui-focus-guard
as="button"
type="button"
onFocus={handleBeforeFocus}
/>
)}
{render({
mergeRefs,
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}
/>
)}
</Portal>
</PopoverAPIContext.Provider>
</PopoverPanelContext.Provider>
</ResetOpenClosedProvider>
)
}
@@ -119,7 +119,7 @@ export type TransitionChildProps<TTag extends ReactTag> = Props<
TransitionChildPropsWeControl,
PropsForFeatures<typeof TransitionChildRenderFeatures> &
TransitionClasses &
TransitionEvents & { appear?: boolean }
TransitionEvents & { transition?: boolean; appear?: boolean }
>
function useTransitionContext() {
@@ -298,6 +298,11 @@ function TransitionChildFn<TTag extends ElementType = typeof DEFAULT_TRANSITION_
ref: Ref<HTMLElement>
) {
let {
// Wether or not to enable transitions on the current element (by exposing
// transition data). When set to false, the `Transition` component still
// acts as a transition boundary for `TransitionChild` components.
transition = true,
// Event "handlers"
beforeEnter,
afterEnter,
@@ -402,15 +407,19 @@ function TransitionChildFn<TTag extends ElementType = typeof DEFAULT_TRANSITION_
})
useEffect(() => {
if (requiresRef) return
if (requiresRef && transition) return
// When we don't transition, then we can complete the transition
// immediately.
start(show)
end(show)
}, [show, requiresRef])
}, [show, requiresRef, transition])
let enabled = (() => {
// Should the current component transition? If not, then we can still
// orchestrate the child transitions.
if (!transition) return false
// If we don't require a ref, then we can't transition.
if (!requiresRef) return false
@@ -22,3 +22,7 @@ interface Props {
export function OpenClosedProvider({ value, children }: Props): ReactElement {
return <Context.Provider value={value}>{children}</Context.Provider>
}
export function ResetOpenClosedProvider({ children }: { children: React.ReactNode }): ReactElement {
return <Context.Provider value={null}>{children}</Context.Provider>
}
@@ -5,18 +5,28 @@ import { classNames } from '../../utils/class-names'
export default function Home() {
let [isOpen, setIsOpen] = useState(false)
let [transition, setTransition] = useState(true)
let [transitionBackdrop, setTransitionBackdrop] = useState(true)
let [transitionPanel, setTransitionPanel] = useState(true)
return (
<>
<div className="flex gap-4 p-12">
<Button onClick={() => setIsOpen((v) => !v)}>Toggle!</Button>
<Button onClick={() => setTransition((v) => !v)}>
<span>Toggle transition</span>
<Button onClick={() => setTransitionBackdrop((v) => !v)}>
<span>Toggle transition backdrop</span>
<span
className={classNames(
'ml-2 inline-flex size-4 rounded-md',
transition ? 'bg-green-500' : 'bg-red-500'
transitionBackdrop ? 'bg-green-500' : 'bg-red-500'
)}
></span>
</Button>
<Button onClick={() => setTransitionPanel((v) => !v)}>
<span>Toggle transition panel</span>
<span
className={classNames(
'ml-2 inline-flex size-4 rounded-md',
transitionPanel ? 'bg-green-500' : 'bg-red-500'
)}
></span>
</Button>
@@ -24,13 +34,18 @@ export default function Home() {
<Dialog
open={isOpen}
transition={transition}
onClose={() => setIsOpen(false)}
className="relative z-50"
className="relative z-50 duration-500 data-[closed]:opacity-0"
>
<DialogBackdrop className="fixed inset-0 bg-black/30 duration-500 ease-out data-[closed]:opacity-0" />
<DialogBackdrop
transition={transitionBackdrop}
className="fixed inset-0 bg-black/30 duration-500 ease-out data-[closed]:opacity-0"
/>
<div className="fixed inset-0 flex w-screen items-center justify-center p-4">
<DialogPanel className="w-full max-w-lg space-y-4 bg-white p-12 duration-500 ease-out data-[closed]:scale-95 data-[closed]:opacity-0">
<DialogPanel
transition={transitionPanel}
className="w-full max-w-lg space-y-4 bg-white p-12 duration-500 ease-out data-[closed]:scale-95 data-[closed]:opacity-0"
>
<h1 className="text-2xl font-bold">Dialog</h1>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed pulvinar, nunc nec
+4 -4
View File
@@ -80,17 +80,17 @@ export default function Home() {
<Transition.Child
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-75"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-75"
leaveFrom="opacity-100"
leaveTo="opacity-0"
entered="opacity-75"
entered="opacity-100"
beforeEnter={() => console.log('[Transition.Child] [Overlay] Before enter')}
afterEnter={() => console.log('[Transition.Child] [Overlay] After enter')}
beforeLeave={() => console.log('[Transition.Child] [Overlay] Before leave')}
afterLeave={() => console.log('[Transition.Child] [Overlay] After leave')}
>
<div className="fixed inset-0 bg-gray-500 transition-opacity" />
<div className="fixed inset-0 bg-gray-500/75 transition-opacity" />
</Transition.Child>
<Transition.Child