From 49bcb04e0d573bafeec0dcdf15e9af734700bb64 Mon Sep 17 00:00:00 2001 From: Jenny <32821331+jenny-s51@users.noreply.github.com> Date: Wed, 3 Sep 2025 11:13:45 -0400 Subject: [PATCH 1/5] 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 --- .../src/components/Alert/AlertGroup.tsx | 122 +++++++------ .../src/components/Alert/AlertGroupInline.tsx | 4 +- .../Alert/__tests__/AlertGroup.test.tsx | 12 +- .../DualListSelector/DualListSelector.tsx | 71 ++++---- .../DualListSelectorTreeItem.tsx | 4 +- .../Form/FormFieldGroupExpandable.tsx | 4 +- .../Form/InternalFormFieldGroup.tsx | 4 +- .../components/SearchInput/SearchInput.tsx | 4 +- .../src/components/TreeView/TreeView.tsx | 4 +- .../components/TreeView/TreeViewListItem.tsx | 4 +- .../__snapshots__/TreeView.test.tsx.snap | 2 +- .../AnimationsProvider/AnimationsProvider.tsx | 105 ++++++++++++ .../__tests__/AnimationsProvider.test.tsx | 134 +++++++++++++++ .../examples/AnimationsProvider.md | 161 ++++++++++++++++++ .../examples/AnimationsProviderBasic.tsx | 69 ++++++++ .../src/helpers/AnimationsProvider/index.ts | 1 + packages/react-core/src/helpers/index.ts | 1 + .../src/components/Table/Table.tsx | 4 +- 18 files changed, 595 insertions(+), 115 deletions(-) create mode 100644 packages/react-core/src/helpers/AnimationsProvider/AnimationsProvider.tsx create mode 100644 packages/react-core/src/helpers/AnimationsProvider/__tests__/AnimationsProvider.test.tsx create mode 100644 packages/react-core/src/helpers/AnimationsProvider/examples/AnimationsProvider.md create mode 100644 packages/react-core/src/helpers/AnimationsProvider/examples/AnimationsProviderBasic.tsx create mode 100644 packages/react-core/src/helpers/AnimationsProvider/index.ts diff --git a/packages/react-core/src/components/Alert/AlertGroup.tsx b/packages/react-core/src/components/Alert/AlertGroup.tsx index 26028c1e16b..ed845ac32dc 100644 --- a/packages/react-core/src/components/Alert/AlertGroup.tsx +++ b/packages/react-core/src/components/Alert/AlertGroup.tsx @@ -1,7 +1,8 @@ -import { Component } from 'react'; +import { useEffect, useRef, useState } from 'react'; import * as ReactDOM from 'react-dom'; import { canUseDOM } from '../../helpers'; import { AlertGroupInline } from './AlertGroupInline'; +import { useHasAnimations } from '../../helpers'; export interface AlertGroupProps extends Omit, 'className'> { /** Additional classes added to the AlertGroup */ @@ -26,78 +27,73 @@ export interface AlertGroupProps extends Omit, 'aria-label'?: string; } -interface AlertGroupState { - container: HTMLElement; -} - -class AlertGroup extends Component { - static displayName = 'AlertGroup'; - state = { - container: undefined - } as AlertGroupState; - - componentDidMount() { - const container = document.createElement('div'); - const target: HTMLElement = this.getTargetElement(); - this.setState({ container }); - target.appendChild(container); - } +export const AlertGroup: React.FunctionComponent = ({ + className, + children, + hasAnimations: localHasAnimations, + isToast, + isLiveRegion, + onOverflowClick, + overflowMessage, + 'aria-label': ariaLabel, - componentWillUnmount() { - const target: HTMLElement = this.getTargetElement(); - if (this.state.container) { - target.removeChild(this.state.container); - } - } + appendTo, // do not pass down to ul + ...props +}: AlertGroupProps) => { + const containerRef = useRef(null); + const [isContainerReady, setIsContainerReady] = useState(false); + const hasAnimations = useHasAnimations(localHasAnimations); - getTargetElement() { - const appendTo = this.props.appendTo; + const getTargetElement = () => { if (typeof appendTo === 'function') { return appendTo(); } return appendTo || document.body; - } + }; + + useEffect(() => { + if (isToast && canUseDOM) { + const container = document.createElement('div'); + const target = getTargetElement(); + containerRef.current = container; + target.appendChild(container); + setIsContainerReady(true); - render() { - const { - className, - children, - hasAnimations = false, - isToast, - isLiveRegion, - onOverflowClick, - overflowMessage, - 'aria-label': ariaLabel, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - appendTo, // do not pass down to ul - ...props - } = this.props; - const alertGroup = ( - - {children} - - ); - if (!this.props.isToast) { - return alertGroup; + return () => { + if (containerRef.current) { + target.removeChild(containerRef.current); + containerRef.current = null; + } + setIsContainerReady(false); + }; } + }, [isToast, appendTo]); - const container = this.state.container; + const alertGroup = ( + + {children} + + ); - if (!canUseDOM || !container) { - return null; - } + if (!isToast) { + return alertGroup; + } + + const container = containerRef.current; - return ReactDOM.createPortal(alertGroup, container); + if (!canUseDOM || !container || !isContainerReady) { + return null; } -} -export { AlertGroup }; + return ReactDOM.createPortal(alertGroup, container); +}; +AlertGroup.displayName = 'AlertGroup'; diff --git a/packages/react-core/src/components/Alert/AlertGroupInline.tsx b/packages/react-core/src/components/Alert/AlertGroupInline.tsx index 95622162b11..391647de280 100644 --- a/packages/react-core/src/components/Alert/AlertGroupInline.tsx +++ b/packages/react-core/src/components/Alert/AlertGroupInline.tsx @@ -4,17 +4,19 @@ import styles from '@patternfly/react-styles/css/components/Alert/alert-group'; import { AlertGroupProps } from './AlertGroup'; import { AlertProps } from '../Alert'; import { AlertGroupContext } from './AlertGroupContext'; +import { useHasAnimations } from '../../helpers'; export const AlertGroupInline: React.FunctionComponent = ({ className, children, - hasAnimations, + hasAnimations: localHasAnimations, isToast, isLiveRegion, onOverflowClick, overflowMessage, ...props }: AlertGroupProps) => { + const hasAnimations = useHasAnimations(localHasAnimations); const [handleTransitionEnd, setHandleTransitionEnd] = useState<() => void>(() => () => {}); const updateTransitionEnd = (onTransitionEnd: () => void) => { diff --git a/packages/react-core/src/components/Alert/__tests__/AlertGroup.test.tsx b/packages/react-core/src/components/Alert/__tests__/AlertGroup.test.tsx index 4b5bade997a..aca2c9a8fcd 100644 --- a/packages/react-core/src/components/Alert/__tests__/AlertGroup.test.tsx +++ b/packages/react-core/src/components/Alert/__tests__/AlertGroup.test.tsx @@ -56,14 +56,16 @@ test('Standard Alert Group is not a toast alert group', () => { expect(screen.getByText('alert title').parentElement).not.toHaveClass('pf-m-toast'); }); -test('Toast Alert Group contains expected modifier class', () => { +test('Toast Alert Group contains expected modifier class', async () => { render( ); - expect(screen.getByLabelText('group label')).toHaveClass('pf-m-toast'); + // Wait for the portal to be created and rendered + const alertGroup = await screen.findByLabelText('group label'); + expect(alertGroup).toHaveClass('pf-m-toast'); }); test('Calls the callback set by updateTransitionEnd when transition ends and animations are enabled', async () => { @@ -90,7 +92,8 @@ test('Calls the callback set by updateTransitionEnd when transition ends and ani ); - await user.click(screen.getByLabelText('Close')); + const closeButton = await screen.findByLabelText('Close'); + await user.click(closeButton); expect(mockCallback).not.toHaveBeenCalled(); fireEvent.transitionEnd(screen.getByText('Test Alert').closest('.pf-v6-c-alert-group__item') as HTMLElement); expect(mockCallback).toHaveBeenCalled(); @@ -120,7 +123,8 @@ test('Does not call the callback set by updateTransitionEnd when transition ends ); - await user.click(screen.getByLabelText('Close')); + const closeButton = await screen.findByLabelText('Close'); + await user.click(closeButton); expect(mockCallback).toHaveBeenCalledTimes(1); // The transitionend event firing should not cause the callback to be called again fireEvent.transitionEnd(screen.getByText('Test Alert').closest('.pf-v6-c-alert-group__item') as HTMLElement); diff --git a/packages/react-core/src/components/DualListSelector/DualListSelector.tsx b/packages/react-core/src/components/DualListSelector/DualListSelector.tsx index a8eed78a257..6e4aa6fb69b 100644 --- a/packages/react-core/src/components/DualListSelector/DualListSelector.tsx +++ b/packages/react-core/src/components/DualListSelector/DualListSelector.tsx @@ -1,8 +1,8 @@ -import { Component } from 'react'; import styles from '@patternfly/react-styles/css/components/DualListSelector/dual-list-selector'; import { css } from '@patternfly/react-styles'; -import { GenerateId, PickOptional } from '../../helpers'; +import { GenerateId } from '../../helpers'; import { DualListSelectorContext } from './DualListSelectorContext'; +import { useHasAnimations } from '../../helpers'; /** Acts as a container for all other DualListSelector sub-components when using a * composable dual list selector. @@ -24,41 +24,34 @@ export interface DualListSelectorProps { hasAnimations?: boolean; } -class DualListSelector extends Component { - static displayName = 'DualListSelector'; - static defaultProps: PickOptional = { - children: '', - isTree: false, - hasAnimations: false - }; +export const DualListSelector: React.FunctionComponent = ({ + className, + children, + id, + isTree = false, + hasAnimations: localHasAnimations, + ...props +}: DualListSelectorProps) => { + const hasAnimations = useHasAnimations(localHasAnimations); - constructor(props: DualListSelectorProps) { - super(props); - } - - render() { - const { className, children, id, isTree, hasAnimations, ...props } = this.props; - - return ( - - - {(randomId) => ( -
- {children} -
- )} -
-
- ); - } -} - -export { DualListSelector }; + return ( + + + {(randomId) => ( +
+ {children} +
+ )} +
+
+ ); +}; +DualListSelector.displayName = 'DualListSelector'; diff --git a/packages/react-core/src/components/DualListSelector/DualListSelectorTreeItem.tsx b/packages/react-core/src/components/DualListSelector/DualListSelectorTreeItem.tsx index 94141f9f38e..d9fd32082da 100644 --- a/packages/react-core/src/components/DualListSelector/DualListSelectorTreeItem.tsx +++ b/packages/react-core/src/components/DualListSelector/DualListSelectorTreeItem.tsx @@ -6,6 +6,7 @@ import { Badge } from '../Badge'; import AngleRightIcon from '@patternfly/react-icons/dist/esm/icons/angle-right-icon'; import { flattenTree } from './treeUtils'; import { DualListSelectorListContext } from './DualListSelectorContext'; +import { useHasAnimations } from '../../helpers'; export interface DualListSelectorTreeItemProps extends React.HTMLProps { /** Content rendered inside the dual list selector. */ @@ -58,7 +59,7 @@ const DualListSelectorTreeItemBase: React.FunctionComponent { setIsExpanded(defaultExpanded); diff --git a/packages/react-core/src/components/Form/FormFieldGroupExpandable.tsx b/packages/react-core/src/components/Form/FormFieldGroupExpandable.tsx index 369a5adf3fc..bd0e7f9e21d 100644 --- a/packages/react-core/src/components/Form/FormFieldGroupExpandable.tsx +++ b/packages/react-core/src/components/Form/FormFieldGroupExpandable.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; import { InternalFormFieldGroup } from './InternalFormFieldGroup'; +import { useHasAnimations } from '../../helpers'; export interface FormFieldGroupExpandableProps extends Omit, 'onToggle'> { /** Anything that can be rendered as form field group content. */ @@ -25,10 +26,11 @@ export const FormFieldGroupExpandable: React.FunctionComponent { const [localIsExpanded, setIsExpanded] = useState(isExpanded); + const hasAnimations = useHasAnimations(localHasAnimations); return ( , 'label' | 'onToggle'> { /** Anything that can be rendered as form field group content. */ @@ -33,9 +34,10 @@ export const InternalFormFieldGroup: React.FunctionComponent { + const hasAnimations = useHasAnimations(localHasAnimations); const headerTitleText = header ? header.props.titleText : null; if (isExpandable && !toggleAriaLabel && !headerTitleText) { // eslint-disable-next-line no-console diff --git a/packages/react-core/src/components/SearchInput/SearchInput.tsx b/packages/react-core/src/components/SearchInput/SearchInput.tsx index ddb9f96a1dd..c85d49b0f60 100644 --- a/packages/react-core/src/components/SearchInput/SearchInput.tsx +++ b/packages/react-core/src/components/SearchInput/SearchInput.tsx @@ -13,6 +13,7 @@ import { AdvancedSearchMenu } from './AdvancedSearchMenu'; import { TextInputGroup, TextInputGroupMain, TextInputGroupUtilities } from '../TextInputGroup'; import { InputGroup, InputGroupItem } from '../InputGroup'; import { Popper } from '../../helpers'; +import { useHasAnimations } from '../../helpers'; import textInputGroupStyles from '@patternfly/react-styles/css/components/TextInputGroup/text-input-group'; import inputGroupStyles from '@patternfly/react-styles/css/components/InputGroup/input-group'; @@ -180,7 +181,8 @@ const SearchInputBase: React.FunctionComponent = ({ const popperRef = useRef(null); const [focusAfterExpandChange, setFocusAfterExpandChange] = useState(false); - const { isExpanded, onToggleExpand, toggleAriaLabel, hasAnimations } = expandableInput || {}; + const { isExpanded, onToggleExpand, toggleAriaLabel, hasAnimations: localHasAnimations } = expandableInput || {}; + const hasAnimations = useHasAnimations(localHasAnimations); useEffect(() => { // this effect and the focusAfterExpandChange variable are needed to focus the input/toggle as needed when the diff --git a/packages/react-core/src/components/TreeView/TreeView.tsx b/packages/react-core/src/components/TreeView/TreeView.tsx index 890a86782ba..ebd57522277 100644 --- a/packages/react-core/src/components/TreeView/TreeView.tsx +++ b/packages/react-core/src/components/TreeView/TreeView.tsx @@ -1,6 +1,7 @@ import { TreeViewList } from './TreeViewList'; import { TreeViewCheckProps, TreeViewListItem } from './TreeViewListItem'; import { TreeViewRoot } from './TreeViewRoot'; +import { useHasAnimations } from '../../helpers'; /** Properties that make up a tree view data item. These properties should be passed in as an * object to one of the various tree view component properties which accept TreeViewDataItem as @@ -135,9 +136,10 @@ export const TreeView: React.FunctionComponent = ({ useMemo, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledby, - hasAnimations, + hasAnimations: localHasAnimations, ...props }: TreeViewProps) => { + const hasAnimations = useHasAnimations(localHasAnimations); const treeViewList = ( >, 'checked'> { checked?: boolean | null; @@ -102,10 +103,11 @@ const TreeViewListItemBase: React.FunctionComponent = ({ expandedIcon, action, compareItems, - hasAnimations, + hasAnimations: localHasAnimations, // eslint-disable-next-line @typescript-eslint/no-unused-vars useMemo }: TreeViewListItemProps) => { + const hasAnimations = useHasAnimations(localHasAnimations); const [internalIsExpanded, setIsExpanded] = useState(defaultExpanded); useEffect(() => { if (isExpanded !== undefined && isExpanded !== null) { diff --git a/packages/react-core/src/components/TreeView/__tests__/__snapshots__/TreeView.test.tsx.snap b/packages/react-core/src/components/TreeView/__tests__/__snapshots__/TreeView.test.tsx.snap index 8b523ae077c..6f0bf3de7c5 100644 --- a/packages/react-core/src/components/TreeView/__tests__/__snapshots__/TreeView.test.tsx.snap +++ b/packages/react-core/src/components/TreeView/__tests__/__snapshots__/TreeView.test.tsx.snap @@ -105,7 +105,7 @@ exports[`Matches snapshot 1`] = ` TreeViewListItem useMemo: undefined

- TreeViewListItem hasAnimations: undefined + TreeViewListItem hasAnimations: false