diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index 5e73e4319a1ba..7ab260cd8ab1b 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -4,6 +4,7 @@ ### Enhancements +- `Navigator`: use vanilla CSS animations instead of `framer-motion` ([#56909](https://github.com/WordPress/gutenberg/pull/56909)). - `FormToggle`: fix sass deprecation warning ([#56672](https://github.com/WordPress/gutenberg/pull/56672)). - `QueryControls`: Add opt-in prop for 40px default size ([#56576](https://github.com/WordPress/gutenberg/pull/56576)). - `CheckboxControl`: Add option to not render label ([#56158](https://github.com/WordPress/gutenberg/pull/56158)). diff --git a/packages/components/src/navigator/navigator-provider/component.tsx b/packages/components/src/navigator/navigator-provider/component.tsx index cccbb84f0d093..cd38bea574813 100644 --- a/packages/components/src/navigator/navigator-provider/component.tsx +++ b/packages/components/src/navigator/navigator-provider/component.tsx @@ -2,7 +2,6 @@ * External dependencies */ import type { ForwardedRef } from 'react'; -import { css } from '@emotion/react'; /** * WordPress dependencies @@ -23,15 +22,16 @@ import isShallowEqual from '@wordpress/is-shallow-equal'; import type { WordPressComponentProps } from '../../context'; import { contextConnect, useContextSystem } from '../../context'; import { useCx } from '../../utils/hooks/use-cx'; +import { patternMatch, findParent } from '../utils/router'; import { View } from '../../view'; import { NavigatorContext } from '../context'; +import * as styles from '../styles'; import type { NavigatorProviderProps, NavigatorLocation, NavigatorContext as NavigatorContextType, Screen, } from '../types'; -import { patternMatch, findParent } from '../utils/router'; type MatchedPath = ReturnType< typeof patternMatch >; type ScreenAction = { type: string; screen: Screen }; @@ -248,8 +248,7 @@ function UnconnectedNavigatorProvider( const cx = useCx(); const classes = useMemo( - // Prevents horizontal overflow while animating screen transitions. - () => cx( css( { overflowX: 'hidden' } ), className ), + () => cx( styles.navigatorProviderWrapper, className ), [ className, cx ] ); diff --git a/packages/components/src/navigator/navigator-screen/component.tsx b/packages/components/src/navigator/navigator-screen/component.tsx index ed4ab9629d3a8..29981d46770ee 100644 --- a/packages/components/src/navigator/navigator-screen/component.tsx +++ b/packages/components/src/navigator/navigator-screen/component.tsx @@ -2,11 +2,6 @@ * External dependencies */ import type { ForwardedRef } from 'react'; -// eslint-disable-next-line no-restricted-imports -import type { MotionProps } from 'framer-motion'; -// eslint-disable-next-line no-restricted-imports -import { motion } from 'framer-motion'; -import { css } from '@emotion/react'; /** * WordPress dependencies @@ -19,8 +14,8 @@ import { useRef, useId, } from '@wordpress/element'; -import { useReducedMotion, useMergeRefs } from '@wordpress/compose'; -import { isRTL } from '@wordpress/i18n'; +import { useMergeRefs } from '@wordpress/compose'; +import { isRTL as isRTLFn } from '@wordpress/i18n'; import { escapeAttribute } from '@wordpress/escape-html'; /** @@ -31,22 +26,11 @@ import { contextConnect, useContextSystem } from '../../context'; import { useCx } from '../../utils/hooks/use-cx'; import { View } from '../../view'; import { NavigatorContext } from '../context'; +import * as styles from '../styles'; import type { NavigatorScreenProps } from '../types'; -const animationEnterDelay = 0; -const animationEnterDuration = 0.14; -const animationExitDuration = 0.14; -const animationExitDelay = 0; - -// Props specific to `framer-motion` can't be currently passed to `NavigatorScreen`, -// as some of them would overlap with HTML props (e.g. `onAnimationStart`, ...) -type Props = Omit< - WordPressComponentProps< NavigatorScreenProps, 'div', false >, - Exclude< keyof MotionProps, 'style' | 'children' > ->; - function UnconnectedNavigatorScreen( - props: Props, + props: WordPressComponentProps< NavigatorScreenProps, 'div', false >, forwardedRef: ForwardedRef< any > ) { const screenId = useId(); @@ -55,7 +39,6 @@ function UnconnectedNavigatorScreen( 'NavigatorScreen' ); - const prefersReducedMotion = useReducedMotion(); const { location, match, addScreen, removeScreen } = useContext( NavigatorContext ); const isMatch = match === screenId; @@ -70,19 +53,20 @@ function UnconnectedNavigatorScreen( return () => removeScreen( screen ); }, [ screenId, path, addScreen, removeScreen ] ); + const isRTL = isRTLFn(); + const { isInitial, isBack } = location; const cx = useCx(); const classes = useMemo( () => cx( - css( { - // Ensures horizontal overflow is visually accessible. - overflowX: 'auto', - // In case the root has a height, it should not be exceeded. - maxHeight: '100%', + styles.navigatorScreen( { + isInitial, + isBack, + isRTL, } ), className ), - [ className, cx ] + [ className, cx, isInitial, isBack, isRTL ] ); const locationRef = useRef( location ); @@ -149,73 +133,11 @@ function UnconnectedNavigatorScreen( const mergedWrapperRef = useMergeRefs( [ forwardedRef, wrapperRef ] ); - if ( ! isMatch ) { - return null; - } - - if ( prefersReducedMotion ) { - return ( - - { children } - - ); - } - - const animate = { - opacity: 1, - transition: { - delay: animationEnterDelay, - duration: animationEnterDuration, - ease: 'easeInOut', - }, - x: 0, - }; - // Disable the initial animation if the screen is the very first screen to be - // rendered within the current `NavigatorProvider`. - const initial = - location.isInitial && ! location.isBack - ? false - : { - opacity: 0, - x: - ( isRTL() && location.isBack ) || - ( ! isRTL() && ! location.isBack ) - ? 50 - : -50, - }; - const exit = { - delay: animationExitDelay, - opacity: 0, - x: - ( ! isRTL() && location.isBack ) || ( isRTL() && ! location.isBack ) - ? 50 - : -50, - transition: { - duration: animationExitDuration, - ease: 'easeInOut', - }, - }; - - const animatedProps = { - animate, - exit, - initial, - }; - - return ( - + return isMatch ? ( + { children } - - ); + + ) : null; } /** diff --git a/packages/components/src/navigator/styles.ts b/packages/components/src/navigator/styles.ts new file mode 100644 index 0000000000000..8ec5f11da16d3 --- /dev/null +++ b/packages/components/src/navigator/styles.ts @@ -0,0 +1,71 @@ +/** + * External dependencies + */ +import { css, keyframes } from '@emotion/react'; + +export const navigatorProviderWrapper = css` + /* Prevents horizontal overflow while animating screen transitions */ + overflow-x: hidden; + /* Mark this subsection of the DOM as isolated, providing performance benefits + * by limiting calculations of layout, style, paint, size, or any combination + * to a DOM subtree rather than the entire page. + */ + contain: strict; +`; + +const fadeInFromRight = keyframes( { + '0%': { + opacity: 0, + transform: `translateX( 50px )`, + }, + '100%': { opacity: 1, transform: 'none' }, +} ); + +const fadeInFromLeft = keyframes( { + '0%': { + opacity: 0, + transform: `translateX( -50px )`, + }, + '100%': { opacity: 1, transform: 'none' }, +} ); + +type NavigatorScreenAnimationProps = { + isInitial?: boolean; + isBack?: boolean; + isRTL: boolean; +}; + +const navigatorScreenAnimation = ( { + isInitial, + isBack, + isRTL, +}: NavigatorScreenAnimationProps ) => { + if ( isInitial && ! isBack ) { + return; + } + + const animationName = + ( isRTL && isBack ) || ( ! isRTL && ! isBack ) + ? fadeInFromRight + : fadeInFromLeft; + + return css` + animation-duration: 0.14s; + animation-timing-function: ease-in-out; + will-change: transform, opacity; + animation-name: ${ animationName }; + + @media ( prefers-reduced-motion ) { + animation-duration: 0s; + } + `; +}; + +export const navigatorScreen = ( props: NavigatorScreenAnimationProps ) => css` + /* Ensures horizontal overflow is visually accessible */ + overflow-x: auto; + /* In case the root has a height, it should not be exceeded */ + max-height: 100%; + + ${ navigatorScreenAnimation( props ) } +`; diff --git a/packages/components/src/navigator/test/index.tsx b/packages/components/src/navigator/test/index.tsx index 5a711b8730224..b83bd70d9d744 100644 --- a/packages/components/src/navigator/test/index.tsx +++ b/packages/components/src/navigator/test/index.tsx @@ -769,68 +769,4 @@ describe( 'Navigator', () => { ).toHaveFocus(); } ); } ); - - describe( 'animation', () => { - it( 'should not animate the initial screen', async () => { - const onHomeAnimationStartSpy = jest.fn(); - - render( - - - - To child - - - - ); - - expect( onHomeAnimationStartSpy ).not.toHaveBeenCalled(); - } ); - - it( 'should animate all other screens (including the initial screen when navigating back)', async () => { - const user = userEvent.setup(); - - const onHomeAnimationStartSpy = jest.fn(); - const onChildAnimationStartSpy = jest.fn(); - - render( - - - - To child - - - - - Back to home - - - - ); - - expect( onHomeAnimationStartSpy ).not.toHaveBeenCalled(); - expect( onChildAnimationStartSpy ).not.toHaveBeenCalled(); - - await user.click( - screen.getByRole( 'button', { name: 'To child' } ) - ); - expect( onChildAnimationStartSpy ).toHaveBeenCalledTimes( 1 ); - expect( onHomeAnimationStartSpy ).not.toHaveBeenCalled(); - - await user.click( - screen.getByRole( 'button', { name: 'Back to home' } ) - ); - expect( onChildAnimationStartSpy ).toHaveBeenCalledTimes( 1 ); - expect( onHomeAnimationStartSpy ).toHaveBeenCalledTimes( 1 ); - } ); - } ); } );