diff --git a/packages/eui-theme-borealis/changelogs/upcoming/9178.md b/packages/eui-theme-borealis/changelogs/upcoming/9202.md similarity index 100% rename from packages/eui-theme-borealis/changelogs/upcoming/9178.md rename to packages/eui-theme-borealis/changelogs/upcoming/9202.md diff --git a/packages/eui-theme-common/changelogs/upcoming/9178.md b/packages/eui-theme-common/changelogs/upcoming/9202.md similarity index 100% rename from packages/eui-theme-common/changelogs/upcoming/9178.md rename to packages/eui-theme-common/changelogs/upcoming/9202.md diff --git a/packages/eui/changelogs/upcoming/9178.md b/packages/eui/changelogs/upcoming/9202.md similarity index 100% rename from packages/eui/changelogs/upcoming/9178.md rename to packages/eui/changelogs/upcoming/9202.md diff --git a/packages/eui/src/components/flyout/flyout.component.tsx b/packages/eui/src/components/flyout/flyout.component.tsx index 39865e00e9d..b4553d1a3ab 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,9 @@ import { useFlyoutLayoutMode, useFlyoutId, useFlyoutWidth, + useIsFlyoutActive, + useFlyoutManager, + useHasPushPadding, } from './manager'; import { CommonProps, PropsOfElement } from '../common'; @@ -267,6 +271,20 @@ 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 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, onKeyDown: onKeyDownResizableButton, @@ -294,31 +312,69 @@ 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' + }`; + const managerSide = side === 'left' ? 'left' : 'right'; - // 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, - }); - }; + // 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); + } } - }, [isPushed, setGlobalCSSVariables, side, width]); + + // Cleanup on unmount + return () => { + document.body.style[paddingSide] = ''; + setGlobalCSSVariables({ + [cssVarName]: null, + }); + // Clear manager state on unmount if in managed context + if (isInManagedContext && flyoutManagerRef.current) { + flyoutManagerRef.current.setPushPadding(managerSide, 0); + } + }; + }, [ + 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 +387,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 +652,15 @@ export const EuiFlyoutComponent = forwardRef( const maskCombinedRefs = useCombinedRefs([maskProps?.maskRef, maskRef]); + /** + * 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 = + !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/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 = () => {
- + + + ); 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<{