diff --git a/packages/react-core/src/components/Tabs/Tabs.tsx b/packages/react-core/src/components/Tabs/Tabs.tsx index 677ee8fac92..e49131e6a1b 100644 --- a/packages/react-core/src/components/Tabs/Tabs.tsx +++ b/packages/react-core/src/components/Tabs/Tabs.tsx @@ -17,7 +17,7 @@ import { TabProps } from './Tab'; import { TabsContextProvider } from './TabsContext'; import { OverflowTab, HorizontalOverflowPopperProps } from './OverflowTab'; import { Button } from '../Button'; -import { getOUIAProps, OUIAProps, canUseDOM } from '../../helpers'; +import { getOUIAProps, OUIAProps, canUseDOM, getResizeObserver } from '../../helpers'; import { SSRSafeIds } from '../../helpers/SSRSafeIds/SSRSafeIds'; import { GenerateId } from '../../helpers/GenerateId/GenerateId'; import linkAccentLength from '@patternfly/react-tokens/dist/esm/c_tabs_link_accent_length'; @@ -184,7 +184,9 @@ class Tabs extends Component { static displayName = 'Tabs'; tabList = createRef(); leftScrollButtonRef = createRef(); + private containerRef = createRef(); private direction = 'ltr'; + private unobserveResize = () => {}; constructor(props: TabsProps) { super(props); const hashActiveKey = getTabHashActiveKey(props); @@ -406,14 +408,18 @@ class Tabs extends Component { }; handleResize = () => { + if (!this.tabList.current) { + return; + } this.handleScrollButtons(); this.setAccentStyles(); }; componentDidMount() { if (!this.props.isVertical) { + this.unobserveResize = getResizeObserver(this.containerRef.current, this.handleResize, true); if (canUseDOM) { - window.addEventListener('resize', this.handleResize, false); + window.addEventListener('resize', this.handleResize); } this.direction = getLanguageDirection(this.tabList.current); // call the handle resize function to check if scroll buttons should be shown @@ -424,10 +430,9 @@ class Tabs extends Component { } componentWillUnmount() { - if (!this.props.isVertical) { - if (canUseDOM) { - window.removeEventListener('resize', this.handleResize, false); - } + this.unobserveResize(); + if (canUseDOM) { + window.removeEventListener('resize', this.handleResize); } clearTimeout(this.scrollTimeout); this.leftScrollButtonRef.current?.removeEventListener('transitionend', this.hideScrollButtons); @@ -607,6 +612,7 @@ class Tabs extends Component { }} > { + const originalResizeObserver = window.ResizeObserver; + let resize: ResizeObserverCallback; + const observe = jest.fn(); + const unobserve = jest.fn(); + + beforeEach(() => { + jest.useFakeTimers(); + window.ResizeObserver = jest.fn().mockImplementation((callback) => { + resize = callback; + return { observe, unobserve }; + }); + }); + + afterEach(() => { + window.ResizeObserver = originalResizeObserver; + jest.clearAllMocks(); + jest.restoreAllMocks(); + jest.useRealTimers(); + }); + + test('updates the scroll buttons when only the container width changes', () => { + const { unmount } = render( + + + First content + + + Second content + + + ); + const tabList = screen.getByRole('tablist'); + let width = 200; + jest + .spyOn(tabList, 'getBoundingClientRect') + .mockImplementation(() => ({ left: 0, right: width, width }) as DOMRect); + Array.from(tabList.children).forEach((tab, index) => { + jest.spyOn(tab, 'getBoundingClientRect').mockReturnValue({ + left: index * 100, + right: (index + 1) * 100, + width: 100 + } as DOMRect); + }); + act(() => jest.advanceTimersByTime(200)); + expect(screen.queryByLabelText('Scroll forward')).not.toBeInTheDocument(); + + expect(observe).toHaveBeenCalledWith(screen.getByRole('region')); + width = 150; + act(() => resize([{ target: tabList } as ResizeObserverEntry], {} as ResizeObserver)); + act(() => jest.advanceTimersByTime(200)); + act(() => jest.advanceTimersByTime(100)); + expect(screen.getByLabelText('Scroll forward')).toBeEnabled(); + expect(screen.getByRole('region')).toHaveClass('pf-m-scrollable'); + + width = 200; + act(() => resize([{ target: tabList } as ResizeObserverEntry], {} as ResizeObserver)); + act(() => jest.advanceTimersByTime(200)); + expect(screen.getByRole('region')).not.toHaveClass('pf-m-scrollable'); + expect(screen.getByLabelText('Scroll forward')).toBeDisabled(); + + const observedContainer = screen.getByRole('region'); + unmount(); + expect(unobserve).toHaveBeenCalledWith(observedContainer); + }); + + test('updates scroll controls and the accent on window resize when the container width stays fixed', () => { + const tabsRef = createRef(); + const removeEventListener = jest.spyOn(window, 'removeEventListener'); + const { unmount } = render( + + + First content + + + Second content + + + ); + const tabList = screen.getByRole('tablist'); + const handleResize = tabsRef.current.handleResize; + let tabWidth = 100; + jest.spyOn(tabList, 'getBoundingClientRect').mockReturnValue({ left: 0, right: 200, width: 200 } as DOMRect); + Array.from(tabList.children).forEach((tab, index) => { + jest.spyOn(tab, 'getBoundingClientRect').mockImplementation( + () => + ({ + left: index * tabWidth, + right: (index + 1) * tabWidth, + width: tabWidth + }) as DOMRect + ); + jest.spyOn(tab as HTMLElement, 'offsetWidth', 'get').mockImplementation(() => tabWidth); + }); + act(() => jest.advanceTimersByTime(200)); + expect(screen.queryByLabelText('Scroll forward')).not.toBeInTheDocument(); + + tabWidth = 150; + act(() => window.dispatchEvent(new Event('resize'))); + act(() => jest.advanceTimersByTime(200)); + act(() => jest.advanceTimersByTime(100)); + expect(screen.getByLabelText('Scroll forward')).toBeEnabled(); + expect(screen.getByRole('region')).toHaveStyle('--pf-v6-c-tabs--link-accent--length: 150px'); + + tabWidth = 100; + act(() => window.dispatchEvent(new Event('resize'))); + act(() => jest.advanceTimersByTime(200)); + expect(screen.getByRole('region')).not.toHaveClass('pf-m-scrollable'); + expect(screen.getByRole('region')).toHaveStyle('--pf-v6-c-tabs--link-accent--length: 100px'); + + unmount(); + expect(removeEventListener).toHaveBeenCalledWith('resize', handleResize); + }); +});