Skip to content

Commit

Permalink
Extract useScreenAnimatePresence hook, tidy up
Browse files Browse the repository at this point in the history
  • Loading branch information
ciampo committed Aug 29, 2024
1 parent 15fe373 commit 68d898a
Show file tree
Hide file tree
Showing 3 changed files with 149 additions and 118 deletions.
129 changes: 21 additions & 108 deletions packages/components/src/navigator/navigator-screen/component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,8 @@ import {
useMemo,
useRef,
useId,
useState,
useLayoutEffect,
} from '@wordpress/element';
import {
useMergeRefs,
usePrevious,
useReducedMotion,
} from '@wordpress/compose';
import { isRTL as isRTLFn } from '@wordpress/i18n';
import { useMergeRefs } from '@wordpress/compose';
import { escapeAttribute } from '@wordpress/escape-html';

/**
Expand All @@ -34,17 +27,18 @@ import { View } from '../../view';
import { NavigatorContext } from '../context';
import * as styles from '../styles';
import type { NavigatorScreenProps } from '../types';

const isExitAnimation = ( e: AnimationEvent ) =>
e.animationName === styles.slideToLeft.name || styles.slideToRight.name;
import { useScreenAnimatePresence } from './use-screen-animate-presence';

function UnconnectedNavigatorScreen(
props: WordPressComponentProps< NavigatorScreenProps, 'div', false >,
forwardedRef: ForwardedRef< any >
) {
// Generate a unique ID for the screen.
const screenId = useId();
const animationTimeoutRef = useRef< number >();
const prefersReducedMotion = useReducedMotion();

// Refs
const wrapperRef = useRef< HTMLDivElement >( null );
const mergedWrapperRef = useMergeRefs( [ forwardedRef, wrapperRef ] );

// Read props and components context.
const { children, className, path, ...otherProps } = useContextSystem(
Expand All @@ -57,23 +51,11 @@ function UnconnectedNavigatorScreen(
useContext( NavigatorContext );
const { isInitial, isBack, focusTargetSelector, skipFocus } = location;

// Determine if the screen is currently selected.
// Locally computed state
const isMatch = match === screenId;
const wasMatch = usePrevious( isMatch );

const skipAnimationAndFocusRestoration = !! isInitial && ! isBack;

const wrapperRef = useRef< HTMLDivElement >( null );

// Possible values:
// - idle: first value assigned to the screen when added to the React tree
// - armed: will start an exit animation when deselected
// - animating: the exit animation is happening
// - animated: the exit animation has ended
const [ exitAnimationStatus, setExitAnimationStatus ] = useState<
'idle' | 'armed' | 'animating' | 'animated'
>( 'idle' );

// Register / unregister screen with the navigator context.
useEffect( () => {
const screen = {
id: screenId,
Expand All @@ -83,72 +65,20 @@ function UnconnectedNavigatorScreen(
return () => removeScreen( screen );
}, [ screenId, path, addScreen, removeScreen ] );

// Update animation status.
useLayoutEffect( () => {
if ( ! wasMatch && isMatch ) {
// When the screen becomes selected, set it to 'armed',
// meaning that it will start an exit animation when deselected.
setExitAnimationStatus( 'armed' );
} else if ( wasMatch && ! isMatch ) {
// When the screen becomes deselected, set it to:
// - 'animating' (if animations are enabled)
// - 'animated' (causing the animation to end and the screen to stop
// rendering its contents in the DOM, without the need to wait for
// the `animationend` event)
setExitAnimationStatus(
skipAnimationAndFocusRestoration || prefersReducedMotion
? 'animated'
: 'animating'
);
}
}, [
isMatch,
wasMatch,
skipAnimationAndFocusRestoration,
prefersReducedMotion,
] );
// Animation.
const { animationStyles, onScreenAnimationEnd, shouldRenderScreen } =
useScreenAnimatePresence( {
isMatch,
isBack,
skipAnimation: skipAnimationAndFocusRestoration,
} );

// Styles
const isRTL = isRTLFn();
const cx = useCx();
const animationDirection =
( isRTL && isBack ) || ( ! isRTL && ! isBack )
? 'forwards'
: 'backwards';
const isAnimatingOut =
exitAnimationStatus === 'animating' ||
exitAnimationStatus === 'animated';
const classes = useMemo(
() =>
cx(
styles.navigatorScreen( {
skipInitialAnimation: skipAnimationAndFocusRestoration,
direction: animationDirection,
isAnimatingOut,
} ),
className
),
[
className,
cx,
skipAnimationAndFocusRestoration,
animationDirection,
isAnimatingOut,
]
() => cx( styles.navigatorScreen, animationStyles, className ),
[ className, cx, animationStyles ]
);
// Fallback timeout to ensure the screen is removed from the DOM in case the
// `animationend` event is not triggered.
useEffect( () => {
if ( exitAnimationStatus === 'animating' ) {
animationTimeoutRef.current = window.setTimeout( () => {
setExitAnimationStatus( 'animated' );
animationTimeoutRef.current = undefined;
}, styles.TOTAL_ANIMATION_DURATION_OUT );
} else if ( animationTimeoutRef.current ) {
window.clearTimeout( animationTimeoutRef.current );
animationTimeoutRef.current = undefined;
}
}, [ exitAnimationStatus ] );

// Focus restoration
const locationRef = useRef( location );
Expand Down Expand Up @@ -206,33 +136,16 @@ function UnconnectedNavigatorScreen(
skipFocus,
] );

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

// Remove the screen contents from the DOM only when it not selected
// and its exit animation has ended.
if (
! isMatch &&
( exitAnimationStatus === 'idle' || exitAnimationStatus === 'animated' )
) {
return null;
}

return (
return shouldRenderScreen ? (
<View
ref={ mergedWrapperRef }
className={ classes }
onAnimationEnd={ ( e: AnimationEvent ) => {
if ( ! isMatch && isExitAnimation( e ) ) {
// When the exit animation ends on an unselected screen, set the
// status to 'animated' to remove the screen contents from the DOM.
setExitAnimationStatus( 'animated' );
}
} }
onAnimationEnd={ onScreenAnimationEnd }
{ ...otherProps }
>
{ children }
</View>
);
) : null;
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/**
* WordPress dependencies
*/
import {
useEffect,
useRef,
useState,
useLayoutEffect,
useCallback,
useMemo,
} from '@wordpress/element';
import { usePrevious, useReducedMotion } from '@wordpress/compose';
import { isRTL as isRTLFn } from '@wordpress/i18n';

/**
* Internal dependencies
*/
import * as styles from '../styles';

const isExitAnimation = ( e: AnimationEvent ) =>
e.animationName === styles.slideToLeft.name || styles.slideToRight.name;

export function useScreenAnimatePresence( {
isMatch,
skipAnimation,
isBack,
}: {
isMatch: boolean;
skipAnimation: boolean;
isBack?: boolean;
} ) {
// Possible values:
// - idle: first value assigned to the screen when added to the React tree
// - armed: will start an exit animation when deselected
// - animating: the exit animation is happening
// - animated: the exit animation has ended
const [ exitAnimationStatus, setExitAnimationStatus ] = useState<
'idle' | 'armed' | 'animating' | 'animated'
>( 'idle' );

const isRTL = isRTLFn();
const animationTimeoutRef = useRef< number >();
const prefersReducedMotion = useReducedMotion();

const wasMatch = usePrevious( isMatch );

// Update animation status.
useLayoutEffect( () => {
if ( ! wasMatch && isMatch ) {
// When the screen becomes selected, set it to 'armed',
// meaning that it will start an exit animation when deselected.
setExitAnimationStatus( 'armed' );
} else if ( wasMatch && ! isMatch ) {
// When the screen becomes deselected, set it to:
// - 'animating' (if animations are enabled)
// - 'animated' (causing the animation to end and the screen to stop
// rendering its contents in the DOM, without the need to wait for
// the `animationend` event)
setExitAnimationStatus(
skipAnimation || prefersReducedMotion ? 'animated' : 'animating'
);
}
}, [ isMatch, wasMatch, skipAnimation, prefersReducedMotion ] );

// Fallback timeout to ensure the screen is removed from the DOM in case the
// `animationend` event is not triggered.
useEffect( () => {
if ( exitAnimationStatus === 'animating' ) {
animationTimeoutRef.current = window.setTimeout( () => {
setExitAnimationStatus( 'animated' );
animationTimeoutRef.current = undefined;
}, styles.TOTAL_ANIMATION_DURATION_OUT );
} else if ( animationTimeoutRef.current ) {
window.clearTimeout( animationTimeoutRef.current );
animationTimeoutRef.current = undefined;
}
}, [ exitAnimationStatus ] );

const onScreenAnimationEnd = useCallback(
( e: AnimationEvent ) => {
if ( ! isMatch && isExitAnimation( e ) ) {
// When the exit animation ends on an unselected screen, set the
// status to 'animated' to remove the screen contents from the DOM.
setExitAnimationStatus( 'animated' );
}
},
[ isMatch ]
);

// Styles
const animationDirection =
( isRTL && isBack ) || ( ! isRTL && ! isBack )
? 'forwards'
: 'backwards';
const isAnimatingOut =
exitAnimationStatus === 'animating' ||
exitAnimationStatus === 'animated';
const animationStyles = useMemo(
() =>
styles.navigatorScreenAnimation( {
skipAnimation,
animationDirection,
isAnimatingOut,
} ),
[ skipAnimation, animationDirection, isAnimatingOut ]
);

return {
animationStyles,
// Remove the screen contents from the DOM only when it not selected
// and its exit animation has ended.
shouldRenderScreen:
isMatch ||
exitAnimationStatus === 'armed' ||
exitAnimationStatus === 'animating',
onScreenAnimationEnd,
} as const;
}
20 changes: 10 additions & 10 deletions packages/components/src/navigator/styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,8 @@ export const slideToRight = keyframes( {
} );

type NavigatorScreenAnimationProps = {
skipInitialAnimation: boolean;
direction: 'forwards' | 'backwards';
skipAnimation: boolean;
animationDirection: 'forwards' | 'backwards';
isAnimatingOut: boolean;
};

Expand Down Expand Up @@ -96,9 +96,9 @@ const ANIMATION = {
`,
},
};
const navigatorScreenAnimation = ( {
direction,
skipInitialAnimation,
export const navigatorScreenAnimation = ( {
animationDirection,
skipAnimation,
isAnimatingOut,
}: NavigatorScreenAnimationProps ) => {
return css`
Expand All @@ -109,21 +109,21 @@ const navigatorScreenAnimation = ( {
inset: 0;
` }
animation: ${ skipInitialAnimation
animation: ${ skipAnimation
? 'none'
: ANIMATION[ direction ][ isAnimatingOut ? 'out' : 'in' ] };
: ANIMATION[ animationDirection ][
isAnimatingOut ? 'out' : 'in'
] };
@media ( prefers-reduced-motion ) {
animation: none;
}
`;
};

export const navigatorScreen = ( props: NavigatorScreenAnimationProps ) => css`
export const navigatorScreen = 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 ) }
`;

0 comments on commit 68d898a

Please sign in to comment.