From 81f83c9518165ab33e06c3b7cc0f14ffd9a93a30 Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Tue, 2 Jun 2026 13:50:11 +0200 Subject: [PATCH 01/15] refactor: implement toast UI updates --- .../toast/global_toast_list.styles.ts | 38 ++- .../components/toast/global_toast_list.tsx | 59 +++- .../eui/src/components/toast/toast.styles.ts | 239 +++++++------ packages/eui/src/components/toast/toast.tsx | 320 ++++++++++++++---- .../eui/src/components/toast/toast_action.tsx | 60 ++++ packages/eui/src/components/toast/types.ts | 10 + 6 files changed, 542 insertions(+), 184 deletions(-) create mode 100644 packages/eui/src/components/toast/toast_action.tsx create mode 100644 packages/eui/src/components/toast/types.ts diff --git a/packages/eui/src/components/toast/global_toast_list.styles.ts b/packages/eui/src/components/toast/global_toast_list.styles.ts index ffb6603a02a9..f5917f64cd25 100644 --- a/packages/eui/src/components/toast/global_toast_list.styles.ts +++ b/packages/eui/src/components/toast/global_toast_list.styles.ts @@ -8,6 +8,7 @@ import { css, keyframes } from '@emotion/react'; import { + euiCanAnimate, euiMaxBreakpoint, euiMinBreakpoint, euiScrollBarStyles, @@ -19,7 +20,13 @@ import { UseEuiTheme } from '../../services'; export const euiGlobalToastListStyles = (euiThemeContext: UseEuiTheme) => { const { euiTheme } = euiThemeContext; - const euiToastWidth = euiTheme.base * 25; + const euiToastWidth = euiTheme.base * 27.5; // 440px -> results in 360px toast width + + const showNotificationBadge = keyframes` + from { opacity: 0; } + to { opacity: 1; } + `; + return { /** * 1. Allow list to expand as items are added, but cap it at the screen height. @@ -45,6 +52,12 @@ export const euiGlobalToastListStyles = (euiThemeContext: UseEuiTheme) => { ${logicalSizeCSS(0, 0)} } + /* Pause the toast progress bar animation while the user hovers the list */ + &:hover .euiToastDecor::before, + &:focus-within .euiToastDecor::before { + animation-play-state: paused; + } + /* The top and bottom padding give height to the list creating a dead-zone effect when there's no toasts in the list, meaning you can't click anything beneath it. Only add the padding if there's content. */ @@ -61,6 +74,29 @@ export const euiGlobalToastListStyles = (euiThemeContext: UseEuiTheme) => { } } `, + content: css` + position: relative; + `, + notificationBadge: { + notificationBadge: css` + position: absolute; + inset-block-start: -${euiTheme.size.s}; + inset-inline-start: -${euiTheme.size.s}; + z-index: ${Number(euiTheme.levels.content) + 1}; + + ${euiCanAnimate} { + /* initial fade-in animation on mount; uses \`animation.normal\` to sync it with the toast animation */ + animation: ${euiTheme.animation.normal} ${showNotificationBadge} + ${euiTheme.animation.resistance}; + + /* fade-out animation when a toast is dismissed. Uses \`animation.fast\` to be snappier */ + transition: opacity ${euiTheme.animation.fast}; + } + `, + hasFadeOut: css` + opacity: 0; + `, + }, // Variants right: css` &:not(:empty) { diff --git a/packages/eui/src/components/toast/global_toast_list.tsx b/packages/eui/src/components/toast/global_toast_list.tsx index b6bd4eed094b..bb93b7a35edd 100644 --- a/packages/eui/src/components/toast/global_toast_list.tsx +++ b/packages/eui/src/components/toast/global_toast_list.tsx @@ -26,6 +26,7 @@ import { EuiToast, EuiToastProps } from './toast'; import { euiGlobalToastListStyles } from './global_toast_list.styles'; import { EuiButton } from '../button'; import { EuiI18n } from '../i18n'; +import { EuiNotificationBadge } from '../badge'; type ToastSide = 'right' | 'left'; @@ -75,6 +76,12 @@ export interface EuiGlobalToastListProps extends CommonProps { * @default log */ role?: HTMLAttributes['role']; + /** + * Renders a notification badge indicating the amount of toasts in the list. + * + * @default false + */ + showNotificationBadge?: boolean; } export const EuiGlobalToastList: FunctionComponent = ({ @@ -85,6 +92,7 @@ export const EuiGlobalToastList: FunctionComponent = ({ onClearAllToasts, side = 'right', showClearAllButtonAt = CLEAR_ALL_TOASTS_THRESHOLD_DEFAULT, + showNotificationBadge = false, ...rest }) => { const [toastIdToDismissedMap, setToastIdToDismissedMap] = useState<{ @@ -291,7 +299,8 @@ export const EuiGlobalToastList: FunctionComponent = ({ const renderedToasts = useMemo( () => toasts.map((toast) => { - const { text, toastLifeTimeMs, ...rest } = toast; + const { text, toastLifeTimeMs: perToastLifeTimeMs, ...rest } = toast; + const effectiveLifeTimeMs = perToastLifeTimeMs ?? toastLifeTimeMs; const onClose = () => dismissToast(toast); return ( @@ -304,13 +313,20 @@ export const EuiGlobalToastList: FunctionComponent = ({ onFocus={onMouseEnter} onBlur={onMouseLeave} {...rest} - > - {text} - + animationMs={effectiveLifeTimeMs} + text={text} + /> ); }), - [toasts, toastIdToDismissedMap, dismissToast, onMouseEnter, onMouseLeave] + [ + toasts, + toastIdToDismissedMap, + dismissToast, + onMouseEnter, + onMouseLeave, + toastLifeTimeMs, + ] ); const clearAllButton = useMemo(() => { @@ -334,7 +350,8 @@ export const EuiGlobalToastList: FunctionComponent = ({ ]: string[]) => ( { toasts.forEach((toast) => dismissToastProp(toast)); @@ -361,6 +378,29 @@ export const EuiGlobalToastList: FunctionComponent = ({ const classes = classNames('euiGlobalToastList', className); + const notificationBadge = useMemo(() => { + const toastWasDismissed = toastIdToDismissedMap[toasts[0]?.id] ?? false; + const isListEmpty = toasts.every((t) => toastIdToDismissedMap[t.id]); + + return ( + showNotificationBadge && + toasts.length > 0 && ( + + {toasts.length} + + ) + ); + }, [showNotificationBadge, toasts, toastIdToDismissedMap, styles]); + return (
= ({ className={classes} {...rest} > - {renderedToasts} - {clearAllButton} +
+ {notificationBadge} + {renderedToasts} + {clearAllButton} +
); }; diff --git a/packages/eui/src/components/toast/toast.styles.ts b/packages/eui/src/components/toast/toast.styles.ts index bc4e91560033..a1bdf57228e2 100644 --- a/packages/eui/src/components/toast/toast.styles.ts +++ b/packages/eui/src/components/toast/toast.styles.ts @@ -6,122 +6,157 @@ * Side Public License, v 1. */ -import { CSSProperties } from 'react'; -import { css } from '@emotion/react'; -import { euiShadowLarge, mathWithUnits } from '@elastic/eui-theme-common'; +import { css, keyframes } from '@emotion/react'; +import { + euiCanAnimate, + euiShadowLarge, + mathWithUnits, +} from '@elastic/eui-theme-common'; import { euiTextBreakWord, logicalCSS } from '../../global_styling'; -import { - highContrastModeStyles, - preventForcedColors, -} from '../../global_styling/functions/high_contrast'; +import { preventForcedColors } from '../../global_styling/functions/high_contrast'; import { UseEuiTheme } from '../../services'; import { euiTitle } from '../title/title.styles'; import { euiPanelBorderStyles } from '../panel/panel.styles'; +const TEXT_MAX_WIDTH = 1200; +const CONTAINER_NAME = 'euiToast'; +const CQC_BREAKPOINT_NARROWEST = '(max-width: 320px)'; + +const euiToastAnimation = keyframes` + from { + transform: scaleX(1); + } + to { + transform: scaleX(0); + } +`; + export const euiToastStyles = (euiThemeContext: UseEuiTheme) => { - const { euiTheme, highContrastMode } = euiThemeContext; - - const highlightStyles = ( - color: string, - width?: CSSProperties['borderWidth'] - ) => ` - &:before { - content: ''; - position: absolute; - /* ensure highlight border is on top of panel border */ - z-index: 1; - inset: 0; - border-radius: inherit; - ${logicalCSS('border-top', `${width} solid ${color}`)} - pointer-events: none; - } - `; + const { euiTheme } = euiThemeContext; + + const highlightSize = mathWithUnits( + [euiTheme.border.width.thin, euiTheme.border.width.thick], + (x, y) => x + y + ); + const paddingTop = mathWithUnits( + [euiTheme.size.base, highlightSize], + (x, y) => x + y + ); + const offsetTop = mathWithUnits( + [euiTheme.size.s, highlightSize], + (x, y) => x + y + ); return { // Base euiToast: css` + container-type: inline-size; + container-name: ${CONTAINER_NAME}; + position: relative; + overflow: hidden; border-radius: ${euiTheme.border.radius.medium}; ${euiShadowLarge(euiThemeContext, { borderAllInHighContrastMode: true })} - position: relative; + ${logicalCSS('padding-top', paddingTop)} + ${logicalCSS('padding-bottom', euiTheme.size.base)} ${logicalCSS('padding-horizontal', euiTheme.size.base)} - ${logicalCSS('padding-vertical', euiTheme.size.base)} - background-color: ${euiTheme.colors.emptyShade}; + background-color: ${euiTheme.colors.backgroundBasePlain}; ${logicalCSS('width', '100%')} ${euiTextBreakWord()} /* Prevent long lines from overflowing */ - ${euiPanelBorderStyles(euiThemeContext)} + `, + decor: css` + position: absolute; + inset-block-start: 0; + inset-inline: 0; + ${logicalCSS('height', highlightSize)} + background-color: var(--euiToastTypeBackgroundColor); + + ${preventForcedColors(euiThemeContext)} - &:hover, - &:focus { - [class*='euiToast__closeButton'] { - opacity: 1; + &::before { + content: ''; + position: absolute; + /* ensure highlight is on top of panel border */ + z-index: ${euiTheme.levels.content}; + inset-block-start: 0; + inset-inline: 0; + ${logicalCSS('height', '100%')} + background-color: var(--euiToastTypeColor); + pointer-events: none; + ${preventForcedColors(euiThemeContext)} + transform-origin: left center; + + [dir='rtl'] & { + transform-origin: right center; } } `, - // Elements - euiToast__closeButton: css` + // handles content + actions layout + wrapper: css` + display: flex; + flex-direction: column; + gap: ${euiTheme.size.m}; + inline-size: 100%; + `, + // handles icon + text layout + body: css` + display: flex; + flex-direction: row; + align-self: center; + gap: ${euiTheme.size.m}; + inline-size: 100%; + `, + // handles text layout + content: css` + display: flex; + flex-direction: column; + gap: ${euiTheme.size.xs}; + align-self: center; + inline-size: 100%; + max-inline-size: ${TEXT_MAX_WIDTH}px; + + .euiToast__text + .euiToast__additionalContent { + ${logicalCSS('margin-top', euiTheme.size.s)} + } + `, + icon: css` + grid-area: icon; + position: relative; + ${logicalCSS('margin-vertical', euiTheme.size.xxs)} + `, + actions: css` + grid-area: actions; + display: flex; + gap: ${euiTheme.size.s}; + ${logicalCSS('margin-left', euiTheme.size.xl)} + + /* uses container query directly as it should apply generically independent of size */ + @container ${CONTAINER_NAME} ${CQC_BREAKPOINT_NARROWEST} { + flex-wrap: wrap; + + /* use full width actions */ + > * { + inline-size: 100%; + } + } + `, + dismissButton: css` position: absolute; - ${logicalCSS('top', euiTheme.size.base)} - ${logicalCSS('right', euiTheme.size.base)} + ${logicalCSS('top', offsetTop)}; + ${logicalCSS('right', euiTheme.size.s)} + `, + + hasAnimation: css` + &::before { + ${euiCanAnimate} { + animation: ${euiToastAnimation} var(--euiToastAnimationMs) linear + forwards; + } + } `, - // Variants - colors: { - _getStyles: (color: string) => { - // Increase color/border thickness for all high contrast modes - const borderWidth = highContrastMode - ? mathWithUnits(euiTheme.border.width.thick, (x) => x * 2) - : euiTheme.border.width.thick; - - return highContrastModeStyles(euiThemeContext, { - none: highlightStyles(color, borderWidth), - preferred: ` - ${highlightStyles(color, borderWidth)} - - &::before { - ${logicalCSS( - 'width', - `calc(100% + ${mathWithUnits( - euiTheme.border.width.thin, - (x) => x * 2 - )})` - )} - ${logicalCSS('margin-top', `-${euiTheme.border.width.thin}`)} - ${logicalCSS('margin-left', `-${euiTheme.border.width.thin}`)} - } - `, - // Windows high contrast mode ignores/overrides border colors, which have semantic meaning here. To get around this, we'll use a pseudo element that ignores forced colors - forced: ` - overflow: hidden; - - &::before { - content: ''; - position: absolute; - ${logicalCSS('top', 0)} - ${logicalCSS('horizontal', 0)} - ${logicalCSS('height', borderWidth)} - background-color: ${color}; - ${preventForcedColors(euiThemeContext)} - pointer-events: none; - } - `, - }); - }, - get primary() { - return css(this._getStyles(euiTheme.colors.primary)); - }, - get success() { - return css(this._getStyles(euiTheme.colors.success)); - }, - get warning() { - return css(this._getStyles(euiTheme.colors.warning)); - }, - get danger() { - return css(this._getStyles(euiTheme.colors.danger)); - }, - }, }; }; @@ -133,29 +168,11 @@ export const euiToastHeaderStyles = (euiThemeContext: UseEuiTheme) => { display: flex; /* Align icon with first line of title text if it wraps */ align-items: baseline; - /* Account for close button */ - ${logicalCSS('padding-right', euiTheme.size.l)} - - > * + * { - /* Apply margin to all but last item in the flex */ - ${logicalCSS('margin-left', euiTheme.size.s)} - } - `, - // Elements - euiToastHeader__icon: css` - flex: 0 0 auto; - fill: ${euiTheme.colors.textHeading}; - - /* Vertically center icon with first line of title */ - transform: translateY(2px); - `, - euiToastHeader__title: css` ${euiTitle(euiThemeContext, 'xs')} font-weight: ${euiTheme.font.weight.bold}; `, - // Variants - withBody: css` - ${logicalCSS('margin-bottom', euiTheme.size.s)} + hasDismissButton: css` + padding-inline-end: ${euiTheme.size.l}; `, }; }; diff --git a/packages/eui/src/components/toast/toast.tsx b/packages/eui/src/components/toast/toast.tsx index 44a29b957284..20e9bb3187f6 100644 --- a/packages/eui/src/components/toast/toast.tsx +++ b/packages/eui/src/components/toast/toast.tsx @@ -6,10 +6,20 @@ * Side Public License, v 1. */ -import React, { FunctionComponent, HTMLAttributes, ReactNode } from 'react'; +import React, { + FunctionComponent, + HTMLAttributes, + ReactNode, + useMemo, +} from 'react'; import classNames from 'classnames'; +import { + _EuiThemeBackgroundColors, + _EuiThemeBorderColors, + getTokenName, +} from '@elastic/eui-theme-common'; -import { useEuiMemoizedStyles } from '../../services'; +import { useEuiMemoizedStyles, useEuiTheme } from '../../services'; import { CommonProps } from '../common'; import { EuiScreenReaderOnly } from '../accessibility'; import { EuiButtonIcon } from '../button'; @@ -18,41 +28,248 @@ import { IconType, EuiIcon } from '../icon'; import { EuiText } from '../text'; import { euiToastStyles, euiToastHeaderStyles } from './toast.styles'; +import { + EuiNotificationIcon, + type EuiNotificationIconType, +} from '../notification_icon/notification_icon'; +import { euiNotificationIconStyles } from '../notification_icon/notification_icon.styles'; +import { + EuiToastAction, + EuiToastActionPrimaryProps, + EuiToastActionSecondaryProps, +} from './toast_action'; +import { EuiToastColor } from './types'; +import { EuiTitle } from '../title'; -export const COLORS = ['primary', 'success', 'warning', 'danger'] as const; - -type ToastColor = (typeof COLORS)[number]; +export const COLOR_TO_NOTIFICATION_ICON_MAP: Record< + EuiToastColor, + EuiNotificationIconType +> = { + primary: 'info', + success: 'success', + warning: 'warning', + danger: 'error', +}; export interface EuiToastProps extends CommonProps, Omit, 'title'> { + /** + * Title of the toast. Should be used with text only. Do not pass complex content or custom components. + * Ensure to always pass a title. It's currently marked as optional for backwards compatibility. + * In a future major release, this will be required. + */ title?: ReactNode; - color?: ToastColor; + /** + * Main component text. Accepts text, text block elements such as `

`, and inline elements such as ``, ``, `` or ``. + * Avoid passing complex layouts or custom components. Use `children` instead. + */ + text?: ReactNode; + /** + * Can be used for additional, non-inline content. Use sparingly, as toasts are not meant to have complex content. + * Where possible, use `text` and `actionProps` instead to display text and actions. + */ + children?: ReactNode; + color?: EuiToastColor; + /** + * Defines a custom icon to be displayed. + * When no `iconType` is set, a default icon will be used based on the `color` of the toast. + */ iconType?: IconType; onClose?: () => void; + /** + * Duration in milliseconds that drives a countdown animation on the toast's decor bar. + * When not set the bar is static at full width. + */ + animationMs?: number; + /** + * Props for primary and secondary actions within the toast. + * Secondary actions will only be rendered in combination with a primary action. + */ + actionProps?: { + primary?: EuiToastActionPrimaryProps; + secondary?: EuiToastActionSecondaryProps; + }; } export const EuiToast: FunctionComponent = ({ title, - color, + text, + color = 'primary', iconType, - onClose, children, className, + actionProps, + style, + onClose, + animationMs, ...rest }) => { - const baseStyles = useEuiMemoizedStyles(euiToastStyles); - const baseCss = [baseStyles.euiToast, color && baseStyles.colors[color]]; + const { euiTheme } = useEuiTheme(); + + const styles = useEuiMemoizedStyles(euiToastStyles); + const iconStyles = useEuiMemoizedStyles(euiNotificationIconStyles); const headerStyles = useEuiMemoizedStyles(euiToastHeaderStyles); - const headerCss = [ - headerStyles.euiToastHeader, - children && headerStyles.withBody, - ]; + + const cssStyles = [styles.euiToast]; + const decorCssStyles = [styles.decor, animationMs && styles.hasAnimation]; const classes = classNames('euiToast', className); + const highlightColorToken = getTokenName( + 'borderStrong', + color + ) as keyof _EuiThemeBorderColors; + const typeColor = euiTheme.colors[highlightColorToken]; + const backgroundLightToken = getTokenName( + 'backgroundLight', + color + ) as keyof _EuiThemeBackgroundColors; + const backgroundLightColor = euiTheme.colors[backgroundLightToken]; + + const cssVariables = useMemo( + () => ({ + '--euiToastTypeColor': typeColor, + '--euiToastTypeBackgroundColor': backgroundLightColor, + ...(animationMs && { + '--euiToastAnimationMs': `${animationMs}ms`, + }), + }), + [typeColor, backgroundLightColor, animationMs] + ); + + const dismissButton = useMemo(() => { + if (!onClose) return; + + return ( + + {(dismissToast: string) => ( + + )} + + ); + }, [onClose, styles]); + + const header = useMemo(() => { + if (!title) return; + + const headerCssStyles = [ + headerStyles.euiToastHeader, + onClose && headerStyles.hasDismissButton, + ]; + + return ( + +

{title}

+ + ); + }, [title, headerStyles, onClose]); + + const icon = useMemo(() => { + if (!iconType) { + const defaultIconType = COLOR_TO_NOTIFICATION_ICON_MAP[color] ?? 'info'; + + return ( + + ); + } + + return ( +