Skip to content
Open
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
8 changes: 8 additions & 0 deletions docs/demo/scroll-position.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
title: ScrollPosition
nav:
title: Demo
path: /demo
---

<code src="../examples/scroll-position.tsx"></code>
69 changes: 69 additions & 0 deletions docs/examples/scroll-position.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import React from 'react';
import Tabs from '@rc-component/tabs';
import type { TabsProps } from '@rc-component/tabs';
import '../../assets/index.less';

const items: TabsProps['items'] = [];

for (let i = 0; i < 12; i += 1) {
items.push({ key: String(i), label: `Tab ${i}`, children: `Content of ${i}` });
}

const positions: { label: string; value: TabsProps['scrollPosition'] }[] = [
{ label: 'auto', value: 'auto' },
{ label: 'start', value: 'start' },
{ label: 'center', value: 'center' },
{ label: 'end', value: 'end' },
{ label: '0.25', value: 0.25 },
];

export default () => {
const [scrollPosition, setScrollPosition] = React.useState<TabsProps['scrollPosition']>('center');
const [direction, setDirection] = React.useState<TabsProps['direction']>('ltr');
const [tabPosition, setTabPosition] = React.useState<TabsProps['tabPosition']>('top');

return (
<div style={{ display: 'flex', gap: 24, minHeight: 320 }}>
<div style={{ minWidth: 260 }}>
<h3>scrollPosition</h3>
{positions.map(({ label, value }) => (
<label key={String(label)} style={{ display: 'block' }}>
<input
type="radio"
checked={scrollPosition === value}
onChange={() => setScrollPosition(value)}
/>
{label}
</label>
))}

<h3>direction</h3>
{(['ltr', 'rtl'] as const).map(d => (
<label key={d} style={{ display: 'block' }}>
<input type="radio" checked={direction === d} onChange={() => setDirection(d)} />
{d}
</label>
))}

<h3>tabPosition</h3>
{(['top', 'bottom', 'left', 'right'] as const).map(p => (
<label key={p} style={{ display: 'block' }}>
<input type="radio" checked={tabPosition === p} onChange={() => setTabPosition(p)} />
{p}
</label>
))}
</div>

<div style={{ flex: 1, maxWidth: 360 }} dir={direction}>
<Tabs
defaultActiveKey="6"
items={items}
scrollPosition={scrollPosition}
direction={direction}
tabPosition={tabPosition}
style={{ maxHeight: 160 }}
/>
</div>
</div>
);
};
27 changes: 25 additions & 2 deletions src/TabNavList/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type {
MoreProps,
OnTabScroll,
RenderTabBar,
ScrollPosition,
SizeInfo,
TabBarExtraContent,
TabPosition,
Expand Down Expand Up @@ -54,6 +55,7 @@ export interface TabNavListProps {
size?: GetIndicatorSize;
align?: 'start' | 'center' | 'end';
};
scrollPosition?: ScrollPosition;
classNames?: Partial<Record<SemanticName, string>>;
styles?: Partial<Record<SemanticName, React.CSSProperties>>;
}
Expand Down Expand Up @@ -110,6 +112,7 @@ const TabNavList = React.forwardRef<HTMLDivElement, TabNavListProps>((props, ref
onTabClick,
onTabScroll,
indicator,
scrollPosition,
classNames: tabsClassNames,
styles,
} = props;
Expand Down Expand Up @@ -264,12 +267,29 @@ const TabNavList = React.forwardRef<HTMLDivElement, TabNavListProps>((props, ref
top: 0,
};

let ratio: number | null = null;
if (scrollPosition === 'start') {
ratio = 0;
} else if (scrollPosition === 'center') {
ratio = 0.5;
} else if (scrollPosition === 'end') {
ratio = 1;
} else if (typeof scrollPosition === 'number' && !Number.isNaN(scrollPosition)) {
ratio = Math.min(1, Math.max(0, scrollPosition));
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if (tabPositionTopOrBottom) {
// ============ Align with top & bottom ============
let newTransform = transformLeft;

if (ratio !== null) {
// Align the `ratio` point of the tab with the same point of the viewport.
newTransform = rtl
? tabOffset.right + tabOffset.width * ratio - visibleTabContentValue * ratio
: -(tabOffset.left + tabOffset.width * ratio - visibleTabContentValue * ratio);
}
// RTL
if (rtl) {
else if (rtl) {
if (tabOffset.right < transformLeft) {
newTransform = tabOffset.right;
} else if (tabOffset.right + tabOffset.width > transformLeft + visibleTabContentValue) {
Expand All @@ -289,7 +309,9 @@ const TabNavList = React.forwardRef<HTMLDivElement, TabNavListProps>((props, ref
// ============ Align with left & right ============
let newTransform = transformTop;

if (tabOffset.top < -transformTop) {
if (ratio !== null) {
newTransform = -(tabOffset.top + tabOffset.height * ratio - visibleTabContentValue * ratio);
} else if (tabOffset.top < -transformTop) {
newTransform = -tabOffset.top;
} else if (tabOffset.top + tabOffset.height > -transformTop + visibleTabContentValue) {
newTransform = -(tabOffset.top + tabOffset.height - visibleTabContentValue);
Expand Down Expand Up @@ -550,6 +572,7 @@ const TabNavList = React.forwardRef<HTMLDivElement, TabNavListProps>((props, ref
scrollToTab();
}, [
activeKey,
scrollPosition,
transformMin,
transformMax,
stringify(activeTabOffset),
Expand Down
12 changes: 5 additions & 7 deletions src/Tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import type {
TabBarExtraContent,
TabPosition,
TabsLocale,
ScrollPosition,
} from './interface';

/**
Expand All @@ -35,13 +36,7 @@ import type {
let uuid = 0;

export type SemanticName =
| 'popup'
| 'item'
| 'indicator'
| 'body'
| 'content'
| 'header'
| 'remove';
'popup' | 'item' | 'indicator' | 'body' | 'content' | 'header' | 'remove';

export interface TabsProps extends Omit<
React.HTMLAttributes<HTMLDivElement>,
Expand All @@ -66,6 +61,7 @@ export interface TabsProps extends Omit<
tabBarStyle?: React.CSSProperties;
tabPosition?: TabPosition;
destroyOnHidden?: boolean;
scrollPosition?: ScrollPosition;

onChange?: (activeKey: string) => void;
onTabClick?: (activeKey: string, e: React.KeyboardEvent | React.MouseEvent) => void;
Expand Down Expand Up @@ -112,6 +108,7 @@ const Tabs = React.forwardRef<HTMLDivElement, TabsProps>((props, ref) => {
getPopupContainer,
popupClassName,
indicator,
scrollPosition,
classNames: tabsClassNames,
styles,
...restProps
Expand Down Expand Up @@ -194,6 +191,7 @@ const Tabs = React.forwardRef<HTMLDivElement, TabsProps>((props, ref) => {
getPopupContainer,
popupClassName: clsx(popupClassName, tabsClassNames?.popup),
indicator,
scrollPosition,
styles,
classNames: tabsClassNames,
};
Expand Down
2 changes: 2 additions & 0 deletions src/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ export type TabOffsetMap = Map<React.Key, TabOffset>;

export type TabPosition = 'left' | 'right' | 'top' | 'bottom';

export type ScrollPosition = 'auto' | 'start' | 'center' | 'end' | number;

type RenderTabBarProps = {
id: string;
activeKey: string;
Expand Down
39 changes: 39 additions & 0 deletions tests/overflow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,45 @@ describe('Tabs.Overflow', () => {
jest.useRealTimers();
});

describe('scrollPosition', () => {
const layouts: { position: any; expected: number }[] = [
{ position: 'auto' as const, expected: -40 },
{ position: 'start' as const, expected: -60 },
{ position: 'center' as const, expected: -50 },
{ position: 'end' as const, expected: -40 },
{ position: 0.25 as const, expected: -55 },
{ position: 0.5 as const, expected: -50 },
{ position: 1 as const, expected: -40 },
{ position: 1.5 as const, expected: -40 },
{ position: -0.5 as const, expected: -60 },
{ position: Number.NaN, expected: -40 },
];

it.each(layouts)('top: $position', ({ position, expected }) => {
jest.useFakeTimers();
const { container } = render(getTabs({ activeKey: 'disabled', scrollPosition: position }));
triggerResize(container);
act(() => {
jest.runAllTimers();
});
expect(getTransformX(container)).toEqual(expected);
jest.useRealTimers();
});

it.each(layouts)('left: $position', ({ position, expected }) => {
jest.useFakeTimers();
const { container } = render(
getTabs({ activeKey: 'disabled', tabPosition: 'left', scrollPosition: position }),
);
triggerResize(container);
act(() => {
jest.runAllTimers();
});
expect(getTransformY(container)).toEqual(expected);
jest.useRealTimers();
});
});

it('left', () => {
jest.useFakeTimers();
const onTabScroll = jest.fn();
Expand Down
28 changes: 28 additions & 0 deletions tests/rtl.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,32 @@ describe('Tabs.RTL', () => {

jest.useRealTimers();
});

describe('scrollPosition', () => {
const layouts: { position: any; expected: number }[] = [
{ position: 'auto' as const, expected: 40 },
{ position: 'start' as const, expected: 60 },
{ position: 'center' as const, expected: 50 },
{ position: 'end' as const, expected: 40 },
{ position: 0.25 as const, expected: 55 },
{ position: 0.5 as const, expected: 50 },
{ position: 1 as const, expected: 40 },
{ position: 1.5 as const, expected: 40 },
{ position: -0.5 as const, expected: 60 },
{ position: Number.NaN, expected: 40 },
];

it.each(layouts)('rtl: $position', ({ position, expected }) => {
jest.useFakeTimers();
const { container } = render(
getTabs({ direction: 'rtl', defaultActiveKey: 'disabled', scrollPosition: position }),
);
triggerResize(container);
act(() => {
jest.runAllTimers();
});
expect(getTransformX(container)).toEqual(expected);
jest.useRealTimers();
});
});
});
Loading