Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add transition prop to DialogPanel and DialogBackdrop components #3309

Merged
merged 15 commits into from
Jun 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading