diff --git a/packages/react-aria-components/src/Tabs.tsx b/packages/react-aria-components/src/Tabs.tsx index d2932620df5..c4cde0191c9 100644 --- a/packages/react-aria-components/src/Tabs.tsx +++ b/packages/react-aria-components/src/Tabs.tsx @@ -488,42 +488,52 @@ export const TabPanels = /*#__PURE__*/ createHideableComponent(function TabPanel ); } + let raf: number | undefined; if ( hasTransition.current && selectedKeyRef.current != null && selectedKeyRef.current !== state.selectedKey ) { - // Measure auto size. - el.style.setProperty('--tab-panel-width', 'auto'); - el.style.setProperty('--tab-panel-height', 'auto'); - let {width, height} = el.getBoundingClientRect(); - - if ( - prevSize.current && - (prevSize.current.width !== width || prevSize.current.height !== height) - ) { - // Revert to previous size. - el.style.setProperty('--tab-panel-width', prevSize.current.width + 'px'); - el.style.setProperty('--tab-panel-height', prevSize.current.height + 'px'); - - // Force style re-calculation to trigger animations. - window.getComputedStyle(el).height; - - // Animate to current pixel size. - el.style.setProperty('--tab-panel-width', width + 'px'); - el.style.setProperty('--tab-panel-height', height + 'px'); - - // When animations complete, revert back to auto size. - Promise.all(el.getAnimations().map(a => a.finished)) - .then(() => { - el.style.setProperty('--tab-panel-width', 'auto'); - el.style.setProperty('--tab-panel-height', 'auto'); - }) - .catch(() => {}); - } + // Wait until the next frame to measure, so that content which renders in a follow-up update + // (e.g. the TabList and TabPanel of a nested Tabs) is included. This runs before paint. + raf = requestAnimationFrame(() => { + // Measure auto size. + el.style.setProperty('--tab-panel-width', 'auto'); + el.style.setProperty('--tab-panel-height', 'auto'); + let {width, height} = el.getBoundingClientRect(); + + if ( + prevSize.current && + (prevSize.current.width !== width || prevSize.current.height !== height) + ) { + // Revert to previous size. + el.style.setProperty('--tab-panel-width', prevSize.current.width + 'px'); + el.style.setProperty('--tab-panel-height', prevSize.current.height + 'px'); + + // Force style re-calculation to trigger animations. + window.getComputedStyle(el).height; + + // Animate to current pixel size. + el.style.setProperty('--tab-panel-width', width + 'px'); + el.style.setProperty('--tab-panel-height', height + 'px'); + + // When animations complete, revert back to auto size. + Promise.all(el.getAnimations().map(a => a.finished)) + .then(() => { + el.style.setProperty('--tab-panel-width', 'auto'); + el.style.setProperty('--tab-panel-height', 'auto'); + }) + .catch(() => {}); + } + }); } selectedKeyRef.current = state.selectedKey; + return () => { + if (raf != null) { + cancelAnimationFrame(raf); + } + }; }, [ref, state.selectedKey]); // Store previous size before DOM updates occur. @@ -543,12 +553,20 @@ export const TabPanels = /*#__PURE__*/ createHideableComponent(function TabPanel let DOMProps = filterDOMProps(props, {labelable: true, global: true}); delete DOMProps.id; + // Define the size variables on every TabPanels so that a nested TabPanels does not inherit + // the pixel values of an outer TabPanels while it is animating. + let style = { + '--tab-panel-width': 'auto', + '--tab-panel-height': 'auto', + ...props.style + }; + return ( diff --git a/packages/react-aria-components/stories/Tabs.stories.tsx b/packages/react-aria-components/stories/Tabs.stories.tsx index 55c7930d2ad..1c2322f2edb 100644 --- a/packages/react-aria-components/stories/Tabs.stories.tsx +++ b/packages/react-aria-components/stories/Tabs.stories.tsx @@ -17,7 +17,7 @@ import {Orientation} from '@react-types/shared'; import {OverlayArrow} from '../src/OverlayArrow'; import React, {useState} from 'react'; import {RouterProvider} from 'react-aria/private/utils/openLink'; -import {Tab, TabList, TabPanel, TabProps, Tabs} from '../src/Tabs'; +import {Tab, TabList, TabPanel, TabPanels, TabProps, Tabs} from '../src/Tabs'; import {Tooltip, TooltipTrigger} from '../src/Tooltip'; import './styles.css'; @@ -144,3 +144,53 @@ export const NestedTabs: TabsStory = () => ( Bar ); + +const animatedPanelsStyle = { + height: 'var(--tab-panel-height)', + transition: 'height 400ms', + overflow: 'clip', + background: 'rgba(106, 90, 205, 0.1)' +}; + +const InnerTabs = () => ( + + + One + Two + + + +

Senatus Populusque Romanus.

+
+ +

Arma virumque cano, Troiae qui primus ab oris.

+

Italiam fato profugus, Laviniaque venit litora.

+

Multum ille et terris iactatus et alto.

+
+
+
+); + +export const NestedTabsAnimated: TabsStory = () => ( +
+ + + Foo + Bar + Baz + + + +

Alea jacta est.

+
+ + + + +

Veni, vidi, vici.

+ +
+
+
+
+); diff --git a/packages/react-aria-components/test/Tabs.browser.test.tsx b/packages/react-aria-components/test/Tabs.browser.test.tsx index 8a778b0f008..2a1327f6307 100644 --- a/packages/react-aria-components/test/Tabs.browser.test.tsx +++ b/packages/react-aria-components/test/Tabs.browser.test.tsx @@ -11,9 +11,10 @@ */ import {expect, it} from 'vitest'; +import {page, userEvent} from 'vitest/browser'; import React from 'react'; import {render} from 'vitest-browser-react'; -import {Tab, TabList, TabPanel, Tabs} from '../src/Tabs'; +import {Tab, TabList, TabPanel, TabPanels, Tabs} from '../src/Tabs'; import {User} from '@react-aria/test-utils'; function TabsExample() { @@ -47,3 +48,63 @@ it.each` await tester.triggerTab({tab: tabs[1]}); expect(tester.getSelectedTab()).toBe(tabs[1]); }); + +const panelsStyle = { + height: 'var(--tab-panel-height)', + transition: 'height 500ms linear', + overflow: 'clip' +}; + +function NestedTabsExample() { + return ( + + + General + Nested + + + +
+ + + + + + One + + + Two + + + + +
+ + +
+ + + + + + + ); +} + +it('animates TabPanels to the full size of a nested Tabs', async () => { + await render(); + let outer = document.querySelector('[data-testid=outer]') as HTMLElement; + expect(outer.getBoundingClientRect().height).toBe(50); + + await userEvent.click(page.getByRole('tab', {name: 'Nested'})); + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); + + // Mid transition, the outer panels should be animating towards the nested TabList + TabPanel, + // and the nested panels should not inherit the outer panels' size. + let inner = document.querySelector('[data-testid=inner]') as HTMLElement; + expect(outer.style.getPropertyValue('--tab-panel-height')).toBe('130px'); + expect(inner.getBoundingClientRect().height).toBe(100); + + await Promise.all(outer.getAnimations().map(a => a.finished)); + expect(outer.getBoundingClientRect().height).toBe(130); +});