From 69efbca3ba4522b9b11c56a5e517d123fe9a9071 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Thu, 26 Sep 2024 09:49:47 +0200 Subject: [PATCH] Navigator: add support for exit animation (#64777) * Refactor screen logic for better clarity and hook deps * Add exit animation, rewrite screen animations / DOM rendering logic * Only add inset CSS rule when animating out * Parametrise animation * CHANGELOG * Add fallback timeout * Mention wrapper height in README * Use `useReducedMotion()` hook instead of custom logic * Extract useScreenAnimatePresence hook, tidy up * Forward animationEnd * Add `setWrapperHeight` functionality via context * Use `clip` instead of `hidden` for overflow-x * Less aggressive clipping for screens that are animating out * Better sizing styles for screen, to keep it more stable while transitioning out * Refine internal animation logic for less jumpy animations * Remove unnecessary Storybook styles * Change wording * Improve logic: - more clear animation status names - apply CSS animation only while effectively animating - fix bug in the animationEnd callback which was matching animation end events too loosely, thus causing glitches * Add timeout fallback for in animations too * Fix animation delay for forwards.out animation * Use display: grid instead of absolute positioning to set provider min height Remove unnecessary import * Simplify navigatorScreenAnimation * Remove unnecessary state element ref * Use "start" and "end" instead of "backwards" and "forwards" * Do not rely on `usePrevious` * Use CSS transitions * Fix Storybook example * Revert "Use CSS transitions" This reverts commit 946f9c953232b788f58050d2a94d9d131527a180. * Switch to data-attributes for less runtime emotion calculations * Add back fallback animation timeout * Move CHANGELOG entry to unreleased section * Clean up code, reduce diff for easier reviewing --- Co-authored-by: ciampo Co-authored-by: mirka <0mirka00@git.wordpress.org> Co-authored-by: tyxla Co-authored-by: jsnajdr Co-authored-by: jasmussen --- packages/components/CHANGELOG.md | 1 + .../navigator/navigator-provider/README.md | 6 +- .../navigator/navigator-screen/component.tsx | 84 +++++---- .../use-screen-animate-presence.ts | 177 ++++++++++++++++++ .../src/navigator/stories/index.story.tsx | 11 +- packages/components/src/navigator/styles.ts | 151 +++++++++++---- 6 files changed, 346 insertions(+), 84 deletions(-) create mode 100644 packages/components/src/navigator/navigator-screen/use-screen-animate-presence.ts diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index c3bd150703fc0..94633ec707810 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -16,6 +16,7 @@ - `BorderBoxControl`: promote to stable ([#65586](https://github.com/WordPress/gutenberg/pull/65586)). - `MenuGroup`: Simplify the MenuGroup styles within dropdown menus. ([#65561](https://github.com/WordPress/gutenberg/pull/65561)). - `DatePicker`: Use compact button size. ([#65653](https://github.com/WordPress/gutenberg/pull/65653)). +- `Navigator`: add support for exit animation ([#64777](https://github.com/WordPress/gutenberg/pull/64777)). ## 28.8.0 (2024-09-19) diff --git a/packages/components/src/navigator/navigator-provider/README.md b/packages/components/src/navigator/navigator-provider/README.md index 35bf7a69720be..b9bc8f0c6bcdc 100644 --- a/packages/components/src/navigator/navigator-provider/README.md +++ b/packages/components/src/navigator/navigator-provider/README.md @@ -33,7 +33,7 @@ const MyNavigation = () => ( ); ``` -**Important note** +### Hierarchical `path`s `Navigator` assumes that screens are organized hierarchically according to their `path`, which should follow a URL-like scheme where each path segment starts with and is separated by the `/` character. @@ -47,6 +47,10 @@ For example: - `/parent/:param` is a child of `/parent` as well. - if the current screen has a `path` with value `/parent/child/grand-child`, when going "back" `Navigator` will try to recursively navigate the path hierarchy until a matching screen (or the root `/`) is found. +### Height and animations + +Due to how `NavigatorScreen` animations work, it is recommended that the `NavigatorProvider` component is assigned a `height` to prevent some potential UI jumps while moving across screens. + ## Props The component accepts the following props: diff --git a/packages/components/src/navigator/navigator-screen/component.tsx b/packages/components/src/navigator/navigator-screen/component.tsx index 5882f271d4518..f2d2c26e576c2 100644 --- a/packages/components/src/navigator/navigator-screen/component.tsx +++ b/packages/components/src/navigator/navigator-screen/component.tsx @@ -15,7 +15,6 @@ import { useId, } from '@wordpress/element'; import { useMergeRefs } from '@wordpress/compose'; -import { isRTL as isRTLFn } from '@wordpress/i18n'; import { escapeAttribute } from '@wordpress/escape-html'; import warning from '@wordpress/warning'; @@ -29,6 +28,7 @@ import { View } from '../../view'; import { NavigatorContext } from '../context'; import * as styles from '../styles'; import type { NavigatorScreenProps } from '../types'; +import { useScreenAnimatePresence } from './use-screen-animate-presence'; function UnconnectedNavigatorScreen( props: WordPressComponentProps< NavigatorScreenProps, 'div', false >, @@ -41,16 +41,24 @@ function UnconnectedNavigatorScreen( } const screenId = useId(); - const { children, className, path, ...otherProps } = useContextSystem( - props, - 'NavigatorScreen' - ); + + const { + children, + className, + path, + onAnimationEnd: onAnimationEndProp, + ...otherProps + } = useContextSystem( props, 'NavigatorScreen' ); const { location, match, addScreen, removeScreen } = useContext( NavigatorContext ); + const { isInitial, isBack, focusTargetSelector, skipFocus } = location; + const isMatch = match === screenId; const wrapperRef = useRef< HTMLDivElement >( null ); + const skipAnimationAndFocusRestoration = !! isInitial && ! isBack; + // Register / unregister screen with the navigator context. useEffect( () => { const screen = { id: screenId, @@ -60,31 +68,28 @@ function UnconnectedNavigatorScreen( return () => removeScreen( screen ); }, [ screenId, path, addScreen, removeScreen ] ); - const isRTL = isRTLFn(); - const { isInitial, isBack } = location; + // Animation. + const { animationStyles, shouldRenderScreen, screenProps } = + useScreenAnimatePresence( { + isMatch, + isBack, + onAnimationEnd: onAnimationEndProp, + skipAnimation: skipAnimationAndFocusRestoration, + } ); + const cx = useCx(); const classes = useMemo( - () => - cx( - styles.navigatorScreen( { - isInitial, - isBack, - isRTL, - } ), - className - ), - [ className, cx, isInitial, isBack, isRTL ] + () => cx( styles.navigatorScreen, animationStyles, className ), + [ className, cx, animationStyles ] ); + // Focus restoration const locationRef = useRef( location ); - useEffect( () => { locationRef.current = location; }, [ location ] ); - - // Focus restoration - const isInitialLocation = location.isInitial && ! location.isBack; useEffect( () => { + const wrapperEl = wrapperRef.current; // Only attempt to restore focus: // - if the current location is not the initial one (to avoid moving focus on page load) // - when the screen becomes visible @@ -92,20 +97,20 @@ function UnconnectedNavigatorScreen( // - if focus hasn't already been restored for the current location // - if the `skipFocus` option is not set to `true`. This is useful when we trigger the navigation outside of NavigatorScreen. if ( - isInitialLocation || + skipAnimationAndFocusRestoration || ! isMatch || - ! wrapperRef.current || + ! wrapperEl || locationRef.current.hasRestoredFocus || - location.skipFocus + skipFocus ) { return; } - const activeElement = wrapperRef.current.ownerDocument.activeElement; + const activeElement = wrapperEl.ownerDocument.activeElement; // If an element is already focused within the wrapper do not focus the // element. This prevents inputs or buttons from losing focus unnecessarily. - if ( wrapperRef.current.contains( activeElement ) ) { + if ( wrapperEl.contains( activeElement ) ) { return; } @@ -113,33 +118,36 @@ function UnconnectedNavigatorScreen( // When navigating back, if a selector is provided, use it to look for the // target element (assumed to be a node inside the current NavigatorScreen) - if ( location.isBack && location.focusTargetSelector ) { - elementToFocus = wrapperRef.current.querySelector( - location.focusTargetSelector - ); + if ( isBack && focusTargetSelector ) { + elementToFocus = wrapperEl.querySelector( focusTargetSelector ); } // If the previous query didn't run or find any element to focus, fallback // to the first tabbable element in the screen (or the screen itself). if ( ! elementToFocus ) { - const [ firstTabbable ] = focus.tabbable.find( wrapperRef.current ); - elementToFocus = firstTabbable ?? wrapperRef.current; + const [ firstTabbable ] = focus.tabbable.find( wrapperEl ); + elementToFocus = firstTabbable ?? wrapperEl; } locationRef.current.hasRestoredFocus = true; elementToFocus.focus(); }, [ - isInitialLocation, + skipAnimationAndFocusRestoration, isMatch, - location.isBack, - location.focusTargetSelector, - location.skipFocus, + isBack, + focusTargetSelector, + skipFocus, ] ); const mergedWrapperRef = useMergeRefs( [ forwardedRef, wrapperRef ] ); - return isMatch ? ( - + return shouldRenderScreen ? ( + { children } ) : null; diff --git a/packages/components/src/navigator/navigator-screen/use-screen-animate-presence.ts b/packages/components/src/navigator/navigator-screen/use-screen-animate-presence.ts new file mode 100644 index 0000000000000..af5a47ee12df4 --- /dev/null +++ b/packages/components/src/navigator/navigator-screen/use-screen-animate-presence.ts @@ -0,0 +1,177 @@ +/** + * WordPress dependencies + */ +import { + useState, + useEffect, + useLayoutEffect, + useCallback, +} from '@wordpress/element'; +import { useReducedMotion } from '@wordpress/compose'; +import { isRTL as isRTLFn } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import * as styles from '../styles'; + +// Possible values: +// - 'INITIAL': the initial state +// - 'ANIMATING_IN': start enter animation +// - 'IN': enter animation has ended +// - 'ANIMATING_OUT': start exit animation +// - 'OUT': the exit animation has ended +type AnimationStatus = + | 'INITIAL' + | 'ANIMATING_IN' + | 'IN' + | 'ANIMATING_OUT' + | 'OUT'; + +// Allow an extra 20% of the total animation duration to account for potential +// event loop delays. +const ANIMATION_TIMEOUT_MARGIN = 1.2; + +const isEnterAnimation = ( + animationDirection: 'end' | 'start', + animationStatus: AnimationStatus, + animationName: string +) => + animationStatus === 'ANIMATING_IN' && + animationName === styles.ANIMATION_END_NAMES[ animationDirection ].in; + +const isExitAnimation = ( + animationDirection: 'end' | 'start', + animationStatus: AnimationStatus, + animationName: string +) => + animationStatus === 'ANIMATING_OUT' && + animationName === styles.ANIMATION_END_NAMES[ animationDirection ].out; + +export function useScreenAnimatePresence( { + isMatch, + skipAnimation, + isBack, + onAnimationEnd, +}: { + isMatch: boolean; + skipAnimation: boolean; + isBack?: boolean; + onAnimationEnd?: React.AnimationEventHandler< Element >; +} ) { + const isRTL = isRTLFn(); + const prefersReducedMotion = useReducedMotion(); + + const [ animationStatus, setAnimationStatus ] = + useState< AnimationStatus >( 'INITIAL' ); + + // Start enter and exit animations when the screen is selected or deselected. + // The animation status is set to `IN` or `OUT` immediately if the animation + // should be skipped. + const becameSelected = + animationStatus !== 'ANIMATING_IN' && + animationStatus !== 'IN' && + isMatch; + const becameUnselected = + animationStatus !== 'ANIMATING_OUT' && + animationStatus !== 'OUT' && + ! isMatch; + useLayoutEffect( () => { + if ( becameSelected ) { + setAnimationStatus( + skipAnimation || prefersReducedMotion ? 'IN' : 'ANIMATING_IN' + ); + } else if ( becameUnselected ) { + setAnimationStatus( + skipAnimation || prefersReducedMotion ? 'OUT' : 'ANIMATING_OUT' + ); + } + }, [ + becameSelected, + becameUnselected, + skipAnimation, + prefersReducedMotion, + ] ); + + // Animation attributes (derived state). + const animationDirection = + ( isRTL && isBack ) || ( ! isRTL && ! isBack ) ? 'end' : 'start'; + const isAnimatingIn = animationStatus === 'ANIMATING_IN'; + const isAnimatingOut = animationStatus === 'ANIMATING_OUT'; + let animationType: 'in' | 'out' | undefined; + if ( isAnimatingIn ) { + animationType = 'in'; + } else if ( isAnimatingOut ) { + animationType = 'out'; + } + + const onScreenAnimationEnd = useCallback( + ( e: React.AnimationEvent< HTMLElement > ) => { + onAnimationEnd?.( e ); + + if ( + isExitAnimation( + animationDirection, + animationStatus, + e.animationName + ) + ) { + // When the exit animation ends on an unselected screen, set the + // status to 'OUT' to remove the screen contents from the DOM. + setAnimationStatus( 'OUT' ); + } else if ( + isEnterAnimation( + animationDirection, + animationStatus, + e.animationName + ) + ) { + // When the enter animation ends on a selected screen, set the + // status to 'IN' to ensure the screen is rendered in the DOM. + setAnimationStatus( 'IN' ); + } + }, + [ onAnimationEnd, animationStatus, animationDirection ] + ); + + // Fallback timeout to ensure that the logic is applied even if the + // `animationend` event is not triggered. + useEffect( () => { + let animationTimeout: number | undefined; + + if ( isAnimatingOut ) { + animationTimeout = window.setTimeout( () => { + setAnimationStatus( 'OUT' ); + animationTimeout = undefined; + }, styles.TOTAL_ANIMATION_DURATION.OUT * ANIMATION_TIMEOUT_MARGIN ); + } else if ( isAnimatingIn ) { + animationTimeout = window.setTimeout( () => { + setAnimationStatus( 'IN' ); + animationTimeout = undefined; + }, styles.TOTAL_ANIMATION_DURATION.IN * ANIMATION_TIMEOUT_MARGIN ); + } + + return () => { + if ( animationTimeout ) { + window.clearTimeout( animationTimeout ); + animationTimeout = undefined; + } + }; + }, [ isAnimatingOut, isAnimatingIn ] ); + + return { + animationStyles: styles.navigatorScreenAnimation, + // Render the screen's contents in the DOM not only when the screen is + // selected, but also while it is animating out. + shouldRenderScreen: + isMatch || + animationStatus === 'IN' || + animationStatus === 'ANIMATING_OUT', + screenProps: { + onAnimationEnd: onScreenAnimationEnd, + 'data-animation-direction': animationDirection, + 'data-animation-type': animationType, + 'data-skip-animation': skipAnimation || undefined, + }, + } as const; +} diff --git a/packages/components/src/navigator/stories/index.story.tsx b/packages/components/src/navigator/stories/index.story.tsx index 30b9c71a368c1..8edac7e7b8168 100644 --- a/packages/components/src/navigator/stories/index.story.tsx +++ b/packages/components/src/navigator/stories/index.story.tsx @@ -36,12 +36,12 @@ const meta: Meta< typeof NavigatorProvider > = { return ( <>