diff --git a/packages/eui/changelogs/upcoming/9514.md b/packages/eui/changelogs/upcoming/9514.md new file mode 100644 index 000000000000..f793c7fd53ba --- /dev/null +++ b/packages/eui/changelogs/upcoming/9514.md @@ -0,0 +1,3 @@ +**Bug fixes** + +- Fixed `EuiFlyoutManager` animation flickering when switching between flyout sessions by removing intermediate transition stages (backgrounding, returning, closing) and limiting opening animations to the initial flyout and first child only diff --git a/packages/eui/src/components/flyout/manager/activity_stage.test.tsx b/packages/eui/src/components/flyout/manager/activity_stage.test.tsx index cfcf66979cde..5fb44914eac8 100644 --- a/packages/eui/src/components/flyout/manager/activity_stage.test.tsx +++ b/packages/eui/src/components/flyout/manager/activity_stage.test.tsx @@ -93,13 +93,16 @@ describe('useFlyoutActivityStage', () => { const TestComponent = ({ flyoutId, level, + shouldAnimate, }: { flyoutId: string; level: 'main' | 'child'; + shouldAnimate?: boolean; }) => { const { activityStage, onAnimationEnd } = useFlyoutActivityStage({ flyoutId, level, + shouldAnimate, }); return ( @@ -175,7 +178,7 @@ describe('useFlyoutActivityStage', () => { }); describe('stage transitions based on activity', () => { - it('transitions from ACTIVE to CLOSING when flyout becomes inactive', () => { + it('when shouldAnimate is false (default), transitions directly to final stage: ACTIVE to INACTIVE', () => { let currentMockState = buildMockState({ layoutMode: LAYOUT_MODE_SIDE_BY_SIDE, mainFlyoutId: 'main-1', @@ -197,12 +200,10 @@ describe('useFlyoutActivityStage', () => { ); - // Initially active expect(screen.getByTestSubject('activity-stage')).toHaveTextContent( STAGE_ACTIVE ); - // Change to inactive - session no longer contains main-1 currentMockState = buildMockState({ layoutMode: LAYOUT_MODE_SIDE_BY_SIDE, mainFlyoutId: 'other-main', @@ -217,12 +218,55 @@ describe('useFlyoutActivityStage', () => { }); rerender(); + expect(mockDispatch).toHaveBeenCalledWith( + mockSetActivityStage('main-1', STAGE_INACTIVE) + ); + }); + + it('when shouldAnimate is true, transitions to intermediate CLOSING when flyout becomes inactive', () => { + let currentMockState = buildMockState({ + layoutMode: LAYOUT_MODE_SIDE_BY_SIDE, + mainFlyoutId: 'main-1', + childFlyoutId: null, + flyouts: [ + { + flyoutId: 'main-1', + level: LEVEL_MAIN, + activityStage: STAGE_ACTIVE, + }, + ], + }); + mockUseFlyoutManager.mockImplementation(() => ({ + state: currentMockState, + dispatch: mockDispatch, + })); + + const { rerender } = render( + + ); + + currentMockState = buildMockState({ + layoutMode: LAYOUT_MODE_SIDE_BY_SIDE, + mainFlyoutId: 'other-main', + childFlyoutId: null, + flyouts: [ + { + flyoutId: 'main-1', + level: LEVEL_MAIN, + activityStage: STAGE_ACTIVE, + }, + ], + }); + rerender( + + ); + expect(mockDispatch).toHaveBeenCalledWith( mockSetActivityStage('main-1', STAGE_CLOSING) ); }); - it('transitions from INACTIVE to RETURNING when flyout becomes active', () => { + it('when shouldAnimate is false (default), transitions directly: INACTIVE to ACTIVE', () => { const stateWithInactive = buildMockState({ layoutMode: LAYOUT_MODE_SIDE_BY_SIDE, mainFlyoutId: 'other-main', @@ -245,12 +289,10 @@ describe('useFlyoutActivityStage', () => { ); - // Initially inactive expect(screen.getByTestSubject('activity-stage')).toHaveTextContent( STAGE_INACTIVE ); - // Change to active - session now contains main-1 currentMockState = buildMockState({ layoutMode: LAYOUT_MODE_SIDE_BY_SIDE, mainFlyoutId: 'main-1', @@ -265,6 +307,50 @@ describe('useFlyoutActivityStage', () => { }); rerender(); + expect(mockDispatch).toHaveBeenCalledWith( + mockSetActivityStage('main-1', STAGE_ACTIVE) + ); + }); + + it('when shouldAnimate is true, transitions to intermediate RETURNING when flyout becomes active', () => { + const stateWithInactive = buildMockState({ + layoutMode: LAYOUT_MODE_SIDE_BY_SIDE, + mainFlyoutId: 'other-main', + childFlyoutId: null, + flyouts: [ + { + flyoutId: 'main-1', + level: LEVEL_MAIN, + activityStage: STAGE_INACTIVE, + }, + ], + }); + let currentMockState = stateWithInactive; + mockUseFlyoutManager.mockImplementation(() => ({ + state: currentMockState, + dispatch: mockDispatch, + })); + + const { rerender } = render( + + ); + + currentMockState = buildMockState({ + layoutMode: LAYOUT_MODE_SIDE_BY_SIDE, + mainFlyoutId: 'main-1', + childFlyoutId: null, + flyouts: [ + { + flyoutId: 'main-1', + level: LEVEL_MAIN, + activityStage: STAGE_INACTIVE, + }, + ], + }); + rerender( + + ); + expect(mockDispatch).toHaveBeenCalledWith( mockSetActivityStage('main-1', STAGE_RETURNING) ); @@ -272,7 +358,7 @@ describe('useFlyoutActivityStage', () => { }); describe('main flyout backgrounding logic', () => { - it('transitions to BACKGROUNDING when main flyout is active, has child, and layout is stacked', () => { + it('when shouldAnimate is false (default), transitions directly to BACKGROUNDED when main has child and layout is stacked', () => { const stateWithChild = buildMockState({ layoutMode: LAYOUT_MODE_STACKED, mainFlyoutId: 'main-1', @@ -286,6 +372,27 @@ describe('useFlyoutActivityStage', () => { render(); + expect(mockDispatch).toHaveBeenCalledWith( + mockSetActivityStage('main-1', STAGE_BACKGROUNDED) + ); + }); + + it('when shouldAnimate is true, transitions to BACKGROUNDING when main has child and layout is stacked', () => { + const stateWithChild = buildMockState({ + layoutMode: LAYOUT_MODE_STACKED, + mainFlyoutId: 'main-1', + childFlyoutId: 'child-1', + flyouts: defaultFlyouts, + }); + mockUseFlyoutManager.mockReturnValue({ + state: stateWithChild, + dispatch: mockDispatch, + }); + + render( + + ); + expect(mockDispatch).toHaveBeenCalledWith( mockSetActivityStage('main-1', STAGE_BACKGROUNDING) ); @@ -350,7 +457,7 @@ describe('useFlyoutActivityStage', () => { }); describe('main flyout returning logic', () => { - it('transitions from BACKGROUNDED to RETURNING when child is gone', () => { + it('when shouldAnimate is false (default), transitions directly from BACKGROUNDED to ACTIVE when child is gone', () => { const stateWithBackgrounded = buildMockState({ layoutMode: LAYOUT_MODE_STACKED, mainFlyoutId: 'main-1', @@ -371,11 +478,11 @@ describe('useFlyoutActivityStage', () => { render(); expect(mockDispatch).toHaveBeenCalledWith( - mockSetActivityStage('main-1', STAGE_RETURNING) + mockSetActivityStage('main-1', STAGE_ACTIVE) ); }); - it('transitions from BACKGROUNDING to RETURNING when child is gone', () => { + it('when shouldAnimate is false (default), transitions directly from BACKGROUNDING to ACTIVE when child is gone', () => { const stateWithBackgrounding = buildMockState({ layoutMode: LAYOUT_MODE_STACKED, mainFlyoutId: 'main-1', @@ -395,12 +502,39 @@ describe('useFlyoutActivityStage', () => { render(); + expect(mockDispatch).toHaveBeenCalledWith( + mockSetActivityStage('main-1', STAGE_ACTIVE) + ); + }); + + it('when shouldAnimate is true, transitions from BACKGROUNDED to RETURNING when child is gone', () => { + const stateWithBackgrounded = buildMockState({ + layoutMode: LAYOUT_MODE_STACKED, + mainFlyoutId: 'main-1', + childFlyoutId: null, + flyouts: [ + { + flyoutId: 'main-1', + level: LEVEL_MAIN, + activityStage: STAGE_BACKGROUNDED, + }, + ], + }); + mockUseFlyoutManager.mockReturnValue({ + state: stateWithBackgrounded, + dispatch: mockDispatch, + }); + + render( + + ); + expect(mockDispatch).toHaveBeenCalledWith( mockSetActivityStage('main-1', STAGE_RETURNING) ); }); - it('transitions from BACKGROUNDED to RETURNING when layout changes to side-by-side', () => { + it('when shouldAnimate is false (default), transitions directly from BACKGROUNDED to ACTIVE when layout is side-by-side', () => { const stateWithBackgrounded = buildMockState({ layoutMode: LAYOUT_MODE_SIDE_BY_SIDE, mainFlyoutId: 'main-1', @@ -421,7 +555,7 @@ describe('useFlyoutActivityStage', () => { render(); expect(mockDispatch).toHaveBeenCalledWith( - mockSetActivityStage('main-1', STAGE_RETURNING) + mockSetActivityStage('main-1', STAGE_ACTIVE) ); }); diff --git a/packages/eui/src/components/flyout/manager/activity_stage.ts b/packages/eui/src/components/flyout/manager/activity_stage.ts index 404916a14f7a..8407dc0482d2 100644 --- a/packages/eui/src/components/flyout/manager/activity_stage.ts +++ b/packages/eui/src/components/flyout/manager/activity_stage.ts @@ -26,13 +26,41 @@ import { useFlyoutManager } from './provider'; export interface UseFlyoutActivityStageParams { flyoutId: string; level: EuiFlyoutLevel; + /** When false, skip intermediate stages (CLOSING, RETURNING, BACKGROUNDING) and transition directly to final state. */ + shouldAnimate?: boolean; } export interface UseFlyoutActivityStageReturn { activityStage: EuiFlyoutActivityStage; + /** + * Pass to the flyout's `onAnimationEnd` prop to finalize transitional stages + * (e.g. CLOSING -> INACTIVE). When `shouldAnimate` is false, the intermediate + * CLOSING/RETURNING/BACKGROUNDING stages are skipped, but OPENING -> ACTIVE + * still relies on this handler since new flyouts always start in OPENING. + */ onAnimationEnd: () => void; } +/** + * Returns the final stage after an animation completes. + * OPENING/RETURNING -> ACTIVE; CLOSING -> INACTIVE; BACKGROUNDING -> BACKGROUNDED. + */ +const getNextStage = ( + stage: EuiFlyoutActivityStage +): EuiFlyoutActivityStage | null => { + switch (stage) { + case STAGE_OPENING: + case STAGE_RETURNING: + return STAGE_ACTIVE; + case STAGE_CLOSING: + return STAGE_INACTIVE; + case STAGE_BACKGROUNDING: + return STAGE_BACKGROUNDED; + default: + return null; + } +}; + /** * Encapsulates all activity-stage transitions and animation-driven updates * for managed flyouts. @@ -44,6 +72,7 @@ export interface UseFlyoutActivityStageReturn { export const useFlyoutActivityStage = ({ flyoutId, level, + shouldAnimate = false, }: UseFlyoutActivityStageParams) => { const ctx = useFlyoutManager(); const state = ctx?.state; @@ -74,83 +103,68 @@ export const useFlyoutActivityStage = ({ stageRef.current = stage; } + const transitionTo = useCallback( + (nextStage: EuiFlyoutActivityStage) => { + ctx?.dispatch?.(setActivityStage(flyoutId, nextStage)); + stageRef.current = nextStage; + }, + [ctx, flyoutId] + ); + /** - * 1. ACTIVE -> CLOSING when no longer the active flyout. - * 2. INACTIVE -> RETURNING when it becomes active again (e.g., reopened or brought forward). - * 3. (Main flyout only) ACTIVE + stacked + has child -> BACKGROUNDING (begin background animation). - * 4. (Main only) BACKGROUNDED/BACKGROUNDING + (child gone OR side-by-side) -> RETURNING (bring main to foreground). - * - * Any stages that depend on animation end (OPENING, RETURNING, CLOSING, BACKGROUNDING) are finalized in `onAnimationEnd`. + * 1. ACTIVE -> CLOSING (or INACTIVE when !shouldAnimate) when no longer the active flyout. + * 2. INACTIVE -> RETURNING (or ACTIVE when !shouldAnimate) when it becomes active again. + * 3. (Main only) ACTIVE + stacked + has child -> BACKGROUNDING (or BACKGROUNDED when !shouldAnimate). + * 4. (Main only) BACKGROUNDED/BACKGROUNDING + (child gone OR side-by-side) -> RETURNING (or ACTIVE when !shouldAnimate). */ useEffect(() => { const s = stageRef.current; let next: EuiFlyoutActivityStage | null = null; - if (s === STAGE_ACTIVE && !isActive) next = STAGE_CLOSING; - else if (s === STAGE_INACTIVE && isActive) { - next = STAGE_RETURNING; + if (s === STAGE_ACTIVE && !isActive) { + next = shouldAnimate ? STAGE_CLOSING : STAGE_INACTIVE; + } else if (s === STAGE_INACTIVE && isActive) { + next = shouldAnimate ? STAGE_RETURNING : STAGE_ACTIVE; } else if ( level === LEVEL_MAIN && isActive && s === STAGE_ACTIVE && hasChild && layoutMode === LAYOUT_MODE_STACKED - ) - next = STAGE_BACKGROUNDING; - else if ( + ) { + next = shouldAnimate ? STAGE_BACKGROUNDING : STAGE_BACKGROUNDED; + } else if ( level === LEVEL_MAIN && (s === STAGE_BACKGROUNDED || s === STAGE_BACKGROUNDING) && (!hasChild || layoutMode === LAYOUT_MODE_SIDE_BY_SIDE) - ) - next = STAGE_RETURNING; - - if (next && next !== s) { - ctx?.dispatch?.(setActivityStage(flyoutId, next)); - stageRef.current = next; + ) { + next = shouldAnimate ? STAGE_RETURNING : STAGE_ACTIVE; } - }, [isActive, hasChild, layoutMode, level, ctx, flyoutId, stage]); - /** - * Get the stage to transition to for given current stage. - * Returns `null` if stage should remain unchanged. - * - * Stage transitions: - * - OPENING / RETURNING -> ACTIVE - * - CLOSING -> INACTIVE - * - BACKGROUNDING -> BACKGROUNDED - */ - const getNextStage = ( - stage: EuiFlyoutActivityStage - ): EuiFlyoutActivityStage | null => { - switch (stage) { - case STAGE_OPENING: - case STAGE_RETURNING: - return STAGE_ACTIVE; - - case STAGE_CLOSING: - return STAGE_INACTIVE; - - case STAGE_BACKGROUNDING: - return STAGE_BACKGROUNDED; + if (next && next !== s) { + transitionTo(next); } - - return null; - }; + }, [ + isActive, + hasChild, + layoutMode, + level, + shouldAnimate, + transitionTo, + stage, + ]); /** - * onAnimationEnd event handler that must be passed to EuiFlyout. - * It handles transitions between stages and updates activity stage - * in EuiFlyoutManagerContext. + * onAnimationEnd: browser signal when a CSS animation completes. + * Calls transitionTo to move to the final stage (e.g. CLOSING -> INACTIVE). */ const onAnimationEnd = useCallback(() => { const currentStage = stageRef.current; const nextStage = getNextStage(currentStage); - if (nextStage && nextStage !== currentStage) { - ctx?.dispatch?.(setActivityStage(flyoutId, nextStage)); - stageRef.current = nextStage; + transitionTo(nextStage); } - }, [ctx, flyoutId]); + }, [transitionTo]); return { activityStage: stage, onAnimationEnd }; }; diff --git a/packages/eui/src/components/flyout/manager/flyout_managed.styles.ts b/packages/eui/src/components/flyout/manager/flyout_managed.styles.ts index a4199a294558..b74d0c54f4f8 100644 --- a/packages/eui/src/components/flyout/manager/flyout_managed.styles.ts +++ b/packages/eui/src/components/flyout/manager/flyout_managed.styles.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { css, keyframes } from '@emotion/react'; +import { css } from '@emotion/react'; import { euiCanAnimate, logicalCSS } from '../../../global_styling'; import { UseEuiTheme } from '../../../services'; import { @@ -36,54 +36,6 @@ export const euiManagedFlyoutStyles = (euiThemeContext: UseEuiTheme) => { side: _EuiFlyoutSide = DEFAULT_SIDE, level: EuiFlyoutLevel ) => { - // Animation for moving flyout backwards in 3D space (z-axis) when inactive - const euiFlyoutSlideBack3D = keyframes` - from { - transform: translateZ(0) translateX(0) scale(1); - filter: blur(0px); - opacity: 1; - } - to { - transform: translateZ(-1500px) translateX(${ - side === 'left' ? 'calc(-100vw - 100%)' : 'calc(100vw + 100%)' - }) scale(0.5); - filter: blur(3px); - opacity: 0.6; - } - `; - - // Animation for bringing flyout forward from 3D space when transitioning to active - const euiFlyoutSlideForward3D = keyframes` - from { - transform: translateZ(-500px) translateX(${ - side === 'left' ? 'calc(-100vw - 100%)' : 'calc(100vw + 100%)' - }) scale(0.85); - filter: blur(3px); - opacity: 0.6; - } - to { - transform: translateZ(0) translateX(0) scale(1); - filter: blur(0px); - opacity: 1; - } - `; - // When flyout is becoming inactive, animate backwards in 3D space - const inactiveTransition = css` - ${euiCanAnimate} { - animation: ${euiFlyoutSlideBack3D} ${euiTheme.animation.extraSlow} - ${euiTheme.animation.resistance} forwards; - pointer-events: none; - } - `; - - // When flyout is becoming active from a backgrounded state, animate forward in 3D space - const returningTransition = css` - ${euiCanAnimate} { - animation: ${euiFlyoutSlideForward3D} ${euiTheme.animation.normal} - ${euiTheme.animation.resistance} forwards; - } - `; - const noTransition = css` ${euiCanAnimate} { animation: none; @@ -115,19 +67,19 @@ export const euiManagedFlyoutStyles = (euiThemeContext: UseEuiTheme) => { return [activeFlyout, noTransition]; case STAGE_BACKGROUNDING: - return [inactiveTransition]; + return [inactiveFlyout, noTransition]; case STAGE_BACKGROUNDED: return [inactiveFlyout, noTransition]; case STAGE_RETURNING: - return [activeFlyout, returningTransition]; + return [activeFlyout, noTransition]; case STAGE_INACTIVE: return [inactiveFlyout, noTransition]; case STAGE_CLOSING: - return [inactiveTransition]; + return [inactiveFlyout, noTransition]; } }, managedFlyout: css` diff --git a/packages/eui/src/components/flyout/manager/flyout_managed.tsx b/packages/eui/src/components/flyout/manager/flyout_managed.tsx index 14d57126c34c..bc77c638c6c1 100644 --- a/packages/eui/src/components/flyout/manager/flyout_managed.tsx +++ b/packages/eui/src/components/flyout/manager/flyout_managed.tsx @@ -14,6 +14,7 @@ import React, { useState, forwardRef, } from 'react'; +import { css } from '@emotion/react'; import { flushSync } from 'react-dom'; import { useCombinedRefs, useEuiMemoizedStyles } from '../../../services'; import { useEuiI18n } from '../../i18n'; @@ -131,6 +132,13 @@ export const EuiManagedFlyout = forwardRef( ?.size : undefined; + // Animate opening only for the first main flyout (sole session) or first child (no prior child in session). + const shouldAnimateOpening = + level === LEVEL_MAIN + ? (managerSessions?.length ?? 0) <= 1 && + currentSession?.mainFlyoutId === flyoutId + : (session?.childHistory?.length ?? 0) === 0; + const styles = useEuiMemoizedStyles(euiManagedFlyoutStyles); // Set default size based on level: main defaults to 'm', child defaults to 's' @@ -309,6 +317,7 @@ export const EuiManagedFlyout = forwardRef( const { activityStage, onAnimationEnd } = useFlyoutActivityStage({ flyoutId, level, + shouldAnimate: false, }); // Note: history controls are only relevant for main flyouts @@ -341,6 +350,11 @@ export const EuiManagedFlyout = forwardRef( styles.managedFlyout, customCss, styles.stage(activityStage, props.side, level), + // Suppress EuiFlyout's built-in opening animation for non-initial flyouts. + !shouldAnimateOpening && + css` + animation-duration: 0s !important; + `, ]} {...{ ...props,