diff --git a/packages/react-core/src/components/DatePicker/__tests__/DatePicker.test.tsx b/packages/react-core/src/components/DatePicker/__tests__/DatePicker.test.tsx index 4225fee64e4..1bf4502d22b 100644 --- a/packages/react-core/src/components/DatePicker/__tests__/DatePicker.test.tsx +++ b/packages/react-core/src/components/DatePicker/__tests__/DatePicker.test.tsx @@ -1,4 +1,4 @@ -import { screen, render } from '@testing-library/react'; +import { screen, render, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { HelperText, HelperTextItem } from '../../HelperText'; @@ -93,6 +93,11 @@ test('With popover opened', async () => { await user.click(screen.getByRole('button', { name: 'Toggle date picker' })); await screen.findByRole('button', { name: 'Previous month' }); + // Wait for popper opacity transition after requestAnimationFrame + await waitFor(() => { + const popover = screen.getByRole('dialog'); + expect(popover).toHaveStyle({ opacity: '1' }); + }); expect(asFragment()).toMatchSnapshot(); }); diff --git a/packages/react-core/src/components/Nav/__tests__/Nav.test.tsx b/packages/react-core/src/components/Nav/__tests__/Nav.test.tsx index 6153183accb..b73998c0e9d 100644 --- a/packages/react-core/src/components/Nav/__tests__/Nav.test.tsx +++ b/packages/react-core/src/components/Nav/__tests__/Nav.test.tsx @@ -1,5 +1,5 @@ import { StrictMode } from 'react'; -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import '@testing-library/jest-dom'; @@ -242,6 +242,11 @@ describe('Nav', () => { ); await user.hover(screen.getByText('My custom node')); + // Wait for popper opacity transition after requestAnimationFrame + await waitFor(() => { + const flyout = screen.getByText('Flyout test').parentElement; + expect(flyout).toHaveStyle({ opacity: '1' }); + }); expect(asFragment()).toMatchSnapshot(); }); diff --git a/packages/react-core/src/components/SearchInput/__tests__/SearchInput.test.tsx b/packages/react-core/src/components/SearchInput/__tests__/SearchInput.test.tsx index a545ebe7579..301e9063808 100644 --- a/packages/react-core/src/components/SearchInput/__tests__/SearchInput.test.tsx +++ b/packages/react-core/src/components/SearchInput/__tests__/SearchInput.test.tsx @@ -1,5 +1,5 @@ import { StrictMode } from 'react'; -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { SearchInput } from '../SearchInput'; @@ -151,6 +151,11 @@ describe('SearchInput', () => { expect(screen.getByTestId('test-id')).toContainElement(screen.getByText('First name')); expect(props.onSearch).toHaveBeenCalled(); + // Wait for popper opacity transition after requestAnimationFrame + await waitFor(() => { + const panel = screen.getByText('First name').closest('.pf-v6-c-panel'); + expect(panel?.parentElement).toHaveStyle({ opacity: '1' }); + }); expect(asFragment()).toMatchSnapshot(); }); diff --git a/packages/react-core/src/helpers/Popper/Popper.tsx b/packages/react-core/src/helpers/Popper/Popper.tsx index d3d49a591ae..7de3ab3cdd8 100644 --- a/packages/react-core/src/helpers/Popper/Popper.tsx +++ b/packages/react-core/src/helpers/Popper/Popper.tsx @@ -3,7 +3,7 @@ import * as ReactDOM from 'react-dom'; import { usePopper } from './thirdparty/react-popper/usePopper'; import { Options as OffsetOptions } from './thirdparty/popper-core/modifiers/offset'; import { Placement, Modifier } from './thirdparty/popper-core'; -import { clearTimeouts } from '../util'; +import { clearAnimationFrames, clearTimeouts } from '../util'; import { css } from '@patternfly/react-styles'; import '@patternfly/react-styles/css/components/Popper/Popper.css'; import { getLanguageDirection } from '../util'; @@ -234,6 +234,7 @@ export const Popper: React.FunctionComponent = ({ const transitionTimerRef = useRef(null); const showTimerRef = useRef(null); const hideTimerRef = useRef(null); + const rafRef = useRef(null); const prevExitDelayRef = useRef(undefined); const refOrTrigger = refElement || triggerElement; @@ -275,6 +276,7 @@ export const Popper: React.FunctionComponent = ({ useEffect( () => () => { clearTimeouts([transitionTimerRef, hideTimerRef, showTimerRef]); + clearAnimationFrames([rafRef]); }, [] ); @@ -469,6 +471,7 @@ export const Popper: React.FunctionComponent = ({ useEffect(() => { if (prevExitDelayRef.current < exitDelay) { clearTimeouts([transitionTimerRef, hideTimerRef]); + clearAnimationFrames([rafRef]); hideTimerRef.current = setTimeout(() => { transitionTimerRef.current = setTimeout(() => { setInternalIsVisible(false); @@ -481,16 +484,25 @@ export const Popper: React.FunctionComponent = ({ const show = () => { onShow(); clearTimeouts([transitionTimerRef, hideTimerRef]); + clearAnimationFrames([rafRef]); showTimerRef.current = setTimeout(() => { setInternalIsVisible(true); - setOpacity(1); - onShown(); + // Ensures React has committed the DOM changes and the popper element is rendered + rafRef.current = requestAnimationFrame(() => { + // Ensures Popper.js has calculated and applied the position transform before making element visible + rafRef.current = requestAnimationFrame(() => { + setOpacity(1); + onShown(); + rafRef.current = null; + }); + }); }, entryDelay); }; const hide = () => { onHide(); clearTimeouts([showTimerRef]); + clearAnimationFrames([rafRef]); hideTimerRef.current = setTimeout(() => { setOpacity(0); transitionTimerRef.current = setTimeout(() => { diff --git a/packages/react-core/src/helpers/__mocks__/util.ts b/packages/react-core/src/helpers/__mocks__/util.ts index 5be1b4a6e2c..58c10de56db 100644 --- a/packages/react-core/src/helpers/__mocks__/util.ts +++ b/packages/react-core/src/helpers/__mocks__/util.ts @@ -2,4 +2,6 @@ export const getUniqueId = () => 'unique_id_mock'; export const clearTimeouts = () => {}; +export const clearAnimationFrames = () => {}; + export const getLanguageDirection = () => 'ltr'; diff --git a/packages/react-core/src/helpers/util.ts b/packages/react-core/src/helpers/util.ts index ac63bbaeade..e323815be84 100644 --- a/packages/react-core/src/helpers/util.ts +++ b/packages/react-core/src/helpers/util.ts @@ -524,6 +524,17 @@ export const clearTimeouts = (timeoutRefs: React.RefObject[]) => { }); }; +/** + * @param {React.RefObject[]} animationFrameRefs - Animation frame refs to clear + */ +export const clearAnimationFrames = (animationFrameRefs: React.RefObject[]) => { + animationFrameRefs.forEach((ref) => { + if (ref.current) { + cancelAnimationFrame(ref.current); + } + }); +}; + /** * Helper function to get the language direction of a given element, useful for figuring out if left-to-right * or right-to-left specific logic should be applied. diff --git a/packages/react-integration/cypress/integration/button.spec.ts b/packages/react-integration/cypress/integration/button.spec.ts index 50ba5537d99..2d4491d25d6 100644 --- a/packages/react-integration/cypress/integration/button.spec.ts +++ b/packages/react-integration/cypress/integration/button.spec.ts @@ -9,7 +9,8 @@ describe('Button Demo Test', () => { .focus() .should('have.attr', 'aria-describedby', 'button-with-tooltip-1'); }); - cy.get('.pf-v6-c-tooltip').should('be.visible'); + // Tooltip visibility is async due to requestAnimationFrame-based positioning + cy.get('.pf-v6-c-tooltip', { timeout: 6000 }).should('be.visible'); }); it('Verify isAriaDisabled button has tooltip when hovered', () => { @@ -18,7 +19,7 @@ describe('Button Demo Test', () => { .trigger('mouseover') .should('have.attr', 'aria-describedby', 'button-with-tooltip-1'); }); - cy.get('.pf-v6-c-tooltip').should('be.visible'); + cy.get('.pf-v6-c-tooltip', { timeout: 6000 }).should('be.visible'); }); it('Verify isAriaDisabled button prevents default actions', () => { diff --git a/packages/react-integration/cypress/integration/overflowmenu.spec.ts b/packages/react-integration/cypress/integration/overflowmenu.spec.ts index bffe6612847..58918174d99 100644 --- a/packages/react-integration/cypress/integration/overflowmenu.spec.ts +++ b/packages/react-integration/cypress/integration/overflowmenu.spec.ts @@ -32,7 +32,7 @@ describe('OverflowMenu Demo Test', () => { it('Verify dropdown menu expanded', () => { cy.get('#simple-overflow-menu button').last().click({ force: true }); cy.get('#simple-overflow-menu .pf-v6-c-menu-toggle').should('have.class', 'pf-m-expanded'); - cy.get('.simple-overflow-menu.pf-v6-c-menu').should('be.visible'); + cy.get('.simple-overflow-menu.pf-v6-c-menu', { timeout: 6000 }).should('be.visible'); // close overflow menu again cy.get('#simple-overflow-menu button').last().click({ force: true }); }); @@ -69,7 +69,7 @@ describe('OverflowMenu Demo Test', () => { it('Verify dropdown menu expanded', () => { cy.get('#additional-options-overflow-menu button').last().click({ force: true }); cy.get('#additional-options-overflow-menu .pf-v6-c-menu-toggle').should('have.class', 'pf-m-expanded'); - cy.get('.additional-options-overflow-menu.pf-v6-c-menu').should('be.visible'); + cy.get('.additional-options-overflow-menu.pf-v6-c-menu', { timeout: 6000 }).should('be.visible'); }); }); }); @@ -107,7 +107,7 @@ describe('OverflowMenu Demo Test', () => { it('Verify dropdown menu expanded', () => { cy.get('#persist-overflow-menu button').last().click({ force: true }); cy.get('#persist-overflow-menu .pf-v6-c-menu-toggle').should('have.class', 'pf-m-expanded'); - cy.get('.persist-overflow-menu.pf-v6-c-menu').should('be.visible'); + cy.get('.persist-overflow-menu.pf-v6-c-menu', { timeout: 6000 }).should('be.visible'); }); }); }); @@ -142,7 +142,7 @@ describe('OverflowMenu Demo Test', () => { it('Verify dropdown menu expanded', () => { cy.get('#container-breakpoint-overflow-menu button').last().click({ force: true }); cy.get('#container-breakpoint-overflow-menu .pf-v6-c-menu-toggle').should('have.class', 'pf-m-expanded'); - cy.get('.container-breakpoint-overflow-menu.pf-v6-c-menu').should('be.visible'); + cy.get('.container-breakpoint-overflow-menu.pf-v6-c-menu', { timeout: 6000 }).should('be.visible'); // close overflow menu again cy.get('#container-breakpoint-overflow-menu button').last().click({ force: true }); }); diff --git a/packages/react-integration/cypress/integration/tabsdisable.spec.ts b/packages/react-integration/cypress/integration/tabsdisable.spec.ts index 0512d164183..2ea548b370d 100644 --- a/packages/react-integration/cypress/integration/tabsdisable.spec.ts +++ b/packages/react-integration/cypress/integration/tabsdisable.spec.ts @@ -40,6 +40,7 @@ describe('Disabled Tab Demo Test', () => { it('Verify aria-disabled with tooltip', () => { cy.get(withTooltip.button).trigger('mouseover'); - cy.get('.pf-v6-c-tooltip').should('be.visible'); + // Tooltip visibility is async due to requestAnimationFrame-based positioning + cy.get('.pf-v6-c-tooltip', { timeout: 6000 }).should('be.visible'); }); });