diff --git a/src/TabNavList/index.tsx b/src/TabNavList/index.tsx index 4e64f242..2f88bfcd 100644 --- a/src/TabNavList/index.tsx +++ b/src/TabNavList/index.tsx @@ -60,28 +60,37 @@ export interface TabNavListProps { styles?: Partial>; } -const getTabSize = (tab: HTMLElement, containerRect: { left: number; top: number }) => { +const isScaled = (element: HTMLElement, rect = element.getBoundingClientRect()) => + Math.abs(rect.width - element.offsetWidth) >= 1 || + Math.abs(rect.height - element.offsetHeight) >= 1; + +const getTabSize = ( + tab: HTMLElement, + containerRect: { left: number; top: number }, + forceOffset: boolean, +) => { // tabListRef const { offsetWidth, offsetHeight, offsetTop, offsetLeft } = tab; - const { width, height, left, top } = tab.getBoundingClientRect(); + const rect = tab.getBoundingClientRect(); + const { width, height, left, top } = rect; // Use getBoundingClientRect to avoid decimal inaccuracy - if (Math.abs(width - offsetWidth) < 1) { + if (!forceOffset && !isScaled(tab, rect)) { return [width, height, left - containerRect.left, top - containerRect.top]; } return [offsetWidth, offsetHeight, offsetLeft, offsetTop]; }; -const getSize = (refObj: React.RefObject): SizeInfo => { +const getSize = (refObj: React.RefObject, forceOffset: boolean): SizeInfo => { const { offsetWidth = 0, offsetHeight = 0 } = refObj.current || {}; // Use getBoundingClientRect to avoid decimal inaccuracy - if (refObj.current) { - const { width, height } = refObj.current.getBoundingClientRect(); + if (refObj.current && !forceOffset) { + const rect = refObj.current.getBoundingClientRect(); - if (Math.abs(width - offsetWidth) < 1) { - return [width, height]; + if (!isScaled(refObj.current, rect)) { + return [rect.width, rect.height]; } } @@ -504,8 +513,13 @@ const TabNavList = React.forwardRef((props, ref ); }); + // A scaled nav/list must use layout coordinates for its smaller descendants too. + // Otherwise showing More can change the measured width and repeatedly hide it again. + const shouldUseOffset = () => + [containerRef, tabListRef].some(({ current }) => current && isScaled(current)); + // Update buttons records - const updateTabSizes = () => + const updateTabSizes = (forceOffset = shouldUseOffset()) => setTabSizes(() => { const newSizes: TabSizeMap = new Map(); const listRect = tabListRef.current?.getBoundingClientRect(); @@ -515,7 +529,7 @@ const TabNavList = React.forwardRef((props, ref `[data-node-key="${genDataNodeKey(key)}"]`, ); if (btnNode) { - const [width, height, left, top] = getTabSize(btnNode, listRect); + const [width, height, left, top] = getTabSize(btnNode, listRect, forceOffset); newSizes.set(key, { width, height, left, top }); } }); @@ -527,30 +541,32 @@ const TabNavList = React.forwardRef((props, ref }, [tabs.map(tab => tab.key).join('_')]); const onListHolderResize = useUpdate(() => { + const forceOffset = shouldUseOffset(); + // Update wrapper records - const containerSize = getSize(containerRef); - const extraLeftSize = getSize(extraLeftRef); - const extraRightSize = getSize(extraRightRef); + const containerSize = getSize(containerRef, forceOffset); + const extraLeftSize = getSize(extraLeftRef, forceOffset); + const extraRightSize = getSize(extraRightRef, forceOffset); setContainerExcludeExtraSize([ containerSize[0] - extraLeftSize[0] - extraRightSize[0], containerSize[1] - extraLeftSize[1] - extraRightSize[1], ]); - const newAddSize = getSize(innerAddButtonRef); + const newAddSize = getSize(innerAddButtonRef, forceOffset); setAddSize(newAddSize); - const newOperationSize = getSize(operationsRef); + const newOperationSize = getSize(operationsRef, forceOffset); setOperationSize(newOperationSize); // Which includes add button size - const tabContentFullSize = getSize(tabListRef); + const tabContentFullSize = getSize(tabListRef, forceOffset); setTabContentSize([ tabContentFullSize[0] - newAddSize[0], tabContentFullSize[1] - newAddSize[1], ]); // Update buttons records - updateTabSizes(); + updateTabSizes(forceOffset); }); // ======================== Dropdown ======================= diff --git a/tests/overflow.test.tsx b/tests/overflow.test.tsx index 72421914..257d1d1f 100644 --- a/tests/overflow.test.tsx +++ b/tests/overflow.test.tsx @@ -690,7 +690,8 @@ describe('Tabs.Overflow', () => { left: 14.5, }; } - // console.log('ele!!!', ele.className); + const size = getOffsetSizeFunc(hackOffsetInfo).call(ele); + return { left: 0, top: 0, width: size, height: size }; }; jest.useFakeTimers(); @@ -821,3 +822,183 @@ describe('Tabs.Overflow', () => { jest.useRealTimers(); }); }); + +describe('Tabs.Overflow scaled layout', () => { + let domSpy: ReturnType; + + const items = ['first', 'middle', 'last'].map(key => ({ key, label: key })); + const layouts: { + name: string; + tabPosition: TabsProps['tabPosition']; + direction: TabsProps['direction']; + scaleX: number; + scaleY: number; + }[] = [ + { name: 'horizontal', tabPosition: 'top', direction: 'ltr', scaleX: 1.01, scaleY: 1.01 }, + { name: 'RTL', tabPosition: 'top', direction: 'rtl', scaleX: 1.01, scaleY: 1.01 }, + { name: 'vertical', tabPosition: 'left', direction: 'ltr', scaleX: 1, scaleY: 1.01 }, + ]; + + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + domSpy.mockRestore(); + jest.useRealTimers(); + }); + + function mockLayout( + layout: (typeof layouts)[number], + { containerSize = 198, tabSize = 54, scaleX = layout.scaleX, scaleY = layout.scaleY } = {}, + ) { + const horizontal = layout.tabPosition === 'top'; + const listSize = tabSize * items.length + 18 * (items.length - 1); + + // offset* uses layout coordinates, whereas DOMRect includes the ancestor's scale. + function getLayoutRect(element: HTMLElement) { + let size = 10; + let position = 0; + + if (element.classList.contains('rc-tabs-tab')) { + size = tabSize; + let index = items.findIndex(item => item.key === element.dataset.nodeKey); + if (horizontal && layout.direction === 'rtl') { + index = items.length - index - 1; + } + position = index * (tabSize + 18); + } else if (element.classList.contains('rc-tabs-nav-list')) { + size = listSize; + } else if (element.classList.contains('rc-tabs-nav')) { + size = containerSize; + } else if ( + element.classList.contains('rc-tabs-nav-operations') || + element.classList.contains('rc-tabs-nav-more') + ) { + size = 45; + } + + return horizontal + ? { width: size, height: 35, left: position, top: 0 } + : { width: 35, height: size, left: 0, top: position }; + } + + domSpy = spyElementPrototypes(HTMLElement, { + scrollIntoView: () => {}, + offsetWidth: { + get() { + return Math.round(getLayoutRect(this).width); + }, + }, + offsetHeight: { + get() { + return Math.round(getLayoutRect(this).height); + }, + }, + offsetLeft: { + get() { + return Math.round(getLayoutRect(this).left); + }, + }, + offsetTop: { + get() { + return Math.round(getLayoutRect(this).top); + }, + }, + getBoundingClientRect() { + const { width, height, left, top } = getLayoutRect(this); + return { + width: width * scaleX, + height: height * scaleY, + left: 100 + left * scaleX, + top: 200 + top * scaleY, + }; + }, + }); + } + + function flushResize(container: Element) { + triggerResize(container); + act(() => { + jest.runAllTimers(); + }); + } + + describe.each(layouts)('$name', layout => { + const horizontal = layout.tabPosition === 'top'; + const sizeProperty = horizontal ? 'width' : 'height'; + const positionProperty = horizontal ? (layout.direction === 'rtl' ? 'right' : 'left') : 'top'; + + it('does not hide a tab that fits when the ancestor is scaled', () => { + mockLayout(layout); + const { container, unmount } = render( + , + ); + + flushResize(container); + + // The 198px parent differs from its DOMRect by > 1px, but each 54px tab by < 1px. + expect(container.querySelector('.rc-tabs-nav-operations')).toHaveClass( + 'rc-tabs-nav-operations-hidden', + ); + expect(getTransformX(container)).toBe(0); + expect(getTransformY(container)).toBe(0); + expect(container.querySelector('.rc-tabs-ink-bar')).toHaveStyle({ + [sizeProperty]: '54px', + [positionProperty]: '27px', + }); + unmount(); + }); + + it('keeps genuinely overflowing tabs accessible when the ancestor is scaled', () => { + mockLayout(layout, { containerSize: 170 }); + const onChange = jest.fn(); + const { container, unmount } = render( + , + ); + + flushResize(container); + + expect(container.querySelector('.rc-tabs-nav-operations')).not.toHaveClass( + 'rc-tabs-nav-operations-hidden', + ); + fireEvent.mouseEnter(container.querySelector('.rc-tabs-nav-more')); + act(() => { + jest.runAllTimers(); + }); + const hiddenTabs = document.querySelectorAll('.rc-tabs-dropdown-menu-item'); + expect(Array.from(hiddenTabs, node => node.textContent)).toEqual(['middle', 'last']); + fireEvent.click(hiddenTabs[1]); + expect(onChange).toHaveBeenCalledWith('last'); + unmount(); + }); + + it('preserves fractional layout sizes without scaling', () => { + mockLayout(layout, { containerSize: 199.5, tabSize: 54.5, scaleX: 1, scaleY: 1 }); + const { container, unmount } = render( + , + ); + + flushResize(container); + + expect(container.querySelector('.rc-tabs-nav-operations')).toHaveClass( + 'rc-tabs-nav-operations-hidden', + ); + expect(container.querySelector('.rc-tabs-ink-bar')).toHaveStyle({ + [sizeProperty]: '54.5px', + [positionProperty]: '172.25px', + }); + unmount(); + }); + }); +});