From e183961d38ac9099284a29b76513ab73f800ff0d Mon Sep 17 00:00:00 2001 From: Minwook Shin <163576506+minwookshin@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:23:37 -0400 Subject: [PATCH 1/2] fix(Tabs): update overflow when the container resizes Assisted-by: OpenAI Codex. Verified container resizing in Chromium, Firefox, and WebKit. --- .../react-core/src/components/Tabs/Tabs.tsx | 18 ++--- .../components/Tabs/__tests__/Tabs.test.tsx | 66 +++++++++++++++++++ 2 files changed, 75 insertions(+), 9 deletions(-) diff --git a/packages/react-core/src/components/Tabs/Tabs.tsx b/packages/react-core/src/components/Tabs/Tabs.tsx index 677ee8fac92..5233e288f1a 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,15 +408,16 @@ class Tabs extends Component { }; handleResize = () => { + if (!this.tabList.current) { + return; + } this.handleScrollButtons(); this.setAccentStyles(); }; componentDidMount() { if (!this.props.isVertical) { - if (canUseDOM) { - window.addEventListener('resize', this.handleResize, false); - } + this.unobserveResize = getResizeObserver(this.containerRef.current, this.handleResize, true); this.direction = getLanguageDirection(this.tabList.current); // call the handle resize function to check if scroll buttons should be shown this.handleScrollButtons(); @@ -424,11 +427,7 @@ class Tabs extends Component { } componentWillUnmount() { - if (!this.props.isVertical) { - if (canUseDOM) { - window.removeEventListener('resize', this.handleResize, false); - } - } + this.unobserveResize(); clearTimeout(this.scrollTimeout); this.leftScrollButtonRef.current?.removeEventListener('transitionend', this.hideScrollButtons); } @@ -607,6 +606,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.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); + }); +}); From 1752507657ac17d562380e7c97b3992177eb42e7 Mon Sep 17 00:00:00 2001 From: Minwook Shin <163576506+minwookshin@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:29:46 -0400 Subject: [PATCH 2/2] fix(Tabs): preserve viewport resize updates Assisted-by: OpenAI Codex --- .../react-core/src/components/Tabs/Tabs.tsx | 6 +++ .../components/Tabs/__tests__/Tabs.test.tsx | 49 +++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/packages/react-core/src/components/Tabs/Tabs.tsx b/packages/react-core/src/components/Tabs/Tabs.tsx index 5233e288f1a..e49131e6a1b 100644 --- a/packages/react-core/src/components/Tabs/Tabs.tsx +++ b/packages/react-core/src/components/Tabs/Tabs.tsx @@ -418,6 +418,9 @@ class Tabs extends Component { componentDidMount() { if (!this.props.isVertical) { this.unobserveResize = getResizeObserver(this.containerRef.current, this.handleResize, true); + if (canUseDOM) { + window.addEventListener('resize', this.handleResize); + } this.direction = getLanguageDirection(this.tabList.current); // call the handle resize function to check if scroll buttons should be shown this.handleScrollButtons(); @@ -428,6 +431,9 @@ class Tabs extends Component { componentWillUnmount() { this.unobserveResize(); + if (canUseDOM) { + window.removeEventListener('resize', this.handleResize); + } clearTimeout(this.scrollTimeout); this.leftScrollButtonRef.current?.removeEventListener('transitionend', this.hideScrollButtons); } diff --git a/packages/react-core/src/components/Tabs/__tests__/Tabs.test.tsx b/packages/react-core/src/components/Tabs/__tests__/Tabs.test.tsx index 27555f0d9cf..9a8df83d86b 100644 --- a/packages/react-core/src/components/Tabs/__tests__/Tabs.test.tsx +++ b/packages/react-core/src/components/Tabs/__tests__/Tabs.test.tsx @@ -1024,6 +1024,7 @@ describe('container resizing', () => { afterEach(() => { window.ResizeObserver = originalResizeObserver; jest.clearAllMocks(); + jest.restoreAllMocks(); jest.useRealTimers(); }); @@ -1071,4 +1072,52 @@ describe('container resizing', () => { 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); + }); });