Skip to content
13 changes: 11 additions & 2 deletions packages/react-aria-components/src/Tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import {mergeProps} from 'react-aria/mergeProps';
import {Orientation} from '@react-types/shared';
import React, {
createContext,
CSSProperties,
ForwardedRef,
forwardRef,
JSX,
Expand Down Expand Up @@ -251,6 +252,15 @@ export interface TabPanelRenderProps {
export const TabsContext = createContext<ContextValue<TabsProps, HTMLDivElement>>(null);
export const TabListStateContext = createContext<TabListState<any> | null>(null);

// TabPanels writes --tab-panel-width/height on itself while it animates between
// panel sizes. Initialize them on the element that owns them so a nested
// TabPanels cannot inherit an outer instance's transient pixel values.
// 'auto' matches the value TabPanels settles on at rest.
const tabPanelsSize = {
'--tab-panel-width': 'auto',
'--tab-panel-height': 'auto'
} as CSSProperties;

/**
* Tabs organize content into multiple sections and allow users to navigate between them.
*/
Expand Down Expand Up @@ -548,7 +558,7 @@ export const TabPanels = /*#__PURE__*/ createHideableComponent(function TabPanel
render={props.render}
{...DOMProps}
ref={ref}
style={props.style}
style={{...tabPanelsSize, ...props.style}}
className={props.className || 'react-aria-TabPanels'}>
<Collection {...props} />
</dom.div>
Expand Down Expand Up @@ -627,7 +637,6 @@ function TabPanelInner(
let domProps = isSelected
? mergeProps(DOMProps, tabPanelProps, focusProps, renderProps)
: mergeProps(DOMProps, renderProps);

return (
<dom.div
{...domProps}
Expand Down
44 changes: 43 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,45 @@ export const NestedTabs: TabsStory = () => (
<TabPanel id="bar">Bar</TabPanel>
</Tabs>
);

// Switch the outer tabs and watch the inner panel. Each TabPanels should only ever
// animate to its own content size. Without the reset on TabPanel, the inner TabPanels
// inherits the outer one's transient pixel vars and jumps to the outer panel's size.
// The wrapper pins the story to the top left; the storybook decorator otherwise
// centers stories in a full-height flex container, which makes a size transition
// hard to watch because the whole thing moves.
export const NestedTabsSizeTransition: TabsStory = () => (

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This story is really hard to use due to the scrolling container that centers it.

<div style={{alignSelf: 'start', marginInlineEnd: 'auto', padding: 16}}>
<Tabs>
<TabList aria-label="Outer tabs" style={{display: 'flex', gap: 8}}>
<CustomTab id="nested">Nested tabs</CustomTab>
<CustomTab id="large">Large panel</CustomTab>
</TabList>
<TabPanels className="animated-tabpanels">
<TabPanel id="nested">
<Tabs>
<TabList aria-label="Inner tabs" style={{display: 'flex', gap: 8}}>
<CustomTab id="one">One</CustomTab>
<CustomTab id="two">Two</CustomTab>
</TabList>
<TabPanels className="animated-tabpanels">
<TabPanel id="one">
<div style={{width: 140, padding: 8}}>One</div>
</TabPanel>
<TabPanel id="two">
<div style={{width: 180, padding: 8}}>
Two
<br />
Small inner panel
</div>
</TabPanel>
</TabPanels>
</Tabs>
</TabPanel>
<TabPanel id="large">
<div style={{width: 480, height: 240, padding: 8}}>Large outer panel</div>
</TabPanel>
</TabPanels>
</Tabs>
</div>
);
10 changes: 10 additions & 0 deletions packages/react-aria-components/stories/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,16 @@
}
}

:global(.animated-tabpanels) {
display: block;
overflow: hidden;
width: var(--tab-panel-width);
height: var(--tab-panel-height);
transition:
width 300ms,
height 300ms;
}

.my-modal {
position: fixed;
top: 0;
Expand Down
16 changes: 12 additions & 4 deletions packages/react-aria-components/test/Tabs.ssr.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ describe('Tabs SSR', function () {
await testSSR(
__filename,
`
import {Tabs, TabList, Tab, TabPanel, Label} from '../exports/index.ts';
import {Tabs, TabList, Tab, TabPanel, TabPanels, Label} from '../exports/index.ts';

<React.StrictMode>
<Tabs aria-label="Tabs">
Expand All @@ -26,9 +26,11 @@ describe('Tabs SSR', function () {
<Tab id="2">Middle</Tab>
<Tab id="3">Right</Tab>
</TabList>
<TabPanel id="1">Left content</TabPanel>
<TabPanel id="2">Middle content</TabPanel>
<TabPanel id="3">Right content</TabPanel>
<TabPanels data-testid="tabpanels">
<TabPanel id="1">Left content</TabPanel>
<TabPanel id="2">Middle content</TabPanel>
<TabPanel id="3">Right content</TabPanel>
</TabPanels>
</Tabs>
</React.StrictMode>
`,
Expand All @@ -39,6 +41,9 @@ describe('Tabs SSR', function () {
let tabpanel = screen.getByRole('tabpanel');
expect(tabpanel).toHaveTextContent('Left content');
expect(tabpanel).toHaveAttribute('aria-labelledby', tabs[0].id);
let tabpanels = screen.getByTestId('tabpanels');
expect(tabpanels.style.getPropertyValue('--tab-panel-width')).toBe('auto');
expect(tabpanels.style.getPropertyValue('--tab-panel-height')).toBe('auto');
}
);

Expand All @@ -48,5 +53,8 @@ describe('Tabs SSR', function () {
let tabpanel = screen.getByRole('tabpanel');
expect(tabpanel).toHaveTextContent('Left content');
expect(tabpanel).toHaveAttribute('aria-labelledby', tabs[0].id);
let tabpanels = screen.getByTestId('tabpanels');
expect(tabpanels.style.getPropertyValue('--tab-panel-width')).toBe('auto');
expect(tabpanels.style.getPropertyValue('--tab-panel-height')).toBe('auto');
});
});
98 changes: 98 additions & 0 deletions packages/react-aria-components/test/Tabs.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -695,6 +695,55 @@ describe('Tabs', () => {
expect(innerTabs[1]).toHaveTextContent('Two');
});

it('initializes nested TabPanels size variables on the elements that own them', async () => {
let {getByTestId} = render(
<Tabs>
<TabList aria-label="Outer tabs">
<Tab id="foo">Foo</Tab>
<Tab id="bar">Bar</Tab>
</TabList>
<TabPanels
data-testid="outer-tabpanels"
style={{
'--tab-panel-width': '320px',
'--tab-panel-height': '240px'
}}>
<TabPanel id="foo" data-testid="outer-tabpanel">
<Tabs>
<TabList aria-label="Inner tabs">
<Tab id="one">One</Tab>
<Tab id="two">Two</Tab>
</TabList>
<TabPanels data-testid="inner-tabpanels">
<TabPanel id="one">One</TabPanel>
<TabPanel id="two">Two</TabPanel>
</TabPanels>
</Tabs>
</TabPanel>
<TabPanel id="bar">Bar</TabPanel>
</TabPanels>
</Tabs>
);

// Wait a tick for MutationObserver in useHasTabbableChild to fire.
// This avoids React's "update not wrapped in act" warning.
await waitFor(() => Promise.resolve());

let outerTabPanels = getByTestId('outer-tabpanels');
let outerTabPanel = getByTestId('outer-tabpanel');
let innerTabPanels = getByTestId('inner-tabpanels');

// User styles on a TabPanels still win over the defaults.
expect(outerTabPanels.style.getPropertyValue('--tab-panel-width')).toBe('320px');
expect(outerTabPanels.style.getPropertyValue('--tab-panel-height')).toBe('240px');

// The boundary is on each variable owner, not on an intervening TabPanel.
expect(outerTabPanel.style.getPropertyValue('--tab-panel-width')).toBe('');
expect(outerTabPanel.style.getPropertyValue('--tab-panel-height')).toBe('');
expect(innerTabPanels.style.getPropertyValue('--tab-panel-width')).toBe('auto');
expect(innerTabPanels.style.getPropertyValue('--tab-panel-height')).toBe('auto');
});

it('can add tabs and keep the current selected key', async () => {
let onSelectionChange = jest.fn();
function Example(props) {
Expand Down Expand Up @@ -829,6 +878,55 @@ describe('Tabs', () => {
expect(tabPanels).toHaveStyle({width: '100px'});
});

it('initializes the size variables on each TabPanels, and lets user styles win', () => {
let withTabPanels = (style, fn) => {
let {getByTestId, unmount} = render(
<Tabs>
<TabList aria-label="test">
<Tab id="a">A</Tab>
<Tab id="b">B</Tab>
</TabList>
<TabPanels data-testid="tabpanels" style={style}>
<TabPanel id="a">A</TabPanel>
<TabPanel id="b">B</TabPanel>
</TabPanels>
</Tabs>
);
fn(getByTestId('tabpanels'));
unmount();
};

withTabPanels(undefined, tabPanels => {
expect(tabPanels.style.getPropertyValue('--tab-panel-width')).toBe('auto');
expect(tabPanels.style.getPropertyValue('--tab-panel-height')).toBe('auto');
});

withTabPanels({'--tab-panel-width': '50px', '--tab-panel-height': '75px'}, tabPanels => {
expect(tabPanels.style.getPropertyValue('--tab-panel-width')).toBe('50px');
expect(tabPanels.style.getPropertyValue('--tab-panel-height')).toBe('75px');
});
});

it('merges the size variable initialization with TabPanels styles', () => {
let {getByTestId} = render(
<Tabs>
<TabList aria-label="test">
<Tab id="a">A</Tab>
<Tab id="b">B</Tab>
</TabList>
<TabPanels data-testid="tabpanels" style={{opacity: 1}}>
<TabPanel id="a">A</TabPanel>
<TabPanel id="b">B</TabPanel>
</TabPanels>
</Tabs>
);

let tabPanels = getByTestId('tabpanels');
expect(tabPanels.style.getPropertyValue('--tab-panel-width')).toBe('auto');
expect(tabPanels.style.getPropertyValue('--tab-panel-height')).toBe('auto');
expect(tabPanels).toHaveStyle({opacity: '1'});
});

it('should detect block-size in transition for TabPanels', async () => {
let originalGetComputedStyle = window.getComputedStyle;
window.getComputedStyle = el => ({
Expand Down