diff --git a/packages/react-aria-components/src/Tabs.tsx b/packages/react-aria-components/src/Tabs.tsx index d2932620df5..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,20 @@ 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 ( ( Bar ); + +// 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 = () => ( + + + 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; diff --git a/packages/react-aria-components/test/Tabs.test.js b/packages/react-aria-components/test/Tabs.test.js index d5d34e78e07..9f248afd2b4 100644 --- a/packages/react-aria-components/test/Tabs.test.js +++ b/packages/react-aria-components/test/Tabs.test.js @@ -695,6 +695,52 @@ 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) { @@ -829,6 +875,62 @@ 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 + + + ); + + 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 fallback 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('unset'); + expect(tabPanel.style.getPropertyValue('--tab-panel-height')).toBe('unset'); + expect(tabPanel).toHaveStyle({opacity: '1'}); + }); + it('should detect block-size in transition for TabPanels', async () => { let originalGetComputedStyle = window.getComputedStyle; window.getComputedStyle = el => ({