Skip to content
Draft
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
141 changes: 50 additions & 91 deletions packages/react/src/tabs/list/TabsList.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
'use client';
import * as React from 'react';
import { useStableCallback } from '@base-ui/utils/useStableCallback';
import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect';
import { BaseUIComponentProps, HTMLProps } from '../../utils/types';
import type { TabsRoot } from '../root/TabsRoot';
import { CompositeRoot } from '../../composite/root/CompositeRoot';
Expand Down Expand Up @@ -33,26 +32,69 @@ export const TabsList = React.forwardRef(function TabsList(
getTabElementBySelectedValue,
onValueChange,
orientation,
setTabsListElement,
tabsListElement,
value,
setTabMap,
tabActivationDirection,
} = useTabsRootContext();

const [highlightedTabIndex, setHighlightedTabIndex] = React.useState(0);

const [tabsListElement, setTabsListElement] = React.useState<HTMLElement | null>(null);
// Calculate direction for internal tab clicks
const calculateDirectionForClick = React.useCallback(
(newValue: TabsTab.Value): TabsTab.ActivationDirection => {
if (newValue === value || newValue == null || tabsListElement == null) {
return 'none';
}

const detectActivationDirection = useActivationDirectionDetector(
value, // the old value
orientation,
tabsListElement,
getTabElementBySelectedValue,
// Get the current tab's position
const currentTabElement = getTabElementBySelectedValue(value);
if (currentTabElement == null) {
return 'none';
}

const { left: currentTabLeft, top: currentTabTop } =
currentTabElement.getBoundingClientRect();
const { left: listLeft, top: listTop } = tabsListElement.getBoundingClientRect();
const currentTabEdge =
orientation === 'horizontal' ? currentTabLeft - listLeft : currentTabTop - listTop;

// Get the new tab's position
const newTabElement = getTabElementBySelectedValue(newValue);
if (newTabElement == null) {
return 'none';
}

const { left: newTabLeft, top: newTabTop } = newTabElement.getBoundingClientRect();
const newTabEdge = orientation === 'horizontal' ? newTabLeft - listLeft : newTabTop - listTop;

// Calculate direction
if (orientation === 'horizontal') {
if (newTabEdge < currentTabEdge) {
return 'left';
}
if (newTabEdge > currentTabEdge) {
return 'right';
}
} else {
if (newTabEdge < currentTabEdge) {
return 'up';
}
if (newTabEdge > currentTabEdge) {
return 'down';
}
}

return 'none';
},
[value, tabsListElement, getTabElementBySelectedValue, orientation],
);

const onTabActivation = useStableCallback(
(newValue: TabsTab.Value, eventDetails: TabsRoot.ChangeEventDetails) => {
if (newValue !== value) {
const activationDirection = detectActivationDirection(newValue);
const activationDirection = calculateDirectionForClick(newValue);
eventDetails.activationDirection = activationDirection;
onValueChange(newValue, eventDetails);
}
Expand Down Expand Up @@ -109,89 +151,6 @@ export const TabsList = React.forwardRef(function TabsList(
);
});

function getInset(tab: HTMLElement, tabsList: HTMLElement) {
const { left: tabLeft, top: tabTop } = tab.getBoundingClientRect();
const { left: listLeft, top: listTop } = tabsList.getBoundingClientRect();

const left = tabLeft - listLeft;
const top = tabTop - listTop;

return { left, top };
}

function useActivationDirectionDetector(
// the old value
activeTabValue: any,
orientation: TabsRoot.Orientation,
tabsListElement: HTMLElement | null,
getTabElement: (selectedValue: any) => HTMLElement | null,
): (newValue: any) => TabsTab.ActivationDirection {
const [previousTabEdge, setPreviousTabEdge] = React.useState<number | null>(null);

useIsoLayoutEffect(() => {
// Whenever orientation changes, reset the state.
if (activeTabValue == null || tabsListElement == null) {
setPreviousTabEdge(null);
return;
}

const activeTab = getTabElement(activeTabValue);
if (activeTab == null) {
setPreviousTabEdge(null);
return;
}

const { left, top } = getInset(activeTab, tabsListElement);
setPreviousTabEdge(orientation === 'horizontal' ? left : top);
}, [orientation, getTabElement, tabsListElement, activeTabValue]);

return React.useCallback(
(newValue: any) => {
if (newValue === activeTabValue) {
return 'none';
}

if (newValue == null) {
setPreviousTabEdge(null);
return 'none';
}

if (newValue != null && tabsListElement != null) {
const activeTabElement = getTabElement(newValue);

if (activeTabElement != null) {
const { left, top } = getInset(activeTabElement, tabsListElement);

if (previousTabEdge == null) {
setPreviousTabEdge(orientation === 'horizontal' ? left : top);
return 'none';
}

if (orientation === 'horizontal') {
if (left < previousTabEdge) {
setPreviousTabEdge(left);
return 'left';
}
if (left > previousTabEdge) {
setPreviousTabEdge(left);
return 'right';
}
} else if (top < previousTabEdge) {
setPreviousTabEdge(top);
return 'up';
} else if (top > previousTabEdge) {
setPreviousTabEdge(top);
return 'down';
}
}
}

return 'none';
},
[getTabElement, orientation, previousTabEdge, tabsListElement, activeTabValue],
);
}

export interface TabsListState extends TabsRoot.State {}

export interface TabsListProps extends BaseUIComponentProps<'div', TabsList.State> {
Expand Down
95 changes: 94 additions & 1 deletion packages/react/src/tabs/root/TabsRoot.test.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import * as React from 'react';
import { expect } from 'chai';
import { spy } from 'sinon';
import { act, flushMicrotasks, fireEvent, screen, waitFor } from '@mui/internal-test-utils';
import { DirectionProvider, type TextDirection } from '@base-ui/react/direction-provider';
import { Popover } from '@base-ui/react/popover';
import { Dialog } from '@base-ui/react/dialog';
import { Tabs } from '@base-ui/react/tabs';
import { Tabs, TabsTab } from '@base-ui/react/tabs';
import { createRenderer, describeConformance, isJSDOM } from '#test-utils';

describe('<Tabs.Root />', () => {
Expand Down Expand Up @@ -1346,6 +1347,98 @@ describe('<Tabs.Root />', () => {

expect(root).to.have.attribute('data-activation-direction', 'up');
});

describe('programmatic value changes', () => {
const tabValues = [0, 1, 2] as const;
function ControlledTabs(
props: Tabs.Root.Props & {
onRenderCallback?: (tabActivationDirection: TabsTab.ActivationDirection) => void;
},
) {
const [value, setValue] = React.useState(props.value ?? 0);

const { onRenderCallback, ...other } = props;

return (
<Tabs.Root data-testid="root" value={value} onValueChange={setValue} {...other}>
<Tabs.List>
{tabValues.map((tabValue) => (
<Tabs.Tab
key={tabValue}
value={tabValue}
style={props.orientation === 'vertical' ? { display: 'block' } : undefined}
>
Tab {tabValue}
</Tabs.Tab>
))}
</Tabs.List>
{tabValues.map((tabValue) => (
<Tabs.Panel
key={tabValue}
value={tabValue}
render={(renderProps, state) => {
onRenderCallback?.(state.tabActivationDirection);
return <div {...renderProps}>{state.tabActivationDirection}</div>;
}}
/>
))}
<button onClick={() => setValue(0)}>Set 0</button>
<button onClick={() => setValue(1)}>Set 1</button>
</Tabs.Root>
);
}

it('should set `data-activation-direction` when value is changed programmatically with orientation=horizontal', async () => {
const spyFn = vi.fn();

const { user } = await render(<ControlledTabs onRenderCallback={spyFn} />);

// reset initial render calls
spyFn.mock.calls = [];

const root = screen.getByTestId('root');
expect(root).to.have.attribute('data-activation-direction', 'none');

await user.click(screen.getByText('Set 1'));
expect(root).to.have.attribute('data-activation-direction', 'right');
expect(screen.getByRole('tabpanel')).to.have.text('right');
expect(spyFn.mock.calls[0][0]).to.equal('right');

// reset before new render
spyFn.mock.calls = [];

await user.click(screen.getByText('Set 0'));
expect(root).to.have.attribute('data-activation-direction', 'left');
expect(screen.getByRole('tabpanel')).to.have.text('left');
expect(spyFn.mock.calls[0][0]).to.equal('left');
});

it('should set `data-activation-direction` when value is changed programmatically with orientation=vertical', async () => {
const spyFn = vi.fn();
const { user } = await render(
<ControlledTabs orientation="vertical" onRenderCallback={spyFn} />,
);

// reset initial render calls
spyFn.mock.calls = [];

const root = screen.getByTestId('root');
expect(root).to.have.attribute('data-activation-direction', 'none');

await user.click(screen.getByText('Set 1'));
expect(root).to.have.attribute('data-activation-direction', 'down');
expect(screen.getByRole('tabpanel')).to.have.text('down');
expect(spyFn.mock.calls[0][0]).to.equal('down');

// reset before new render
spyFn.mock.calls = [];

await user.click(screen.getByText('Set 0'));
expect(root).to.have.attribute('data-activation-direction', 'up');
expect(screen.getByRole('tabpanel')).to.have.text('up');
expect(spyFn.mock.calls[0][0]).to.equal('up');
});
});
});

describe('popups', () => {
Expand Down
Loading