From ee1115012e4c5aec7309d0e4f5a2a6bef36f8f18 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Sat, 24 Aug 2024 17:25:16 +0200 Subject: [PATCH 01/32] Refactor screen logic for better clarity and hook deps --- .../navigator/navigator-screen/component.tsx | 33 +++++++++++-------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/packages/components/src/navigator/navigator-screen/component.tsx b/packages/components/src/navigator/navigator-screen/component.tsx index 5882f271d4518f..fd903a61c9bb66 100644 --- a/packages/components/src/navigator/navigator-screen/component.tsx +++ b/packages/components/src/navigator/navigator-screen/component.tsx @@ -41,14 +41,23 @@ function UnconnectedNavigatorScreen( } const screenId = useId(); + + // Read props and components context. const { children, className, path, ...otherProps } = useContextSystem( props, 'NavigatorScreen' ); + // Read navigator context, destructure location props. const { location, match, addScreen, removeScreen } = useContext( NavigatorContext ); + const { isInitial, isBack, focusTargetSelector, skipFocus } = location; + + // Determine if the screen is currently selected. const isMatch = match === screenId; + + const skipAnimationAndFocusRestoration = !! isInitial && ! isBack; + const wrapperRef = useRef< HTMLDivElement >( null ); useEffect( () => { @@ -76,14 +85,11 @@ function UnconnectedNavigatorScreen( [ className, cx, isInitial, isBack, isRTL ] ); + // Focus restoration const locationRef = useRef( location ); - useEffect( () => { locationRef.current = location; }, [ location ] ); - - // Focus restoration - const isInitialLocation = location.isInitial && ! location.isBack; useEffect( () => { // Only attempt to restore focus: // - if the current location is not the initial one (to avoid moving focus on page load) @@ -92,11 +98,11 @@ 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 || locationRef.current.hasRestoredFocus || - location.skipFocus + skipFocus ) { return; } @@ -113,10 +119,9 @@ 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 = + wrapperRef.current.querySelector( focusTargetSelector ); } // If the previous query didn't run or find any element to focus, fallback @@ -129,11 +134,11 @@ function UnconnectedNavigatorScreen( locationRef.current.hasRestoredFocus = true; elementToFocus.focus(); }, [ - isInitialLocation, + skipAnimationAndFocusRestoration, isMatch, - location.isBack, - location.focusTargetSelector, - location.skipFocus, + isBack, + focusTargetSelector, + skipFocus, ] ); const mergedWrapperRef = useMergeRefs( [ forwardedRef, wrapperRef ] ); From 2832ef0e11f823c12b2edccaf260513f7a6158df Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Sat, 24 Aug 2024 17:26:46 +0200 Subject: [PATCH 02/32] Add exit animation, rewrite screen animations / DOM rendering logic --- .../navigator/navigator-screen/component.tsx | 92 +++++++++++++++++-- packages/components/src/navigator/styles.ts | 81 ++++++++++------ 2 files changed, 136 insertions(+), 37 deletions(-) diff --git a/packages/components/src/navigator/navigator-screen/component.tsx b/packages/components/src/navigator/navigator-screen/component.tsx index fd903a61c9bb66..c71ebd90e439a8 100644 --- a/packages/components/src/navigator/navigator-screen/component.tsx +++ b/packages/components/src/navigator/navigator-screen/component.tsx @@ -13,8 +13,10 @@ import { useMemo, useRef, useId, + useState, + useLayoutEffect, } from '@wordpress/element'; -import { useMergeRefs } from '@wordpress/compose'; +import { useMergeRefs, usePrevious } from '@wordpress/compose'; import { isRTL as isRTLFn } from '@wordpress/i18n'; import { escapeAttribute } from '@wordpress/escape-html'; import warning from '@wordpress/warning'; @@ -30,6 +32,12 @@ import { NavigatorContext } from '../context'; import * as styles from '../styles'; import type { NavigatorScreenProps } from '../types'; +const isReducedMotion = ( w: Window | null | undefined ) => + !! w && w.matchMedia( `(prefers-reduced-motion)` ).matches === true; + +const isExitAnimation = ( e: AnimationEvent ) => + e.animationName === styles.slideToLeft.name || styles.slideToRight.name; + function UnconnectedNavigatorScreen( props: WordPressComponentProps< NavigatorScreenProps, 'div', false >, forwardedRef: ForwardedRef< any > @@ -55,11 +63,21 @@ function UnconnectedNavigatorScreen( // Determine if the screen is currently selected. 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' ); + useEffect( () => { const screen = { id: screenId, @@ -69,20 +87,56 @@ 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 || + isReducedMotion( + wrapperRef.current?.ownerDocument?.defaultView + ) + ? 'animated' + : 'animating' + ); + } + }, [ isMatch, wasMatch, skipAnimationAndFocusRestoration ] ); + + // Styles const isRTL = isRTLFn(); - const { isInitial, isBack } = location; const cx = useCx(); + const animationDirection = + ( isRTL && isBack ) || ( ! isRTL && ! isBack ) + ? 'forwards' + : 'backwards'; + const isAnimatingOut = + exitAnimationStatus === 'animating' || + exitAnimationStatus === 'animated'; const classes = useMemo( () => cx( styles.navigatorScreen( { - isInitial, - isBack, - isRTL, + skipInitialAnimation: skipAnimationAndFocusRestoration, + direction: animationDirection, + isAnimatingOut, } ), className ), - [ className, cx, isInitial, isBack, isRTL ] + [ + className, + cx, + skipAnimationAndFocusRestoration, + animationDirection, + isAnimatingOut, + ] ); // Focus restoration @@ -143,11 +197,31 @@ function UnconnectedNavigatorScreen( const mergedWrapperRef = useMergeRefs( [ forwardedRef, wrapperRef ] ); - return isMatch ? ( - + // 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 ( + { + 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' ); + } + } } + { ...otherProps } + > { children } - ) : null; + ); } /** diff --git a/packages/components/src/navigator/styles.ts b/packages/components/src/navigator/styles.ts index 0203edbdf1816a..ccf77d5a316df0 100644 --- a/packages/components/src/navigator/styles.ts +++ b/packages/components/src/navigator/styles.ts @@ -4,6 +4,7 @@ import { css, keyframes } from '@emotion/react'; export const navigatorProviderWrapper = css` + position: relative; /* Prevents horizontal overflow while animating screen transitions */ overflow-x: hidden; /* Mark this subsection of the DOM as isolated, providing performance benefits @@ -13,50 +14,74 @@ export const navigatorProviderWrapper = css` contain: content; `; -const fadeInFromRight = keyframes( { - '0%': { +const fadeIn = keyframes( { + from: { opacity: 0, - transform: `translateX( 50px )`, }, - '100%': { opacity: 1, transform: 'none' }, } ); -const fadeInFromLeft = keyframes( { - '0%': { +const fadeOut = keyframes( { + to: { opacity: 0, - transform: `translateX( -50px )`, }, - '100%': { opacity: 1, transform: 'none' }, +} ); + +const slideFromRight = keyframes( { + from: { + transform: 'translateX(100px)', + }, +} ); + +export const slideToLeft = keyframes( { + to: { + transform: 'translateX(-80px)', + }, +} ); + +const slideFromLeft = keyframes( { + from: { + transform: 'translateX(-100px)', + }, +} ); + +export const slideToRight = keyframes( { + to: { + transform: 'translateX(80px)', + }, } ); type NavigatorScreenAnimationProps = { - isInitial?: boolean; - isBack?: boolean; - isRTL: boolean; + skipInitialAnimation: boolean; + direction: 'forwards' | 'backwards'; + isAnimatingOut: boolean; }; +const ANIMATION = { + forwards: { + in: css`70ms cubic-bezier(0, 0, 0.2, 1) 70ms both ${ fadeIn }, 300ms cubic-bezier(0.4, 0, 0.2, 1) both ${ slideFromRight }`, + out: css`70ms cubic-bezier(0.4, 0, 1, 1) 40ms forwards ${ fadeOut }, 300ms cubic-bezier(0.4, 0, 0.2, 1) forwards ${ slideToLeft }`, + }, + backwards: { + in: css`70ms cubic-bezier(0, 0, 0.2, 1) 70ms both ${ fadeIn }, 300ms cubic-bezier(0.4, 0, 0.2, 1) both ${ slideFromLeft }`, + out: css`70ms cubic-bezier(0.4, 0, 1, 1) 40ms forwards ${ fadeOut }, 300ms cubic-bezier(0.4, 0, 0.2, 1) forwards ${ slideToRight }`, + }, +}; const navigatorScreenAnimation = ( { - isInitial, - isBack, - isRTL, + direction, + skipInitialAnimation, + isAnimatingOut, }: 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 }; + position: ${ isAnimatingOut ? 'absolute' : 'relative' }; + z-index: ${ isAnimatingOut ? 0 : 1 }; + inset: 0; + + animation: ${ skipInitialAnimation + ? 'none' + : ANIMATION[ direction ][ isAnimatingOut ? 'out' : 'in' ] }; @media ( prefers-reduced-motion ) { - animation-duration: 0s; + animation: none; } `; }; From e6ab671dfe8878cefd96a041b58d0d8b61ddb360 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Sat, 24 Aug 2024 17:42:40 +0200 Subject: [PATCH 03/32] Only add inset CSS rule when animating out --- packages/components/src/navigator/styles.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/components/src/navigator/styles.ts b/packages/components/src/navigator/styles.ts index ccf77d5a316df0..e01ea73aad1076 100644 --- a/packages/components/src/navigator/styles.ts +++ b/packages/components/src/navigator/styles.ts @@ -74,7 +74,10 @@ const navigatorScreenAnimation = ( { return css` position: ${ isAnimatingOut ? 'absolute' : 'relative' }; z-index: ${ isAnimatingOut ? 0 : 1 }; - inset: 0; + ${ isAnimatingOut && + css` + inset: 0; + ` } animation: ${ skipInitialAnimation ? 'none' From 0a869dc20b0721d2d3b8cb9e101078eacde7c9f1 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Sat, 24 Aug 2024 17:42:49 +0200 Subject: [PATCH 04/32] Parametrise animation --- packages/components/src/navigator/styles.ts | 33 ++++++++++++++++++--- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/packages/components/src/navigator/styles.ts b/packages/components/src/navigator/styles.ts index e01ea73aad1076..648c4f38a0b674 100644 --- a/packages/components/src/navigator/styles.ts +++ b/packages/components/src/navigator/styles.ts @@ -56,14 +56,39 @@ type NavigatorScreenAnimationProps = { isAnimatingOut: boolean; }; +const FADE = { + DURATION: '70ms', + EASING: 'linear', + DELAY: { + IN: '70ms', + OUT: '40ms', + }, +}; +const SLIDE = { + DURATION: '300ms', + EASING: 'cubic-bezier(0.33, 0, 0, 1)', +}; + const ANIMATION = { forwards: { - in: css`70ms cubic-bezier(0, 0, 0.2, 1) 70ms both ${ fadeIn }, 300ms cubic-bezier(0.4, 0, 0.2, 1) both ${ slideFromRight }`, - out: css`70ms cubic-bezier(0.4, 0, 1, 1) 40ms forwards ${ fadeOut }, 300ms cubic-bezier(0.4, 0, 0.2, 1) forwards ${ slideToLeft }`, + in: css` + ${ FADE.DURATION } ${ FADE.EASING } ${ FADE.DELAY + .IN } both ${ fadeIn }, ${ SLIDE.DURATION } ${ SLIDE.EASING } both ${ slideFromRight } + `, + out: css` + ${ FADE.DURATION } ${ FADE.EASING } ${ FADE.DELAY + .IN } both ${ fadeOut }, ${ SLIDE.DURATION } ${ SLIDE.EASING } both ${ slideToLeft } + `, }, backwards: { - in: css`70ms cubic-bezier(0, 0, 0.2, 1) 70ms both ${ fadeIn }, 300ms cubic-bezier(0.4, 0, 0.2, 1) both ${ slideFromLeft }`, - out: css`70ms cubic-bezier(0.4, 0, 1, 1) 40ms forwards ${ fadeOut }, 300ms cubic-bezier(0.4, 0, 0.2, 1) forwards ${ slideToRight }`, + in: css` + ${ FADE.DURATION } ${ FADE.EASING } ${ FADE.DELAY + .IN } both ${ fadeIn }, ${ SLIDE.DURATION } ${ SLIDE.EASING } both ${ slideFromLeft } + `, + out: css` + ${ FADE.DURATION } ${ FADE.EASING } ${ FADE.DELAY + .OUT } both ${ fadeOut }, ${ SLIDE.DURATION } ${ SLIDE.EASING } both ${ slideToRight } + `, }, }; const navigatorScreenAnimation = ( { From 85b5214e1f6979162e26096e52b94a6cda7faf8b Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Sun, 25 Aug 2024 17:00:17 +0200 Subject: [PATCH 05/32] CHANGELOG --- packages/components/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index 08e1027f3e7870..d2cb644f76b9bc 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -95,6 +95,7 @@ - Contains internal visual changes from this PR: - `AnglePickerControl` - `ColorPicker` +- `Navigator`: add support for exit animation ([#64777](https://github.com/WordPress/gutenberg/pull/64777)). - Decrease horizontal padding from 16px to 12px on the following components, when in the 40px default size ([#64708](https://github.com/WordPress/gutenberg/pull/64708)). - `AnglePickerControl` - `ColorPicker` (on the inputs) From 7b2df6aa040f212a74d39aa87c126019b011e4ae Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Mon, 26 Aug 2024 13:09:50 +0200 Subject: [PATCH 06/32] Add fallback timeout --- .../navigator/navigator-screen/component.tsx | 14 +++++++++ packages/components/src/navigator/styles.ts | 29 +++++++++++-------- 2 files changed, 31 insertions(+), 12 deletions(-) diff --git a/packages/components/src/navigator/navigator-screen/component.tsx b/packages/components/src/navigator/navigator-screen/component.tsx index c71ebd90e439a8..2a57ff26f7075f 100644 --- a/packages/components/src/navigator/navigator-screen/component.tsx +++ b/packages/components/src/navigator/navigator-screen/component.tsx @@ -49,6 +49,7 @@ function UnconnectedNavigatorScreen( } const screenId = useId(); + const animationTimeoutRef = useRef< number >(); // Read props and components context. const { children, className, path, ...otherProps } = useContextSystem( @@ -138,6 +139,19 @@ function UnconnectedNavigatorScreen( isAnimatingOut, ] ); + // 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 ); diff --git a/packages/components/src/navigator/styles.ts b/packages/components/src/navigator/styles.ts index 648c4f38a0b674..679b43138ead1f 100644 --- a/packages/components/src/navigator/styles.ts +++ b/packages/components/src/navigator/styles.ts @@ -57,37 +57,42 @@ type NavigatorScreenAnimationProps = { }; const FADE = { - DURATION: '70ms', + DURATION: 70, EASING: 'linear', DELAY: { - IN: '70ms', - OUT: '40ms', + IN: 70, + OUT: 40, }, }; const SLIDE = { - DURATION: '300ms', + DURATION: 300, EASING: 'cubic-bezier(0.33, 0, 0, 1)', }; +export const TOTAL_ANIMATION_DURATION_OUT = Math.max( + FADE.DURATION + FADE.DELAY.OUT, + SLIDE.DURATION +); + const ANIMATION = { forwards: { in: css` - ${ FADE.DURATION } ${ FADE.EASING } ${ FADE.DELAY - .IN } both ${ fadeIn }, ${ SLIDE.DURATION } ${ SLIDE.EASING } both ${ slideFromRight } + ${ FADE.DURATION }ms ${ FADE.EASING } ${ FADE.DELAY + .IN }ms both ${ fadeIn }, ${ SLIDE.DURATION }ms ${ SLIDE.EASING } both ${ slideFromRight } `, out: css` - ${ FADE.DURATION } ${ FADE.EASING } ${ FADE.DELAY - .IN } both ${ fadeOut }, ${ SLIDE.DURATION } ${ SLIDE.EASING } both ${ slideToLeft } + ${ FADE.DURATION }ms ${ FADE.EASING } ${ FADE.DELAY + .IN }ms both ${ fadeOut }, ${ SLIDE.DURATION }ms ${ SLIDE.EASING } both ${ slideToLeft } `, }, backwards: { in: css` - ${ FADE.DURATION } ${ FADE.EASING } ${ FADE.DELAY - .IN } both ${ fadeIn }, ${ SLIDE.DURATION } ${ SLIDE.EASING } both ${ slideFromLeft } + ${ FADE.DURATION }ms ${ FADE.EASING } ${ FADE.DELAY + .IN }ms both ${ fadeIn }, ${ SLIDE.DURATION }ms ${ SLIDE.EASING } both ${ slideFromLeft } `, out: css` - ${ FADE.DURATION } ${ FADE.EASING } ${ FADE.DELAY - .OUT } both ${ fadeOut }, ${ SLIDE.DURATION } ${ SLIDE.EASING } both ${ slideToRight } + ${ FADE.DURATION }ms ${ FADE.EASING } ${ FADE.DELAY + .OUT }ms both ${ fadeOut }, ${ SLIDE.DURATION }ms ${ SLIDE.EASING } both ${ slideToRight } `, }, }; From 9792e280877518274f51863e94b054015219ac60 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Wed, 28 Aug 2024 23:55:58 +0200 Subject: [PATCH 07/32] Mention wrapper height in README --- .../components/src/navigator/navigator-provider/README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/components/src/navigator/navigator-provider/README.md b/packages/components/src/navigator/navigator-provider/README.md index 35bf7a69720be2..3290b39c83010c 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 given enough height to match the tallest `NavigatorScreen`. Not doing so could result in glitchy animations, especially when transitioning from a taller to a shorter `NavigatorScreen`. + ## Props The component accepts the following props: From 12a185ffaf8759b4525fca8f72d519c04a5820ab Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Thu, 29 Aug 2024 11:25:31 +0200 Subject: [PATCH 08/32] Use `useReducedMotion()` hook instead of custom logic --- .../navigator/navigator-screen/component.tsx | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/packages/components/src/navigator/navigator-screen/component.tsx b/packages/components/src/navigator/navigator-screen/component.tsx index 2a57ff26f7075f..6a283524deefcb 100644 --- a/packages/components/src/navigator/navigator-screen/component.tsx +++ b/packages/components/src/navigator/navigator-screen/component.tsx @@ -16,7 +16,11 @@ import { useState, useLayoutEffect, } from '@wordpress/element'; -import { useMergeRefs, usePrevious } from '@wordpress/compose'; +import { + useMergeRefs, + usePrevious, + useReducedMotion, +} from '@wordpress/compose'; import { isRTL as isRTLFn } from '@wordpress/i18n'; import { escapeAttribute } from '@wordpress/escape-html'; import warning from '@wordpress/warning'; @@ -32,9 +36,6 @@ import { NavigatorContext } from '../context'; import * as styles from '../styles'; import type { NavigatorScreenProps } from '../types'; -const isReducedMotion = ( w: Window | null | undefined ) => - !! w && w.matchMedia( `(prefers-reduced-motion)` ).matches === true; - const isExitAnimation = ( e: AnimationEvent ) => e.animationName === styles.slideToLeft.name || styles.slideToRight.name; @@ -50,6 +51,7 @@ function UnconnectedNavigatorScreen( const screenId = useId(); const animationTimeoutRef = useRef< number >(); + const prefersReducedMotion = useReducedMotion(); // Read props and components context. const { children, className, path, ...otherProps } = useContextSystem( @@ -101,15 +103,17 @@ function UnconnectedNavigatorScreen( // rendering its contents in the DOM, without the need to wait for // the `animationend` event) setExitAnimationStatus( - skipAnimationAndFocusRestoration || - isReducedMotion( - wrapperRef.current?.ownerDocument?.defaultView - ) + skipAnimationAndFocusRestoration || prefersReducedMotion ? 'animated' : 'animating' ); } - }, [ isMatch, wasMatch, skipAnimationAndFocusRestoration ] ); + }, [ + isMatch, + wasMatch, + skipAnimationAndFocusRestoration, + prefersReducedMotion, + ] ); // Styles const isRTL = isRTLFn(); From 668a2221289edae11ee5a75bc1a1a69207918d8e Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Thu, 29 Aug 2024 11:47:51 +0200 Subject: [PATCH 09/32] Extract useScreenAnimatePresence hook, tidy up --- .../navigator/navigator-screen/component.tsx | 129 +++--------------- .../use-screen-animate-presence.ts | 118 ++++++++++++++++ packages/components/src/navigator/styles.ts | 20 +-- 3 files changed, 149 insertions(+), 118 deletions(-) create mode 100644 packages/components/src/navigator/navigator-screen/use-screen-animate-presence.ts diff --git a/packages/components/src/navigator/navigator-screen/component.tsx b/packages/components/src/navigator/navigator-screen/component.tsx index 6a283524deefcb..ad8eb672bf366e 100644 --- a/packages/components/src/navigator/navigator-screen/component.tsx +++ b/packages/components/src/navigator/navigator-screen/component.tsx @@ -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'; import warning from '@wordpress/warning'; @@ -35,9 +28,7 @@ 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 >, @@ -49,9 +40,12 @@ function UnconnectedNavigatorScreen( ); } + // 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( @@ -64,23 +58,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, @@ -90,72 +72,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 ); @@ -213,33 +143,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 ? ( { - 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 } - ); + ) : 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 00000000000000..6b91593a99d6a3 --- /dev/null +++ b/packages/components/src/navigator/navigator-screen/use-screen-animate-presence.ts @@ -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; +} diff --git a/packages/components/src/navigator/styles.ts b/packages/components/src/navigator/styles.ts index 679b43138ead1f..bf1b57dab69d92 100644 --- a/packages/components/src/navigator/styles.ts +++ b/packages/components/src/navigator/styles.ts @@ -51,8 +51,8 @@ export const slideToRight = keyframes( { } ); type NavigatorScreenAnimationProps = { - skipInitialAnimation: boolean; - direction: 'forwards' | 'backwards'; + skipAnimation: boolean; + animationDirection: 'forwards' | 'backwards'; isAnimatingOut: boolean; }; @@ -96,9 +96,9 @@ const ANIMATION = { `, }, }; -const navigatorScreenAnimation = ( { - direction, - skipInitialAnimation, +export const navigatorScreenAnimation = ( { + animationDirection, + skipAnimation, isAnimatingOut, }: NavigatorScreenAnimationProps ) => { return css` @@ -109,9 +109,11 @@ 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; @@ -119,11 +121,9 @@ const navigatorScreenAnimation = ( { `; }; -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 ) } `; From 98c7b201cf3f132a21bf4c01b7aedf585f60f35e Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Thu, 29 Aug 2024 13:14:09 +0200 Subject: [PATCH 10/32] Forward animationEnd --- .../src/navigator/navigator-screen/component.tsx | 16 ++++++++++------ .../use-screen-animate-presence.ts | 16 ++++++++++------ 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/packages/components/src/navigator/navigator-screen/component.tsx b/packages/components/src/navigator/navigator-screen/component.tsx index ad8eb672bf366e..c54025c0809328 100644 --- a/packages/components/src/navigator/navigator-screen/component.tsx +++ b/packages/components/src/navigator/navigator-screen/component.tsx @@ -48,10 +48,13 @@ function UnconnectedNavigatorScreen( const mergedWrapperRef = useMergeRefs( [ forwardedRef, wrapperRef ] ); // Read props and components context. - const { children, className, path, ...otherProps } = useContextSystem( - props, - 'NavigatorScreen' - ); + const { + children, + className, + path, + onAnimationEnd: onAnimationEndProp, + ...otherProps + } = useContextSystem( props, 'NavigatorScreen' ); // Read navigator context, destructure location props. const { location, match, addScreen, removeScreen } = @@ -73,10 +76,11 @@ function UnconnectedNavigatorScreen( }, [ screenId, path, addScreen, removeScreen ] ); // Animation. - const { animationStyles, onScreenAnimationEnd, shouldRenderScreen } = + const { animationStyles, shouldRenderScreen, onAnimationEnd } = useScreenAnimatePresence( { isMatch, isBack, + onAnimationEnd: onAnimationEndProp, skipAnimation: skipAnimationAndFocusRestoration, } ); @@ -147,7 +151,7 @@ function UnconnectedNavigatorScreen( { children } 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 index 6b91593a99d6a3..05e57c46070834 100644 --- a/packages/components/src/navigator/navigator-screen/use-screen-animate-presence.ts +++ b/packages/components/src/navigator/navigator-screen/use-screen-animate-presence.ts @@ -17,17 +17,19 @@ import { isRTL as isRTLFn } from '@wordpress/i18n'; */ import * as styles from '../styles'; -const isExitAnimation = ( e: AnimationEvent ) => - e.animationName === styles.slideToLeft.name || styles.slideToRight.name; +const isExitAnimation = ( animationName: string ) => + animationName === styles.slideToLeft.name || styles.slideToRight.name; export function useScreenAnimatePresence( { isMatch, skipAnimation, isBack, + onAnimationEnd, }: { isMatch: boolean; skipAnimation: boolean; isBack?: boolean; + onAnimationEnd?: React.AnimationEventHandler< Element >; } ) { // Possible values: // - idle: first value assigned to the screen when added to the React tree @@ -77,14 +79,16 @@ export function useScreenAnimatePresence( { }, [ exitAnimationStatus ] ); const onScreenAnimationEnd = useCallback( - ( e: AnimationEvent ) => { - if ( ! isMatch && isExitAnimation( e ) ) { + ( e: React.AnimationEvent< HTMLElement > ) => { + onAnimationEnd?.( e ); + + if ( ! isMatch && isExitAnimation( e.animationName ) ) { // 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 ] + [ onAnimationEnd, isMatch ] ); // Styles @@ -113,6 +117,6 @@ export function useScreenAnimatePresence( { isMatch || exitAnimationStatus === 'armed' || exitAnimationStatus === 'animating', - onScreenAnimationEnd, + onAnimationEnd: onScreenAnimationEnd, } as const; } From 808780e1204422895e0f29bb8ece7391f627ef97 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Thu, 29 Aug 2024 17:15:55 +0200 Subject: [PATCH 11/32] Add `setWrapperHeight` functionality via context --- packages/components/src/navigator/context.ts | 1 + .../navigator-provider/component.tsx | 29 +++++++++++++++++-- packages/components/src/navigator/types.ts | 1 + 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/packages/components/src/navigator/context.ts b/packages/components/src/navigator/context.ts index e195621b03d205..01ac64b7e8b1da 100644 --- a/packages/components/src/navigator/context.ts +++ b/packages/components/src/navigator/context.ts @@ -15,6 +15,7 @@ const initialContextValue: NavigatorContextType = { goToParent: () => {}, addScreen: () => {}, removeScreen: () => {}, + setWrapperHeight: () => {}, params: {}, }; export const NavigatorContext = createContext( initialContextValue ); diff --git a/packages/components/src/navigator/navigator-provider/component.tsx b/packages/components/src/navigator/navigator-provider/component.tsx index 01254b743f87d0..9bc3780393b0e5 100644 --- a/packages/components/src/navigator/navigator-provider/component.tsx +++ b/packages/components/src/navigator/navigator-provider/component.tsx @@ -6,7 +6,8 @@ import type { ForwardedRef } from 'react'; /** * WordPress dependencies */ -import { useMemo, useReducer } from '@wordpress/element'; +import { useMergeRefs } from '@wordpress/compose'; +import { useMemo, useReducer, useState, useRef } from '@wordpress/element'; import isShallowEqual from '@wordpress/is-shallow-equal'; import warning from '@wordpress/warning'; @@ -236,6 +237,10 @@ function UnconnectedNavigatorProvider( } ) ); + const wrapperRef = useRef< HTMLElement | null >( null ); + const mergedWrapperRef = useMergeRefs( [ forwardedRef, wrapperRef ] ); + const [ wrapperHeight, setWrapperHeight ] = useState< number >(); + // The methods are constant forever, create stable references to them. const methods = useMemo( () => ( { @@ -268,6 +273,18 @@ function UnconnectedNavigatorProvider( location: currentLocation, params: matchedPath?.params ?? {}, match: matchedPath?.id, + setWrapperHeight: ( height: number | undefined ) => { + setWrapperHeight( + height === undefined + ? // An undefined `height` is used to remove the inline style. + undefined + : // Ensure the height is at least the outer height. + Math.max( + height, + wrapperRef.current?.offsetHeight ?? 0 + ) + ); + }, ...methods, } ), [ currentLocation, matchedPath, methods ] @@ -280,7 +297,15 @@ function UnconnectedNavigatorProvider( ); return ( - + { children } diff --git a/packages/components/src/navigator/types.ts b/packages/components/src/navigator/types.ts index 855787b4d0a193..3153e2643d3ad3 100644 --- a/packages/components/src/navigator/types.ts +++ b/packages/components/src/navigator/types.ts @@ -84,6 +84,7 @@ export type NavigatorContext = Navigator & { addScreen: ( screen: Screen ) => void; removeScreen: ( screen: Screen ) => void; match?: string; + setWrapperHeight?: ( height: number | undefined ) => void; }; export type NavigatorProviderProps = { From 1e0ae6bffdb7f0ecd607c0b6ad5577d227543a49 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Thu, 29 Aug 2024 17:16:20 +0200 Subject: [PATCH 12/32] Use `clip` instead of `hidden` for overflow-x --- packages/components/src/navigator/styles.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/components/src/navigator/styles.ts b/packages/components/src/navigator/styles.ts index bf1b57dab69d92..a2788550f64b75 100644 --- a/packages/components/src/navigator/styles.ts +++ b/packages/components/src/navigator/styles.ts @@ -6,10 +6,10 @@ import { css, keyframes } from '@emotion/react'; export const navigatorProviderWrapper = css` position: relative; /* 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 and paint to a DOM subtree rather * than the entire page. + overflow-x: clip; */ contain: content; `; From 4a19bb16594436617b92edb557f7488aadb27ca0 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Thu, 29 Aug 2024 17:17:00 +0200 Subject: [PATCH 13/32] Less aggressive clipping for screens that are animating out --- packages/components/src/navigator/styles.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/components/src/navigator/styles.ts b/packages/components/src/navigator/styles.ts index a2788550f64b75..3089fc5d768411 100644 --- a/packages/components/src/navigator/styles.ts +++ b/packages/components/src/navigator/styles.ts @@ -6,12 +6,12 @@ import { css, keyframes } from '@emotion/react'; export const navigatorProviderWrapper = css` position: relative; /* Prevents horizontal overflow while animating screen transitions */ - /* Mark this subsection of the DOM as isolated, providing performance benefits - * by limiting calculations of layout, style and paint to a DOM subtree rather - * than the entire page. overflow-x: clip; + /* + * Mark this DOM subtree as isolated when it comes to layout calculations, + * providing performance benefits. */ - contain: content; + contain: layout; `; const fadeIn = keyframes( { From 5dde1286301053472a021196906bd0cc0a47d479 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Thu, 29 Aug 2024 17:17:25 +0200 Subject: [PATCH 14/32] Better sizing styles for screen, to keep it more stable while transitioning out --- packages/components/src/navigator/styles.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/components/src/navigator/styles.ts b/packages/components/src/navigator/styles.ts index 3089fc5d768411..744650095af0c2 100644 --- a/packages/components/src/navigator/styles.ts +++ b/packages/components/src/navigator/styles.ts @@ -104,10 +104,9 @@ export const navigatorScreenAnimation = ( { return css` position: ${ isAnimatingOut ? 'absolute' : 'relative' }; z-index: ${ isAnimatingOut ? 0 : 1 }; - ${ isAnimatingOut && - css` - inset: 0; - ` } + inset-block-start: ${ isAnimatingOut ? 0 : 'initial' }; + inset-inline-start: ${ isAnimatingOut ? 0 : 'initial' }; + inset-inline-end: ${ isAnimatingOut ? 0 : 'initial' }; animation: ${ skipAnimation ? 'none' @@ -126,4 +125,5 @@ export const navigatorScreen = css` overflow-x: auto; /* In case the root has a height, it should not be exceeded */ max-height: 100%; + box-sizing: border-box; `; From ea5f1eddef9fe7aa4b29f725e0876a694f8b0f5f Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Thu, 29 Aug 2024 17:26:35 +0200 Subject: [PATCH 15/32] Refine internal animation logic for less jumpy animations --- .../navigator/navigator-screen/component.tsx | 33 +++---- .../use-screen-animate-presence.ts | 85 ++++++++++++------- packages/components/src/navigator/styles.ts | 4 +- 3 files changed, 73 insertions(+), 49 deletions(-) diff --git a/packages/components/src/navigator/navigator-screen/component.tsx b/packages/components/src/navigator/navigator-screen/component.tsx index c54025c0809328..19d387350fa912 100644 --- a/packages/components/src/navigator/navigator-screen/component.tsx +++ b/packages/components/src/navigator/navigator-screen/component.tsx @@ -13,6 +13,7 @@ import { useMemo, useRef, useId, + useState, } from '@wordpress/element'; import { useMergeRefs } from '@wordpress/compose'; import { escapeAttribute } from '@wordpress/escape-html'; @@ -43,11 +44,14 @@ function UnconnectedNavigatorScreen( // Generate a unique ID for the screen. const screenId = useId(); - // Refs - const wrapperRef = useRef< HTMLDivElement >( null ); - const mergedWrapperRef = useMergeRefs( [ forwardedRef, wrapperRef ] ); + const [ wrapperEl, setWrapperEl ] = useState< HTMLElement | null >( null ); + const wrapperRefCallback: React.RefCallback< HTMLElement > = ( el ) => + setWrapperEl( el ); + const mergedWrapperRef = useMergeRefs( [ + forwardedRef, + wrapperRefCallback, + ] ); - // Read props and components context. const { children, className, @@ -56,12 +60,10 @@ function UnconnectedNavigatorScreen( ...otherProps } = useContextSystem( props, 'NavigatorScreen' ); - // Read navigator context, destructure location props. - const { location, match, addScreen, removeScreen } = + const { location, match, addScreen, removeScreen, setWrapperHeight } = useContext( NavigatorContext ); const { isInitial, isBack, focusTargetSelector, skipFocus } = location; - // Locally computed state const isMatch = match === screenId; const skipAnimationAndFocusRestoration = !! isInitial && ! isBack; @@ -82,9 +84,10 @@ function UnconnectedNavigatorScreen( isBack, onAnimationEnd: onAnimationEndProp, skipAnimation: skipAnimationAndFocusRestoration, + screenEl: wrapperEl, + setWrapperHeight, } ); - // Styles const cx = useCx(); const classes = useMemo( () => cx( styles.navigatorScreen, animationStyles, className ), @@ -106,18 +109,18 @@ function UnconnectedNavigatorScreen( if ( skipAnimationAndFocusRestoration || ! isMatch || - ! wrapperRef.current || + ! wrapperEl || locationRef.current.hasRestoredFocus || 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; } @@ -126,15 +129,14 @@ 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 ( isBack && focusTargetSelector ) { - elementToFocus = - wrapperRef.current.querySelector( 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; @@ -145,6 +147,7 @@ function UnconnectedNavigatorScreen( isBack, focusTargetSelector, skipFocus, + wrapperEl, ] ); return shouldRenderScreen ? ( 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 index 05e57c46070834..694fa1956baf52 100644 --- a/packages/components/src/navigator/navigator-screen/use-screen-animate-presence.ts +++ b/packages/components/src/navigator/navigator-screen/use-screen-animate-presence.ts @@ -17,6 +17,8 @@ import { isRTL as isRTLFn } from '@wordpress/i18n'; */ import * as styles from '../styles'; +const isEnterAnimation = ( animationName: string ) => + animationName === styles.slideFromLeft.name || styles.slideFromRight.name; const isExitAnimation = ( animationName: string ) => animationName === styles.slideToLeft.name || styles.slideToRight.name; @@ -25,58 +27,74 @@ export function useScreenAnimatePresence( { skipAnimation, isBack, onAnimationEnd, + screenEl, + setWrapperHeight, }: { isMatch: boolean; skipAnimation: boolean; isBack?: boolean; onAnimationEnd?: React.AnimationEventHandler< Element >; + screenEl?: HTMLElement | null; + setWrapperHeight?: ( height: number | undefined ) => void; } ) { - // 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(); + // Possible values: + // - 'INITIAL': the initial state + // - 'IN_START': start enter animation + // - 'IN_END': enter animation has ended + // - 'OUT_START': start exit animation + // - 'OUT_END': the exit animation has ended + const [ animationStatus, setAnimationStatus ] = useState< + 'INITIAL' | 'IN_START' | 'IN_END' | 'OUT_START' | 'OUT_END' + >( 'INITIAL' ); + const wasMatch = usePrevious( isMatch ); - // Update animation status. + const screenHeightRef = useRef< number | undefined >(); + + // Start enter and exit animations when the screen is selected or deselected. + // The animation status is set to `*_END` immediately if the animation should + // be skipped. 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' ); + screenHeightRef.current = undefined; + setAnimationStatus( + skipAnimation || prefersReducedMotion ? 'IN_END' : 'IN_START' + ); } 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' + screenHeightRef.current = screenEl?.offsetHeight; + setAnimationStatus( + skipAnimation || prefersReducedMotion ? 'OUT_END' : 'OUT_START' ); } - }, [ isMatch, wasMatch, skipAnimation, prefersReducedMotion ] ); + }, [ isMatch, wasMatch, skipAnimation, prefersReducedMotion, screenEl ] ); + + // When starting an animation, set the wrapper height to the screen height, + // to prevent layout shifts during the animation. + useEffect( () => { + if ( animationStatus === 'OUT_START' ) { + setWrapperHeight?.( screenHeightRef.current ?? 0 ); + } else if ( animationStatus === 'OUT_END' ) { + setWrapperHeight?.( undefined ); + } + }, [ screenEl, animationStatus, setWrapperHeight ] ); // Fallback timeout to ensure the screen is removed from the DOM in case the // `animationend` event is not triggered. useEffect( () => { - if ( exitAnimationStatus === 'animating' ) { + if ( animationStatus === 'OUT_START' ) { animationTimeoutRef.current = window.setTimeout( () => { - setExitAnimationStatus( 'animated' ); + setAnimationStatus( 'OUT_END' ); animationTimeoutRef.current = undefined; }, styles.TOTAL_ANIMATION_DURATION_OUT ); } else if ( animationTimeoutRef.current ) { window.clearTimeout( animationTimeoutRef.current ); animationTimeoutRef.current = undefined; } - }, [ exitAnimationStatus ] ); + }, [ animationStatus ] ); const onScreenAnimationEnd = useCallback( ( e: React.AnimationEvent< HTMLElement > ) => { @@ -84,8 +102,12 @@ export function useScreenAnimatePresence( { if ( ! isMatch && isExitAnimation( e.animationName ) ) { // When the exit animation ends on an unselected screen, set the - // status to 'animated' to remove the screen contents from the DOM. - setExitAnimationStatus( 'animated' ); + // status to 'OUT_END' to remove the screen contents from the DOM. + setAnimationStatus( 'OUT_END' ); + } else if ( isMatch && isEnterAnimation( e.animationName ) ) { + // When the enter animation ends on a selected screen, set the + // status to 'IN_END' to ensure the screen is rendered in the DOM. + setAnimationStatus( 'IN_END' ); } }, [ onAnimationEnd, isMatch ] @@ -97,8 +119,7 @@ export function useScreenAnimatePresence( { ? 'forwards' : 'backwards'; const isAnimatingOut = - exitAnimationStatus === 'animating' || - exitAnimationStatus === 'animated'; + animationStatus === 'OUT_START' || animationStatus === 'OUT_END'; const animationStyles = useMemo( () => styles.navigatorScreenAnimation( { @@ -111,12 +132,12 @@ export function useScreenAnimatePresence( { return { animationStyles, - // Remove the screen contents from the DOM only when it not selected - // and its exit animation has ended. + // Render the screen's contents in the DOM not only when the screen is + // selected, but also while it is animating out. shouldRenderScreen: isMatch || - exitAnimationStatus === 'armed' || - exitAnimationStatus === 'animating', + animationStatus === 'IN_END' || + animationStatus === 'OUT_START', onAnimationEnd: onScreenAnimationEnd, } as const; } diff --git a/packages/components/src/navigator/styles.ts b/packages/components/src/navigator/styles.ts index 744650095af0c2..9469cd25780e8d 100644 --- a/packages/components/src/navigator/styles.ts +++ b/packages/components/src/navigator/styles.ts @@ -26,7 +26,7 @@ const fadeOut = keyframes( { }, } ); -const slideFromRight = keyframes( { +export const slideFromRight = keyframes( { from: { transform: 'translateX(100px)', }, @@ -38,7 +38,7 @@ export const slideToLeft = keyframes( { }, } ); -const slideFromLeft = keyframes( { +export const slideFromLeft = keyframes( { from: { transform: 'translateX(-100px)', }, From c9e9998fc3fd1d2a889462d49aba3d15aae3e1cb Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Thu, 29 Aug 2024 17:26:50 +0200 Subject: [PATCH 16/32] Remove unnecessary Storybook styles --- packages/components/src/navigator/stories/index.story.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/components/src/navigator/stories/index.story.tsx b/packages/components/src/navigator/stories/index.story.tsx index 30b9c71a368c1a..f0e11b6fdf0608 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 ( <>