Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ export const SharedElement = forwardRef(function SharedElement(
let scope = scopeRef.current;
let prevSnapshot = scope[name];
let frame: number | null = null;
let restoreStyles: (() => void) | null = null;

if (element && isVisible && prevSnapshot) {
// Element is transitioning from a previous instance.
Expand Down Expand Up @@ -125,11 +126,14 @@ export const SharedElement = forwardRef(function SharedElement(
}

// Remove overrides after one frame to animate to the current values.
frame = requestAnimationFrame(() => {
frame = null;
restoreStyles = () => {
for (let [property, value] of values) {
element.style[property] = value;
}
};
frame = requestAnimationFrame(() => {
frame = null;
restoreStyles?.();
});

delete scope[name];
Expand Down Expand Up @@ -160,6 +164,7 @@ export const SharedElement = forwardRef(function SharedElement(
return () => {
if (frame != null) {
cancelAnimationFrame(frame);
restoreStyles?.();
}

if (element && element.isConnected && !element.hasAttribute('data-exiting')) {
Expand Down
31 changes: 30 additions & 1 deletion packages/react-aria-components/stories/Tabs.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@ import {Button} from '../src/Button';
import {Meta, StoryFn} from '@storybook/react';
import {Orientation} from '@react-types/shared';
import {OverlayArrow} from '../src/OverlayArrow';
import React, {useState} from 'react';
import React, {StrictMode, useState} from 'react';
import {RouterProvider} from 'react-aria/private/utils/openLink';
import {SelectionIndicator} from '../src/SelectionIndicator';
import {Tab, TabList, TabPanel, TabProps, Tabs} from '../src/Tabs';
import {Tooltip, TooltipTrigger} from '../src/Tooltip';
import './styles.css';
Expand All @@ -28,6 +29,34 @@ export default {

export type TabsStory = StoryFn<typeof Tabs>;

export const AnimatedSelectionIndicator: TabsStory = () => (
<StrictMode>
<Tabs defaultSelectedKey="settings">
<TabList aria-label="Sections" style={{display: 'flex', gap: 12}}>
{['overview', 'activity', 'settings'].map(key => (
<Tab key={key} id={key} style={{position: 'relative', padding: '12px 20px'}}>
<SelectionIndicator
style={{
position: 'absolute',
inset: 0,
border: '2px solid currentColor',
borderRadius: 4,
pointerEvents: 'none',
transitionProperty: 'translate, width, height',
transitionDuration: '200ms'
}}
/>
{key}
</Tab>
))}
</TabList>
<TabPanel id="overview">Overview</TabPanel>
<TabPanel id="activity">Activity</TabPanel>
<TabPanel id="settings">Settings</TabPanel>
</Tabs>
</StrictMode>
);

export const TabsExample: TabsStory = () => {
let [url, setUrl] = useState('/FoR');

Expand Down
81 changes: 80 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,8 +11,11 @@
*/

import {expect, it} from 'vitest';
import React from 'react';
import {hydrateRoot} from 'react-dom/client';
import React, {StrictMode, useEffect} from 'react';
import {render} from 'vitest-browser-react';
import {renderToString} from 'react-dom/server.browser';
import {SelectionIndicator} from '../src/SelectionIndicator';
import {Tab, TabList, TabPanel, Tabs} from '../src/Tabs';
import {User} from '@react-aria/test-utils';

Expand All @@ -31,6 +34,82 @@ function TabsExample() {
);
}

it.each([
{strict: false, selectedKey: 'one'},
{strict: false, selectedKey: 'five'},
{strict: true, selectedKey: 'one'},
{strict: true, selectedKey: 'five'}
])(
'aligns the indicator after hydration (strict: $strict, selected: $selectedKey)',
async ({strict, selectedKey}) => {
let hydrated = false;
function HydrationMarker() {
useEffect(() => {
hydrated = true;
}, []);
return null;
}
let keys = ['one', 'two', 'three', 'four', 'five'];
let tree = (
<Tabs defaultSelectedKey={selectedKey}>
<HydrationMarker />
<TabList aria-label="Hydrated tabs" style={{display: 'flex', gap: 12}}>
{keys.map(key => (
<Tab key={key} id={key} style={{position: 'relative', padding: '12px 20px'}}>
<SelectionIndicator
style={{
position: 'absolute',
inset: 0,
transitionProperty: 'translate, width, height',
transitionDuration: '200ms'
}}
/>
{key}
</Tab>
))}
</TabList>
{keys.map(key => (
<TabPanel key={key} id={key}>
{key}
</TabPanel>
))}
</Tabs>
);
if (strict) {
tree = <StrictMode>{tree}</StrictMode>;
}
let container = document.createElement('div');
document.body.appendChild(container);
container.innerHTML = renderToString(tree);
let root: ReturnType<typeof hydrateRoot> | undefined;
try {
root = hydrateRoot(container, tree);
await expect.poll(() => hydrated).toBe(true);
await expect
.poll(() => container.querySelector('[role="tab"][aria-selected="true"]')?.textContent)
.toBe(selectedKey);
let selectedTab = container.querySelector(
'[role="tab"][aria-selected="true"]'
) as HTMLElement;
let indicator = selectedTab.querySelector('.react-aria-SelectionIndicator') as HTMLElement;
expect(selectedTab.textContent).toBe(selectedKey);
await expect.poll(() => indicator.style.translate).toBe('');
await expect
.poll(() =>
Math.abs(
indicator.getBoundingClientRect().left - selectedTab.getBoundingClientRect().left
)
)
.toBeLessThan(1);
expect(indicator.style.width).toBe('');
expect(indicator.style.height).toBe('');
} finally {
root?.unmount();
container.remove();
}
}
);

it.each`
interactionType
${'mouse'}
Expand Down