diff --git a/docs/demo/scroll-position.md b/docs/demo/scroll-position.md new file mode 100644 index 00000000..5f815ffc --- /dev/null +++ b/docs/demo/scroll-position.md @@ -0,0 +1,8 @@ +--- +title: ScrollPosition +nav: + title: Demo + path: /demo +--- + + diff --git a/docs/examples/scroll-position.tsx b/docs/examples/scroll-position.tsx new file mode 100644 index 00000000..5ebfcda7 --- /dev/null +++ b/docs/examples/scroll-position.tsx @@ -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('center'); + const [direction, setDirection] = React.useState('ltr'); + const [tabPosition, setTabPosition] = React.useState('top'); + + return ( +
+
+

scrollPosition

+ {positions.map(({ label, value }) => ( + + ))} + +

direction

+ {(['ltr', 'rtl'] as const).map(d => ( + + ))} + +

tabPosition

+ {(['top', 'bottom', 'left', 'right'] as const).map(p => ( + + ))} +
+ +
+ +
+
+ ); +}; diff --git a/src/TabNavList/index.tsx b/src/TabNavList/index.tsx index 25a7e05d..4e64f242 100644 --- a/src/TabNavList/index.tsx +++ b/src/TabNavList/index.tsx @@ -17,6 +17,7 @@ import type { MoreProps, OnTabScroll, RenderTabBar, + ScrollPosition, SizeInfo, TabBarExtraContent, TabPosition, @@ -54,6 +55,7 @@ export interface TabNavListProps { size?: GetIndicatorSize; align?: 'start' | 'center' | 'end'; }; + scrollPosition?: ScrollPosition; classNames?: Partial>; styles?: Partial>; } @@ -110,6 +112,7 @@ const TabNavList = React.forwardRef((props, ref onTabClick, onTabScroll, indicator, + scrollPosition, classNames: tabsClassNames, styles, } = props; @@ -264,12 +267,29 @@ const TabNavList = React.forwardRef((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)); + } + 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) { @@ -289,7 +309,9 @@ const TabNavList = React.forwardRef((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); @@ -550,6 +572,7 @@ const TabNavList = React.forwardRef((props, ref scrollToTab(); }, [ activeKey, + scrollPosition, transformMin, transformMax, stringify(activeTabOffset), diff --git a/src/Tabs.tsx b/src/Tabs.tsx index 30bfdecf..8fa6e5f5 100644 --- a/src/Tabs.tsx +++ b/src/Tabs.tsx @@ -19,6 +19,7 @@ import type { TabBarExtraContent, TabPosition, TabsLocale, + ScrollPosition, } from './interface'; /** @@ -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, @@ -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; @@ -112,6 +108,7 @@ const Tabs = React.forwardRef((props, ref) => { getPopupContainer, popupClassName, indicator, + scrollPosition, classNames: tabsClassNames, styles, ...restProps @@ -194,6 +191,7 @@ const Tabs = React.forwardRef((props, ref) => { getPopupContainer, popupClassName: clsx(popupClassName, tabsClassNames?.popup), indicator, + scrollPosition, styles, classNames: tabsClassNames, }; diff --git a/src/interface.ts b/src/interface.ts index e0827ea3..a3a20ddb 100644 --- a/src/interface.ts +++ b/src/interface.ts @@ -46,6 +46,8 @@ export type TabOffsetMap = Map; export type TabPosition = 'left' | 'right' | 'top' | 'bottom'; +export type ScrollPosition = 'auto' | 'start' | 'center' | 'end' | number; + type RenderTabBarProps = { id: string; activeKey: string; diff --git a/tests/overflow.test.tsx b/tests/overflow.test.tsx index 03a70ad6..012c31c1 100644 --- a/tests/overflow.test.tsx +++ b/tests/overflow.test.tsx @@ -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(); diff --git a/tests/rtl.test.tsx b/tests/rtl.test.tsx index d372055e..4ab21643 100644 --- a/tests/rtl.test.tsx +++ b/tests/rtl.test.tsx @@ -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(); + }); + }); });