diff --git a/packages/react-aria-components/src/SharedElementTransition.tsx b/packages/react-aria-components/src/SharedElementTransition.tsx index f4b45d73081..e6c0a9f62b2 100644 --- a/packages/react-aria-components/src/SharedElementTransition.tsx +++ b/packages/react-aria-components/src/SharedElementTransition.tsx @@ -96,6 +96,7 @@ export const SharedElement = forwardRef(function SharedElement( let scope = scopeRef.current; let prevSnapshot = scope[name]; let frame: number | null = null; + let restoreStyles: (() => void) | null = null; if (element && isVisible && prevSnapshot) { // Element is transitioning from a previous instance. @@ -125,11 +126,14 @@ export const SharedElement = forwardRef(function SharedElement( } // Remove overrides after one frame to animate to the current values. - frame = requestAnimationFrame(() => { - frame = null; + restoreStyles = () => { for (let [property, value] of values) { element.style[property] = value; } + }; + frame = requestAnimationFrame(() => { + frame = null; + restoreStyles?.(); }); delete scope[name]; @@ -160,6 +164,7 @@ export const SharedElement = forwardRef(function SharedElement( return () => { if (frame != null) { cancelAnimationFrame(frame); + restoreStyles?.(); } if (element && element.isConnected && !element.hasAttribute('data-exiting')) { diff --git a/packages/react-aria-components/stories/Tabs.stories.tsx b/packages/react-aria-components/stories/Tabs.stories.tsx index 55c7930d2ad..2bf4173a4c7 100644 --- a/packages/react-aria-components/stories/Tabs.stories.tsx +++ b/packages/react-aria-components/stories/Tabs.stories.tsx @@ -15,8 +15,9 @@ import {Button} from '../src/Button'; import {Meta, StoryFn} from '@storybook/react'; import {Orientation} from '@react-types/shared'; import {OverlayArrow} from '../src/OverlayArrow'; -import React, {useState} from 'react'; +import React, {StrictMode, useState} from 'react'; import {RouterProvider} from 'react-aria/private/utils/openLink'; +import {SelectionIndicator} from '../src/SelectionIndicator'; import {Tab, TabList, TabPanel, TabProps, Tabs} from '../src/Tabs'; import {Tooltip, TooltipTrigger} from '../src/Tooltip'; import './styles.css'; @@ -28,6 +29,34 @@ export default { export type TabsStory = StoryFn; +export const AnimatedSelectionIndicator: TabsStory = () => ( + + + + {['overview', 'activity', 'settings'].map(key => ( + + + {key} + + ))} + + Overview + Activity + Settings + + +); + export const TabsExample: TabsStory = () => { let [url, setUrl] = useState('/FoR'); diff --git a/packages/react-aria-components/test/Tabs.browser.test.tsx b/packages/react-aria-components/test/Tabs.browser.test.tsx index 8a778b0f008..9b423feb01e 100644 --- a/packages/react-aria-components/test/Tabs.browser.test.tsx +++ b/packages/react-aria-components/test/Tabs.browser.test.tsx @@ -11,8 +11,11 @@ */ import {expect, it} from 'vitest'; -import React from 'react'; +import {hydrateRoot} from 'react-dom/client'; +import React, {StrictMode, useEffect} from 'react'; import {render} from 'vitest-browser-react'; +import {renderToString} from 'react-dom/server.browser'; +import {SelectionIndicator} from '../src/SelectionIndicator'; import {Tab, TabList, TabPanel, Tabs} from '../src/Tabs'; import {User} from '@react-aria/test-utils'; @@ -31,6 +34,82 @@ function TabsExample() { ); } +it.each([ + {strict: false, selectedKey: 'one'}, + {strict: false, selectedKey: 'five'}, + {strict: true, selectedKey: 'one'}, + {strict: true, selectedKey: 'five'} +])( + 'aligns the indicator after hydration (strict: $strict, selected: $selectedKey)', + async ({strict, selectedKey}) => { + let hydrated = false; + function HydrationMarker() { + useEffect(() => { + hydrated = true; + }, []); + return null; + } + let keys = ['one', 'two', 'three', 'four', 'five']; + let tree = ( + + + + {keys.map(key => ( + + + {key} + + ))} + + {keys.map(key => ( + + {key} + + ))} + + ); + if (strict) { + tree = {tree}; + } + let container = document.createElement('div'); + document.body.appendChild(container); + container.innerHTML = renderToString(tree); + let root: ReturnType | undefined; + try { + root = hydrateRoot(container, tree); + await expect.poll(() => hydrated).toBe(true); + await expect + .poll(() => container.querySelector('[role="tab"][aria-selected="true"]')?.textContent) + .toBe(selectedKey); + let selectedTab = container.querySelector( + '[role="tab"][aria-selected="true"]' + ) as HTMLElement; + let indicator = selectedTab.querySelector('.react-aria-SelectionIndicator') as HTMLElement; + expect(selectedTab.textContent).toBe(selectedKey); + await expect.poll(() => indicator.style.translate).toBe(''); + await expect + .poll(() => + Math.abs( + indicator.getBoundingClientRect().left - selectedTab.getBoundingClientRect().left + ) + ) + .toBeLessThan(1); + expect(indicator.style.width).toBe(''); + expect(indicator.style.height).toBe(''); + } finally { + root?.unmount(); + container.remove(); + } + } +); + it.each` interactionType ${'mouse'}