Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/eui/changelogs/upcoming/10250.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
**Bug fixes**

- Fixed resizable main flyouts in the flyout system switching to stacked layout mode when a child flyout opens or the container narrows after the main flyout was resized wider. The main flyout now shrinks to the largest width that fits beside the child, and returns to the width the user dragged it to once there is room again.
1 change: 1 addition & 0 deletions packages/eui/src/components/flyout/flyout.component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -658,6 +658,7 @@ export const EuiFlyoutComponent = forwardRef(
onResize,
side,
size: _size,
isChildFlyout: flyoutIdentity.hasValidSession && !isMainFlyout,
});

// Publish the main flyout's resolved width as a CSS custom property on
Expand Down
2 changes: 2 additions & 0 deletions packages/eui/src/components/flyout/manager/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ All child flyouts are of type `overlay`, and have `ownFocus` set to false, since
Child flyouts are positioned absolutely and moved to the side by the width of the main flyout, which is stored
in the state and extracted with the [`useFlyoutWidth`](./selectors.ts) hook.

In `side-by-side` mode, the resize clamp keeps a resizable main flyout and its child within 90% of the reference width, so a main flyout that the user dragged wide shrinks to the largest width that fits when a child opens or the container narrows. When there is room again, the main flyout returns to the dragged width, which scales with the reference width for a named `size`. A child flyout keeps its clamped width, because the main flyout and the child each clamp against the other's measured width, and if both grew back they would shrink and grow in turn. Layout mode decides from the width the clamp will apply (`clampFlyoutWidth`), not from the stored width: deciding from the stored width would switch to `stacked` before the clamp applies, and the clamp is off in `stacked` mode. A clamped pair is held at a fixed width and can't thrash, so it returns from `stacked` as soon as it is under the 95% threshold, skipping the 85% hysteresis. A clamp that leaves the main flyout no width is ignored, because the resize hook then keeps the flyout at its `size`. Layout mode learns that a flyout is resizable from the manager state. The `resizable` flag is set at registration and updated with `setFlyoutResizable`, because toggling it must not re-register the flyout and close its session.

### EuiManagedFlyout

[EuiManagedFlyout](./flyout_managed.tsx) is the primary logical piece of the flyout session management system.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export const createMockFunctions = (): Omit<
closeAllFlyouts: mockCloseAllFlyouts,
setActiveFlyout: jest.fn(),
setFlyoutWidth: jest.fn(),
setFlyoutResizable: jest.fn(),
setPagination: jest.fn(),
goBack: jest.fn(),
goToFlyout: jest.fn(),
Expand Down
26 changes: 25 additions & 1 deletion packages/eui/src/components/flyout/manager/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ export const ACTION_CLOSE_ALL = `${PREFIX}/closeAll` as const;
export const ACTION_SET_ACTIVE = `${PREFIX}/setActive` as const;
/** Dispatched when an active flyout's pixel width changes (for responsive layout). */
export const ACTION_SET_WIDTH = `${PREFIX}/setWidth` as const;
/** Dispatched when a flyout's `resizable` prop changes. */
export const ACTION_SET_RESIZABLE = `${PREFIX}/setResizable` as const;
/** Dispatched to set a flyout's menu pagination. */
export const ACTION_SET_PAGINATION = `${PREFIX}/setPagination` as const;
/** Dispatched to switch layout mode between `side-by-side` and `stacked`. */
Expand Down Expand Up @@ -67,6 +69,7 @@ export interface AddFlyoutAction extends BaseAction {
historyKey?: symbol;
iconType?: IconType;
minWidth?: number;
resizable?: boolean;
}

/** Remove a flyout from manager state. Also updates the active session. */
Expand All @@ -93,6 +96,13 @@ export interface SetWidthAction extends BaseAction {
width: number;
}

/** Update whether a flyout is resizable. */
export interface SetResizableAction extends BaseAction {
type: typeof ACTION_SET_RESIZABLE;
flyoutId: string;
resizable: boolean;
}

/** Set the manager pagination value for a flyout. */
export interface SetPaginationAction extends BaseAction {
type: typeof ACTION_SET_PAGINATION;
Expand Down Expand Up @@ -162,6 +172,7 @@ export type Action =
| CloseAllFlyoutsAction
| SetActiveFlyoutAction
| SetWidthAction
| SetResizableAction
| SetPaginationAction
| SetLayoutModeAction
| SetActivityStageAction
Expand All @@ -180,6 +191,7 @@ export type Action =
* - Optional `size` is the named EUI size (e.g. `s`, `m`, `l`).
* - Optional `historyKey` (Symbol) scopes history; only flyouts with the same reference share Back/history. Omit for a unique group per session.
* - Optional `iconType` is shown next to the session title in the history menu.
* - Optional `resizable` lets layout mode account for the resize clamp.
*/
export const addFlyout = (
flyoutId: string,
Expand All @@ -188,7 +200,8 @@ export const addFlyout = (
size?: string,
historyKey?: symbol,
iconType?: IconType,
minWidth?: number
minWidth?: number,
resizable?: boolean
): AddFlyoutAction => ({
type: ACTION_ADD,
flyoutId,
Expand All @@ -198,6 +211,7 @@ export const addFlyout = (
historyKey,
iconType,
minWidth,
resizable,
});

/** Unregister a flyout and update the session accordingly. */
Expand Down Expand Up @@ -229,6 +243,16 @@ export const setFlyoutWidth = (
width,
});

/** Record whether a flyout is resizable. */
export const setFlyoutResizable = (
flyoutId: string,
resizable: boolean
): SetResizableAction => ({
type: ACTION_SET_RESIZABLE,
flyoutId,
resizable,
});

/**
* Set (or clear) the menu pagination config for a flyout. When set, this
* overrides any pagination passed via `flyoutMenuProps.pagination` on the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@ jest.mock('../flyout.component', () => {
ref: any
) {
// Extract non-DOM props to prevent them from being passed to DOM
const { flyoutMenuProps, minWidth, container, ...domProps } = props;
const { flyoutMenuProps, minWidth, resizable, container, ...domProps } =
props;
mockFlyoutRender(flyoutMenuProps);
return React.createElement('div', {
ref,
Expand Down Expand Up @@ -200,6 +201,38 @@ describe('EuiManagedFlyout', () => {
expect(mockCloseAllFlyouts).not.toHaveBeenCalled();
});

it('updates `resizable` in the manager without re-registering', () => {
const { rerender } = renderInProvider(
<EuiManagedFlyout
id="main"
level={LEVEL_MAIN}
onClose={() => {}}
resizable
/>
);
expect(mockFlyoutManager.addFlyout).toHaveBeenCalledTimes(1);
expect(jest.mocked(mockFlyoutManager.addFlyout).mock.calls[0][7]).toBe(
true
);

rerender(
<EuiFlyoutManager>
<EuiManagedFlyout
id="main"
level={LEVEL_MAIN}
onClose={() => {}}
resizable={false}
/>
</EuiFlyoutManager>
);
expect(mockFlyoutManager.addFlyout).toHaveBeenCalledTimes(1);
expect(mockCloseAllFlyouts).not.toHaveBeenCalled();
expect(mockFlyoutManager.setFlyoutResizable).toHaveBeenLastCalledWith(
'main',
false
);
});

it('registers child flyout and sets data-level child', () => {
const { getByTestSubject } = renderInProvider(
<EuiManagedFlyout level={LEVEL_CHILD} onClose={() => {}} />
Expand Down
22 changes: 20 additions & 2 deletions packages/eui/src/components/flyout/manager/flyout_managed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ import React, {
} from 'react';
import { css } from '@emotion/react';
import { flushSync } from 'react-dom';
import { useCombinedRefs, useEuiMemoizedStyles } from '../../../services';
import {
useCombinedRefs,
useEuiMemoizedStyles,
useLatest,
} from '../../../services';
import { useEuiI18n } from '../../i18n';
import { useResizeObserver } from '../../observer/resize_observer';
import {
Expand Down Expand Up @@ -89,6 +93,7 @@ export const EuiManagedFlyout = forwardRef<HTMLElement, EuiManagedFlyoutProps>(
level,
size: sizeProp,
minWidth,
resizable,
historyKey,
css: customCss,
flyoutMenuProps: _flyoutMenuProps,
Expand All @@ -107,6 +112,7 @@ export const EuiManagedFlyout = forwardRef<HTMLElement, EuiManagedFlyoutProps>(
closeFlyout,
closeAllFlyouts,
setFlyoutWidth,
setFlyoutResizable,
goBack,
historyItems: _historyItems,
state: managerState,
Expand Down Expand Up @@ -236,6 +242,11 @@ export const EuiManagedFlyout = forwardRef<HTMLElement, EuiManagedFlyoutProps>(
isBackgroundedMain ? closeFlyout(flyoutId) : closeAllFlyouts();
}, [level, flyoutId, closeFlyout, closeAllFlyouts]);

// Toggling `resizable` must not re-register the flyout, because that closes
// its session, so registration reads it from a ref and the effect below
// keeps the manager up to date
const resizableRef = useLatest(resizable);

// Register with flyout manager context when open, remove when closed
// Using useLayoutEffect to run synchronously before DOM updates
useLayoutEffect(() => {
Expand All @@ -246,7 +257,8 @@ export const EuiManagedFlyout = forwardRef<HTMLElement, EuiManagedFlyoutProps>(
size as string,
level === LEVEL_MAIN ? historyKey : undefined,
_flyoutMenuProps?.iconType,
typeof minWidth === 'number' ? minWidth : undefined
typeof minWidth === 'number' ? minWidth : undefined,
!!resizableRef.current
);

return () => {
Expand Down Expand Up @@ -278,8 +290,13 @@ export const EuiManagedFlyout = forwardRef<HTMLElement, EuiManagedFlyoutProps>(
_flyoutMenuProps?.iconType,
addFlyout,
closeOwnFlyouts,
resizableRef,
]);

useLayoutEffect(() => {
setFlyoutResizable(flyoutId, !!resizable);
}, [flyoutId, resizable, setFlyoutResizable]);

// Detect when flyout has been removed from manager state (e.g., via Back button)
// and trigger onClose callback to notify the parent component
useEffect(() => {
Expand Down Expand Up @@ -420,6 +437,7 @@ export const EuiManagedFlyout = forwardRef<HTMLElement, EuiManagedFlyoutProps>(
onClose,
size,
minWidth,
resizable,
flyoutMenuProps,
onAnimationEnd,
[PROPERTY_FLYOUT]: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,14 @@ const buildMockManagerState = ({
level: 'main' as const,
size: 'm',
width: 600,
} as { flyoutId: string; level: string; size: string; width?: number } | null,
} as {
flyoutId: string;
level: string;
size: string;
width?: number;
minWidth?: number;
resizable?: boolean;
} | null,
childFlyout = {
flyoutId: 'child-1',
level: 'child' as const,
Expand Down Expand Up @@ -478,6 +485,72 @@ describe('layout_mode', () => {
);
});

describe('resizable main flyout', () => {
// 800 + 400 = 100% of 1200px, but a resizable main is clamped to
// 90% of 1200px βˆ’ 400 = 680px
const renderWithMain = ({
minWidth,
childWidth = 400,
layoutMode = LAYOUT_MODE_SIDE_BY_SIDE,
}: {
minWidth?: number;
childWidth?: number;
layoutMode?:
| typeof LAYOUT_MODE_SIDE_BY_SIDE
| typeof LAYOUT_MODE_STACKED;
} = {}) => {
Object.defineProperty(window, 'innerWidth', {
writable: true,
value: 1200,
});
mockUseFlyoutManager.mockReturnValue({
state: buildMockManagerState({
layoutMode,
mainFlyout: {
flyoutId: 'main-1',
level: 'main',
size: 'm',
width: 800,
resizable: true,
minWidth,
},
childFlyout: {
flyoutId: 'child-1',
level: 'child',
size: 's',
width: childWidth,
},
}),
dispatch: jest.fn(),
});
render(<TestComponent />);
};

it('stays SIDE_BY_SIDE when the clamp makes the pair fit', () => {
renderWithMain();
expect(mockSetLayoutMode).not.toHaveBeenCalledWith(LAYOUT_MODE_STACKED);
});

it('returns to SIDE_BY_SIDE from STACKED when the clamp makes the pair fit', () => {
// The clamped pair is 90%, above the 85% return threshold
renderWithMain({ layoutMode: LAYOUT_MODE_STACKED });
expect(mockSetLayoutMode).toHaveBeenCalledWith(
LAYOUT_MODE_SIDE_BY_SIDE
);
});

it('switches to STACKED when its minWidth leaves no room for the child', () => {
renderWithMain({ minWidth: 800 });
expect(mockSetLayoutMode).toHaveBeenCalledWith(LAYOUT_MODE_STACKED);
});

it('switches to STACKED when the clamp leaves the main flyout no width', () => {
// 90% of 1200px βˆ’ 1100px is below 0, so the main flyout keeps its width
renderWithMain({ childWidth: 1100 });
expect(mockSetLayoutMode).toHaveBeenCalledWith(LAYOUT_MODE_STACKED);
});
});

it('switches to SIDE_BY_SIDE when using size-based width calculation and currently in STACKED mode', () => {
// Set window width to be large enough to not trigger the small window check
Object.defineProperty(window, 'innerWidth', {
Expand Down
24 changes: 23 additions & 1 deletion packages/eui/src/components/flyout/manager/layout_mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { useEuiTheme } from '../../../services';
import { usePropsWithComponentDefaults } from '../../provider/component_defaults';
import { useResizeObserver } from '../../observer/resize_observer';
import { setLayoutMode, setReferenceWidth } from './actions';
import { clampFlyoutWidth } from '../use_flyout_resizable';
import { useFlyoutManager } from './hooks';
import { LAYOUT_MODE_SIDE_BY_SIDE, LAYOUT_MODE_STACKED } from './const';
import { EuiFlyoutLayoutMode } from './types';
Expand Down Expand Up @@ -185,6 +186,23 @@ export const useApplyFlyoutLayoutMode = () => {
return LAYOUT_MODE_SIDE_BY_SIDE;
}

// A resizable main flyout is clamped to fit beside its child, so decide
// from the clamped width. Deciding from the wider width would stack the
// pair before the clamp applies, and the clamp is off in stacked mode.
let isClampedToFit = false;
if (parentFlyout?.resizable && !hasFill) {
const clampedWidth = clampFlyoutWidth(parentWidthValue, {
minWidth: parentFlyout.minWidth,
referenceWidth,
siblingWidth: childWidthValue,
});
// The resize hook keeps the flyout at `size` when the clamp leaves no width
if (clampedWidth > 0) {
isClampedToFit = clampedWidth < parentWidthValue;
parentWidthValue = clampedWidth;
}
}

const combinedWidth = parentWidthValue + childWidthValue;
const combinedWidthPercentage = (combinedWidth / referenceWidth) * 100;

Expand All @@ -196,7 +214,9 @@ export const useApplyFlyoutLayoutMode = () => {
: LAYOUT_MODE_STACKED;
}

if (currentLayoutMode === LAYOUT_MODE_STACKED) {
// The clamp holds a clamped pair at a fixed width, so it can't thrash near
// the threshold, and the stricter return threshold would keep it stacked
if (currentLayoutMode === LAYOUT_MODE_STACKED && !isClampedToFit) {
return combinedWidthPercentage <= THRESHOLD_TO_SIDE_BY_SIDE
? LAYOUT_MODE_SIDE_BY_SIDE
: LAYOUT_MODE_STACKED;
Expand All @@ -213,6 +233,8 @@ export const useApplyFlyoutLayoutMode = () => {
parentWidth,
childWidth,
parentFlyout?.size,
parentFlyout?.resizable,
parentFlyout?.minWidth,
childFlyout?.size,
currentLayoutMode,
]);
Expand Down
Loading
Loading