Skip to content

Commit

Permalink
Add transition prop to DialogPanel and DialogBackdrop components (
Browse files Browse the repository at this point in the history
#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>
  • Loading branch information
RobinMalfait and thecrypticace authored Jun 20, 2024
1 parent 7a40af6 commit 07ba551
Show file tree
Hide file tree
Showing 9 changed files with 213 additions and 197 deletions.
2 changes: 1 addition & 1 deletion packages/@headlessui-react/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down
35 changes: 0 additions & 35 deletions packages/@headlessui-react/src/components/dialog/dialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
197 changes: 103 additions & 94 deletions packages/@headlessui-react/src/components/dialog/dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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,
Expand Down Expand Up @@ -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}`,
Expand All @@ -146,7 +120,6 @@ function DialogFn<TTag extends ElementType = typeof DEFAULT_DIALOG_TAG>(
initialFocus,
role = 'dialog',
autoFocus = true,
transition = false,
__demoMode = false,
...theirProps
} = props
Expand Down Expand Up @@ -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, {
Expand Down Expand Up @@ -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}>
Expand Down Expand Up @@ -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} />
}

// ---
Expand All @@ -404,15 +411,17 @@ 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>(
props: DialogPanelProps<TTag>,
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)

Expand All @@ -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>
)
}

Expand All @@ -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(
Expand All @@ -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>
Expand Down
33 changes: 20 additions & 13 deletions packages/@headlessui-react/src/components/disclosure/disclosure.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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>
)
}

Expand Down
Loading

0 comments on commit 07ba551

Please sign in to comment.