Skip to content

Commit 07ba551

Browse files
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 <[email protected]>
1 parent 7a40af6 commit 07ba551

File tree

9 files changed

+213
-197
lines changed

9 files changed

+213
-197
lines changed

packages/@headlessui-react/CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

1212
- Add ability to render multiple `Dialog` components at once (without nesting them) ([#3242](https://github.com/tailwindlabs/headlessui/pull/3242))
1313
- Add CSS based transitions using `data-*` attributes ([#3273](https://github.com/tailwindlabs/headlessui/pull/3273), [#3285](https://github.com/tailwindlabs/headlessui/pull/3285))
14-
- Add `transition` prop to `Dialog` component ([#3307](https://github.com/tailwindlabs/headlessui/pull/3307))
14+
- 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))
1515
- Add `DialogBackdrop` component ([#3307](https://github.com/tailwindlabs/headlessui/pull/3307))
1616
- Add `PopoverBackdrop` component to replace `PopoverOverlay` ([#3308](https://github.com/tailwindlabs/headlessui/pull/3308))
1717

packages/@headlessui-react/src/components/dialog/dialog.test.tsx

Lines changed: 0 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1019,41 +1019,6 @@ describe('Mouse interactions', () => {
10191019
})
10201020
)
10211021

1022-
it(
1023-
'should be possible to close the dialog, and keep focus on the focusable element',
1024-
suppressConsoleLogs(async () => {
1025-
function Example() {
1026-
let [isOpen, setIsOpen] = useState(false)
1027-
return (
1028-
<>
1029-
<button>Hello</button>
1030-
<button onClick={() => setIsOpen((v) => !v)}>Trigger</button>
1031-
<Dialog autoFocus={false} open={isOpen} onClose={setIsOpen}>
1032-
Contents
1033-
<TabSentinel />
1034-
</Dialog>
1035-
</>
1036-
)
1037-
}
1038-
render(<Example />)
1039-
1040-
// Open dialog
1041-
await click(getByText('Trigger'))
1042-
1043-
// Verify it is open
1044-
assertDialog({ state: DialogState.Visible })
1045-
1046-
// Click the button to close (outside click)
1047-
await click(getByText('Hello'))
1048-
1049-
// Verify it is closed
1050-
assertDialog({ state: DialogState.InvisibleUnmounted })
1051-
1052-
// Verify the button is focused
1053-
assertActiveElement(getByText('Hello'))
1054-
})
1055-
)
1056-
10571022
it(
10581023
'should be possible to submit a form inside a Dialog',
10591024
suppressConsoleLogs(async () => {

packages/@headlessui-react/src/components/dialog/dialog.tsx

Lines changed: 103 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import { useServerHandoffComplete } from '../../hooks/use-server-handoff-complet
3131
import { useSyncRefs } from '../../hooks/use-sync-refs'
3232
import { CloseProvider } from '../../internal/close-provider'
3333
import { HoistFormFields } from '../../internal/form-fields'
34-
import { State, useOpenClosed } from '../../internal/open-closed'
34+
import { ResetOpenClosedProvider, State, useOpenClosed } from '../../internal/open-closed'
3535
import { ForcePortalRoot } from '../../internal/portal-force-root'
3636
import type { Props } from '../../types'
3737
import { match } from '../../utils/match'
@@ -52,8 +52,6 @@ import { FocusTrap, FocusTrapFeatures } from '../focus-trap/focus-trap'
5252
import { Portal, PortalGroup, useNestedPortals } from '../portal/portal'
5353
import { Transition, TransitionChild } from '../transition/transition'
5454

55-
let WithTransitionWrapper = createContext(false)
56-
5755
enum DialogStates {
5856
Open,
5957
Closed,
@@ -111,33 +109,9 @@ function stateReducer(state: StateDefinition, action: Actions) {
111109

112110
// ---
113111

114-
let DEFAULT_DIALOG_TAG = 'div' as const
115-
type DialogRenderPropArg = {
116-
open: boolean
117-
}
118-
type DialogPropsWeControl = 'aria-describedby' | 'aria-labelledby' | 'aria-modal'
119-
120-
let DialogRenderFeatures = RenderFeatures.RenderStrategy | RenderFeatures.Static
121-
122-
export type DialogProps<TTag extends ElementType = typeof DEFAULT_DIALOG_TAG> = Props<
123-
TTag,
124-
DialogRenderPropArg,
125-
DialogPropsWeControl,
126-
PropsForFeatures<typeof DialogRenderFeatures> & {
127-
open?: boolean
128-
onClose(value: boolean): void
129-
initialFocus?: MutableRefObject<HTMLElement | null>
130-
role?: 'dialog' | 'alertdialog'
131-
autoFocus?: boolean
132-
__demoMode?: boolean
133-
transition?: boolean
134-
}
135-
>
136-
137-
function DialogFn<TTag extends ElementType = typeof DEFAULT_DIALOG_TAG>(
138-
props: DialogProps<TTag>,
139-
ref: Ref<HTMLElement>
140-
) {
112+
let InternalDialog = forwardRefWithAs(function InternalDialog<
113+
TTag extends ElementType = typeof DEFAULT_DIALOG_TAG,
114+
>(props: DialogProps<TTag>, ref: Ref<HTMLElement>) {
141115
let internalId = useId()
142116
let {
143117
id = `headlessui-dialog-${internalId}`,
@@ -146,7 +120,6 @@ function DialogFn<TTag extends ElementType = typeof DEFAULT_DIALOG_TAG>(
146120
initialFocus,
147121
role = 'dialog',
148122
autoFocus = true,
149-
transition = false,
150123
__demoMode = false,
151124
...theirProps
152125
} = props
@@ -179,39 +152,6 @@ function DialogFn<TTag extends ElementType = typeof DEFAULT_DIALOG_TAG>(
179152

180153
let ownerDocument = useOwnerDocument(internalDialogRef)
181154

182-
// Validations
183-
let hasOpen = props.hasOwnProperty('open') || usesOpenClosedState !== null
184-
let hasOnClose = props.hasOwnProperty('onClose')
185-
if (!hasOpen && !hasOnClose) {
186-
throw new Error(
187-
`You have to provide an \`open\` and an \`onClose\` prop to the \`Dialog\` component.`
188-
)
189-
}
190-
191-
if (!hasOpen) {
192-
throw new Error(
193-
`You provided an \`onClose\` prop to the \`Dialog\`, but forgot an \`open\` prop.`
194-
)
195-
}
196-
197-
if (!hasOnClose) {
198-
throw new Error(
199-
`You provided an \`open\` prop to the \`Dialog\`, but forgot an \`onClose\` prop.`
200-
)
201-
}
202-
203-
if (typeof open !== 'boolean') {
204-
throw new Error(
205-
`You provided an \`open\` prop to the \`Dialog\`, but the value is not a boolean. Received: ${open}`
206-
)
207-
}
208-
209-
if (typeof onClose !== 'function') {
210-
throw new Error(
211-
`You provided an \`onClose\` prop to the \`Dialog\`, but the value is not a function. Received: ${onClose}`
212-
)
213-
}
214-
215155
let dialogState = open ? DialogStates.Open : DialogStates.Closed
216156

217157
let [state, dispatch] = useReducer(stateReducer, {
@@ -343,19 +283,8 @@ function DialogFn<TTag extends ElementType = typeof DEFAULT_DIALOG_TAG>(
343283
}
344284
}
345285

346-
if (transition) {
347-
let { transition: _transition, open, ...rest } = props
348-
return (
349-
<WithTransitionWrapper.Provider value={true}>
350-
<Transition show={open}>
351-
<Dialog ref={ref} {...rest} />
352-
</Transition>
353-
</WithTransitionWrapper.Provider>
354-
)
355-
}
356-
357286
return (
358-
<>
287+
<ResetOpenClosedProvider>
359288
<ForcePortalRoot force={true}>
360289
<Portal>
361290
<DialogContext.Provider value={contextBag}>
@@ -391,8 +320,86 @@ function DialogFn<TTag extends ElementType = typeof DEFAULT_DIALOG_TAG>(
391320
<HoistFormFields>
392321
<MainTreeNode />
393322
</HoistFormFields>
394-
</>
323+
</ResetOpenClosedProvider>
395324
)
325+
})
326+
327+
// ---
328+
329+
let DEFAULT_DIALOG_TAG = 'div' as const
330+
type DialogRenderPropArg = {
331+
open: boolean
332+
}
333+
type DialogPropsWeControl = 'aria-describedby' | 'aria-labelledby' | 'aria-modal'
334+
335+
let DialogRenderFeatures = RenderFeatures.RenderStrategy | RenderFeatures.Static
336+
337+
export type DialogProps<TTag extends ElementType = typeof DEFAULT_DIALOG_TAG> = Props<
338+
TTag,
339+
DialogRenderPropArg,
340+
DialogPropsWeControl,
341+
PropsForFeatures<typeof DialogRenderFeatures> & {
342+
open?: boolean
343+
onClose(value: boolean): void
344+
initialFocus?: MutableRefObject<HTMLElement | null>
345+
role?: 'dialog' | 'alertdialog'
346+
autoFocus?: boolean
347+
transition?: boolean
348+
__demoMode?: boolean
349+
}
350+
>
351+
352+
function DialogFn<TTag extends ElementType = typeof DEFAULT_DIALOG_TAG>(
353+
props: DialogProps<TTag>,
354+
ref: Ref<HTMLElement>
355+
) {
356+
let { transition = false, open, ...rest } = props
357+
358+
// Validations
359+
let usesOpenClosedState = useOpenClosed()
360+
let hasOpen = props.hasOwnProperty('open') || usesOpenClosedState !== null
361+
let hasOnClose = props.hasOwnProperty('onClose')
362+
363+
if (!hasOpen && !hasOnClose) {
364+
throw new Error(
365+
`You have to provide an \`open\` and an \`onClose\` prop to the \`Dialog\` component.`
366+
)
367+
}
368+
369+
if (!hasOpen) {
370+
throw new Error(
371+
`You provided an \`onClose\` prop to the \`Dialog\`, but forgot an \`open\` prop.`
372+
)
373+
}
374+
375+
if (!hasOnClose) {
376+
throw new Error(
377+
`You provided an \`open\` prop to the \`Dialog\`, but forgot an \`onClose\` prop.`
378+
)
379+
}
380+
381+
if (!usesOpenClosedState && typeof props.open !== 'boolean') {
382+
throw new Error(
383+
`You provided an \`open\` prop to the \`Dialog\`, but the value is not a boolean. Received: ${props.open}`
384+
)
385+
}
386+
387+
if (typeof props.onClose !== 'function') {
388+
throw new Error(
389+
`You provided an \`onClose\` prop to the \`Dialog\`, but the value is not a function. Received: ${props.onClose}`
390+
)
391+
}
392+
393+
let inTransitionComponent = usesOpenClosedState !== null
394+
if (!inTransitionComponent && open !== undefined && !rest.static) {
395+
return (
396+
<Transition show={open} transition={transition} unmount={rest.unmount}>
397+
<InternalDialog ref={ref} {...rest} />
398+
</Transition>
399+
)
400+
}
401+
402+
return <InternalDialog ref={ref} open={open} {...rest} />
396403
}
397404

398405
// ---
@@ -404,15 +411,17 @@ type PanelRenderPropArg = {
404411

405412
export type DialogPanelProps<TTag extends ElementType = typeof DEFAULT_PANEL_TAG> = Props<
406413
TTag,
407-
PanelRenderPropArg
414+
PanelRenderPropArg,
415+
never,
416+
{ transition?: boolean }
408417
>
409418

410419
function PanelFn<TTag extends ElementType = typeof DEFAULT_PANEL_TAG>(
411420
props: DialogPanelProps<TTag>,
412421
ref: Ref<HTMLElement>
413422
) {
414423
let internalId = useId()
415-
let { id = `headlessui-dialog-panel-${internalId}`, ...theirProps } = props
424+
let { id = `headlessui-dialog-panel-${internalId}`, transition = false, ...theirProps } = props
416425
let [{ dialogState }, state] = useDialogContext('Dialog.Panel')
417426
let panelRef = useSyncRefs(ref, state.panelRef)
418427

@@ -433,20 +442,18 @@ function PanelFn<TTag extends ElementType = typeof DEFAULT_PANEL_TAG>(
433442
onClick: handleClick,
434443
}
435444

436-
let Wrapper = useContext(WithTransitionWrapper) ? TransitionChild : Fragment
445+
let Wrapper = transition ? TransitionChild : Fragment
437446

438447
return (
439-
<WithTransitionWrapper.Provider value={false}>
440-
<Wrapper>
441-
{render({
442-
ourProps,
443-
theirProps,
444-
slot,
445-
defaultTag: DEFAULT_PANEL_TAG,
446-
name: 'Dialog.Panel',
447-
})}
448-
</Wrapper>
449-
</WithTransitionWrapper.Provider>
448+
<Wrapper>
449+
{render({
450+
ourProps,
451+
theirProps,
452+
slot,
453+
defaultTag: DEFAULT_PANEL_TAG,
454+
name: 'Dialog.Panel',
455+
})}
456+
</Wrapper>
450457
)
451458
}
452459

@@ -459,14 +466,16 @@ type BackdropRenderPropArg = {
459466

460467
export type DialogBackdropProps<TTag extends ElementType = typeof DEFAULT_BACKDROP_TAG> = Props<
461468
TTag,
462-
BackdropRenderPropArg
469+
BackdropRenderPropArg,
470+
never,
471+
{ transition?: boolean }
463472
>
464473

465474
function BackdropFn<TTag extends ElementType = typeof DEFAULT_BACKDROP_TAG>(
466475
props: DialogBackdropProps<TTag>,
467476
ref: Ref<HTMLElement>
468477
) {
469-
let theirProps = props
478+
let { transition = false, ...theirProps } = props
470479
let [{ dialogState }] = useDialogContext('Dialog.Backdrop')
471480

472481
let slot = useMemo(
@@ -476,7 +485,7 @@ function BackdropFn<TTag extends ElementType = typeof DEFAULT_BACKDROP_TAG>(
476485

477486
let ourProps = { ref }
478487

479-
let Wrapper = useContext(WithTransitionWrapper) ? TransitionChild : Fragment
488+
let Wrapper = transition ? TransitionChild : Fragment
480489

481490
return (
482491
<Wrapper>

packages/@headlessui-react/src/components/disclosure/disclosure.tsx

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,12 @@ import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
2626
import { optionalRef, useSyncRefs } from '../../hooks/use-sync-refs'
2727
import { useTransition, type TransitionData } from '../../hooks/use-transition'
2828
import { CloseProvider } from '../../internal/close-provider'
29-
import { OpenClosedProvider, State, useOpenClosed } from '../../internal/open-closed'
29+
import {
30+
OpenClosedProvider,
31+
ResetOpenClosedProvider,
32+
State,
33+
useOpenClosed,
34+
} from '../../internal/open-closed'
3035
import type { Props } from '../../types'
3136
import { isDisabledReactIssue7711 } from '../../utils/bugs'
3237
import { match } from '../../utils/match'
@@ -480,18 +485,20 @@ function PanelFn<TTag extends ElementType = typeof DEFAULT_PANEL_TAG>(
480485
}
481486

482487
return (
483-
<DisclosurePanelContext.Provider value={state.panelId}>
484-
{render({
485-
mergeRefs,
486-
ourProps,
487-
theirProps,
488-
slot,
489-
defaultTag: DEFAULT_PANEL_TAG,
490-
features: PanelRenderFeatures,
491-
visible,
492-
name: 'Disclosure.Panel',
493-
})}
494-
</DisclosurePanelContext.Provider>
488+
<ResetOpenClosedProvider>
489+
<DisclosurePanelContext.Provider value={state.panelId}>
490+
{render({
491+
mergeRefs,
492+
ourProps,
493+
theirProps,
494+
slot,
495+
defaultTag: DEFAULT_PANEL_TAG,
496+
features: PanelRenderFeatures,
497+
visible,
498+
name: 'Disclosure.Panel',
499+
})}
500+
</DisclosurePanelContext.Provider>
501+
</ResetOpenClosedProvider>
495502
)
496503
}
497504

0 commit comments

Comments
 (0)