From 6ce95b627bea5b08455ddcf56af0fec5904831c2 Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Thu, 18 Jun 2026 14:38:27 +0200 Subject: [PATCH 1/3] refactor: replace native CSS container-queries with resize observer --- .../components/call_out/call_out.styles.ts | 74 +++---------------- .../eui/src/components/call_out/call_out.tsx | 8 +- .../call_out/use_layout_observer.ts | 68 +++++++++++++++++ 3 files changed, 87 insertions(+), 63 deletions(-) create mode 100644 packages/eui/src/components/call_out/use_layout_observer.ts diff --git a/packages/eui/src/components/call_out/call_out.styles.ts b/packages/eui/src/components/call_out/call_out.styles.ts index 85847bd6a5db..338cc586ede0 100644 --- a/packages/eui/src/components/call_out/call_out.styles.ts +++ b/packages/eui/src/components/call_out/call_out.styles.ts @@ -14,46 +14,9 @@ import { preventForcedColors, } from '../../global_styling'; import { UseEuiTheme } from '../../services'; -import { EuiCallOutSize } from './types'; /** Maximum reading width for `text` and `children` slots. */ const TEXT_MAX_WIDTH = 1200; -const CONTAINER_NAME = 'euiCallOut'; -const CQC_LAYOUTS = ['superNarrow', 'wide'] as const; -type EuiCallOutLayouts = (typeof CQC_LAYOUTS)[number]; -const CQC_BREAKPOINTS: Record< - EuiCallOutSize, - Record -> = { - s: { - superNarrow: '(max-width: 400px)', - wide: '(min-width: 800px)', - }, - m: { - superNarrow: '(max-width: 600px)', - wide: '(min-width: 1000px)', - }, -}; - -const withContainerQuery = ({ - layout, - styles, -}: { - layout: EuiCallOutLayouts; - styles: string; -}) => { - return Object.keys(CQC_BREAKPOINTS) - .map( - (sizeKey) => ` - @container ${CONTAINER_NAME}--${sizeKey} ${ - CQC_BREAKPOINTS[sizeKey as EuiCallOutSize][layout] - } { - ${styles} - } - ` - ) - .join('\n'); -}; export const euiCallOutStyles = (euiThemeContext: UseEuiTheme) => { const { euiTheme } = euiThemeContext; @@ -76,11 +39,9 @@ export const euiCallOutStyles = (euiThemeContext: UseEuiTheme) => { return { euiCallOut: css` - container-type: inline-size; - container-name: ${CONTAINER_NAME}; position: relative; display: flex; - inline-size: 100%; + max-inline-size: 100%; align-items: center; border: ${euiTheme.border.width.thin} solid; border-radius: ${borderRadius}; @@ -104,14 +65,10 @@ export const euiCallOutStyles = (euiThemeContext: UseEuiTheme) => { } &:where([data-size='s']) { - container-name: ${CONTAINER_NAME} ${CONTAINER_NAME}--s; - ${logicalShorthandCSS('padding', `${paddingSizes.s} ${paddingSizes.m}`)} } &:where([data-size='m']) { - container-name: ${CONTAINER_NAME} ${CONTAINER_NAME}--m; - padding: ${paddingSizes.m}; } `, @@ -129,13 +86,10 @@ export const euiCallOutStyles = (euiThemeContext: UseEuiTheme) => { gap: ${euiTheme.size.m}; } - ${withContainerQuery({ - layout: 'wide', - styles: ` - flex-direction: row; - gap: ${euiTheme.size.xxl}; - `, - })} + &:where([data-layout='wide'] &) { + flex-direction: row; + gap: ${euiTheme.size.xxl}; + } `, // handles icon + text layout body: css` @@ -232,8 +186,7 @@ export const euiCallOutStyles = (euiThemeContext: UseEuiTheme) => { ${logicalCSS('margin-left', euiTheme.size.xl)} } - /* uses container query directly as it should apply generically independent of size */ - @container ${CONTAINER_NAME} ${CQC_BREAKPOINTS.s.superNarrow} { + &:where([data-layout='superNarrow'] &) { flex-wrap: wrap; /* use full width actions */ @@ -243,15 +196,12 @@ export const euiCallOutStyles = (euiThemeContext: UseEuiTheme) => { } } - ${withContainerQuery({ - layout: 'wide', - styles: ` - ${logicalCSS('margin-left', '0')} - align-self: center; - flex-shrink: 0; - flex-direction: row-reverse; - `, - })} + &:where([data-layout='wide'] &) { + ${logicalCSS('margin-left', '0')} + align-self: center; + flex-shrink: 0; + flex-direction: row-reverse; + } `, }; }; diff --git a/packages/eui/src/components/call_out/call_out.tsx b/packages/eui/src/components/call_out/call_out.tsx index ee267d3d4d97..21f063bed1bb 100644 --- a/packages/eui/src/components/call_out/call_out.tsx +++ b/packages/eui/src/components/call_out/call_out.tsx @@ -26,6 +26,7 @@ import { type EuiNotificationIconType, } from '../notification_icon/notification_icon'; +import { useLayoutObserver } from './use_layout_observer'; import { EuiCallOutAction, EuiCallOutActionPrimaryProps, @@ -133,6 +134,11 @@ export const EuiCallOut = forwardRef( const { euiTheme } = useEuiTheme(); const color = getCallOutColor(_color); + /* Uses resize observer to determine the container width/layout instead of native container queries, + because callouts can be placed in containers without defined size (absolute positioned, no-grow flex layout etc.) + where container queries would collapse by design instead of adjusting to the content dimensions. */ + const panelRef = useLayoutObserver(size, ref); + const borderColors = useEuiBorderColorCSS(); const styles = useEuiMemoizedStyles(euiCallOutStyles); const cssStyles = [ @@ -307,7 +313,7 @@ export const EuiCallOut = forwardRef( // uses custom padding paddingSize="none" className={classes} - panelRef={ref} + panelRef={panelRef} grow={false} style={{ ...cssVariables, ...style }} data-size={size} diff --git a/packages/eui/src/components/call_out/use_layout_observer.ts b/packages/eui/src/components/call_out/use_layout_observer.ts new file mode 100644 index 000000000000..df2da8e32884 --- /dev/null +++ b/packages/eui/src/components/call_out/use_layout_observer.ts @@ -0,0 +1,68 @@ +/* + * 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 React, { useCallback, useEffect, useRef } from 'react'; + +import { hasResizeObserver } from '../observer/resize_observer/resize_observer'; +import { EuiCallOutSize } from './types'; + +const SUPER_NARROW_WIDTH = 400; +const WIDE_BREAKPOINTS: Record = { + s: 800, + m: 1000, +}; + +/** + * Observes the rendered width and sets `data-layout` on the root + * element so that CSS can respond to size changes. + * + * This is an alternative to native CSS container queries. Its purpose is to handle cases where + * container queries would collapse if the element is placed inside a container without a defined size. + */ +export const useLayoutObserver = ( + size: EuiCallOutSize, + ref: React.ForwardedRef +): ((node: HTMLDivElement | null) => void) => { + const elementRef = useRef(null); + const forwardedRefRef = useRef(ref); + forwardedRefRef.current = ref; + + const panelRef = useCallback((node: HTMLDivElement | null) => { + elementRef.current = node; + const fRef = forwardedRefRef.current; + if (typeof fRef === 'function') { + fRef(node); + } else if (fRef) { + (fRef as React.MutableRefObject).current = node; + } + }, []); + + useEffect(() => { + const element = elementRef.current; + if (!element || !hasResizeObserver) return; + + const wide = WIDE_BREAKPOINTS[size]; + + const observer = new ResizeObserver(([entry]) => { + const width = entry.borderBoxSize[0].inlineSize; + + if (width <= SUPER_NARROW_WIDTH) { + element.setAttribute('data-layout', 'superNarrow'); + } else if (width >= wide) { + element.setAttribute('data-layout', 'wide'); + } else { + element.removeAttribute('data-layout'); + } + }); + + observer.observe(element); + return () => observer.disconnect(); + }, [size]); + + return panelRef; +}; From ac7e9c5efd201c39937c0a0d5eb7c3167e504f80 Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Thu, 18 Jun 2026 14:38:44 +0200 Subject: [PATCH 2/3] test: update snapshots --- .../__snapshots__/call_out.test.tsx.snap | 16 ++++++++-------- .../form/__snapshots__/form.test.tsx.snap | 6 +++--- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/eui/src/components/call_out/__snapshots__/call_out.test.tsx.snap b/packages/eui/src/components/call_out/__snapshots__/call_out.test.tsx.snap index 1c2bda5cd1c0..590eaf6d9404 100644 --- a/packages/eui/src/components/call_out/__snapshots__/call_out.test.tsx.snap +++ b/packages/eui/src/components/call_out/__snapshots__/call_out.test.tsx.snap @@ -9,7 +9,7 @@ exports[`EuiCallOut is rendered 1`] = ` style="--euiCallOutTypeColor: #0B64DD;" >
Date: Thu, 18 Jun 2026 15:17:08 +0200 Subject: [PATCH 3/3] docs(Storybook): add wrapper kitchensink story - showcases behavior in different container contexts --- .../components/call_out/call_out.stories.tsx | 126 ++++++++++++++++-- 1 file changed, 115 insertions(+), 11 deletions(-) diff --git a/packages/eui/src/components/call_out/call_out.stories.tsx b/packages/eui/src/components/call_out/call_out.stories.tsx index bbe82cbb616a..67da543d01c8 100644 --- a/packages/eui/src/components/call_out/call_out.stories.tsx +++ b/packages/eui/src/components/call_out/call_out.stories.tsx @@ -17,14 +17,27 @@ import { import { LOKI_SELECTORS } from '../../../.storybook/loki'; import { EuiButton } from '../button'; import { EuiCallOut, EuiCallOutProps } from './call_out'; -import { EuiFlexGroup } from '../flex'; +import { EuiFlexGroup, EuiFlexItem } from '../flex'; import { EuiSpacer } from '../spacer'; import { EuiText } from '../text'; +import { EuiPanel } from '../panel'; +import { EuiPopover } from '../popover'; +import { EuiTitle } from '../title'; const title = 'Callout title'; const text = 'A short callout text'; const textLong = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.'; +const defaultActionProps = { + primary: { + children: 'Primary action', + onClick: action('primary onClick'), + }, + secondary: { + children: 'Secondary action', + onClick: action('secondary onClick'), + }, +}; const meta: Meta = { title: 'Display/EuiCallOut', @@ -88,16 +101,7 @@ export const WithActions: Story = { args: { title, text, - actionProps: { - primary: { - children: 'Primary action', - onClick: action('primary onClick'), - }, - secondary: { - children: 'Secondary action', - onClick: action('secondary onClick'), - }, - }, + actionProps: defaultActionProps, }, }; @@ -365,3 +369,103 @@ export const KitchenSinkCustomChildren: Story = { ); }, }; + +export const WrapperKitchenSink: Story = { + tags: ['vrt-only'], + render: function Render() { + return ( +
+ +

within EuiPanel

+
+ + + + + + + + +

within EuiPopover

+
+ + Show popover} + isOpen + anchorPosition="downCenter" + closePopover={() => {}} + aria-label="Popover containing a CallOut" + > + +

{textLong}

+
+ +
+ + Show popover} + isOpen + anchorPosition="downCenter" + closePopover={() => {}} + aria-label="Popover containing a CallOut" + > + + + +
+ + +

within EuiFlexGroup

+
+ + + + + + + + + + + + + + + + + + + + +
+ ); + }, +};