From 9391cdf19389590e3d2ba81581dd4ed9cfe69632 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Fri, 3 Jul 2026 02:37:18 -0700 Subject: [PATCH 1/6] fix: reset TabPanels size variables on TabPanelInner so nested Tabs do not inherit them --- packages/react-aria-components/src/Tabs.tsx | 6 ++ .../react-aria-components/test/Tabs.test.js | 102 ++++++++++++++++++ 2 files changed, 108 insertions(+) diff --git a/packages/react-aria-components/src/Tabs.tsx b/packages/react-aria-components/src/Tabs.tsx index d2932620df5..fc93173cf1a 100644 --- a/packages/react-aria-components/src/Tabs.tsx +++ b/packages/react-aria-components/src/Tabs.tsx @@ -627,11 +627,17 @@ function TabPanelInner( let domProps = isSelected ? mergeProps(DOMProps, tabPanelProps, focusProps, renderProps) : mergeProps(DOMProps, renderProps); + let style = { + '--tab-panel-width': 'auto', + '--tab-panel-height': 'auto', + ...renderProps.style + } as React.CSSProperties; return ( { expect(innerTabs[1]).toHaveTextContent('Two'); }); + it('resets tab panel transition variables for nested tabpanels', async () => { + let {getByTestId} = render( + + + Foo + Bar + + + + + + One + Two + + + One + Two + + + + Bar + + + ); + + // 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'); + + expect(outerTabPanels.style.getPropertyValue('--tab-panel-width')).toBe('320px'); + expect(outerTabPanels.style.getPropertyValue('--tab-panel-height')).toBe('240px'); + expect(outerTabPanel.style.getPropertyValue('--tab-panel-width')).toBe('auto'); + expect(outerTabPanel.style.getPropertyValue('--tab-panel-height')).toBe('auto'); + expect(innerTabPanels.style.getPropertyValue('--tab-panel-width')).toBe(''); + expect(innerTabPanels.style.getPropertyValue('--tab-panel-height')).toBe(''); + }); + it('can add tabs and keep the current selected key', async () => { let onSelectionChange = jest.fn(); function Example(props) { @@ -829,6 +875,62 @@ describe('Tabs', () => { expect(tabPanels).toHaveStyle({width: '100px'}); }); + it('should allow tab panel styles to override transition variable resets', () => { + let {getByTestId} = render( + + + A + B + + + + A + + B + + + ); + + let tabPanel = getByTestId('tabpanel'); + expect(tabPanel.style.getPropertyValue('--tab-panel-width')).toBe('50px'); + expect(tabPanel.style.getPropertyValue('--tab-panel-height')).toBe('75px'); + }); + + it('should merge tab panel transition variable resets with style render props', () => { + let {getByTestId} = render( + + + A + B + + + 'selected'} + data-testid="tabpanel" + style={() => ({ + opacity: 1 + })}> + A + + B + + + ); + + let tabPanel = getByTestId('tabpanel'); + expect(tabPanel).toHaveAttribute('class', 'selected'); + expect(tabPanel.style.getPropertyValue('--tab-panel-width')).toBe('auto'); + expect(tabPanel.style.getPropertyValue('--tab-panel-height')).toBe('auto'); + expect(tabPanel).toHaveStyle({opacity: '1'}); + }); + it('should detect block-size in transition for TabPanels', async () => { let originalGetComputedStyle = window.getComputedStyle; window.getComputedStyle = el => ({ From 4bb8d2a82072f6318d931b6cf38b673052f8a805 Mon Sep 17 00:00:00 2001 From: mvanhorn Date: Mon, 6 Jul 2026 08:18:01 -0700 Subject: [PATCH 2/6] Add NestedTabsSizeTransition story for the CSS variable bleed Address @snowystinger's request for a browser-testable story: the existing NestedTabs story does not apply the size-transition CSS, so it never exercises the --tab-panel-width/height inheritance. Add a story that puts an animated-tabpanels class (consuming those vars with a width/height transition) on both an outer and a nested TabPanels, so the bleed is observable in Storybook/Chromatic - without the per-TabPanel reset the inner panels inherit the outer TabPanels' pixel vars; with it they size to their own content. --- .../stories/Tabs.stories.tsx | 40 ++++++++++++++++++- .../react-aria-components/stories/styles.css | 8 ++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/packages/react-aria-components/stories/Tabs.stories.tsx b/packages/react-aria-components/stories/Tabs.stories.tsx index 55c7930d2ad..e7609eece7c 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,41 @@ export const NestedTabs: TabsStory = () => ( Bar ); + +// With the reset to auto on the enclosing TabPanel, inner TabPanels size to +// their own content. Without it, they inherit the outer TabPanels' pixel vars. +export const NestedTabsSizeTransition: TabsStory = () => ( + + + Nested tabs + Large panel + + + + + + One + Two + + + +
One
+
+ +
+ Two +
+ Small inner panel +
+
+
+
+
+ +
+ Large outer panel +
+
+
+
+); diff --git a/packages/react-aria-components/stories/styles.css b/packages/react-aria-components/stories/styles.css index 7f8b969fa90..f04fac00ff3 100644 --- a/packages/react-aria-components/stories/styles.css +++ b/packages/react-aria-components/stories/styles.css @@ -22,6 +22,14 @@ } } +: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; From 6fd08923110cd35f4c10906c1fcb147efb8d2a7e Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Tue, 7 Jul 2026 09:10:04 -0700 Subject: [PATCH 3/6] fix: register tab-panel size vars as non-inheriting, fall back to unset reset --- packages/react-aria-components/src/Tabs.tsx | 51 +++++++++++++++++-- .../stories/Tabs.stories.tsx | 8 ++- .../react-aria-components/test/Tabs.test.js | 14 ++--- 3 files changed, 56 insertions(+), 17 deletions(-) diff --git a/packages/react-aria-components/src/Tabs.tsx b/packages/react-aria-components/src/Tabs.tsx index fc93173cf1a..3eac788458d 100644 --- a/packages/react-aria-components/src/Tabs.tsx +++ b/packages/react-aria-components/src/Tabs.tsx @@ -251,6 +251,44 @@ export interface TabPanelRenderProps { export const TabsContext = createContext>(null); export const TabListStateContext = createContext | null>(null); +interface CSSPropertyDefinition { + name: string; + syntax: string; + inherits: boolean; + initialValue: string; +} + +interface CSSWithRegisterProperty { + registerProperty: (definition: CSSPropertyDefinition) => void; +} + +let supportsTabPanelSizePropertyRegistration = registerTabPanelSizeProperties(); + +function registerTabPanelSizeProperties(): boolean { + if ( + typeof CSS === 'undefined' || + typeof (CSS as unknown as CSSWithRegisterProperty).registerProperty !== 'function' + ) { + return false; + } + + let css = CSS as unknown as CSSWithRegisterProperty; + for (let name of ['--tab-panel-width', '--tab-panel-height']) { + try { + css.registerProperty({ + name, + syntax: '*', + inherits: false, + initialValue: 'auto' + }); + } catch { + continue; + } + } + + return true; +} + /** * Tabs organize content into multiple sections and allow users to navigate between them. */ @@ -627,11 +665,14 @@ function TabPanelInner( let domProps = isSelected ? mergeProps(DOMProps, tabPanelProps, focusProps, renderProps) : mergeProps(DOMProps, renderProps); - let style = { - '--tab-panel-width': 'auto', - '--tab-panel-height': 'auto', - ...renderProps.style - } as React.CSSProperties; + let style = renderProps.style; + if (!supportsTabPanelSizePropertyRegistration) { + style = { + '--tab-panel-width': 'unset', + '--tab-panel-height': 'unset', + ...renderProps.style + } as React.CSSProperties; + } return ( ( ); -// With the reset to auto on the enclosing TabPanel, inner TabPanels size to -// their own content. Without it, they inherit the outer TabPanels' pixel vars. +// With non-inheriting panel size variables, inner TabPanels size to their own content. +// Without it, they inherit the outer TabPanels' pixel vars. export const NestedTabsSizeTransition: TabsStory = () => ( @@ -175,9 +175,7 @@ export const NestedTabsSizeTransition: TabsStory = () => ( -
- Large outer panel -
+
Large outer panel
diff --git a/packages/react-aria-components/test/Tabs.test.js b/packages/react-aria-components/test/Tabs.test.js index b55555a5a12..9f248afd2b4 100644 --- a/packages/react-aria-components/test/Tabs.test.js +++ b/packages/react-aria-components/test/Tabs.test.js @@ -695,7 +695,7 @@ describe('Tabs', () => { expect(innerTabs[1]).toHaveTextContent('Two'); }); - it('resets tab panel transition variables for nested tabpanels', async () => { + it('falls back to resetting tab panel transition variables for nested tabpanels', async () => { let {getByTestId} = render( @@ -735,8 +735,8 @@ describe('Tabs', () => { expect(outerTabPanels.style.getPropertyValue('--tab-panel-width')).toBe('320px'); expect(outerTabPanels.style.getPropertyValue('--tab-panel-height')).toBe('240px'); - expect(outerTabPanel.style.getPropertyValue('--tab-panel-width')).toBe('auto'); - expect(outerTabPanel.style.getPropertyValue('--tab-panel-height')).toBe('auto'); + expect(outerTabPanel.style.getPropertyValue('--tab-panel-width')).toBe('unset'); + expect(outerTabPanel.style.getPropertyValue('--tab-panel-height')).toBe('unset'); expect(innerTabPanels.style.getPropertyValue('--tab-panel-width')).toBe(''); expect(innerTabPanels.style.getPropertyValue('--tab-panel-height')).toBe(''); }); @@ -875,7 +875,7 @@ describe('Tabs', () => { expect(tabPanels).toHaveStyle({width: '100px'}); }); - it('should allow tab panel styles to override transition variable resets', () => { + it('should allow tab panel styles to override fallback transition variable resets', () => { let {getByTestId} = render( @@ -902,7 +902,7 @@ describe('Tabs', () => { expect(tabPanel.style.getPropertyValue('--tab-panel-height')).toBe('75px'); }); - it('should merge tab panel transition variable resets with style render props', () => { + it('should merge fallback transition variable resets with style render props', () => { let {getByTestId} = render( @@ -926,8 +926,8 @@ describe('Tabs', () => { let tabPanel = getByTestId('tabpanel'); expect(tabPanel).toHaveAttribute('class', 'selected'); - expect(tabPanel.style.getPropertyValue('--tab-panel-width')).toBe('auto'); - expect(tabPanel.style.getPropertyValue('--tab-panel-height')).toBe('auto'); + expect(tabPanel.style.getPropertyValue('--tab-panel-width')).toBe('unset'); + expect(tabPanel.style.getPropertyValue('--tab-panel-height')).toBe('unset'); expect(tabPanel).toHaveStyle({opacity: '1'}); }); From b805a3ed38172f12fd898bf9ce85a0273af4f02b Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:39:23 -0700 Subject: [PATCH 4/6] style: format transition declaration per lint Fix the lint failure by formatting the multi-value transition declaration the way Prettier expects. --- packages/react-aria-components/stories/styles.css | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/react-aria-components/stories/styles.css b/packages/react-aria-components/stories/styles.css index f04fac00ff3..d53da93b4cb 100644 --- a/packages/react-aria-components/stories/styles.css +++ b/packages/react-aria-components/stories/styles.css @@ -27,7 +27,9 @@ overflow: hidden; width: var(--tab-panel-width); height: var(--tab-panel-height); - transition: width 300ms, height 300ms; + transition: + width 300ms, + height 300ms; } .my-modal { From c2acecf5c531f78a84308d8b2b51552ea07b320b Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Fri, 31 Jul 2026 20:55:34 -0700 Subject: [PATCH 5/6] fix: reset TabPanels size vars unconditionally instead of registering them Drops the CSS.registerProperty path in favour of an unconditional inline reset on TabPanel. The previous fallback used 'unset', which is a no-op here: custom properties are inherited, so unset computes to inherit and the outer TabPanels' pixel values still bled through. Any browser without CSS.registerProperty got no fix at all. 'auto' actually resets, and matches the value TabPanels settles on at rest. Registering the properties also mutated the document's property registry on import, which made --tab-panel-width/height non-inheriting for consumer CSS too, a wider behaviour change than this fix needs. Tests now assert the reset itself rather than the feature-detection branch, and fail both when the reset is removed and when it is set back to 'unset'. Anchor the story top left so the size transition is watchable in the centering storybook container. --- packages/react-aria-components/src/Tabs.tsx | 59 ++-------- .../stories/Tabs.stories.tsx | 72 ++++++------ .../react-aria-components/test/Tabs.test.js | 111 ++++++------------ 3 files changed, 86 insertions(+), 156 deletions(-) diff --git a/packages/react-aria-components/src/Tabs.tsx b/packages/react-aria-components/src/Tabs.tsx index 3eac788458d..9b3405d4d5e 100644 --- a/packages/react-aria-components/src/Tabs.tsx +++ b/packages/react-aria-components/src/Tabs.tsx @@ -60,6 +60,7 @@ import {mergeProps} from 'react-aria/mergeProps'; import {Orientation} from '@react-types/shared'; import React, { createContext, + CSSProperties, ForwardedRef, forwardRef, JSX, @@ -251,43 +252,16 @@ export interface TabPanelRenderProps { export const TabsContext = createContext>(null); export const TabListStateContext = createContext | null>(null); -interface CSSPropertyDefinition { - name: string; - syntax: string; - inherits: boolean; - initialValue: string; -} - -interface CSSWithRegisterProperty { - registerProperty: (definition: CSSPropertyDefinition) => void; -} - -let supportsTabPanelSizePropertyRegistration = registerTabPanelSizeProperties(); - -function registerTabPanelSizeProperties(): boolean { - if ( - typeof CSS === 'undefined' || - typeof (CSS as unknown as CSSWithRegisterProperty).registerProperty !== 'function' - ) { - return false; - } - - let css = CSS as unknown as CSSWithRegisterProperty; - for (let name of ['--tab-panel-width', '--tab-panel-height']) { - try { - css.registerProperty({ - name, - syntax: '*', - inherits: false, - initialValue: 'auto' - }); - } catch { - continue; - } - } - - return true; -} +// TabPanels writes --tab-panel-width/height on itself while it animates between +// panel sizes. Custom properties inherit, so a TabPanels nested inside a TabPanel +// would otherwise pick up the outer one's transient pixel values. Reset them on +// every TabPanel so each TabPanels only ever sees its own. +// 'auto' matches the value TabPanels settles on at rest. Note 'unset' does NOT +// work here: custom properties are inherited, so unset computes to inherit. +const tabPanelSizeReset = { + '--tab-panel-width': 'auto', + '--tab-panel-height': 'auto' +} as CSSProperties; /** * Tabs organize content into multiple sections and allow users to navigate between them. @@ -665,20 +639,11 @@ function TabPanelInner( let domProps = isSelected ? mergeProps(DOMProps, tabPanelProps, focusProps, renderProps) : mergeProps(DOMProps, renderProps); - let style = renderProps.style; - if (!supportsTabPanelSizePropertyRegistration) { - style = { - '--tab-panel-width': 'unset', - '--tab-panel-height': 'unset', - ...renderProps.style - } as React.CSSProperties; - } - return ( ( ); -// With non-inheriting panel size variables, inner TabPanels size to their own content. -// Without it, they inherit the outer TabPanels' pixel vars. +// 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 = () => ( - - - Nested tabs - Large panel - - - - - - One - Two - - - -
One
-
- -
- Two -
- Small inner panel -
-
-
-
-
- -
Large outer panel
-
-
-
+
+ + + Nested tabs + Large panel + + + + + + One + Two + + + +
One
+
+ +
+ Two +
+ Small inner panel +
+
+
+
+
+ +
Large outer panel
+
+
+
+
); diff --git a/packages/react-aria-components/test/Tabs.test.js b/packages/react-aria-components/test/Tabs.test.js index 9f248afd2b4..a261accc6ac 100644 --- a/packages/react-aria-components/test/Tabs.test.js +++ b/packages/react-aria-components/test/Tabs.test.js @@ -695,52 +695,6 @@ describe('Tabs', () => { expect(innerTabs[1]).toHaveTextContent('Two'); }); - it('falls back to resetting tab panel transition variables for nested tabpanels', async () => { - let {getByTestId} = render( - - - Foo - Bar - - - - - - One - Two - - - One - Two - - - - Bar - - - ); - - // 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'); - - expect(outerTabPanels.style.getPropertyValue('--tab-panel-width')).toBe('320px'); - expect(outerTabPanels.style.getPropertyValue('--tab-panel-height')).toBe('240px'); - expect(outerTabPanel.style.getPropertyValue('--tab-panel-width')).toBe('unset'); - expect(outerTabPanel.style.getPropertyValue('--tab-panel-height')).toBe('unset'); - expect(innerTabPanels.style.getPropertyValue('--tab-panel-width')).toBe(''); - expect(innerTabPanels.style.getPropertyValue('--tab-panel-height')).toBe(''); - }); - it('can add tabs and keep the current selected key', async () => { let onSelectionChange = jest.fn(); function Example(props) { @@ -875,34 +829,42 @@ describe('Tabs', () => { expect(tabPanels).toHaveStyle({width: '100px'}); }); - it('should allow tab panel styles to override fallback transition variable resets', () => { - let {getByTestId} = render( - - - A - B - - - - A - - B - - - ); + it('resets the TabPanels size variables on each TabPanel, and lets user styles win', () => { + // Only the selected panel renders, so each case needs its own tree. + let withPanel = (style, fn) => { + let {getByTestId, unmount} = render( + + + A + B + + + + A + + B + + + ); + fn(getByTestId('tabpanel')); + unmount(); + }; + + // 'auto' is what TabPanels settles on at rest, so a nested TabPanels reading + // these inherits a no-op rather than the outer panel's transient pixel size. + // 'unset' would not work: custom properties inherit, so unset means inherit. + withPanel(undefined, panel => { + expect(panel.style.getPropertyValue('--tab-panel-width')).toBe('auto'); + expect(panel.style.getPropertyValue('--tab-panel-height')).toBe('auto'); + }); - let tabPanel = getByTestId('tabpanel'); - expect(tabPanel.style.getPropertyValue('--tab-panel-width')).toBe('50px'); - expect(tabPanel.style.getPropertyValue('--tab-panel-height')).toBe('75px'); + withPanel({'--tab-panel-width': '50px', '--tab-panel-height': '75px'}, panel => { + expect(panel.style.getPropertyValue('--tab-panel-width')).toBe('50px'); + expect(panel.style.getPropertyValue('--tab-panel-height')).toBe('75px'); + }); }); - it('should merge fallback transition variable resets with style render props', () => { + it('merges the size variable reset with style render props', () => { let {getByTestId} = render( @@ -914,9 +876,7 @@ describe('Tabs', () => { id="a" className={() => 'selected'} data-testid="tabpanel" - style={() => ({ - opacity: 1 - })}> + style={() => ({opacity: 1})}> A B @@ -926,8 +886,7 @@ describe('Tabs', () => { let tabPanel = getByTestId('tabpanel'); expect(tabPanel).toHaveAttribute('class', 'selected'); - expect(tabPanel.style.getPropertyValue('--tab-panel-width')).toBe('unset'); - expect(tabPanel.style.getPropertyValue('--tab-panel-height')).toBe('unset'); + expect(tabPanel.style.getPropertyValue('--tab-panel-width')).toBe('auto'); expect(tabPanel).toHaveStyle({opacity: '1'}); }); From 5b4fcb7120de5a0cd157c166736f5cb539c6d7a3 Mon Sep 17 00:00:00 2001 From: mvanhorn Date: Sun, 2 Aug 2026 07:45:09 -0700 Subject: [PATCH 6/6] fix: initialize the size variables on TabPanels rather than resetting on TabPanel c2acecf reset --tab-panel-width/height on every TabPanel, which works only while a TabPanel actually sits between an outer TabPanels and a nested one. Initialize them on TabPanels itself instead: the element that owns and imperatively mutates those properties is the one that declares them, so a nested TabPanels can never inherit an outer instance's transient pixel values regardless of what renders in between. Also drops the registerProperty side effect, which mutated the document property registry on import and made --tab-panel-width/height non-inheriting for consumer CSS too, and removes the associated casts and feature detection. Tests assert the reset rather than the branch, and fail when it is removed. --- packages/react-aria-components/src/Tabs.tsx | 13 +-- .../test/Tabs.ssr.test.js | 16 ++- .../react-aria-components/test/Tabs.test.js | 97 +++++++++++++------ 3 files changed, 84 insertions(+), 42 deletions(-) diff --git a/packages/react-aria-components/src/Tabs.tsx b/packages/react-aria-components/src/Tabs.tsx index 9b3405d4d5e..9f4c599d085 100644 --- a/packages/react-aria-components/src/Tabs.tsx +++ b/packages/react-aria-components/src/Tabs.tsx @@ -253,12 +253,10 @@ export const TabsContext = createContext export const TabListStateContext = createContext | null>(null); // TabPanels writes --tab-panel-width/height on itself while it animates between -// panel sizes. Custom properties inherit, so a TabPanels nested inside a TabPanel -// would otherwise pick up the outer one's transient pixel values. Reset them on -// every TabPanel so each TabPanels only ever sees its own. -// 'auto' matches the value TabPanels settles on at rest. Note 'unset' does NOT -// work here: custom properties are inherited, so unset computes to inherit. -const tabPanelSizeReset = { +// 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; @@ -560,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'}>
@@ -643,7 +641,6 @@ function TabPanelInner( @@ -26,9 +26,11 @@ describe('Tabs SSR', function () { Middle Right - Left content - Middle content - Right content + + Left content + Middle content + Right content + `, @@ -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'); } ); @@ -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'); }); }); diff --git a/packages/react-aria-components/test/Tabs.test.js b/packages/react-aria-components/test/Tabs.test.js index a261accc6ac..9f8c440c0f3 100644 --- a/packages/react-aria-components/test/Tabs.test.js +++ b/packages/react-aria-components/test/Tabs.test.js @@ -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( + + + Foo + Bar + + + + + + One + Two + + + One + Two + + + + Bar + + + ); + + // 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) { @@ -829,65 +878,53 @@ describe('Tabs', () => { expect(tabPanels).toHaveStyle({width: '100px'}); }); - it('resets the TabPanels size variables on each TabPanel, and lets user styles win', () => { - // Only the selected panel renders, so each case needs its own tree. - let withPanel = (style, fn) => { + it('initializes the size variables on each TabPanels, and lets user styles win', () => { + let withTabPanels = (style, fn) => { let {getByTestId, unmount} = render( A B - - - A - + + A B ); - fn(getByTestId('tabpanel')); + fn(getByTestId('tabpanels')); unmount(); }; - // 'auto' is what TabPanels settles on at rest, so a nested TabPanels reading - // these inherits a no-op rather than the outer panel's transient pixel size. - // 'unset' would not work: custom properties inherit, so unset means inherit. - withPanel(undefined, panel => { - expect(panel.style.getPropertyValue('--tab-panel-width')).toBe('auto'); - expect(panel.style.getPropertyValue('--tab-panel-height')).toBe('auto'); + withTabPanels(undefined, tabPanels => { + expect(tabPanels.style.getPropertyValue('--tab-panel-width')).toBe('auto'); + expect(tabPanels.style.getPropertyValue('--tab-panel-height')).toBe('auto'); }); - withPanel({'--tab-panel-width': '50px', '--tab-panel-height': '75px'}, panel => { - expect(panel.style.getPropertyValue('--tab-panel-width')).toBe('50px'); - expect(panel.style.getPropertyValue('--tab-panel-height')).toBe('75px'); + 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 reset with style render props', () => { + it('merges the size variable initialization with TabPanels styles', () => { let {getByTestId} = render( A B - - 'selected'} - data-testid="tabpanel" - style={() => ({opacity: 1})}> - A - + + A B ); - let tabPanel = getByTestId('tabpanel'); - expect(tabPanel).toHaveAttribute('class', 'selected'); - expect(tabPanel.style.getPropertyValue('--tab-panel-width')).toBe('auto'); - expect(tabPanel).toHaveStyle({opacity: '1'}); + 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 () => {