From 0eb5a30ce34e9b0be8e642554c0b4e62ce88018c Mon Sep 17 00:00:00 2001 From: Timothy Sullivan Date: Wed, 29 Oct 2025 18:50:49 -0700 Subject: [PATCH 1/5] Improve multi-session storybook --- .../manager/flyout_sessions.stories.tsx | 393 ++++++++++-------- 1 file changed, 225 insertions(+), 168 deletions(-) diff --git a/packages/eui/src/components/flyout/manager/flyout_sessions.stories.tsx b/packages/eui/src/components/flyout/manager/flyout_sessions.stories.tsx index 5ea91759a1d..32437b1d81b 100644 --- a/packages/eui/src/components/flyout/manager/flyout_sessions.stories.tsx +++ b/packages/eui/src/components/flyout/manager/flyout_sessions.stories.tsx @@ -12,7 +12,6 @@ import React, { useCallback, useEffect, useLayoutEffect, - useMemo, useRef, useState, } from 'react'; @@ -57,9 +56,6 @@ interface FlyoutSessionProps { mainMaxWidth?: number; childSize?: 's' | 'm' | 'fill'; childMaxWidth?: number; - flyoutType: 'overlay' | 'push'; - ownFocus?: boolean; - hasChildBackground: boolean; } const DisplayContext: React.FC<{ title: string }> = ({ title }) => { @@ -87,21 +83,15 @@ const DisplayContext: React.FC<{ title: string }> = ({ title }) => { ); }; -const FlyoutSession: React.FC = React.memo((props) => { - const { - title, - mainSize, - childSize, - mainMaxWidth, - childMaxWidth, - flyoutType, - ownFocus = false, - hasChildBackground, - } = props; +const FlyoutSession: React.FC = (props) => { + const { title, mainSize, childSize, mainMaxWidth, childMaxWidth } = props; const [isFlyoutVisible, setIsFlyoutVisible] = useState(false); const [isChildFlyoutVisible, setIsChildFlyoutVisible] = useState(false); + const [flyoutType, setFlyoutType] = useState<'overlay' | 'push'>('push'); + const [flyoutOwnFocus, setFlyoutOwnFocus] = useState(false); + // Handlers for "Open" buttons const handleOpenMainFlyout = () => { @@ -137,11 +127,36 @@ const FlyoutSession: React.FC = React.memo((props) => { return ( <> - - - Open {title} - - + + + + + + setFlyoutType(e.target.checked ? 'overlay' : 'push') + } + /> + + + + setFlyoutOwnFocus(e.target.checked) + } + /> + + + + + + Open {title} flyout + + + {isFlyoutVisible && ( = React.memo((props) => { size={mainSize} maxWidth={mainMaxWidth} type={flyoutType} - ownFocus={ownFocus} + ownFocus={flyoutOwnFocus} pushAnimation={true} onActive={mainFlyoutOnActive} onClose={mainFlyoutOnClose} @@ -196,7 +211,6 @@ const FlyoutSession: React.FC = React.memo((props) => { maxWidth={childMaxWidth} onActive={childFlyoutOnActive} onClose={childFlyoutOnClose} - hasChildBackground={hasChildBackground} > @@ -227,14 +241,12 @@ const FlyoutSession: React.FC = React.memo((props) => { )} ); -}); - -FlyoutSession.displayName = 'FlyoutSession'; +}; -const NonSessionFlyout: React.FC<{ - flyoutType: 'overlay' | 'push'; -}> = React.memo(({ flyoutType }) => { +const NonSessionFlyout: React.FC<{ size: string }> = ({ size }) => { const [isFlyoutVisible, setIsFlyoutVisible] = useState(false); + const [flyoutType, setFlyoutType] = useState<'overlay' | 'push'>('push'); + const [flyoutOwnFocus, setFlyoutOwnFocus] = useState(false); const handleOpenFlyout = () => { setIsFlyoutVisible(true); @@ -245,20 +257,47 @@ const NonSessionFlyout: React.FC<{ setIsFlyoutVisible(false); }, []); + // Render + return ( <> - - - Open non-session flyout - - + + + + + + setFlyoutType(e.target.checked ? 'overlay' : 'push') + } + /> + + + + setFlyoutOwnFocus(e.target.checked) + } + /> + + + + + + Open non-session flyout + + + {isFlyoutVisible && ( ); -}); - -NonSessionFlyout.displayName = 'NonSessionFlyout'; +}; const MultiSessionFlyoutDemo: React.FC = () => { - const [flyoutType, setFlyoutType] = useState<'overlay' | 'push'>('overlay'); - const [hasChildBackground, setChildBackgroundShaded] = useState(false); - - const handleFlyoutTypeToggle = useCallback((e: EuiSwitchEvent) => { - setFlyoutType(e.target.checked ? 'push' : 'overlay'); - }, []); - - const listItems = useMemo( - () => [ - { - title: 'Session A: main size = s, child size = s', - description: ( - - ), - }, - { - title: 'Session B: main size = m, child size = s', - description: ( - - ), - }, - { - title: 'Session C: main size = s, child size = fill', - description: ( - - ), - }, - { - title: 'Session D: main size = fill, child size = s', - description: ( - - ), - }, - { - title: 'Session E: main size = fill', - description: ( - - ), - }, - { - title: - 'Session F: main size = undefined, child size = fill (maxWidth 1000px)', - description: ( - - ), - }, - { - title: 'Session G: main size = fill (maxWidth 1000px), child size = s', - description: ( - - ), - }, - { - title: 'Session H: main size = s, child size = s, ownFocus = true', - description: ( - - ), - }, - { - title: 'Non-session flyout', - description: , - }, - ], - [flyoutType, hasChildBackground] - ); + const listItems = [ + { + title: 'Session A: main size = s, child size = s', + description: ( + + ), + }, + { + title: 'Session B: main size = m, child size = s', + description: ( + + ), + }, + { + title: 'Session C: main size = s, child size = fill', + description: ( + + ), + }, + { + title: 'Session D: main size = fill, child size = s', + description: ( + + ), + }, + { + title: 'Session E: main size = fill', + description: ( + + ), + }, + { + title: + 'Session F: main size = undefined, child size = fill (maxWidth 1000px)', + description: ( + + ), + }, + { + title: 'Session G: main size = fill (maxWidth 1000px), child size = s', + description: ( + + ), + }, + { + title: 'Session H: main size = s, child size = s', + description: ( + + ), + }, + { + title: 'Non-session flyout', + description: , + }, + ]; return ( <> - - - setChildBackgroundShaded((prev) => !prev)} - /> - - + + + + + + + +

+ The following filler text is used for testing scrolling and push + behaviors. +

+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque + eleifend ex nec urna efficitur, at convallis erat facilisis. Sed + laoreet, nunc id gravida cursus, ligula erat facilisis risus, in + dignissim libero odio a justo. Nullam euismod, nisi vel consectetur + interdum, nisl nisi aliquam nunc, eget aliquam massa nisl quis nunc. + Donec euismod, nisi vel consectetur interdum, nisl nisi aliquam nunc, + eget aliquam massa nisl quis nunc. Donec euismod, nisi vel consectetur + interdum, nisl nisi aliquam nunc, eget aliquam massa nisl quis nunc. + Donec euismod, nisi vel consectetur interdum, nisl nisi aliquam nunc, + eget aliquam massa nisl quis nunc. +

+

+ Vivamus luctus urna sed urna ultricies ac tempor dui sagittis. In + condimentum facilisis porta. Sed nec diam eu diam mattis viverra. + Nulla fringilla, orci ac euismod semper, magna diam porttitor mauris, + quis sollicitudin sapien justo in libero. Fusce lacinia arcu et nulla. + Nulla vitae mauris non felis mollis faucibus. Phasellus sodales + volutpat urna, id fringilla mi consectetur nec. +

+

+ Pellentesque habitant morbi tristique senectus et netus et malesuada + fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, + ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam + egestas semper. Aenean ultricies mi vitae est. Mauris placerat + eleifend leo. +

+

+ Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat + wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean + fermentum, elit eget tincidunt condimentum, eros ipsum rutrum orci, + sagittis tempus lacus enim ac dui. Donec non enim in turpis pulvinar + facilisis. Ut felis. Praesent dapibus, neque id cursus faucibus, + tortor neque egestas augue, eu vulputate magna eros eu erat. Aliquam + erat volutpat. Nam dui mi, tincidunt quis, accumsan porttitor, + facilisis luctus, metus. +

+
); }; @@ -630,7 +685,9 @@ const MultiRootFlyoutDemo: React.FC = () => {
- + + + ); From 6a6f36e3579f4764fa02857cff852612fa2f2371 Mon Sep 17 00:00:00 2001 From: Timothy Sullivan Date: Wed, 29 Oct 2025 16:12:29 -0700 Subject: [PATCH 2/5] Fix managed flyout body padding bug by conditionally applying push padding only for active flyouts and disabling scroll lock for overlay flyouts when push padding exists. --- .../components/flyout/flyout.component.tsx | 118 ++++++++++++++---- 1 file changed, 93 insertions(+), 25 deletions(-) diff --git a/packages/eui/src/components/flyout/flyout.component.tsx b/packages/eui/src/components/flyout/flyout.component.tsx index 39865e00e9d..e39788c6f0d 100644 --- a/packages/eui/src/components/flyout/flyout.component.tsx +++ b/packages/eui/src/components/flyout/flyout.component.tsx @@ -10,6 +10,7 @@ import React, { useEffect, + useLayoutEffect, useRef, useMemo, useCallback, @@ -40,6 +41,7 @@ import { useFlyoutLayoutMode, useFlyoutId, useFlyoutWidth, + useIsFlyoutActive, } from './manager'; import { CommonProps, PropsOfElement } from '../common'; @@ -49,6 +51,7 @@ import type { EuiButtonIconPropsForButton } from '../button'; import { EuiI18n } from '../i18n'; import { useResizeObserver } from '../observer/resize_observer'; import { EuiScreenReaderOnly } from '../accessibility'; +import { useMutationObserver } from '../observer/mutation_observer'; import { EuiFlyoutCloseButton } from './_flyout_close_button'; import { euiFlyoutStyles, composeFlyoutInlineStyles } from './flyout.styles'; @@ -267,6 +270,13 @@ export const EuiFlyoutComponent = forwardRef( const internalParentFlyoutRef = useRef(null); const isPushed = useIsPushed({ type, pushMinBreakpoint }); + // Get managed flyout context early so it's available for effects + const currentSession = useCurrentSession(); + const isInManagedContext = useIsInManagedFlyout(); + const flyoutId = useFlyoutId(id); + const layoutMode = useFlyoutLayoutMode(); + const isActiveManagedFlyout = useIsFlyoutActive(flyoutId); + const { onMouseDown: onMouseDownResizableButton, onKeyDown: onKeyDownResizableButton, @@ -294,31 +304,56 @@ export const EuiFlyoutComponent = forwardRef( ]); const { width } = useResizeObserver(isPushed ? resizeRef : null, 'width'); - useEffect(() => { + /** + * Use useLayoutEffect (not useEffect) to ensure padding changes happen synchronously + * before child components render. This prevents RemoveScrollBar from measuring the body + * in an inconsistent state during flyout transitions. + */ + useLayoutEffect(() => { /** - * Accomodate for the `isPushed` state by adding padding to the body equal to the width of the element + * Accomodate for the `isPushed` state by adding padding to the body equal to the width of the element. + * For managed flyouts, only apply padding if this flyout is active. */ - if (isPushed) { - const paddingSide = - side === 'left' ? 'paddingInlineStart' : 'paddingInlineEnd'; - const cssVarName = `--euiPushFlyoutOffset${ - side === 'left' ? 'InlineStart' : 'InlineEnd' - }`; + if (!isPushed) { + return; // Only push-type flyouts manage body padding + } - document.body.style[paddingSide] = `${width}px`; + const shouldApplyPadding = !isInManagedContext || isActiveManagedFlyout; + + const paddingSide = + side === 'left' ? 'paddingInlineStart' : 'paddingInlineEnd'; + const cssVarName = `--euiPushFlyoutOffset${ + side === 'left' ? 'InlineStart' : 'InlineEnd' + }`; - // EUI doesn't use this css variable, but it is useful for consumers + if (shouldApplyPadding) { + document.body.style[paddingSide] = `${width}px`; setGlobalCSSVariables({ [cssVarName]: `${width}px`, }); - return () => { - document.body.style[paddingSide] = ''; - setGlobalCSSVariables({ - [cssVarName]: null, - }); - }; + } else { + // Explicitly remove padding when this push flyout becomes inactive + document.body.style[paddingSide] = ''; + setGlobalCSSVariables({ + [cssVarName]: null, + }); } - }, [isPushed, setGlobalCSSVariables, side, width]); + + // Cleanup on unmount + return () => { + document.body.style[paddingSide] = ''; + setGlobalCSSVariables({ + [cssVarName]: null, + }); + }; + }, [ + isPushed, + isInManagedContext, + isActiveManagedFlyout, + setGlobalCSSVariables, + side, + width, + ]); /** * This class doesn't actually do anything by EUI, but is nice to add for consumers (JIC) @@ -331,13 +366,6 @@ export const EuiFlyoutComponent = forwardRef( }; }, []); - const currentSession = useCurrentSession(); - const isInManagedContext = useIsInManagedFlyout(); - - // Get flyout manager context for dynamic width calculation - const flyoutId = useFlyoutId(id); - const layoutMode = useFlyoutLayoutMode(); - // Memoize flyout identification and relationships to prevent race conditions const flyoutIdentity = useMemo(() => { if (!flyoutId || !currentSession) { @@ -603,6 +631,46 @@ export const EuiFlyoutComponent = forwardRef( const maskCombinedRefs = useCombinedRefs([maskProps?.maskRef, maskRef]); + /** + * Track whether body padding from a push flyout exists to coordinate scroll locking. + * Use state to allow updates when padding changes. + */ + const [hasPushFlyoutPadding, setHasPushFlyoutPadding] = useState(() => { + if (isPushed || !isInManagedContext) return false; + const leftPadding = document.body.style.paddingInlineStart; + const rightPadding = document.body.style.paddingInlineEnd; + return !!(leftPadding || rightPadding); + }); + + /** + * Monitor body padding and update state when it changes. + * This allows overlay flyouts to enable scroll lock once push padding is removed. + */ + const checkPadding = useCallback(() => { + const leftPadding = document.body.style.paddingInlineStart; + const rightPadding = document.body.style.paddingInlineEnd; + const hasPadding = !!(leftPadding || rightPadding); + setHasPushFlyoutPadding(hasPadding); + }, []); + + // Monitor body style changes for overlay flyouts in managed contexts + useMutationObserver( + isPushed || !isInManagedContext ? null : document.body, + checkPadding, + { attributeFilter: ['style'] } + ); + + // Check padding state immediately after render + useLayoutEffect(() => { + if (!isPushed && isInManagedContext) { + checkPadding(); + } else { + setHasPushFlyoutPadding(false); + } + }, [isPushed, isInManagedContext, checkPadding]); + + const shouldUseScrollLock = hasOverlayMask && !hasPushFlyoutPadding; + return ( Date: Mon, 10 Nov 2025 15:41:57 -0700 Subject: [PATCH 3/5] Use flyout manager state for push offsets --- .../components/flyout/flyout.component.tsx | 66 ++++++++----------- .../src/components/flyout/manager/actions.ts | 22 ++++++- .../src/components/flyout/manager/hooks.ts | 2 + .../src/components/flyout/manager/index.ts | 3 + .../components/flyout/manager/reducer.test.ts | 1 + .../src/components/flyout/manager/reducer.ts | 14 ++++ .../flyout/manager/selectors.test.tsx | 5 ++ .../components/flyout/manager/selectors.ts | 15 +++++ .../src/components/flyout/manager/store.ts | 4 ++ .../src/components/flyout/manager/types.ts | 10 +++ 10 files changed, 103 insertions(+), 39 deletions(-) diff --git a/packages/eui/src/components/flyout/flyout.component.tsx b/packages/eui/src/components/flyout/flyout.component.tsx index e39788c6f0d..b4553d1a3ab 100644 --- a/packages/eui/src/components/flyout/flyout.component.tsx +++ b/packages/eui/src/components/flyout/flyout.component.tsx @@ -42,6 +42,8 @@ import { useFlyoutId, useFlyoutWidth, useIsFlyoutActive, + useFlyoutManager, + useHasPushPadding, } from './manager'; import { CommonProps, PropsOfElement } from '../common'; @@ -51,7 +53,6 @@ import type { EuiButtonIconPropsForButton } from '../button'; import { EuiI18n } from '../i18n'; import { useResizeObserver } from '../observer/resize_observer'; import { EuiScreenReaderOnly } from '../accessibility'; -import { useMutationObserver } from '../observer/mutation_observer'; import { EuiFlyoutCloseButton } from './_flyout_close_button'; import { euiFlyoutStyles, composeFlyoutInlineStyles } from './flyout.styles'; @@ -276,6 +277,13 @@ export const EuiFlyoutComponent = forwardRef( const flyoutId = useFlyoutId(id); const layoutMode = useFlyoutLayoutMode(); const isActiveManagedFlyout = useIsFlyoutActive(flyoutId); + const flyoutManager = useFlyoutManager(); + + // Use a ref to access the latest flyoutManager without triggering effect re-runs + const flyoutManagerRef = useRef(flyoutManager); + useEffect(() => { + flyoutManagerRef.current = flyoutManager; + }, [flyoutManager]); const { onMouseDown: onMouseDownResizableButton, @@ -325,18 +333,27 @@ export const EuiFlyoutComponent = forwardRef( const cssVarName = `--euiPushFlyoutOffset${ side === 'left' ? 'InlineStart' : 'InlineEnd' }`; + const managerSide = side === 'left' ? 'left' : 'right'; if (shouldApplyPadding) { document.body.style[paddingSide] = `${width}px`; setGlobalCSSVariables({ [cssVarName]: `${width}px`, }); + // Update manager state if in managed context + if (isInManagedContext && flyoutManagerRef.current) { + flyoutManagerRef.current.setPushPadding(managerSide, width); + } } else { // Explicitly remove padding when this push flyout becomes inactive document.body.style[paddingSide] = ''; setGlobalCSSVariables({ [cssVarName]: null, }); + // Clear manager state if in managed context + if (isInManagedContext && flyoutManagerRef.current) { + flyoutManagerRef.current.setPushPadding(managerSide, 0); + } } // Cleanup on unmount @@ -345,6 +362,10 @@ export const EuiFlyoutComponent = forwardRef( setGlobalCSSVariables({ [cssVarName]: null, }); + // Clear manager state on unmount if in managed context + if (isInManagedContext && flyoutManagerRef.current) { + flyoutManagerRef.current.setPushPadding(managerSide, 0); + } }; }, [ isPushed, @@ -632,44 +653,13 @@ export const EuiFlyoutComponent = forwardRef( const maskCombinedRefs = useCombinedRefs([maskProps?.maskRef, maskRef]); /** - * Track whether body padding from a push flyout exists to coordinate scroll locking. - * Use state to allow updates when padding changes. - */ - const [hasPushFlyoutPadding, setHasPushFlyoutPadding] = useState(() => { - if (isPushed || !isInManagedContext) return false; - const leftPadding = document.body.style.paddingInlineStart; - const rightPadding = document.body.style.paddingInlineEnd; - return !!(leftPadding || rightPadding); - }); - - /** - * Monitor body padding and update state when it changes. - * This allows overlay flyouts to enable scroll lock once push padding is removed. + * For overlay flyouts in managed contexts, coordinate scroll locking with push flyout state. + * Only enable scroll lock when there's no active push padding in the manager. */ - const checkPadding = useCallback(() => { - const leftPadding = document.body.style.paddingInlineStart; - const rightPadding = document.body.style.paddingInlineEnd; - const hasPadding = !!(leftPadding || rightPadding); - setHasPushFlyoutPadding(hasPadding); - }, []); - - // Monitor body style changes for overlay flyouts in managed contexts - useMutationObserver( - isPushed || !isInManagedContext ? null : document.body, - checkPadding, - { attributeFilter: ['style'] } - ); - - // Check padding state immediately after render - useLayoutEffect(() => { - if (!isPushed && isInManagedContext) { - checkPadding(); - } else { - setHasPushFlyoutPadding(false); - } - }, [isPushed, isInManagedContext, checkPadding]); - - const shouldUseScrollLock = hasOverlayMask && !hasPushFlyoutPadding; + const hasPushPaddingInManager = useHasPushPadding(); + const shouldDeferScrollLock = + !isPushed && isInManagedContext && hasPushPaddingInManager; + const shouldUseScrollLock = hasOverlayMask && !shouldDeferScrollLock; return ( ({ type: ACTION_GO_TO_FLYOUT, flyoutId, }); + +/** Set push padding offset for a specific side. */ +export const setPushPadding = ( + side: 'left' | 'right', + width: number +): SetPushPaddingAction => ({ + type: ACTION_SET_PUSH_PADDING, + side, + width, +}); diff --git a/packages/eui/src/components/flyout/manager/hooks.ts b/packages/eui/src/components/flyout/manager/hooks.ts index 04c4dd72551..5f32b1cca1e 100644 --- a/packages/eui/src/components/flyout/manager/hooks.ts +++ b/packages/eui/src/components/flyout/manager/hooks.ts @@ -25,6 +25,8 @@ export { useFlyoutWidth, useParentFlyoutSize, useHasChildFlyout, + usePushPaddingOffsets, + useHasPushPadding, } from './selectors'; export { useFlyoutLayoutMode } from './layout_mode'; diff --git a/packages/eui/src/components/flyout/manager/index.ts b/packages/eui/src/components/flyout/manager/index.ts index b87e143705b..73176e75d83 100644 --- a/packages/eui/src/components/flyout/manager/index.ts +++ b/packages/eui/src/components/flyout/manager/index.ts @@ -14,6 +14,7 @@ export { closeFlyout as closeFlyoutAction, setActiveFlyout as setActiveFlyoutAction, setFlyoutWidth as setFlyoutWidthAction, + setPushPadding as setPushPaddingAction, setActivityStage as setActivityStageAction, } from './actions'; @@ -42,6 +43,8 @@ export { useIsInManagedFlyout, useHasActiveSession, useParentFlyoutSize, + usePushPaddingOffsets, + useHasPushPadding, } from './hooks'; export { EuiFlyoutChild, type EuiFlyoutChildProps } from './flyout_child'; diff --git a/packages/eui/src/components/flyout/manager/reducer.test.ts b/packages/eui/src/components/flyout/manager/reducer.test.ts index 4e95ad459fe..2bead31aea9 100644 --- a/packages/eui/src/components/flyout/manager/reducer.test.ts +++ b/packages/eui/src/components/flyout/manager/reducer.test.ts @@ -49,6 +49,7 @@ describe('flyoutManagerReducer', () => { sessions: [], flyouts: [], layoutMode: LAYOUT_MODE_SIDE_BY_SIDE, + pushPadding: { left: 0, right: 0 }, }); }); }); diff --git a/packages/eui/src/components/flyout/manager/reducer.ts b/packages/eui/src/components/flyout/manager/reducer.ts index f92ab141855..aa70e7f3746 100644 --- a/packages/eui/src/components/flyout/manager/reducer.ts +++ b/packages/eui/src/components/flyout/manager/reducer.ts @@ -15,6 +15,7 @@ import { ACTION_SET_ACTIVITY_STAGE, ACTION_GO_BACK, ACTION_GO_TO_FLYOUT, + ACTION_SET_PUSH_PADDING, Action, } from './actions'; import { LAYOUT_MODE_SIDE_BY_SIDE, LEVEL_MAIN, STAGE_OPENING } from './const'; @@ -31,6 +32,7 @@ export const initialState: EuiFlyoutManagerState = { sessions: [], flyouts: [], layoutMode: LAYOUT_MODE_SIDE_BY_SIDE, + pushPadding: { left: 0, right: 0 }, }; /** @@ -257,6 +259,18 @@ export function flyoutManagerReducer( return { ...state, sessions: newSessions, flyouts: newFlyouts }; } + // Set push padding offset for a specific side + case ACTION_SET_PUSH_PADDING: { + const { side, width } = action; + return { + ...state, + pushPadding: { + ...(state.pushPadding ?? { left: 0, right: 0 }), + [side]: width, + }, + }; + } + default: return state; } diff --git a/packages/eui/src/components/flyout/manager/selectors.test.tsx b/packages/eui/src/components/flyout/manager/selectors.test.tsx index 1a0eaeaba2f..978175cac6c 100644 --- a/packages/eui/src/components/flyout/manager/selectors.test.tsx +++ b/packages/eui/src/components/flyout/manager/selectors.test.tsx @@ -45,6 +45,7 @@ describe('Flyout Manager Selectors', () => { closeFlyout: jest.fn(), setActiveFlyout: jest.fn(), setFlyoutWidth: jest.fn(), + setPushPadding: jest.fn(), goBack: jest.fn(), goToFlyout: jest.fn(), historyItems: [], @@ -77,6 +78,7 @@ describe('Flyout Manager Selectors', () => { closeFlyout: jest.fn(), setActiveFlyout: jest.fn(), setFlyoutWidth: jest.fn(), + setPushPadding: jest.fn(), goBack: jest.fn(), goToFlyout: jest.fn(), historyItems: [], @@ -101,6 +103,7 @@ describe('Flyout Manager Selectors', () => { closeFlyout: jest.fn(), setActiveFlyout: jest.fn(), setFlyoutWidth: jest.fn(), + setPushPadding: jest.fn(), goBack: jest.fn(), goToFlyout: jest.fn(), historyItems: [], @@ -132,6 +135,7 @@ describe('Flyout Manager Selectors', () => { closeFlyout: jest.fn(), setActiveFlyout: jest.fn(), setFlyoutWidth: jest.fn(), + setPushPadding: jest.fn(), goBack: jest.fn(), goToFlyout: jest.fn(), historyItems: [], @@ -173,6 +177,7 @@ describe('Flyout Manager Selectors', () => { closeFlyout: jest.fn(), setActiveFlyout: jest.fn(), setFlyoutWidth: jest.fn(), + setPushPadding: jest.fn(), goBack: jest.fn(), goToFlyout: jest.fn(), historyItems: [], diff --git a/packages/eui/src/components/flyout/manager/selectors.ts b/packages/eui/src/components/flyout/manager/selectors.ts index baab124a43b..eda7f0fe506 100644 --- a/packages/eui/src/components/flyout/manager/selectors.ts +++ b/packages/eui/src/components/flyout/manager/selectors.ts @@ -87,3 +87,18 @@ export const useHasChildFlyout = (flyoutId: string) => { const session = useSession(flyoutId); return !!session?.childFlyoutId; }; + +/** Get the current push padding offsets from manager state. */ +export const usePushPaddingOffsets = () => { + const context = useFlyoutManager(); + if (!context) { + return { left: 0, right: 0 }; + } + return context.state.pushPadding ?? { left: 0, right: 0 }; +}; + +/** True if there's any active push padding (left or right side). */ +export const useHasPushPadding = () => { + const pushPadding = usePushPaddingOffsets(); + return pushPadding.left > 0 || pushPadding.right > 0; +}; diff --git a/packages/eui/src/components/flyout/manager/store.ts b/packages/eui/src/components/flyout/manager/store.ts index 983b7d600fc..580be158f46 100644 --- a/packages/eui/src/components/flyout/manager/store.ts +++ b/packages/eui/src/components/flyout/manager/store.ts @@ -13,6 +13,7 @@ import { closeFlyout as closeFlyoutAction, setActiveFlyout as setActiveFlyoutAction, setFlyoutWidth as setFlyoutWidthAction, + setPushPadding as setPushPaddingAction, goBack as goBackAction, goToFlyout as goToFlyoutAction, } from './actions'; @@ -34,6 +35,7 @@ export interface FlyoutManagerStore { closeFlyout: (flyoutId: string) => void; setActiveFlyout: (flyoutId: string | null) => void; setFlyoutWidth: (flyoutId: string, width: number) => void; + setPushPadding: (side: 'left' | 'right', width: number) => void; goBack: () => void; goToFlyout: (flyoutId: string) => void; historyItems: Array<{ @@ -106,6 +108,8 @@ function createStore( setActiveFlyout: (flyoutId) => dispatch(setActiveFlyoutAction(flyoutId)), setFlyoutWidth: (flyoutId, width) => dispatch(setFlyoutWidthAction(flyoutId, width)), + setPushPadding: (side, width) => + dispatch(setPushPaddingAction(side, width)), goBack: () => dispatch(goBackAction()), goToFlyout: (flyoutId) => dispatch(goToFlyoutAction(flyoutId)), historyItems: computeHistoryItems(), // Initialize with current state diff --git a/packages/eui/src/components/flyout/manager/types.ts b/packages/eui/src/components/flyout/manager/types.ts index dc652486db4..5d200e768e6 100644 --- a/packages/eui/src/components/flyout/manager/types.ts +++ b/packages/eui/src/components/flyout/manager/types.ts @@ -56,10 +56,19 @@ export interface FlyoutSession { title: string; } +export interface PushPaddingOffsets { + /** Push padding applied to the left side (in pixels) */ + left: number; + /** Push padding applied to the right side (in pixels) */ + right: number; +} + export interface EuiFlyoutManagerState { sessions: FlyoutSession[]; flyouts: EuiManagedFlyoutState[]; layoutMode: EuiFlyoutLayoutMode; + /** Active push padding offsets (updated by active push flyouts) */ + pushPadding?: PushPaddingOffsets; } /** @@ -78,6 +87,7 @@ export interface FlyoutManagerApi { closeFlyout: (flyoutId: string) => void; setActiveFlyout: (flyoutId: string | null) => void; setFlyoutWidth: (flyoutId: string, width: number) => void; + setPushPadding: (side: 'left' | 'right', width: number) => void; goBack: () => void; goToFlyout: (flyoutId: string) => void; historyItems: Array<{ From 6e24b9d9d9a73aec25b76b5a676cccbce52a4762 Mon Sep 17 00:00:00 2001 From: Timothy Sullivan Date: Tue, 11 Nov 2025 13:23:06 -0700 Subject: [PATCH 4/5] Clean up comments --- .../eui/src/components/flyout/flyout.component.tsx | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/packages/eui/src/components/flyout/flyout.component.tsx b/packages/eui/src/components/flyout/flyout.component.tsx index b4553d1a3ab..fd923eaa5b0 100644 --- a/packages/eui/src/components/flyout/flyout.component.tsx +++ b/packages/eui/src/components/flyout/flyout.component.tsx @@ -271,7 +271,6 @@ export const EuiFlyoutComponent = forwardRef( const internalParentFlyoutRef = useRef(null); const isPushed = useIsPushed({ type, pushMinBreakpoint }); - // Get managed flyout context early so it's available for effects const currentSession = useCurrentSession(); const isInManagedContext = useIsInManagedFlyout(); const flyoutId = useFlyoutId(id); @@ -313,15 +312,12 @@ export const EuiFlyoutComponent = forwardRef( const { width } = useResizeObserver(isPushed ? resizeRef : null, 'width'); /** - * Use useLayoutEffect (not useEffect) to ensure padding changes happen synchronously - * before child components render. This prevents RemoveScrollBar from measuring the body - * in an inconsistent state during flyout transitions. + * Effect for adding push padding to body. Using useLayoutEffect to ensure + * padding changes happen synchronously before child components render - + * this is needed to prevent RemoveScrollBar from measuring the body in an + * inconsistent state during flyout transitions. */ useLayoutEffect(() => { - /** - * Accomodate for the `isPushed` state by adding padding to the body equal to the width of the element. - * For managed flyouts, only apply padding if this flyout is active. - */ if (!isPushed) { return; // Only push-type flyouts manage body padding } @@ -654,7 +650,6 @@ export const EuiFlyoutComponent = forwardRef( /** * For overlay flyouts in managed contexts, coordinate scroll locking with push flyout state. - * Only enable scroll lock when there's no active push padding in the manager. */ const hasPushPaddingInManager = useHasPushPadding(); const shouldDeferScrollLock = From 1b725aebc3e04bfba3f256d8ca13b0069489c95b Mon Sep 17 00:00:00 2001 From: Timothy Sullivan Date: Tue, 11 Nov 2025 13:31:55 -0700 Subject: [PATCH 5/5] Add unit test --- .../eui/src/components/flyout/flyout.test.tsx | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/packages/eui/src/components/flyout/flyout.test.tsx b/packages/eui/src/components/flyout/flyout.test.tsx index fac7862d66d..407ad179dd9 100644 --- a/packages/eui/src/components/flyout/flyout.test.tsx +++ b/packages/eui/src/components/flyout/flyout.test.tsx @@ -437,6 +437,51 @@ describe('EuiFlyout', () => { }); }); + describe('push padding manager state coordination', () => { + it('applies body padding for push flyouts', () => { + const { container } = render( + {}} + type="push" + pushMinBreakpoint="xs" + data-test-subj="push-flyout" + /> + ); + + const flyout = container.querySelector('[data-test-subj="push-flyout"]'); + expect(flyout).toBeInTheDocument(); + + // Body should have padding applied + const bodyPaddingEnd = document.body.style.paddingInlineEnd; + expect(bodyPaddingEnd).toBeTruthy(); + }); + + it('removes body padding on unmount', () => { + const { unmount } = render( + {}} + type="push" + pushMinBreakpoint="xs" + data-test-subj="push-flyout" + /> + ); + + // Verify padding was applied + expect(document.body.style.paddingInlineEnd).toBeTruthy(); + + unmount(); + + // Verify padding was cleared + expect(document.body.style.paddingInlineEnd).toBe(''); + }); + + afterEach(() => { + // Clean up body styles after each test + document.body.style.paddingInlineStart = ''; + document.body.style.paddingInlineEnd = ''; + }); + }); + describe('flyout routing logic', () => { it('routes to child flyout when session is undefined and there is an active session', () => { // First render with just the main flyout to establish a session