[Flyouts] Add container prop for app-level or global flyouts + fixes for resizable flyouts#9377
[Flyouts] Add container prop for app-level or global flyouts + fixes for resizable flyouts#9377tsullivan wants to merge 53 commits intoelastic:mainfrom
container prop for app-level or global flyouts + fixes for resizable flyouts#9377Conversation
5c73c84 to
31eb10d
Compare
Refactor `useEuiFlyoutResizable` to output percentage-based widths instead of raw pixels. The hook now clamps resize at 90% of a reference width (defaulting to the viewport), accounts for a sibling flyout's width, and scales proportionally when the container resizes in either direction. Updates unit and Cypress tests accordingly. Co-authored-by: Cursor <cursoragent@cursor.com>
…rlay Add `containerElement` and `minWidth` to the flyout manager state, actions, reducer, and store. Extend `EuiPortal` with an optional `container` prop for mounting into a specific element. Enrich `EuiFlyoutParentContext` to share the parent's container element with child flyouts. Add `container` prop to the flyout overlay for portal targeting. Refactor selector tests to use a `createMockManager` factory that includes the new `setContainerElement` API. Co-authored-by: Cursor <cursoragent@cursor.com>
Introduce the `container` prop on `EuiFlyout` for positioning flyouts relative to an application container instead of the viewport. When a container is provided, the flyout uses `position: absolute` with container queries; otherwise it uses `position: fixed` with media queries. Width values use `%` units throughout (identical to `vw` for fixed positioning, container-relative for absolute). Key changes: - Refactor `euiFlyoutStyles` into shared base + position variants (`fixed`/`absolute`) + sizing modes (`viewport`/`container`) - Wire container through `EuiFlyoutParentContext` for child inheritance - Set `container-type: inline-size` and scroll-reset listener on the container element; report it to manager state for layout calculations - `layout_mode.ts` uses container width for responsive breakpoints - Add `container` to `EuiProvider` component defaults for global config - Consolidate selector hooks: read `useFlyoutManager()` once per component and derive values inline (reduces redundant calls) - Deprecation warnings for `maskProps` and `includeFixedHeadersInFocusTrap` when `container` is provided - `--euiFlyoutMainWidth` CSS variable for synchronous fill-child sizing Co-authored-by: Cursor <cursoragent@cursor.com>
Add website documentation for the `container` prop explaining container-relative positioning, `position: relative` requirement, and the `container-type: inline-size` caveat for fixed descendants. Add `FlyoutContainerDemo` storybook demonstrating both "app" flyouts (container-relative, push type, with child) and "global" flyouts (viewport-relative, overlay type) in a simulated app layout with left navigation and right tools sidebar. Co-authored-by: Cursor <cursoragent@cursor.com>
31eb10d to
57861f6
Compare
This reverts commit 45f4d0d.
There was a problem hiding this comment.
Pull request overview
This PR enhances the EUI Flyout system by adding container-scoped (app-level) flyouts and improving resizable flyout behavior to prevent layout mode “stacking” glitches and sizing drift when the reference width changes.
Changes:
- Added an optional
containerprop (and provider defaults support) to scope flyout positioning/sizing to an app container instead of the viewport. - Updated resizable flyout logic to use reference-width-relative percentages and clamp widths (including sibling-aware clamping).
- Updated flyout manager state/layout mode logic to use a shared “reference width” and added stories/docs/tests/changelog to cover the new behavior.
Reviewed changes
Copilot reviewed 27 out of 27 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/website/docs/components/containers/flyout/index.mdx | Documents new container-relative positioning and child inheritance behavior |
| packages/eui/src/components/provider/component_defaults/component_defaults.tsx | Allows setting EuiFlyout container via EuiProvider defaults |
| packages/eui/src/components/flyout/use_flyout_z_index.ts | Refactors z-index API to use headerZindexLocation directly |
| packages/eui/src/components/flyout/use_flyout_z_index.test.ts | Updates tests to match new z-index hook API |
| packages/eui/src/components/flyout/use_flyout_resizable.ts | Adds reference-width-based percent sizing and sibling-aware clamping |
| packages/eui/src/components/flyout/use_flyout_resizable.test.ts | Updates/adds unit tests for percent sizing and clamp logic |
| packages/eui/src/components/flyout/manager/types.ts | Extends manager state for minWidth, containerElement, and referenceWidth |
| packages/eui/src/components/flyout/manager/store.ts | Wires new manager actions/APIs (minWidth, setContainerElement) |
| packages/eui/src/components/flyout/manager/selectors.ts | Adds selector for managed flyout minWidth |
| packages/eui/src/components/flyout/manager/selectors.test.tsx | Updates selector test mocks for new manager API |
| packages/eui/src/components/flyout/manager/reducer.ts | Stores minWidth, container element, and reference width in reducer state |
| packages/eui/src/components/flyout/manager/layout_mode.ts | Computes layout mode using container/viewport “reference width” and stores it in state |
| packages/eui/src/components/flyout/manager/layout_mode.test.tsx | Updates tests to match new layout-mode computation approach |
| packages/eui/src/components/flyout/manager/flyout_managed.tsx | Passes minWidth into manager state and derives manager-dependent values from single state read |
| packages/eui/src/components/flyout/manager/flyout_managed.test.tsx | Updates test mock to strip new non-DOM props |
| packages/eui/src/components/flyout/manager/flyout_containers.stories.tsx | Adds Storybook demo for app-scoped vs global flyouts and resizing scenarios |
| packages/eui/src/components/flyout/manager/flyout_child.tsx | Uses CSS var fallback to reduce position lag during resize (side-by-side) |
| packages/eui/src/components/flyout/manager/activity_stage.ts | Refactors activity-stage logic to derive values from a single manager state read |
| packages/eui/src/components/flyout/manager/activity_stage.test.tsx | Updates tests to match refactored activity-stage implementation |
| packages/eui/src/components/flyout/manager/actions.ts | Adds actions for container element and reference width; extends addFlyout with minWidth |
| packages/eui/src/components/flyout/manager/mocks/index.ts | Extends mocks with setContainerElement |
| packages/eui/src/components/flyout/flyout_resizable.spec.tsx | Updates Cypress expectations for 90% reference-width clamping |
| packages/eui/src/components/flyout/flyout.styles.ts | Converts named sizing from vw to % and updates fill sizing math to use CSS vars |
| packages/eui/src/components/flyout/flyout.styles.test.tsx | Updates style tests to expect %-based sizing and updated calc strings |
| packages/eui/src/components/flyout/flyout.component.tsx | Implements container resolution, container-rect positioning, reference-width wiring, and deprecation warnings |
| packages/eui/src/components/flyout/_flyout_overlay.tsx | Adds headerZindexLocation plumbing to overlay mask |
| packages/eui/changelogs/upcoming/9377.md | Adds changelog entry describing new container prop, deprecations, and resizing fixes |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
packages/eui/src/components/flyout/use_flyout_resizable.test.ts
Outdated
Show resolved
Hide resolved
…hook can treat it as unbounded
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 27 out of 27 changed files in this pull request and generated 8 comments.
Comments suppressed due to low confidence (1)
packages/eui/src/components/flyout/flyout.component.tsx:601
- The
previousPaddingvalue captured at line 559 is only set once when the effect runs, but the cleanup function uses it to restore padding. If the effect re-runs due to dependency changes (e.g.,containerchanges), the cleanup will restore the wrongpreviousPaddingvalue from the previous effect run, not the original padding before any flyout was opened. The capture should happen outside the effect or tracked in a ref to ensure the original value is preserved across effect re-runs.
// Capture pre-existing inline padding so it can be restored on cleanup
const previousPadding = paddingTarget.style[paddingSide];
const paddingWidth =
layoutMode === LAYOUT_MODE_STACKED &&
isMainFlyout &&
_siblingFlyoutWidth
? _siblingFlyoutWidth
: width;
if (shouldApplyPadding) {
paddingTarget.style[paddingSide] = `${paddingWidth}px`;
if (shouldSetGlobalPushVars) {
setGlobalCSSVariables({
[cssVarName]: `${paddingWidth}px`,
});
}
if (isInManagedContext && flyoutManagerRef.current) {
flyoutManagerRef.current.setPushPadding(managerSide, paddingWidth);
}
} else {
paddingTarget.style[paddingSide] = previousPadding;
if (shouldSetGlobalPushVars) {
setGlobalCSSVariables({
[cssVarName]: null,
});
}
if (isInManagedContext && flyoutManagerRef.current) {
flyoutManagerRef.current.setPushPadding(managerSide, 0);
}
}
return () => {
paddingTarget.style[paddingSide] = previousPadding;
if (shouldSetGlobalPushVars) {
setGlobalCSSVariables({
[cssVarName]: null,
});
}
if (isInManagedContext && flyoutManagerRef.current) {
flyoutManagerRef.current.setPushPadding(managerSide, 0);
}
};
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| useLayoutEffect(() => { | ||
| if (!isMainFlyout) return; | ||
|
|
||
| // Only set when we have a computed percentage (during active resize) | ||
| if (typeof size === 'string' && size.endsWith('%')) { | ||
| document.documentElement.style.setProperty( | ||
| '--euiFlyoutMainWidth', | ||
| size | ||
| ); | ||
| } | ||
|
|
||
| return () => { | ||
| document.documentElement.style.removeProperty('--euiFlyoutMainWidth'); | ||
| }; | ||
| }, [isMainFlyout, size]); |
There was a problem hiding this comment.
The CSS custom property --euiFlyoutMainWidth is set on document.documentElement but only cleaned up when the component unmounts or when isMainFlyout or size changes. If a main flyout transitions to a non-percentage size (e.g., from resize to a named size), the CSS variable may persist with a stale percentage value. This could cause child flyouts to use the wrong offset.
Consider also removing the CSS variable when size is not a percentage string to ensure it's only present when actively needed during resize.
There was a problem hiding this comment.
When size transitions from a percentage (e.g. '50%') to a named size (e.g. 'm'), React re-runs the effect. The cleanup from the previous execution fires first - and therefore clears any stale value. The CSS variable will not be set at all if the size is set to a named value because of the size.endsWith('%') check
| // Use container width when provided. When referenceWidth is 0 (e.g. container | ||
| // not yet measured by ResizeObserver), do not fall back to viewport — that | ||
| // would allow resizing beyond the container (e.g. over a sidebar). Use 0 so | ||
| // the clamp keeps the flyout at minWidth until the real width is available. | ||
| const _referenceWidth = | ||
| referenceWidth !== undefined | ||
| ? referenceWidth | ||
| : typeof window !== 'undefined' | ||
| ? window.innerWidth | ||
| : Infinity; |
There was a problem hiding this comment.
When referenceWidth is 0 (e.g., container not yet measured), the hook uses 0 to prevent resizing beyond the container. However, this means getFlyoutMinMaxWidth will clamp to minWidth even if the user tries to resize. If minWidth is 0 or very small, the flyout could appear to "snap" to a tiny size until the container is measured. Consider showing a loading state or deferring the resize functionality until referenceWidth is available and greater than zero.
| const resolveContainer = ( | ||
| raw: HTMLElement | string | (() => HTMLElement | null) | null | undefined | ||
| ): HTMLElement | null => { | ||
| if (raw == null) return null; | ||
| if (typeof raw === 'string') { | ||
| if (typeof document === 'undefined') return null; | ||
| const el = document.querySelector(raw); | ||
| return el instanceof HTMLElement ? el : null; | ||
| } | ||
| if (typeof raw === 'function') return raw(); | ||
| if (typeof HTMLElement === 'undefined') return null; | ||
| return raw instanceof HTMLElement ? raw : null; | ||
| }; |
There was a problem hiding this comment.
The resolveContainer function at line 240 does not handle the case where typeof HTMLElement === 'undefined' before checking raw instanceof HTMLElement. In server-side rendering (SSR) or environments without a DOM, HTMLElement may be undefined, and the instanceof check will throw a ReferenceError. The check at line 250 should occur before line 241 to prevent this error.
There was a problem hiding this comment.
The
resolveContainerfunction at line 240 does not handle the case wheretypeof HTMLElement === 'undefined'before checkingraw instanceof HTMLElement. ...
That case is checked on line 250
| @@ -511,9 +822,13 @@ export const EuiFlyoutComponent = forwardRef( | |||
| siblingFlyoutWidth, | |||
| maxWidth, | |||
| flyoutZIndex, | |||
| containerRect, | |||
| side, | |||
| isChildFlyout, | |||
| ]); | |||
There was a problem hiding this comment.
The inlineStyles useMemo at line 689 has layoutMode in its dependency array (line 820), but layoutMode is derived from managerState?.layoutMode which is not directly in the dependencies. If managerState changes but layoutMode stays the same, the memo won't recalculate. However, since siblingFlyoutWidth is also derived from managerState and is in the dependencies, this will likely trigger recalculation anyway. Consider adding managerState?.layoutMode directly to dependencies for clarity and to ensure the memo properly updates when the manager state changes.
There was a problem hiding this comment.
Adding managerState?.layoutMode alongside layoutMode would be redundant. They're literally the same value.
| title: string; | ||
| level: EuiFlyoutLevel; | ||
| size?: string; | ||
| minWidth?: number; |
There was a problem hiding this comment.
This is needed for when a flyout resizes next to a fill-size sibling: the resize hook needs the sibling's minWidth to compute the clamp limit.
💚 Build SucceededHistory
|
💚 Build Succeeded
History
|
tkajtoch
left a comment
There was a problem hiding this comment.
Code changes look great, I'm currently validating everything in Kibana and will approve when finished.
I'm seeing the implementation slowly getting too complicated for my liking. We should definitely consider refactoring in the future to better isolate logic into hooks
Thanks @tkajtoch! I'm looking forward to getting this merged and moving on to the next changes, which I see as having a smaller surface area than this one.
I'd be happy to sync up and jam with you on this. I suggest we pull in @angeles-mb too. |
Summary
Closes #9144
Unblocks elastic/kibana#242610
Why are we making this change?
This PR addresses two related issues with the EUI Flyout System:
Resizable flyouts cause strange stacking effects (1️⃣ [Flyout System] resizable flyouts cause strange stacking effects #9144) — When a main or child flyout in the Flyout System is resizable, the user can drag the resize handle to a point where the layout mode switches from side-by-side to stacked. This causes content to shift around mid-drag, creating a confusing experience. The fix introduces sibling-aware clamping so the resize handle stops before the combined flyout width triggers a layout mode change.
Child flyouts have wrong positioning when a sidebar is visible ([Grid Layout / Flyout System] When sidebar is visible, child flyouts have wrong positioning kibana#242610) — In Kibana's grid layout with sidebars enabled, child flyouts are positioned with an incorrect offset because the flyout system assumes it owns the full viewport width. The flyout's
position: fixedandvw-based sizing do not account for the sidebar taking up horizontal space, causing child flyouts to be misaligned relative to their parent.Both issues are interconnected: container-scoped flyouts need container-aware resize clamping, and the resize clamping fix requires a reference width that can be either the container or the viewport. The implementation solves both problems together through a shared concept of a "reference width" that all flyout sizing and layout calculations use.
How it works
containerpropAn optional
containerprop is added toEuiFlyout. When provided, the container element is used as a reference element for positioning and dimension calculations.EuiProvidercomponent defaults: it is recommended to set the default to the application container for convenience.container={null}on a child forces it to usedocument.bodyas the reference container element.Resize hook improvements
useEuiFlyoutResizablenow:Fill-size flyout coordination
When one flyout uses
size="fill"alongside a fixed-size sibling in side-by-side mode, the fill flyout dynamically takes the remaining available space (90% of container minus the sibling's width). This works symmetrically — fill main + fixed child, or fixed main + fill child.Deprecations
maskProps: Deprecated in favor of thecontainerprop. Usecontainerto scope flyouts to an application area, orcontainer={null}for a true global (viewport-relative) flyout. Previously,maskProps.headerZindexLocationwas often used to place a flyout above or below a fixed header; withcontainer, positioning is derived from the container. Whencontaineris provided,maskPropsis ignored and a console warning is emitted in development.includeFixedHeadersInFocusTrap: Deprecated when using thecontainerprop. For app-level flyouts, useincludeSelectorInFocusTrapto include specific elements (e.g. app headers) in the focus trap. Whencontaineris provided,includeFixedHeadersInFocusTrapis ignored and a console warning is emitted in development.Screenshots
flyout-with-container-prop-test.mp4
Impact to users
Testing
This PR adds a new "Container demo" storybook to the Flyout offering of storybook. To test these changes in Kibana, try the following:
yarn start --run-examples/app/sidebarExamples/app/flyoutSystemExamplesQA
Remove or strikethrough items that do not apply to your PR.
General checklist
Checked in both light and dark modesChecked in both MacOS and Windows high contrast modes(emulate forced colors if you do not have access to a Windows machine.)@defaultif default values are missing) and playground togglesChecked Code Sandbox works for any docs examplesUpdated visual regression testsIf applicable, added the breaking change issue label (and filled out the breaking change checklist)Designer checklistIf applicable, file an issue to update EUI's Figma library with any corresponding UI changes. (This is an internal repo, if you are external to Elastic, ask a maintainer to submit this request)