Skip to content

Commit 74dd258

Browse files
feat(Alert): added animations (#11495)
* feat(Alert): added animations * Updated order of rendering in examples * Added cypress test for animation classes * Updated to use transitionend handler for alert removal * Fixed failing tests * Updated animation cypress tests * Updated animation test * Refactored transitionend handling * Updated tests * Feedback from Austin * Resolved React imports * Resolved remaining React imports * Bumped core version for updates * Updated logic to be opt in by default
1 parent f2479b1 commit 74dd258

27 files changed

+310
-82
lines changed

packages/react-core/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@
5454
"tslib": "^2.8.1"
5555
},
5656
"devDependencies": {
57-
"@patternfly/patternfly": "6.2.0-prerelease.23",
57+
"@patternfly/patternfly": "6.2.0-prerelease.24",
5858
"case-anything": "^3.1.2",
5959
"css": "^3.0.0",
6060
"fs-extra": "^11.3.0"

packages/react-core/src/components/Alert/Alert.tsx

+34-5
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1-
import { Fragment, useEffect, useRef, useState } from 'react';
1+
import { Fragment, useEffect, useRef, useState, useContext } from 'react';
22
import { css } from '@patternfly/react-styles';
33
import styles from '@patternfly/react-styles/css/components/Alert/alert';
4+
import alertGroupStyles from '@patternfly/react-styles/css/components/Alert/alert-group';
45
import { AlertIcon } from './AlertIcon';
56
import { capitalize, useOUIAProps, OUIAProps } from '../../helpers';
67
import { AlertContext } from './AlertContext';
8+
import { AlertGroupContext } from './AlertGroupContext';
79
import maxLines from '@patternfly/react-tokens/dist/esm/c_alert__title_max_lines';
810
import { Tooltip, TooltipPosition } from '../Tooltip';
911
import { AlertToggleExpandButton } from './AlertToggleExpandButton';
@@ -140,7 +142,30 @@ export const Alert: React.FunctionComponent<AlertProps> = ({
140142
const [timedOutAnimation, setTimedOutAnimation] = useState(true);
141143
const [isMouseOver, setIsMouseOver] = useState<boolean | undefined>();
142144
const [containsFocus, setContainsFocus] = useState<boolean | undefined>();
143-
const dismissed = timedOut && timedOutAnimation && !isMouseOver && !containsFocus;
145+
const shouldDismiss = timedOut && timedOutAnimation && !isMouseOver && !containsFocus;
146+
const [isDismissed, setIsDismissed] = useState(false);
147+
const { hasAnimations, updateTransitionEnd } = useContext(AlertGroupContext);
148+
const { offstageRight } = alertGroupStyles.modifiers;
149+
150+
const getParentAlertGroupItem = () => divRef.current?.closest(`.${alertGroupStyles.alertGroupItem}`);
151+
useEffect(() => {
152+
const shouldSetDismissed = shouldDismiss && !isDismissed;
153+
if (!shouldSetDismissed) {
154+
return;
155+
}
156+
157+
const alertGroupItem = getParentAlertGroupItem();
158+
alertGroupItem?.classList.add(offstageRight);
159+
160+
if (hasAnimations) {
161+
updateTransitionEnd(() => {
162+
setIsDismissed(true);
163+
});
164+
} else {
165+
setIsDismissed(true);
166+
}
167+
}, [shouldDismiss, isDismissed]);
168+
144169
useEffect(() => {
145170
const calculatedTimeout = timeout === true ? 8000 : Number(timeout);
146171
if (calculatedTimeout > 0) {
@@ -171,8 +196,12 @@ export const Alert: React.FunctionComponent<AlertProps> = ({
171196
}
172197
}, [containsFocus, isMouseOver, timeoutAnimation]);
173198
useEffect(() => {
174-
dismissed && onTimeout();
175-
}, [dismissed, onTimeout]);
199+
isDismissed && onTimeout();
200+
}, [isDismissed, onTimeout]);
201+
useEffect(() => {
202+
const alertGroupItem = getParentAlertGroupItem();
203+
setTimeout(() => alertGroupItem?.classList.remove(alertGroupStyles.modifiers.offstageTop), 0);
204+
}, []);
176205

177206
const [isExpanded, setIsExpanded] = useState(false);
178207
const onToggleExpand = () => {
@@ -190,7 +219,7 @@ export const Alert: React.FunctionComponent<AlertProps> = ({
190219
onMouseLeave(ev);
191220
};
192221

193-
if (dismissed) {
222+
if (shouldDismiss && isDismissed) {
194223
return null;
195224
}
196225
const Title = (

packages/react-core/src/components/Alert/AlertActionCloseButton.tsx

+34-14
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
import { useRef, useContext } from 'react';
12
import { Button, ButtonVariant, ButtonProps } from '../Button';
23
import TimesIcon from '@patternfly/react-icons/dist/esm/icons/times-icon';
34
import { AlertContext } from './AlertContext';
5+
import { AlertGroupContext } from './AlertGroupContext';
6+
import alertGroupStyles from '@patternfly/react-styles/css/components/Alert/alert-group';
47

58
/** Renders a close button for a dismissable alert when this sub-component is passed into
69
* the alert's actionClose property.
@@ -23,18 +26,35 @@ export const AlertActionCloseButton: React.FunctionComponent<AlertActionCloseBut
2326
'aria-label': ariaLabel = '',
2427
variantLabel,
2528
...props
26-
}: AlertActionCloseButtonProps) => (
27-
<AlertContext.Consumer>
28-
{({ title, variantLabel: alertVariantLabel }) => (
29-
<Button
30-
variant={ButtonVariant.plain}
31-
onClick={onClose}
32-
aria-label={ariaLabel === '' ? `Close ${variantLabel || alertVariantLabel} alert: ${title}` : ariaLabel}
33-
className={className}
34-
icon={<TimesIcon />}
35-
{...props}
36-
/>
37-
)}
38-
</AlertContext.Consumer>
39-
);
29+
}: AlertActionCloseButtonProps) => {
30+
const closeButtonRef = useRef(null);
31+
const { hasAnimations, updateTransitionEnd } = useContext(AlertGroupContext);
32+
const { offstageRight } = alertGroupStyles.modifiers;
33+
34+
const getParentAlertGroupItem = () => closeButtonRef.current?.closest(`.${alertGroupStyles.alertGroupItem}`);
35+
const handleOnClick = () => {
36+
if (hasAnimations) {
37+
getParentAlertGroupItem()?.classList.add(offstageRight);
38+
updateTransitionEnd(onClose);
39+
} else {
40+
onClose();
41+
}
42+
};
43+
44+
return (
45+
<AlertContext.Consumer>
46+
{({ title, variantLabel: alertVariantLabel }) => (
47+
<Button
48+
ref={closeButtonRef}
49+
variant={ButtonVariant.plain}
50+
onClick={handleOnClick}
51+
aria-label={ariaLabel === '' ? `Close ${variantLabel || alertVariantLabel} alert: ${title}` : ariaLabel}
52+
className={className}
53+
icon={<TimesIcon />}
54+
{...props}
55+
/>
56+
)}
57+
</AlertContext.Consumer>
58+
);
59+
};
4060
AlertActionCloseButton.displayName = 'AlertActionCloseButton';

packages/react-core/src/components/Alert/AlertGroup.tsx

+6
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ export interface AlertGroupProps extends Omit<React.HTMLProps<HTMLUListElement>,
88
className?: string;
99
/** Alerts to be rendered in the AlertGroup */
1010
children?: React.ReactNode;
11+
/** @beta Flag to indicate whether Alerts are animated upon rendering and being dismissed. This is intended
12+
* to remain false for testing purposes only.
13+
*/
14+
hasAnimations?: boolean;
1115
/** Toast notifications are positioned at the top right corner of the viewport */
1216
isToast?: boolean;
1317
/** Turns the container into a live region so that changes to content within the AlertGroup, such as appending an Alert, are reliably announced to assistive technology. */
@@ -58,6 +62,7 @@ class AlertGroup extends Component<AlertGroupProps, AlertGroupState> {
5862
const {
5963
className,
6064
children,
65+
hasAnimations = false,
6166
isToast,
6267
isLiveRegion,
6368
onOverflowClick,
@@ -75,6 +80,7 @@ class AlertGroup extends Component<AlertGroupProps, AlertGroupState> {
7580
isLiveRegion={isLiveRegion}
7681
overflowMessage={overflowMessage}
7782
aria-label={ariaLabel}
83+
hasAnimations={hasAnimations}
7884
{...props}
7985
>
8086
{children}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { createContext } from 'react';
2+
3+
interface AlertGroupContext {
4+
hasAnimations?: boolean;
5+
updateTransitionEnd?: (onTransitionEnd: () => void) => void;
6+
}
7+
8+
export const AlertGroupContext = createContext<AlertGroupContext>({
9+
hasAnimations: false,
10+
updateTransitionEnd: () => {}
11+
});
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,74 @@
1-
import { Children } from 'react';
1+
import { Children, useState } from 'react';
22
import { css } from '@patternfly/react-styles';
33
import styles from '@patternfly/react-styles/css/components/Alert/alert-group';
44
import { AlertGroupProps } from './AlertGroup';
55
import { AlertProps } from '../Alert';
6+
import { AlertGroupContext } from './AlertGroupContext';
67

78
export const AlertGroupInline: React.FunctionComponent<AlertGroupProps> = ({
89
className,
910
children,
11+
hasAnimations,
1012
isToast,
1113
isLiveRegion,
1214
onOverflowClick,
1315
overflowMessage,
14-
...rest
15-
}: AlertGroupProps) => (
16-
<ul
17-
role="list"
18-
aria-live={isLiveRegion ? 'polite' : null}
19-
aria-atomic={isLiveRegion ? false : null}
20-
className={css(styles.alertGroup, className, isToast ? styles.modifiers.toast : '')}
21-
{...rest}
22-
>
23-
{Children.toArray(children).map((alert, index) => (
24-
<li key={(alert as React.ReactElement<AlertProps>).props?.id || index}>{alert}</li>
25-
))}
26-
{overflowMessage && (
27-
<li>
28-
<button onClick={onOverflowClick} className={css(styles.alertGroupOverflowButton)}>
29-
{overflowMessage}
30-
</button>
31-
</li>
32-
)}
33-
</ul>
34-
);
16+
...props
17+
}: AlertGroupProps) => {
18+
const [handleTransitionEnd, setHandleTransitionEnd] = useState(() => () => {});
19+
const updateTransitionEnd = (onTransitionEnd: () => void) => {
20+
setHandleTransitionEnd(() => onTransitionEnd);
21+
};
22+
23+
const onTransitionEnd = (event: React.TransitionEvent<HTMLLIElement>) => {
24+
if (!hasAnimations) {
25+
return;
26+
}
27+
28+
const prefersReducedMotion = !window.matchMedia('(prefers-reduced-motion: no-preference)')?.matches;
29+
if (
30+
// If a user has no motion preference, we want to target the grid template rows transition
31+
// so that the onClose is called after the "slide up" animation of other alerts finishes.
32+
// If they have motion preference, we don't need to check for a specific transition since only opacity should fire.
33+
(prefersReducedMotion || (!prefersReducedMotion && event.propertyName === 'grid-template-rows')) &&
34+
(event.target as HTMLElement).className.includes(styles.modifiers.offstageRight)
35+
) {
36+
handleTransitionEnd();
37+
}
38+
};
39+
40+
return (
41+
<AlertGroupContext.Provider value={{ hasAnimations, updateTransitionEnd }}>
42+
<ul
43+
role="list"
44+
aria-live={isLiveRegion ? 'polite' : null}
45+
aria-atomic={isLiveRegion ? false : null}
46+
className={css(styles.alertGroup, className, isToast ? styles.modifiers.toast : '')}
47+
{...props}
48+
>
49+
{Children.toArray(children).map((alert, index) => (
50+
<li
51+
onTransitionEnd={onTransitionEnd}
52+
className={css(styles.alertGroupItem, hasAnimations && styles.modifiers.offstageTop)}
53+
key={
54+
(alert as React.ReactElement<AlertProps>).props?.id ||
55+
`alertGroupItem-${(alert as React.ReactElement<AlertProps>).key}` ||
56+
index
57+
}
58+
>
59+
{alert}
60+
</li>
61+
))}
62+
{overflowMessage && (
63+
<li>
64+
<button onClick={onOverflowClick} className={css(styles.alertGroupOverflowButton)}>
65+
{overflowMessage}
66+
</button>
67+
</li>
68+
)}
69+
</ul>
70+
</AlertGroupContext.Provider>
71+
);
72+
};
73+
3574
AlertGroupInline.displayName = 'AlertGroupInline';

packages/react-core/src/components/Alert/__tests__/AlertActionCloseButton.test.tsx

+20-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { render, screen } from '@testing-library/react';
22
import userEvent from '@testing-library/user-event';
3-
43
import { AlertActionCloseButton } from '../AlertActionCloseButton';
4+
import { AlertGroupContext } from '../AlertGroupContext';
55
import { AlertContext } from '../AlertContext';
66

77
jest.mock('../../Button');
@@ -59,8 +59,8 @@ test('Does not call the callback provided via onClose when it is not clicked', (
5959
});
6060

6161
test('Calls the callback provided via onClose when clicked', async () => {
62-
const onCloseMock = jest.fn();
6362
const user = userEvent.setup();
63+
const onCloseMock = jest.fn();
6464

6565
render(
6666
<AlertContext.Provider value={{ title: 'title', variantLabel: 'variantLabel' }}>
@@ -73,6 +73,24 @@ test('Calls the callback provided via onClose when clicked', async () => {
7373
expect(onCloseMock).toHaveBeenCalledTimes(1);
7474
});
7575

76+
test('Calls updateTransitionEnd with onClose when animations are enabled', async () => {
77+
const user = userEvent.setup();
78+
const onClose = jest.fn();
79+
const updateMock = jest.fn();
80+
render(
81+
<AlertGroupContext.Provider value={{ hasAnimations: true, updateTransitionEnd: updateMock }}>
82+
<AlertContext.Provider value={{ title: 'title', variantLabel: 'variantLabel' }}>
83+
<AlertActionCloseButton onClose={onClose} />
84+
</AlertContext.Provider>
85+
</AlertGroupContext.Provider>
86+
);
87+
88+
expect(updateMock).not.toHaveBeenCalled();
89+
await user.click(screen.getByRole('button'));
90+
expect(updateMock).toHaveBeenCalledWith(onClose);
91+
expect(updateMock).toHaveBeenCalledTimes(1);
92+
});
93+
7694
test('Renders with an aria label composed with the title and variantLabel provided via a context by default', () => {
7795
render(
7896
<AlertContext.Provider value={{ title: 'title', variantLabel: 'variantLabel' }}>

packages/react-core/src/components/Alert/__tests__/AlertGroup.test.tsx

+48-5
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { render, screen } from '@testing-library/react';
1+
import { render, screen, fireEvent } from '@testing-library/react';
22
import userEvent from '@testing-library/user-event';
33

44
import { Alert } from '../../Alert';
@@ -66,20 +66,63 @@ test('Toast Alert Group contains expected modifier class', () => {
6666
expect(screen.getByLabelText('group label')).toHaveClass('pf-m-toast');
6767
});
6868

69-
test('alertgroup closes when alerts are closed', async () => {
70-
const onClose = jest.fn();
69+
test('Calls the callback set by updateTransitionEnd when transition ends and animations are enabled', async () => {
70+
window.matchMedia = (query) => ({
71+
matches: false,
72+
media: query,
73+
onchange: null,
74+
addListener: () => {}, // deprecated
75+
removeListener: () => {}, // deprecated
76+
addEventListener: () => {},
77+
removeEventListener: () => {},
78+
dispatchEvent: () => true
79+
});
80+
const mockCallback = jest.fn();
81+
const user = userEvent.setup();
82+
83+
render(
84+
<AlertGroup hasAnimations isToast appendTo={document.body}>
85+
<Alert
86+
isLiveRegion
87+
title={'Test Alert'}
88+
actionClose={<AlertActionCloseButton aria-label="Close" onClose={mockCallback} />}
89+
/>
90+
</AlertGroup>
91+
);
92+
93+
await user.click(screen.getByLabelText('Close'));
94+
expect(mockCallback).not.toHaveBeenCalled();
95+
fireEvent.transitionEnd(screen.getByText('Test Alert').closest('.pf-v6-c-alert-group__item') as HTMLElement);
96+
expect(mockCallback).toHaveBeenCalled();
97+
});
98+
99+
test('Does not call the callback set by updateTransitionEnd when transition ends and animations are disabled', async () => {
100+
window.matchMedia = (query) => ({
101+
matches: false,
102+
media: query,
103+
onchange: null,
104+
addListener: () => {}, // deprecated
105+
removeListener: () => {}, // deprecated
106+
addEventListener: () => {},
107+
removeEventListener: () => {},
108+
dispatchEvent: () => true
109+
});
110+
const mockCallback = jest.fn();
71111
const user = userEvent.setup();
72112

73113
render(
74114
<AlertGroup isToast appendTo={document.body}>
75115
<Alert
76116
isLiveRegion
77117
title={'Test Alert'}
78-
actionClose={<AlertActionCloseButton aria-label="Close" onClose={onClose} />}
118+
actionClose={<AlertActionCloseButton aria-label="Close" onClose={mockCallback} />}
79119
/>
80120
</AlertGroup>
81121
);
82122

83123
await user.click(screen.getByLabelText('Close'));
84-
expect(onClose).toHaveBeenCalled();
124+
expect(mockCallback).toHaveBeenCalledTimes(1);
125+
// The transitionend event firing should not cause the callback to be called again
126+
fireEvent.transitionEnd(screen.getByText('Test Alert').closest('.pf-v6-c-alert-group__item') as HTMLElement);
127+
expect(mockCallback).toHaveBeenCalledTimes(1);
85128
});

0 commit comments

Comments
 (0)