Skip to content

Commit

Permalink
Navigator: use CSS animations instead of framer-motion (#56909)
Browse files Browse the repository at this point in the history
* Move navigator provider styles to separate file

* Move navigator screen styles to separate file, use CSS animations instead of framer motion

* Remove unused import

* Spacing

* Use standard ease-in-out easing function

* Remove stale comments

* Remove animation-specific tests (as they can't be tested in jsdom)

* CHANGELOG

* Add comment

* Avoid running the `css` function when unnecessary
  • Loading branch information
ciampo authored Dec 13, 2023
1 parent 029e90a commit dc4f3e6
Show file tree
Hide file tree
Showing 5 changed files with 90 additions and 161 deletions.
1 change: 1 addition & 0 deletions packages/components/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,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 >,
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 }>
{ 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;
`;

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

1 comment on commit dc4f3e6

@github-actions
Copy link

Choose a reason for hiding this comment

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

Flaky tests detected in dc4f3e6.
Some tests passed with failed attempts. The failures may not be related to this commit but are still reported for visibility. See the documentation for more information.

🔍 Workflow run URL: https://github.com/WordPress/gutenberg/actions/runs/7194828287
📝 Reported issues:

Please sign in to comment.