diff --git a/packages/@headlessui-react/CHANGELOG.md b/packages/@headlessui-react/CHANGELOG.md index 6429626..e757863 100644 --- a/packages/@headlessui-react/CHANGELOG.md +++ b/packages/@headlessui-react/CHANGELOG.md @@ -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)) diff --git a/packages/@headlessui-react/src/components/dialog/dialog.test.tsx b/packages/@headlessui-react/src/components/dialog/dialog.test.tsx index 99df8e6..2967dde 100644 --- a/packages/@headlessui-react/src/components/dialog/dialog.test.tsx +++ b/packages/@headlessui-react/src/components/dialog/dialog.test.tsx @@ -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 ( - <> - - - - Contents - - - - ) - } - render() - - // 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 () => { diff --git a/packages/@headlessui-react/src/components/dialog/dialog.tsx b/packages/@headlessui-react/src/components/dialog/dialog.tsx index 21b4d09..4bf680c 100644 --- a/packages/@headlessui-react/src/components/dialog/dialog.tsx +++ b/packages/@headlessui-react/src/components/dialog/dialog.tsx @@ -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 = Props< - TTag, - DialogRenderPropArg, - DialogPropsWeControl, - PropsForFeatures & { - open?: boolean - onClose(value: boolean): void - initialFocus?: MutableRefObject - role?: 'dialog' | 'alertdialog' - autoFocus?: boolean - __demoMode?: boolean - transition?: boolean - } -> - -function DialogFn( - props: DialogProps, - ref: Ref -) { +let InternalDialog = forwardRefWithAs(function InternalDialog< + TTag extends ElementType = typeof DEFAULT_DIALOG_TAG, +>(props: DialogProps, ref: Ref) { let internalId = useId() let { id = `headlessui-dialog-${internalId}`, @@ -146,7 +120,6 @@ function DialogFn( initialFocus, role = 'dialog', autoFocus = true, - transition = false, __demoMode = false, ...theirProps } = props @@ -179,39 +152,6 @@ function DialogFn( 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( } } - if (transition) { - let { transition: _transition, open, ...rest } = props - return ( - - - - - - ) - } - return ( - <> + @@ -391,8 +320,86 @@ function DialogFn( - + ) +}) + +// --- + +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 = Props< + TTag, + DialogRenderPropArg, + DialogPropsWeControl, + PropsForFeatures & { + open?: boolean + onClose(value: boolean): void + initialFocus?: MutableRefObject + role?: 'dialog' | 'alertdialog' + autoFocus?: boolean + transition?: boolean + __demoMode?: boolean + } +> + +function DialogFn( + props: DialogProps, + ref: Ref +) { + 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 ( + + + + ) + } + + return } // --- @@ -404,7 +411,9 @@ type PanelRenderPropArg = { export type DialogPanelProps = Props< TTag, - PanelRenderPropArg + PanelRenderPropArg, + never, + { transition?: boolean } > function PanelFn( @@ -412,7 +421,7 @@ function PanelFn( ref: Ref ) { 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( onClick: handleClick, } - let Wrapper = useContext(WithTransitionWrapper) ? TransitionChild : Fragment + let Wrapper = transition ? TransitionChild : Fragment return ( - - - {render({ - ourProps, - theirProps, - slot, - defaultTag: DEFAULT_PANEL_TAG, - name: 'Dialog.Panel', - })} - - + + {render({ + ourProps, + theirProps, + slot, + defaultTag: DEFAULT_PANEL_TAG, + name: 'Dialog.Panel', + })} + ) } @@ -459,14 +466,16 @@ type BackdropRenderPropArg = { export type DialogBackdropProps = Props< TTag, - BackdropRenderPropArg + BackdropRenderPropArg, + never, + { transition?: boolean } > function BackdropFn( props: DialogBackdropProps, ref: Ref ) { - let theirProps = props + let { transition = false, ...theirProps } = props let [{ dialogState }] = useDialogContext('Dialog.Backdrop') let slot = useMemo( @@ -476,7 +485,7 @@ function BackdropFn( let ourProps = { ref } - let Wrapper = useContext(WithTransitionWrapper) ? TransitionChild : Fragment + let Wrapper = transition ? TransitionChild : Fragment return ( diff --git a/packages/@headlessui-react/src/components/disclosure/disclosure.tsx b/packages/@headlessui-react/src/components/disclosure/disclosure.tsx index fa47e65..a173ff6 100644 --- a/packages/@headlessui-react/src/components/disclosure/disclosure.tsx +++ b/packages/@headlessui-react/src/components/disclosure/disclosure.tsx @@ -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( } return ( - - {render({ - mergeRefs, - ourProps, - theirProps, - slot, - defaultTag: DEFAULT_PANEL_TAG, - features: PanelRenderFeatures, - visible, - name: 'Disclosure.Panel', - })} - + + + {render({ + mergeRefs, + ourProps, + theirProps, + slot, + defaultTag: DEFAULT_PANEL_TAG, + features: PanelRenderFeatures, + visible, + name: 'Disclosure.Panel', + })} + + ) } diff --git a/packages/@headlessui-react/src/components/popover/popover.tsx b/packages/@headlessui-react/src/components/popover/popover.tsx index 1310c5d..3c99182 100644 --- a/packages/@headlessui-react/src/components/popover/popover.tsx +++ b/packages/@headlessui-react/src/components/popover/popover.tsx @@ -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( }) return ( - - - - {visible && isPortalled && ( - - )} - {render({ - mergeRefs, - ourProps, - theirProps, - slot, - defaultTag: DEFAULT_PANEL_TAG, - features: PanelRenderFeatures, - visible, - name: 'Popover.Panel', - })} - {visible && isPortalled && ( - - )} - - - + + + + + {visible && isPortalled && ( + + )} + {render({ + mergeRefs, + ourProps, + theirProps, + slot, + defaultTag: DEFAULT_PANEL_TAG, + features: PanelRenderFeatures, + visible, + name: 'Popover.Panel', + })} + {visible && isPortalled && ( + + )} + + + + ) } diff --git a/packages/@headlessui-react/src/components/transition/transition.tsx b/packages/@headlessui-react/src/components/transition/transition.tsx index 0a676d7..9a7281c 100644 --- a/packages/@headlessui-react/src/components/transition/transition.tsx +++ b/packages/@headlessui-react/src/components/transition/transition.tsx @@ -119,7 +119,7 @@ export type TransitionChildProps = Props< TransitionChildPropsWeControl, PropsForFeatures & TransitionClasses & - TransitionEvents & { appear?: boolean } + TransitionEvents & { transition?: boolean; appear?: boolean } > function useTransitionContext() { @@ -298,6 +298,11 @@ function TransitionChildFn ) { 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 { - 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 diff --git a/packages/@headlessui-react/src/internal/open-closed.tsx b/packages/@headlessui-react/src/internal/open-closed.tsx index b5ce312..2cb0f29 100644 --- a/packages/@headlessui-react/src/internal/open-closed.tsx +++ b/packages/@headlessui-react/src/internal/open-closed.tsx @@ -22,3 +22,7 @@ interface Props { export function OpenClosedProvider({ value, children }: Props): ReactElement { return {children} } + +export function ResetOpenClosedProvider({ children }: { children: React.ReactNode }): ReactElement { + return {children} +} diff --git a/playgrounds/react/pages/dialog/dialog-built-in-transition.tsx b/playgrounds/react/pages/dialog/dialog-built-in-transition.tsx index a5de69e..d489b14 100644 --- a/playgrounds/react/pages/dialog/dialog-built-in-transition.tsx +++ b/playgrounds/react/pages/dialog/dialog-built-in-transition.tsx @@ -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 ( <>
- + @@ -24,13 +34,18 @@ export default function Home() { setIsOpen(false)} - className="relative z-50" + className="relative z-50 duration-500 data-[closed]:opacity-0" > - +
- +

Dialog

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed pulvinar, nunc nec diff --git a/playgrounds/react/pages/dialog/dialog.tsx b/playgrounds/react/pages/dialog/dialog.tsx index 3ceb653..d3d3f29 100644 --- a/playgrounds/react/pages/dialog/dialog.tsx +++ b/playgrounds/react/pages/dialog/dialog.tsx @@ -80,17 +80,17 @@ export default function Home() { 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')} > -

+