Skip to content

Commit fdd6433

Browse files
authored
feat(AnimationsProvider): Add Support for AnimationsProvider (#11990)
* feat(AnimationsProvider): update opt-in components with useHasAnimations, add AnimationsProvider to helpers, add md files for documentation fix(components): update hasAnimations to fall back to context fix(md): remove cssPrefix fix(alertgroup): fix tests * chore(AnimationsProvider): Add tests * feat(AnimationsProvider): update tests, set config to required, rename prop to hasAnimationsProp to match existing conventions * feat(AnimationsProvider): delete AnimationsProvider.md * remove comment in AlertGroup for appendTo
1 parent fe63802 commit fdd6433

File tree

22 files changed

+601
-116
lines changed

22 files changed

+601
-116
lines changed
Lines changed: 59 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import { Component } from 'react';
1+
import { useEffect, useRef, useState } from 'react';
22
import * as ReactDOM from 'react-dom';
33
import { canUseDOM } from '../../helpers';
44
import { AlertGroupInline } from './AlertGroupInline';
5+
import { useHasAnimations } from '../../helpers';
56

67
export interface AlertGroupProps extends Omit<React.HTMLProps<HTMLUListElement>, 'className'> {
78
/** Additional classes added to the AlertGroup */
@@ -26,78 +27,73 @@ export interface AlertGroupProps extends Omit<React.HTMLProps<HTMLUListElement>,
2627
'aria-label'?: string;
2728
}
2829

29-
interface AlertGroupState {
30-
container: HTMLElement;
31-
}
32-
33-
class AlertGroup extends Component<AlertGroupProps, AlertGroupState> {
34-
static displayName = 'AlertGroup';
35-
state = {
36-
container: undefined
37-
} as AlertGroupState;
38-
39-
componentDidMount() {
40-
const container = document.createElement('div');
41-
const target: HTMLElement = this.getTargetElement();
42-
this.setState({ container });
43-
target.appendChild(container);
44-
}
30+
export const AlertGroup: React.FunctionComponent<AlertGroupProps> = ({
31+
className,
32+
children,
33+
hasAnimations: hasAnimationsProp,
34+
isToast,
35+
isLiveRegion,
36+
onOverflowClick,
37+
overflowMessage,
38+
'aria-label': ariaLabel,
4539

46-
componentWillUnmount() {
47-
const target: HTMLElement = this.getTargetElement();
48-
if (this.state.container) {
49-
target.removeChild(this.state.container);
50-
}
51-
}
40+
appendTo,
41+
...props
42+
}: AlertGroupProps) => {
43+
const containerRef = useRef<HTMLElement | null>(null);
44+
const [isContainerReady, setIsContainerReady] = useState(false);
45+
const hasAnimations = useHasAnimations(hasAnimationsProp);
5246

53-
getTargetElement() {
54-
const appendTo = this.props.appendTo;
47+
const getTargetElement = () => {
5548
if (typeof appendTo === 'function') {
5649
return appendTo();
5750
}
5851
return appendTo || document.body;
59-
}
52+
};
53+
54+
useEffect(() => {
55+
if (isToast && canUseDOM) {
56+
const container = document.createElement('div');
57+
const target = getTargetElement();
58+
containerRef.current = container;
59+
target.appendChild(container);
60+
setIsContainerReady(true);
6061

61-
render() {
62-
const {
63-
className,
64-
children,
65-
hasAnimations = false,
66-
isToast,
67-
isLiveRegion,
68-
onOverflowClick,
69-
overflowMessage,
70-
'aria-label': ariaLabel,
71-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
72-
appendTo, // do not pass down to ul
73-
...props
74-
} = this.props;
75-
const alertGroup = (
76-
<AlertGroupInline
77-
onOverflowClick={onOverflowClick}
78-
className={className}
79-
isToast={isToast}
80-
isLiveRegion={isLiveRegion}
81-
overflowMessage={overflowMessage}
82-
aria-label={ariaLabel}
83-
hasAnimations={hasAnimations}
84-
{...props}
85-
>
86-
{children}
87-
</AlertGroupInline>
88-
);
89-
if (!this.props.isToast) {
90-
return alertGroup;
62+
return () => {
63+
if (containerRef.current) {
64+
target.removeChild(containerRef.current);
65+
containerRef.current = null;
66+
}
67+
setIsContainerReady(false);
68+
};
9169
}
70+
}, [isToast, appendTo]);
9271

93-
const container = this.state.container;
72+
const alertGroup = (
73+
<AlertGroupInline
74+
onOverflowClick={onOverflowClick}
75+
className={className}
76+
isToast={isToast}
77+
isLiveRegion={isLiveRegion}
78+
overflowMessage={overflowMessage}
79+
aria-label={ariaLabel}
80+
hasAnimations={hasAnimations}
81+
{...props}
82+
>
83+
{children}
84+
</AlertGroupInline>
85+
);
9486

95-
if (!canUseDOM || !container) {
96-
return null;
97-
}
87+
if (!isToast) {
88+
return alertGroup;
89+
}
90+
91+
const container = containerRef.current;
9892

99-
return ReactDOM.createPortal(alertGroup, container);
93+
if (!canUseDOM || !container || !isContainerReady) {
94+
return null;
10095
}
101-
}
10296

103-
export { AlertGroup };
97+
return ReactDOM.createPortal(alertGroup, container);
98+
};
99+
AlertGroup.displayName = 'AlertGroup';

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,19 @@ import styles from '@patternfly/react-styles/css/components/Alert/alert-group';
44
import { AlertGroupProps } from './AlertGroup';
55
import { AlertProps } from '../Alert';
66
import { AlertGroupContext } from './AlertGroupContext';
7+
import { useHasAnimations } from '../../helpers';
78

89
export const AlertGroupInline: React.FunctionComponent<AlertGroupProps> = ({
910
className,
1011
children,
11-
hasAnimations,
12+
hasAnimations: hasAnimationsProp,
1213
isToast,
1314
isLiveRegion,
1415
onOverflowClick,
1516
overflowMessage,
1617
...props
1718
}: AlertGroupProps) => {
19+
const hasAnimations = useHasAnimations(hasAnimationsProp);
1820
const [handleTransitionEnd, setHandleTransitionEnd] = useState<() => void>(() => () => {});
1921

2022
const updateTransitionEnd = (onTransitionEnd: () => void) => {

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

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import userEvent from '@testing-library/user-event';
44
import { Alert } from '../../Alert';
55
import { AlertGroup } from '../../Alert';
66
import { AlertActionCloseButton } from '../../../components/Alert/AlertActionCloseButton';
7+
import { AnimationsProvider } from '../../../helpers/AnimationsProvider';
78

89
test('Alert Group renders without children', () => {
910
render(
@@ -56,14 +57,16 @@ test('Standard Alert Group is not a toast alert group', () => {
5657
expect(screen.getByText('alert title').parentElement).not.toHaveClass('pf-m-toast');
5758
});
5859

59-
test('Toast Alert Group contains expected modifier class', () => {
60+
test('Toast Alert Group contains expected modifier class', async () => {
6061
render(
6162
<AlertGroup isToast aria-label="group label">
6263
<Alert variant="warning" title="alert title" />
6364
</AlertGroup>
6465
);
6566

66-
expect(screen.getByLabelText('group label')).toHaveClass('pf-m-toast');
67+
// Wait for the portal to be created and rendered
68+
const alertGroup = await screen.findByLabelText('group label');
69+
expect(alertGroup).toHaveClass('pf-m-toast');
6770
});
6871

6972
test('Calls the callback set by updateTransitionEnd when transition ends and animations are enabled', async () => {
@@ -90,8 +93,11 @@ test('Calls the callback set by updateTransitionEnd when transition ends and ani
9093
</AlertGroup>
9194
);
9295

93-
await user.click(screen.getByLabelText('Close'));
96+
const closeButton = await screen.findByLabelText('Close');
97+
await user.click(closeButton);
9498
expect(mockCallback).not.toHaveBeenCalled();
99+
// fireEvent is needed here because transitionEnd is a browser event that occurs automatically
100+
// when CSS transitions complete, not a user interaction that userEvent can simulate
95101
fireEvent.transitionEnd(screen.getByText('Test Alert').closest('.pf-v6-c-alert-group__item') as HTMLElement);
96102
expect(mockCallback).toHaveBeenCalled();
97103
});
@@ -120,9 +126,54 @@ test('Does not call the callback set by updateTransitionEnd when transition ends
120126
</AlertGroup>
121127
);
122128

123-
await user.click(screen.getByLabelText('Close'));
129+
const closeButton = await screen.findByLabelText('Close');
130+
await user.click(closeButton);
124131
expect(mockCallback).toHaveBeenCalledTimes(1);
125132
// The transitionend event firing should not cause the callback to be called again
133+
// fireEvent is needed here because transitionEnd is a browser event that occurs automatically
134+
// when CSS transitions complete, not a user interaction that userEvent can simulate
126135
fireEvent.transitionEnd(screen.getByText('Test Alert').closest('.pf-v6-c-alert-group__item') as HTMLElement);
127136
expect(mockCallback).toHaveBeenCalledTimes(1);
128137
});
138+
139+
describe('Animation context behavior', () => {
140+
test('respects AnimationsProvider context when no local hasAnimations prop', () => {
141+
render(
142+
<AnimationsProvider config={{ hasAnimations: true }}>
143+
<AlertGroup>
144+
<Alert title="Test Alert" />
145+
</AlertGroup>
146+
</AnimationsProvider>
147+
);
148+
149+
// Should apply animation class when animations are enabled via context
150+
const alertGroupItem = screen.getByText('Test Alert').closest('.pf-v6-c-alert-group__item');
151+
expect(alertGroupItem).toHaveClass('pf-m-offstage-top');
152+
});
153+
154+
test('local hasAnimations prop takes precedence over context', () => {
155+
render(
156+
<AnimationsProvider config={{ hasAnimations: true }}>
157+
<AlertGroup hasAnimations={false}>
158+
<Alert title="Test Alert" />
159+
</AlertGroup>
160+
</AnimationsProvider>
161+
);
162+
163+
// Should not apply animation class when local hasAnimations=false overrides context
164+
const alertGroupItem = screen.getByText('Test Alert').closest('.pf-v6-c-alert-group__item');
165+
expect(alertGroupItem).not.toHaveClass('pf-m-offstage-top');
166+
});
167+
168+
test('works without AnimationsProvider (backward compatibility)', () => {
169+
render(
170+
<AlertGroup>
171+
<Alert title="Test Alert" />
172+
</AlertGroup>
173+
);
174+
175+
// Should not apply animation class when no context and no local hasAnimations
176+
const alertGroupItem = screen.getByText('Test Alert').closest('.pf-v6-c-alert-group__item');
177+
expect(alertGroupItem).not.toHaveClass('pf-m-offstage-top');
178+
});
179+
});
Lines changed: 32 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import { Component } from 'react';
21
import styles from '@patternfly/react-styles/css/components/DualListSelector/dual-list-selector';
32
import { css } from '@patternfly/react-styles';
4-
import { GenerateId, PickOptional } from '../../helpers';
3+
import { GenerateId } from '../../helpers';
54
import { DualListSelectorContext } from './DualListSelectorContext';
5+
import { useHasAnimations } from '../../helpers';
66

77
/** Acts as a container for all other DualListSelector sub-components when using a
88
* composable dual list selector.
@@ -24,41 +24,34 @@ export interface DualListSelectorProps {
2424
hasAnimations?: boolean;
2525
}
2626

27-
class DualListSelector extends Component<DualListSelectorProps> {
28-
static displayName = 'DualListSelector';
29-
static defaultProps: PickOptional<DualListSelectorProps> = {
30-
children: '',
31-
isTree: false,
32-
hasAnimations: false
33-
};
27+
export const DualListSelector: React.FunctionComponent<DualListSelectorProps> = ({
28+
className,
29+
children,
30+
id,
31+
isTree = false,
32+
hasAnimations: hasAnimationsProp,
33+
...props
34+
}: DualListSelectorProps) => {
35+
const hasAnimations = useHasAnimations(hasAnimationsProp);
3436

35-
constructor(props: DualListSelectorProps) {
36-
super(props);
37-
}
38-
39-
render() {
40-
const { className, children, id, isTree, hasAnimations, ...props } = this.props;
41-
42-
return (
43-
<DualListSelectorContext.Provider value={{ isTree, hasAnimations }}>
44-
<GenerateId>
45-
{(randomId) => (
46-
<div
47-
className={css(
48-
styles.dualListSelector,
49-
hasAnimations && isTree && styles.modifiers.animateExpand,
50-
className
51-
)}
52-
id={id || randomId}
53-
{...props}
54-
>
55-
{children}
56-
</div>
57-
)}
58-
</GenerateId>
59-
</DualListSelectorContext.Provider>
60-
);
61-
}
62-
}
63-
64-
export { DualListSelector };
37+
return (
38+
<DualListSelectorContext.Provider value={{ isTree, hasAnimations }}>
39+
<GenerateId>
40+
{(randomId) => (
41+
<div
42+
className={css(
43+
styles.dualListSelector,
44+
hasAnimations && isTree && styles.modifiers.animateExpand,
45+
className
46+
)}
47+
id={id || randomId}
48+
{...props}
49+
>
50+
{children}
51+
</div>
52+
)}
53+
</GenerateId>
54+
</DualListSelectorContext.Provider>
55+
);
56+
};
57+
DualListSelector.displayName = 'DualListSelector';

packages/react-core/src/components/DualListSelector/DualListSelectorTreeItem.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { Badge } from '../Badge';
66
import AngleRightIcon from '@patternfly/react-icons/dist/esm/icons/angle-right-icon';
77
import { flattenTree } from './treeUtils';
88
import { DualListSelectorListContext } from './DualListSelectorContext';
9+
import { useHasAnimations } from '../../helpers';
910

1011
export interface DualListSelectorTreeItemProps extends React.HTMLProps<HTMLLIElement> {
1112
/** Content rendered inside the dual list selector. */
@@ -58,14 +59,15 @@ const DualListSelectorTreeItemBase: React.FunctionComponent<DualListSelectorTree
5859
badgeProps,
5960
itemData,
6061
isDisabled = false,
61-
hasAnimations,
62+
hasAnimations: hasAnimationsProp,
6263
// eslint-disable-next-line @typescript-eslint/no-unused-vars
6364
useMemo,
6465
...props
6566
}: DualListSelectorTreeItemProps) => {
6667
const ref = useRef(null);
6768
const [isExpanded, setIsExpanded] = useState(defaultExpanded || false);
6869
const { setFocusedOption } = useContext(DualListSelectorListContext);
70+
const hasAnimations = useHasAnimations(hasAnimationsProp);
6971

7072
useEffect(() => {
7173
setIsExpanded(defaultExpanded);

0 commit comments

Comments
 (0)