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

Navigator: use CSS animations instead of framer-motion #56909

Merged
merged 10 commits into from
Dec 13, 2023
1 change: 1 addition & 0 deletions packages/components/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
* External dependencies
*/
import type { ForwardedRef } from 'react';
import { css } from '@emotion/react';

/**
* WordPress dependencies
Expand All @@ -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 };
Expand Down Expand Up @@ -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 ]
);

Expand Down
108 changes: 15 additions & 93 deletions packages/components/src/navigator/navigator-screen/component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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';

/**
Expand All @@ -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 >,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice simplification of the types 👍

Any backward compatibility concerns? For example, we won't be able to use any of the framer motion animation events like onAnimationStart / onAnimationComplete.

Copy link
Contributor Author

@ciampo ciampo Dec 13, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I recall correctly (and interpret the previous TypeScript code correctly), that shouldn't be a problem because Navigator was previously not documenting props specific to framer-motion that would overlap with standard HTML properties (like onAnimationStart).

So, with the changes from this PR, the component should now accept the default HTML onAnimationStart etc

forwardedRef: ForwardedRef< any >
) {
const screenId = useId();
Expand All @@ -55,7 +39,6 @@ function UnconnectedNavigatorScreen(
'NavigatorScreen'
);

const prefersReducedMotion = useReducedMotion();
const { location, match, addScreen, removeScreen } =
useContext( NavigatorContext );
const isMatch = match === screenId;
Expand All @@ -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 );
Expand Down Expand Up @@ -149,73 +133,11 @@ function UnconnectedNavigatorScreen(

const mergedWrapperRef = useMergeRefs( [ forwardedRef, wrapperRef ] );

if ( ! isMatch ) {
return null;
}

if ( prefersReducedMotion ) {
return (
<View
ref={ mergedWrapperRef }
className={ classes }
{ ...otherProps }
>
{ children }
</View>
);
}

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 (
<motion.div
ref={ mergedWrapperRef }
className={ classes }
{ ...otherProps }
{ ...animatedProps }
>
return isMatch ? (
<View ref={ mergedWrapperRef } className={ classes } { ...otherProps }>
ciampo marked this conversation as resolved.
Show resolved Hide resolved
{ children }
</motion.div>
);
</View>
) : null;
}

/**
Expand Down
71 changes: 71 additions & 0 deletions packages/components/src/navigator/styles.ts
Original file line number Diff line number Diff line change
@@ -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;
ciampo marked this conversation as resolved.
Show resolved Hide resolved
`;

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 ) }
`;
64 changes: 0 additions & 64 deletions packages/components/src/navigator/test/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -769,68 +769,4 @@ describe( 'Navigator', () => {
).toHaveFocus();
} );
} );

describe( 'animation', () => {
it( 'should not animate the initial screen', async () => {
const onHomeAnimationStartSpy = jest.fn();

render(
<NavigatorProvider initialPath="/">
<NavigatorScreen
path="/"
onAnimationStart={ onHomeAnimationStartSpy }
>
<CustomNavigatorButton path="/child">
To child
</CustomNavigatorButton>
</NavigatorScreen>
</NavigatorProvider>
);

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(
<NavigatorProvider initialPath="/">
<NavigatorScreen
path="/"
onAnimationStart={ onHomeAnimationStartSpy }
>
<CustomNavigatorButton path="/child">
To child
</CustomNavigatorButton>
</NavigatorScreen>
<NavigatorScreen
path="/child"
onAnimationStart={ onChildAnimationStartSpy }
>
<CustomNavigatorBackButton>
Back to home
</CustomNavigatorBackButton>
</NavigatorScreen>
</NavigatorProvider>
);

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 );
} );
} );
} );