diff --git a/packages/eui/changelogs/upcoming/9592.md b/packages/eui/changelogs/upcoming/9592.md new file mode 100644 index 000000000000..6e537cb884b9 --- /dev/null +++ b/packages/eui/changelogs/upcoming/9592.md @@ -0,0 +1,3 @@ +**Bug fixes** + +- Fixed `EuiFlyout` to compare `pushMinBreakpoint` against the container's width, instead of the viewport width, when the `container` prop is provided. This ensures app-scoped flyouts switch between push and overlay modes based on the space actually available inside their container. diff --git a/packages/eui/src/components/flyout/flyout.component.tsx b/packages/eui/src/components/flyout/flyout.component.tsx index d9ee36f5cd05..523646de10cc 100644 --- a/packages/eui/src/components/flyout/flyout.component.tsx +++ b/packages/eui/src/components/flyout/flyout.component.tsx @@ -347,7 +347,20 @@ export const EuiFlyoutComponent = forwardRef( // Ref for the main flyout element to pass to context const internalParentFlyoutRef = useRef(null); - const isPushed = useIsPushed({ type, pushMinBreakpoint }); + + // Observe the container's dimensions so the resize hook and + // positioning styles stay aligned with its bounding rect. + // When no container is provided, these remain inert (null/undefined). + const containerDimensions = useResizeObserver(container ?? null, 'width'); + const containerReferenceWidth = container + ? containerDimensions.width || container.clientWidth + : undefined; + + const isPushed = useIsPushed({ + type, + pushMinBreakpoint, + containerWidth: containerReferenceWidth, + }); // When no explicit container is provided, push padding targets // document.body and global push-offset CSS vars are set. When a // container is provided, only that element receives padding. @@ -477,14 +490,6 @@ export const EuiFlyoutComponent = forwardRef( const siblingFlyoutWidth = layoutMode === LAYOUT_MODE_STACKED ? 0 : _siblingFlyoutWidth; - // Observe the container's dimensions so the resize hook and - // positioning styles stay aligned with its bounding rect. - // When no container is provided, these remain inert (null/undefined). - const containerDimensions = useResizeObserver(container ?? null, 'width'); - const containerReferenceWidth = container - ? containerDimensions.width || container.clientWidth - : undefined; - // Track the container's bounding rect for positioning the flyout. const [containerRect, setContainerRect] = useState(null); diff --git a/packages/eui/src/components/flyout/hooks.test.ts b/packages/eui/src/components/flyout/hooks.test.ts new file mode 100644 index 000000000000..c4e4af76e410 --- /dev/null +++ b/packages/eui/src/components/flyout/hooks.test.ts @@ -0,0 +1,120 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { renderHook } from '../../test/rtl/render_hook'; +import { useIsPushed } from './hooks'; + +// Mock the viewport-based breakpoint hook so tests can control it +// independently of the jsdom window size. +jest.mock('../../services', () => { + const actual = jest.requireActual('../../services'); + return { + ...actual, + useIsWithinMinBreakpoint: jest.fn(() => false), + }; +}); + +const mockUseIsWithinMinBreakpoint = jest.requireMock('../../services') + .useIsWithinMinBreakpoint as jest.Mock; + +describe('useIsPushed', () => { + beforeEach(() => { + mockUseIsWithinMinBreakpoint.mockReset().mockReturnValue(false); + }); + + describe('viewport fallback (no containerWidth)', () => { + it('returns true when type is push and viewport is large enough', () => { + mockUseIsWithinMinBreakpoint.mockReturnValue(true); + const { result } = renderHook(() => + useIsPushed({ type: 'push', pushMinBreakpoint: 'm' }) + ); + expect(result.current).toBe(true); + }); + + it('returns false when viewport is too small', () => { + mockUseIsWithinMinBreakpoint.mockReturnValue(false); + const { result } = renderHook(() => + useIsPushed({ type: 'push', pushMinBreakpoint: 'm' }) + ); + expect(result.current).toBe(false); + }); + }); + + describe('container width', () => { + it('returns true when container width exceeds breakpoint', () => { + // Viewport mock returns false — should be ignored + mockUseIsWithinMinBreakpoint.mockReturnValue(false); + const { result } = renderHook(() => + useIsPushed({ + type: 'push', + pushMinBreakpoint: 'm', + containerWidth: 800, // > 768 + }) + ); + expect(result.current).toBe(true); + }); + + it('returns false when container width is below breakpoint', () => { + // Viewport mock returns true — should be ignored + mockUseIsWithinMinBreakpoint.mockReturnValue(true); + const { result } = renderHook(() => + useIsPushed({ + type: 'push', + pushMinBreakpoint: 'm', + containerWidth: 700, // < 768 + }) + ); + expect(result.current).toBe(false); + }); + + it('returns true when container width equals breakpoint exactly', () => { + const { result } = renderHook(() => + useIsPushed({ + type: 'push', + pushMinBreakpoint: 'm', + containerWidth: 768, // === 768 + }) + ); + expect(result.current).toBe(true); + }); + + it('falls back to the viewport hook when the breakpoint key is not on the theme', () => { + mockUseIsWithinMinBreakpoint.mockReturnValue(true); + const { result } = renderHook(() => + useIsPushed({ + type: 'push', + pushMinBreakpoint: 'xxl', // not a default theme key + containerWidth: 2000, + }) + ); + expect(result.current).toBe(true); + expect(mockUseIsWithinMinBreakpoint).toHaveBeenCalledWith('xxl'); + }); + }); + + describe('non-push type', () => { + it('returns false for overlay type regardless of containerWidth', () => { + const { result } = renderHook(() => + useIsPushed({ + type: 'overlay', + pushMinBreakpoint: 'm', + containerWidth: 1200, + }) + ); + expect(result.current).toBe(false); + }); + + it('returns false for overlay type regardless of viewport', () => { + mockUseIsWithinMinBreakpoint.mockReturnValue(true); + const { result } = renderHook(() => + useIsPushed({ type: 'overlay', pushMinBreakpoint: 'm' }) + ); + expect(result.current).toBe(false); + }); + }); +}); diff --git a/packages/eui/src/components/flyout/hooks.ts b/packages/eui/src/components/flyout/hooks.ts index 036b9cc35837..934ac2c53c6a 100644 --- a/packages/eui/src/components/flyout/hooks.ts +++ b/packages/eui/src/components/flyout/hooks.ts @@ -6,23 +6,39 @@ * Side Public License, v 1. */ -import { useIsWithinMinBreakpoint } from '../../services'; +import { useIsWithinMinBreakpoint, useEuiTheme } from '../../services'; import { EuiFlyoutProps } from './flyout'; import { usePropsWithComponentDefaults } from '../provider/component_defaults'; import { DEFAULT_PUSH_MIN_BREAKPOINT, DEFAULT_TYPE } from './const'; /** * Determines if a flyout should be rendered in a "pushed" state based on its - * configuration and the current window size. + * configuration and the current window or container size. */ export const useIsPushed = ( - props: Pick + props: Pick & { + containerWidth?: number; + } ) => { const { type = DEFAULT_TYPE, pushMinBreakpoint = DEFAULT_PUSH_MIN_BREAKPOINT, } = usePropsWithComponentDefaults('EuiFlyout', props); + const { + euiTheme: { breakpoint: breakpoints }, + } = useEuiTheme(); + + // Always called to satisfy React hook rules; used as fallback + // when no container width is provided or the breakpoint key is + // not present on the theme. const windowIsLargeEnoughToPush = useIsWithinMinBreakpoint(pushMinBreakpoint); - return type === 'push' && windowIsLargeEnoughToPush; + + const minWidth = breakpoints[pushMinBreakpoint]; + const isLargeEnoughToPush = + props.containerWidth != null && minWidth != null + ? props.containerWidth >= minWidth + : windowIsLargeEnoughToPush; + + return type === 'push' && isLargeEnoughToPush; }; diff --git a/packages/eui/src/components/flyout/manager/flyout_main.tsx b/packages/eui/src/components/flyout/manager/flyout_main.tsx index 5b6bc3ccde2e..c4ffad89c0e4 100644 --- a/packages/eui/src/components/flyout/manager/flyout_main.tsx +++ b/packages/eui/src/components/flyout/manager/flyout_main.tsx @@ -9,7 +9,7 @@ import React, { forwardRef } from 'react'; import { EuiManagedFlyout, type EuiManagedFlyoutProps } from './flyout_managed'; -import { useHasChildFlyout, useFlyoutId } from './hooks'; +import { useHasChildFlyout, useFlyoutId, useFlyoutManager } from './hooks'; import { euiMainFlyoutStyles } from './flyout_main.styles'; import { useEuiMemoizedStyles } from '../../../services'; import { @@ -44,7 +44,12 @@ export const EuiFlyoutMain = forwardRef( const flyoutId = useFlyoutId(id); const hasChildFlyout = useHasChildFlyout(flyoutId); const styles = useEuiMemoizedStyles(euiMainFlyoutStyles); - const isPushed = useIsPushed({ type, pushMinBreakpoint }); + const context = useFlyoutManager(); + const isPushed = useIsPushed({ + type, + pushMinBreakpoint, + containerWidth: context?.state.referenceWidth, + }); const cssStyles = [ hasChildFlyout && !isPushed && styles.hasChildFlyout[side],