fix: Reset TabPanels size variables on TabPanel so nested Tabs do not inherit them#10293
fix: Reset TabPanels size variables on TabPanel so nested Tabs do not inherit them#10293mvanhorn wants to merge 5 commits into
Conversation
…o not inherit them
snowystinger
left a comment
There was a problem hiding this comment.
How did you verify that this worked in a browser to a satisfactory degree? I don't see a story to test it with.
| '--tab-panel-width': 'auto', | ||
| '--tab-panel-height': 'auto', |
There was a problem hiding this comment.
| '--tab-panel-width': 'auto', | |
| '--tab-panel-height': 'auto', | |
| '--tab-panel-width': 'unset', | |
| '--tab-panel-height': 'unset', |
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.
|
Added a Verified in the browser via DOM inspection: with the fix, every One thing worth flagging: |
|
alternatively we could do |
|
Went with @devongovett's suggestion — registered |
|
Looks like lint is failing. The commands to run are The agents sometimes reach a little too quickly for the direct binaries. I'm not sure why. I have a Claude.md file that I'm proposing which should help with that. |
Fix the lint failure by formatting the multi-value transition declaration the way Prettier expects.
|
Lint fixed in b805a3e - ran it via yarn per your note (fair point about agents reaching for the direct binaries; the Claude.md proposal sounds like the right fix). Both Tabs suites and the focused reruns stay green. |
snowystinger
left a comment
There was a problem hiding this comment.
Have you tried the fix in a browser? I attempted it with S2 and it seems to have the same problem as the stackblitz
maybe I missed something though
import {Checkbox, CheckboxGroup, Form, TextField, Radio, RadioGroup} from '../exports';
import {TabPanels} from 'react-aria-components/Tabs';
export const Repro: Story = {
render: args => (
<Tabs aria-label="Settings" UNSAFE_style={{alignSelf: 'stretch'}}>
<TabList aria-label="Settings">
<Tab id="general">General</Tab>
<Tab id="appearance">Appearance</Tab>
<Tab id="profile">Profile</Tab>
<Tab id="notifications">Notifications</Tab>
</TabList>
<TabPanels
style={{
height: 'var(--tab-panel-height)',
width: 'var(--tab-panel-width)',
transition: 'height 400ms',
overflow: 'clip'
}}>
<TabPanel id="general">
<Form>
<TextField label="Homepage" defaultValue="react-aria.adobe.com" />
<Checkbox defaultSelected>Show sidebar</Checkbox>
<Checkbox>Show status bar</Checkbox>
</Form>
<Tabs aria-label="Settings">
<TabList aria-label="Settings">
<Tab id="notifications">Notifications</Tab>
<Tab id="profile">Profile</Tab>
</TabList>
<TabPanels
style={{
height: 'var(--tab-panel-height)',
width: 'var(--tab-panel-width)',
transition: 'height 400ms',
overflow: 'clip'
}}>
<TabPanel id="notifications">
<CheckboxGroup label="Notifications settings" defaultValue={['account', 'dms']}>
<Checkbox value="account">Account activity</Checkbox>
<Checkbox value="mentions">Mentions</Checkbox>
<Checkbox value="dms">Direct message</Checkbox>
<Checkbox value="marketing">Marketing emails</Checkbox>
</CheckboxGroup>
</TabPanel>
<TabPanel id="profile">
<Form>
<TextField label="Name" defaultValue="Devon Govett" />
<TextField label="Username" defaultValue="@devongovett" />
<Button>Update profile</Button>
</Form>
</TabPanel>
</TabPanels>
</Tabs>
</TabPanel>
<TabPanel id="appearance">
<Form>
<RadioGroup label="Theme" defaultValue="auto">
<Radio value="auto">Auto</Radio>
<Radio value="light">Light</Radio>
<Radio value="dark">Dark</Radio>
</RadioGroup>
<RadioGroup label="Font size" defaultValue="medium">
<Radio value="small">Small</Radio>
<Radio value="medium">Medium</Radio>
<Radio value="large">Large</Radio>
</RadioGroup>
</Form>
<Tabs aria-label="Settings" defaultSelectedKey="notifications">
<TabList aria-label="Settings" UNSAFE_style={{minHeight: 40}}>
<Tab id="notifications">Notifications</Tab>
<Tab id="profile">Profile</Tab>
</TabList>
<TabPanels
style={{
height: 'var(--tab-panel-height)',
width: 'var(--tab-panel-width)',
transition: 'height 400ms',
overflow: 'clip'
}}>
<TabPanel id="notifications">
<CheckboxGroup label="Notifications settings" defaultValue={['account', 'dms']}>
<Checkbox value="account">Account activity</Checkbox>
<Checkbox value="mentions">Mentions</Checkbox>
<Checkbox value="dms">Direct message</Checkbox>
<Checkbox value="marketing">Marketing emails</Checkbox>
</CheckboxGroup>
</TabPanel>
<TabPanel id="profile">
<Form>
<TextField label="Name" defaultValue="Devon Govett" />
<TextField label="Username" defaultValue="@devongovett" />
<Button>Update profile</Button>
</Form>
</TabPanel>
</TabPanels>
</Tabs>
</TabPanel>
<TabPanel id="profile">
<Form>
<TextField label="Name" defaultValue="Devon Govett" />
<TextField label="Username" defaultValue="@devongovett" />
<Button>Update profile</Button>
</Form>
<Tabs aria-label="Settings" defaultSelectedKey="profile">
<TabList aria-label="Settings" UNSAFE_style={{minHeight: 40}}>
<Tab id="notifications">Notifications</Tab>
<Tab id="profile">Profile</Tab>
</TabList>
<TabPanels
style={{
height: 'var(--tab-panel-height)',
width: 'var(--tab-panel-width)',
transition: 'height 400ms',
overflow: 'clip'
}}>
<TabPanel id="notifications">
<CheckboxGroup label="Notifications settings" defaultValue={['account', 'dms']}>
<Checkbox value="account">Account activity</Checkbox>
<Checkbox value="mentions">Mentions</Checkbox>
<Checkbox value="dms">Direct message</Checkbox>
<Checkbox value="marketing">Marketing emails</Checkbox>
</CheckboxGroup>
</TabPanel>
<TabPanel id="profile">
<Form>
<p>
Nested TabPanels should not inherit transition CSS variables from parent
TabPanels (perhaps TabPanel should reset them natively).
</p>
<TextField label="Name" defaultValue="Devon Govett" />
<TextField label="Username" defaultValue="@devongovett" />
<Button>Update profile</Button>
</Form>
</TabPanel>
</TabPanels>
</Tabs>
</TabPanel>
<TabPanel id="notifications">
<Form>
<TextField label="Name" defaultValue="Devon Govett" />
<TextField label="Username" defaultValue="@devongovett" />
<Button>Update profile</Button>
</Form>
<Tabs aria-label="Settings" defaultSelectedKey="profile">
<TabList aria-label="Settings" UNSAFE_style={{minHeight: 40}}>
<Tab id="notifications">Notifications</Tab>
<Tab id="profile">Profile</Tab>
</TabList>
<TabPanels
style={{
height: 'var(--tab-panel-height)',
width: 'var(--tab-panel-width)',
transition: 'height 400ms',
overflow: 'clip'
}}>
<TabPanel id="notifications">
<CheckboxGroup label="Notifications settings" defaultValue={['account', 'dms']}>
<Checkbox value="account">Account activity</Checkbox>
<Checkbox value="mentions">Mentions</Checkbox>
<Checkbox value="dms">Direct message</Checkbox>
<Checkbox value="marketing">Marketing emails</Checkbox>
</CheckboxGroup>
</TabPanel>
<TabPanel id="profile">
<Form>
<p>
Nested TabPanels should not inherit transition CSS variables from parent
TabPanels (perhaps TabPanel should reset them natively).
</p>
<TextField label="Name" defaultValue="Devon Govett" />
<TextField label="Username" defaultValue="@devongovett" />
<Button>Update profile</Button>
</Form>
</TabPanel>
</TabPanels>
</Tabs>
</TabPanel>
</TabPanels>
</Tabs>
),
parameters: {
layout: 'fullscreen'
}
};
| expect(innerTabs[1]).toHaveTextContent('Two'); | ||
| }); | ||
|
|
||
| it('falls back to resetting tab panel transition variables for nested tabpanels', async () => { |
There was a problem hiding this comment.
These tests aren't necessary, they can't actually test anything
|
|
||
| // 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 = () => ( |
There was a problem hiding this comment.
This story is really hard to use due to the scrolling container that centers it.
| interface CSSPropertyDefinition { | ||
| name: string; | ||
| syntax: string; | ||
| inherits: boolean; | ||
| initialValue: string; | ||
| } | ||
|
|
||
| interface CSSWithRegisterProperty { | ||
| registerProperty: (definition: CSSPropertyDefinition) => void; | ||
| } |
| typeof (CSS as unknown as CSSWithRegisterProperty).registerProperty !== 'function' | ||
| ) { | ||
| return false; | ||
| } | ||
|
|
||
| let css = CSS as unknown as CSSWithRegisterProperty; |
There was a problem hiding this comment.
This seems to work just fine?
| typeof (CSS as unknown as CSSWithRegisterProperty).registerProperty !== 'function' | |
| ) { | |
| return false; | |
| } | |
| let css = CSS as unknown as CSSWithRegisterProperty; | |
| typeof CSS.registerProperty !== 'function' | |
| ) { | |
| return false; | |
| } | |
| let css = CSS; |
|
Verified in Chrome against this branch using your exact S2 Repro story. What I observed:
One thing worth flagging: while verifying I initially reproduced something that looked exactly like the stackblitz bug, and it turned out to be an artifact of the tab being backgrounded - document.timeline freezes, the transition never finishes, and the inline px var never reverts, leaving the panel pinned and clipping content until the tab is foregrounded. If your S2 attempt was in a background tab or on another Space at any point, that could explain it. If not, could you share which browser and what you saw exactly? Happy to add your S2 Repro as a story on this PR, apply the registerProperty simplification you suggested, and drop the tests you flagged. |
Nested
TabPanelsinherit the parent's inline--tab-panel-width/--tab-panel-heightpixel values while they mount, because CSS custom properties inherit andunsetcannot stop that (for inherited properties it meansinherit). With the documented.react-aria-TabPanels { height: var(--tab-panel-height) }pattern, a freshly mounted innerTabPanelssizes itself from the outer panel's measurements and the height transition jumps.TabPanelInnernow sets--tab-panel-width: autoand--tab-panel-height: autoon itsdom.div(the reset point snowystinger suggested in the issue), so the variables stop at the panel boundary and a nestedTabPanelsmeasures cleanly. User-provided inline styles are spread after the reset and still win. The two 0-height initial-render reports in the same issue are separate timing problems in the async collection build and are not touched here.Closes #10292
✅ Pull Request Checklist:
📝 Test Instructions:
Run
yarn jest packages/react-aria-components/test/Tabs.test.js. The new tests render nested Tabs mid-transition and assert the inner panel's--tab-panel-width/--tab-panel-heightresolve toautorather than the outer panel's pixel values, and that a user style passed toTabPanelstill overrides the reset. To reproduce manually, nest aTabsinside aTabPanelwith the height-transition CSS from the TabPanels docs and switch tabs: without the reset the inner panels flash to the outer panel's height.🧢 Your Project:
Personal open source contribution