From 07ba551e6335c8bcd999c14ae6af5e14c5d7f87c Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Fri, 21 Jun 2024 00:44:12 +0200 Subject: [PATCH] Add `transition` prop to `DialogPanel` and `DialogBackdrop` components (#3309) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 // OpenClosedProvider // ResetOpenClosedProvider // Can safely wrap `` in `` ``` * use `ResetOpenClosedProvider` in `PopoverPanel` and `DisclosurePanel` * add `transition` prop to `` 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 `` around the `` 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 --- packages/@headlessui-react/CHANGELOG.md | 2 +- .../src/components/dialog/dialog.test.tsx | 35 ---- .../src/components/dialog/dialog.tsx | 197 +++++++++--------- .../src/components/disclosure/disclosure.tsx | 33 +-- .../src/components/popover/popover.tsx | 85 ++++---- .../src/components/transition/transition.tsx | 15 +- .../src/internal/open-closed.tsx | 4 + .../dialog/dialog-built-in-transition.tsx | 31 ++- playgrounds/react/pages/dialog/dialog.tsx | 8 +- 9 files changed, 213 insertions(+), 197 deletions(-) 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')} > -

+