Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[EuiToast] Wrap overflowing text in titles + perf optimizations #7568

Merged
merged 5 commits into from
Mar 12, 2024
Merged
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 changelogs/upcoming/7568.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
**Bug fixes**

- Fixed `EuiToast` title text to wrap instead of overflowing out of the container
Original file line number Diff line number Diff line change
@@ -57,7 +57,7 @@ exports[`EuiGlobalToastList props side can be changed to left 1`] = `
/>
</button>
<div
class="euiText emotion-euiText-s-euiToastBody"
class="euiText emotion-euiText-s"
data-test-subj="euiToastBody"
>
a
@@ -104,7 +104,7 @@ exports[`EuiGlobalToastList props side can be changed to left 1`] = `
/>
</button>
<div
class="euiText emotion-euiText-s-euiToastBody"
class="euiText emotion-euiText-s"
data-test-subj="euiToastBody"
>
b
@@ -160,7 +160,7 @@ exports[`EuiGlobalToastList props toasts is rendered 1`] = `
/>
</button>
<div
class="euiText emotion-euiText-s-euiToastBody"
class="euiText emotion-euiText-s"
data-test-subj="euiToastBody"
>
a
@@ -207,7 +207,7 @@ exports[`EuiGlobalToastList props toasts is rendered 1`] = `
/>
</button>
<div
class="euiText emotion-euiText-s-euiToastBody"
class="euiText emotion-euiText-s"
data-test-subj="euiToastBody"
>
b
2 changes: 1 addition & 1 deletion src/components/toast/__snapshots__/toast.test.tsx.snap
Original file line number Diff line number Diff line change
@@ -173,7 +173,7 @@ exports[`EuiToast is rendered 1`] = `
</span>
</div>
<div
class="euiText emotion-euiText-s-euiToastBody"
class="euiText emotion-euiText-s"
data-test-subj="euiToastBody"
>
<p>
178 changes: 93 additions & 85 deletions src/components/toast/global_toast_list.tsx
Original file line number Diff line number Diff line change
@@ -12,13 +12,14 @@ import React, {
ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import classNames from 'classnames';

import { CommonProps, keysOf } from '../common';
import { useEuiTheme } from '../../services';
import { useEuiMemoizedStyles } from '../../services';
import { Timer } from '../../services/time';
import { EuiGlobalToastListItem } from './global_toast_list_item';
import { EuiToast, EuiToastProps } from './toast';
@@ -107,11 +108,10 @@ export const EuiGlobalToastList: FunctionComponent<EuiGlobalToastListProps> = ({

const listElement = useRef<HTMLDivElement | null>(null);

const euiTheme = useEuiTheme();
const styles = euiGlobalToastListStyles(euiTheme);
const styles = useEuiMemoizedStyles(euiGlobalToastListStyles);
const cssStyles = [styles.euiGlobalToastList, styles[side]];

const startScrollingToBottom = () => {
const startScrollingToBottom = useCallback(() => {
isScrollingToBottom.current = true;

const scrollToBottom = () => {
@@ -143,9 +143,9 @@ export const EuiGlobalToastList: FunctionComponent<EuiGlobalToastListProps> = ({

startScrollingAnimationFrame.current =
window.requestAnimationFrame(scrollToBottom);
};
}, []);

const onMouseEnter = () => {
const onMouseEnter = useCallback(() => {
// Stop scrolling to bottom if we're in mid-scroll, because the user wants to interact with
// the list.
isScrollingToBottom.current = false;
@@ -158,19 +158,19 @@ export const EuiGlobalToastList: FunctionComponent<EuiGlobalToastListProps> = ({
timer.pause();
}
}
};
}, []);

const onMouseLeave = () => {
const onMouseLeave = useCallback(() => {
isUserInteracting.current = false;
for (const toastId in toastIdToTimerMap.current) {
if (toastIdToTimerMap.current.hasOwnProperty(toastId)) {
const timer = toastIdToTimerMap.current[toastId];
timer.resume();
}
}
};
}, []);

const onScroll = () => {
const onScroll = useCallback(() => {
// Given that this method also gets invoked by the synthetic scroll that happens when a new toast gets added,
// we want to evaluate if the scroll bottom has been reached only when the user is interacting with the toast,
// this way we always retain the scroll position the user has set despite adding in new toasts.
@@ -180,7 +180,7 @@ export const EuiGlobalToastList: FunctionComponent<EuiGlobalToastListProps> = ({
listElement.current.scrollHeight - listElement.current.scrollTop ===
listElement.current.clientHeight;
}
};
}, []);

const dismissToast = useCallback((toast: Toast) => {
// Remove the toast after it's done fading out.
@@ -215,35 +215,28 @@ export const EuiGlobalToastList: FunctionComponent<EuiGlobalToastListProps> = ({
});
}, [scheduleToastForDismissal, toasts]);

const addListeners = () => {
if (listElement.current) {
listElement.current.addEventListener('scroll', onScroll);
listElement.current.addEventListener('mouseenter', onMouseEnter);
listElement.current.addEventListener('mouseleave', onMouseLeave);
}
};

const removeListeners = () => {
if (listElement.current) {
listElement.current.removeEventListener('scroll', onScroll);
listElement.current.removeEventListener('mouseenter', onMouseEnter);
listElement.current.removeEventListener('mouseleave', onMouseLeave);
}
};

// componentDidMount
useEffect(() => {
addListeners();
const listenerEl = listElement.current;
if (listenerEl) {
listenerEl.addEventListener('scroll', onScroll);
listenerEl.addEventListener('mouseenter', onMouseEnter);
listenerEl.addEventListener('mouseleave', onMouseLeave);
}

// componentWillUnmount
return () => {
if (listenerEl) {
listenerEl.removeEventListener('scroll', onScroll);
listenerEl.removeEventListener('mouseenter', onMouseEnter);
listenerEl.removeEventListener('mouseleave', onMouseLeave);
}
if (isScrollingAnimationFrame.current !== 0) {
window.cancelAnimationFrame(isScrollingAnimationFrame.current);
}
if (startScrollingAnimationFrame.current !== 0) {
window.cancelAnimationFrame(startScrollingAnimationFrame.current);
}
removeListeners();
dismissTimeoutIds.current.forEach(clearTimeout); // eslint-disable-line react-hooks/exhaustive-deps
for (const toastId in toastIdToTimerMap.current) {
if (toastIdToTimerMap.current.hasOwnProperty(toastId)) {
@@ -252,7 +245,7 @@ export const EuiGlobalToastList: FunctionComponent<EuiGlobalToastListProps> = ({
}
}
};
}, []); // eslint-disable-line react-hooks/exhaustive-deps
}, [onMouseEnter, onMouseLeave, onScroll]);

// componentDidUpdate
useEffect(() => {
@@ -268,7 +261,7 @@ export const EuiGlobalToastList: FunctionComponent<EuiGlobalToastListProps> = ({
}
}
prevToasts.current = toasts;
}, [toasts, scheduleAllToastsForDismissal]);
}, [toasts, scheduleAllToastsForDismissal, startScrollingToBottom]);

// Toast dismissal side effect
// Ensure the callback has correct state by not enclosing it in `setTimeout`
@@ -294,62 +287,76 @@ export const EuiGlobalToastList: FunctionComponent<EuiGlobalToastListProps> = ({
}
}, [toastToDismiss, dismissToastProp]);

const renderedToasts = toasts.map((toast) => {
const { text, toastLifeTimeMs, ...rest } = toast;
const onClose = () => dismissToast(toast);

return (
<EuiGlobalToastListItem
key={toast.id}
isDismissed={toastIdToDismissedMap[toast.id]}
>
<EuiToast
onClose={onClose}
onFocus={onMouseEnter}
onBlur={onMouseLeave}
{...rest}
>
{text}
</EuiToast>
</EuiGlobalToastListItem>
);
});

if (showClearAllButtonAt && toasts.length >= showClearAllButtonAt) {
const dismissAllToasts = () => {
toasts.forEach((toast) => dismissToastProp(toast));
onClearAllToasts?.();
};

renderedToasts.push(
<EuiI18n
key="euiClearAllToasts"
tokens={[
'euiGlobalToastList.clearAllToastsButtonAriaLabel',
'euiGlobalToastList.clearAllToastsButtonDisplayText',
]}
defaults={['Clear all toast notifications', 'Clear all']}
>
{([
clearAllToastsButtonAriaLabel,
clearAllToastsButtonDisplayText,
]: string[]) => (
<EuiGlobalToastListItem isDismissed={false}>
<EuiButton
fill
color="text"
onClick={dismissAllToasts}
css={[styles.euiGlobalToastListDismissButton]}
aria-label={clearAllToastsButtonAriaLabel}
data-test-subj="euiClearAllToastsButton"
const renderedToasts = useMemo(
() =>
toasts.map((toast) => {
const { text, toastLifeTimeMs, ...rest } = toast;
const onClose = () => dismissToast(toast);

return (
<EuiGlobalToastListItem
key={toast.id}
isDismissed={toastIdToDismissedMap[toast.id]}
>
<EuiToast
onClose={onClose}
onFocus={onMouseEnter}
onBlur={onMouseLeave}
{...rest}
>
{clearAllToastsButtonDisplayText}
</EuiButton>
{text}
</EuiToast>
</EuiGlobalToastListItem>
)}
</EuiI18n>
);
}
);
}),
[toasts, toastIdToDismissedMap, dismissToast, onMouseEnter, onMouseLeave]
);

const clearAllButton = useMemo(() => {
if (
toasts.length &&
showClearAllButtonAt &&
toasts.length >= showClearAllButtonAt
) {
return (
<EuiI18n
key="euiClearAllToasts"
tokens={[
'euiGlobalToastList.clearAllToastsButtonAriaLabel',
'euiGlobalToastList.clearAllToastsButtonDisplayText',
]}
defaults={['Clear all toast notifications', 'Clear all']}
>
{([
clearAllToastsButtonAriaLabel,
clearAllToastsButtonDisplayText,
]: string[]) => (
<EuiGlobalToastListItem isDismissed={false}>
<EuiButton
fill
color="text"
onClick={() => {
toasts.forEach((toast) => dismissToastProp(toast));
onClearAllToasts?.();
}}
css={styles.euiGlobalToastListDismissButton}
aria-label={clearAllToastsButtonAriaLabel}
data-test-subj="euiClearAllToastsButton"
>
{clearAllToastsButtonDisplayText}
</EuiButton>
</EuiGlobalToastListItem>
)}
</EuiI18n>
);
}
}, [
showClearAllButtonAt,
onClearAllToasts,
toasts,
dismissToastProp,
styles,
]);

const classes = classNames('euiGlobalToastList', className);

@@ -363,6 +370,7 @@ export const EuiGlobalToastList: FunctionComponent<EuiGlobalToastListProps> = ({
{...rest}
>
{renderedToasts}
{clearAllButton}
</div>
);
};
11 changes: 3 additions & 8 deletions src/components/toast/toast.styles.ts
Original file line number Diff line number Diff line change
@@ -7,7 +7,7 @@
*/

import { css } from '@emotion/react';
import { logicalCSS } from '../../global_styling';
import { euiTextBreakWord, logicalCSS } from '../../global_styling';
import { UseEuiTheme } from '../../services';
import { euiShadowLarge } from '../../themes/amsterdam';
import { euiTitle } from '../title/title.styles';
@@ -27,6 +27,8 @@ export const euiToastStyles = (euiThemeContext: UseEuiTheme) => {
background-color: ${euiTheme.colors.emptyShade};
${logicalCSS('width', '100%')}

${euiTextBreakWord()} /* Prevent long lines from overflowing */

&:hover,
&:focus {
[class*='euiToast__closeButton'] {
@@ -90,10 +92,3 @@ export const euiToastHeaderStyles = (euiThemeContext: UseEuiTheme) => {
`,
};
};

export const euiToastBodyStyles = () => ({
// Base
euiToastBody: css`
word-wrap: break-word; /* Prevent long lines from overflowing */
`,
});
104 changes: 38 additions & 66 deletions src/components/toast/toast.tsx
Original file line number Diff line number Diff line change
@@ -6,27 +6,18 @@
* Side Public License, v 1.
*/

import React, {
FunctionComponent,
HTMLAttributes,
ReactElement,
ReactNode,
} from 'react';
import React, { FunctionComponent, HTMLAttributes, ReactNode } from 'react';
import classNames from 'classnames';

import { useEuiTheme } from '../../services';
import { useEuiMemoizedStyles } from '../../services';
import { CommonProps } from '../common';
import { EuiScreenReaderOnly } from '../accessibility';
import { EuiButtonIcon } from '../button';
import { EuiI18n } from '../i18n';
import { IconType, EuiIcon } from '../icon';
import { EuiText } from '../text';

import {
euiToastStyles,
euiToastBodyStyles,
euiToastHeaderStyles,
} from './toast.styles';
import { euiToastStyles, euiToastHeaderStyles } from './toast.styles';

export const COLORS = ['primary', 'success', 'warning', 'danger'] as const;

@@ -50,67 +41,19 @@ export const EuiToast: FunctionComponent<EuiToastProps> = ({
className,
...rest
}) => {
const euiTheme = useEuiTheme();
const baseStyles = euiToastStyles(euiTheme);
const baseStyles = useEuiMemoizedStyles(euiToastStyles);
const baseCss = [baseStyles.euiToast, color && baseStyles[color]];
const bodyStyles = euiToastBodyStyles();
const headerStyles = euiToastHeaderStyles(euiTheme);
const headerStyles = useEuiMemoizedStyles(euiToastHeaderStyles);
const headerCss = [
headerStyles.euiToastHeader,
children && headerStyles.withBody,
];

const classes = classNames('euiToast', className);

let headerIcon: ReactElement;

if (iconType) {
headerIcon = (
<EuiIcon
css={headerStyles.euiToastHeader__icon}
type={iconType}
size="m"
aria-hidden="true"
/>
);
}

let closeButton;

if (onClose) {
closeButton = (
<EuiI18n token="euiToast.dismissToast" default="Dismiss toast">
{(dismissToast: string) => (
<EuiButtonIcon
css={baseStyles.euiToast__closeButton}
iconType="cross"
color="text"
size="xs"
aria-label={dismissToast}
onClick={onClose}
data-test-subj="toastCloseButton"
/>
)}
</EuiI18n>
);
}

let optionalBody;

if (children) {
optionalBody = (
<EuiText
css={bodyStyles.euiToastBody}
size="s"
data-test-subj="euiToastBody"
>
{children}
</EuiText>
);
}

return (
<div css={baseCss} className={classes} {...rest}>
{/* Screen reader announcement */}
<EuiScreenReaderOnly>
<p>
<EuiI18n
@@ -120,14 +63,22 @@ export const EuiToast: FunctionComponent<EuiToastProps> = ({
</p>
</EuiScreenReaderOnly>

{/* Header */}
<EuiI18n token="euiToast.notification" default="Notification">
{(notification: string) => (
<div
css={headerCss}
aria-label={notification}
data-test-subj="euiToastHeader"
>
{headerIcon}
{iconType && (
<EuiIcon
css={headerStyles.euiToastHeader__icon}
type={iconType}
size="m"
aria-hidden="true"
/>
)}

<span
css={headerStyles.euiToastHeader__title}
@@ -139,8 +90,29 @@ export const EuiToast: FunctionComponent<EuiToastProps> = ({
)}
</EuiI18n>

{closeButton}
{optionalBody}
{/* Close button */}
{onClose && (
<EuiI18n token="euiToast.dismissToast" default="Dismiss toast">
{(dismissToast: string) => (
<EuiButtonIcon
css={baseStyles.euiToast__closeButton}
iconType="cross"
color="text"
size="xs"
aria-label={dismissToast}
onClick={onClose}
data-test-subj="toastCloseButton"
/>
)}
</EuiI18n>
)}

{/* Body */}
{children && (
<EuiText size="s" data-test-subj="euiToastBody">
{children}
</EuiText>
)}
</div>
);
};