Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 47 additions & 29 deletions packages/react-aria-components/src/Tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 (
<dom.div
render={props.render}
{...DOMProps}
ref={ref}
style={props.style}
style={style}
className={props.className || 'react-aria-TabPanels'}>
<Collection {...props} />
</dom.div>
Expand Down
52 changes: 51 additions & 1 deletion packages/react-aria-components/stories/Tabs.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -144,3 +144,53 @@ export const NestedTabs: TabsStory = () => (
<TabPanel id="bar">Bar</TabPanel>
</Tabs>
);

const animatedPanelsStyle = {
height: 'var(--tab-panel-height)',
transition: 'height 400ms',
overflow: 'clip',
background: 'rgba(106, 90, 205, 0.1)'
};

const InnerTabs = () => (
<Tabs>
<TabList aria-label="Inner tabs" style={{display: 'flex', gap: 8}}>
<CustomTab id="one">One</CustomTab>
<CustomTab id="two">Two</CustomTab>
</TabList>
<TabPanels style={animatedPanelsStyle}>
<TabPanel id="one">
<p>Senatus Populusque Romanus.</p>
</TabPanel>
<TabPanel id="two">
<p>Arma virumque cano, Troiae qui primus ab oris.</p>
<p>Italiam fato profugus, Laviniaque venit litora.</p>
<p>Multum ille et terris iactatus et alto.</p>
</TabPanel>
</TabPanels>
</Tabs>
);

export const NestedTabsAnimated: TabsStory = () => (
<div style={{height: '300px', width: '300px'}}>
<Tabs>
<TabList aria-label="Outer tabs" style={{display: 'flex', gap: 8}}>
<CustomTab id="foo">Foo</CustomTab>
<CustomTab id="bar">Bar</CustomTab>
<CustomTab id="baz">Baz</CustomTab>
</TabList>
<TabPanels style={animatedPanelsStyle}>
<TabPanel id="foo">
<p>Alea jacta est.</p>
</TabPanel>
<TabPanel id="bar">
<InnerTabs />
</TabPanel>
<TabPanel id="baz">
<p>Veni, vidi, vici.</p>
<InnerTabs />
</TabPanel>
</TabPanels>
</Tabs>
</div>
);
63 changes: 62 additions & 1 deletion packages/react-aria-components/test/Tabs.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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 (
<Tabs>
<TabList aria-label="Outer">
<Tab id="general">General</Tab>
<Tab id="nested">Nested</Tab>
</TabList>
<TabPanels data-testid="outer" style={panelsStyle}>
<TabPanel id="general">
<div style={{height: 50}} />
</TabPanel>
<TabPanel id="nested">
<Tabs>
<TabList aria-label="Inner" style={{display: 'flex'}}>
<Tab id="one" style={{height: 30}}>
One
</Tab>
<Tab id="two" style={{height: 30}}>
Two
</Tab>
</TabList>
<TabPanels data-testid="inner" style={panelsStyle}>
<TabPanel id="one">
<div style={{height: 100}} />
</TabPanel>
<TabPanel id="two">
<div style={{height: 100}} />
</TabPanel>
</TabPanels>
</Tabs>
</TabPanel>
</TabPanels>
</Tabs>
);
}

it('animates TabPanels to the full size of a nested Tabs', async () => {
await render(<NestedTabsExample />);
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);
});
Loading