From 4ae5d86a0528745f5f1d0ec34cf8b9576da24d4f Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Tue, 3 Feb 2026 07:12:50 -0600 Subject: [PATCH] fix(tab-bar): ensure full tab visibility when scrolling into view Changed from centering the tab to using scrollIntoView with 'nearest' option. This ensures the entire tab including the close button is visible, rather than potentially cutting off the right edge when near container boundaries. --- .../renderer/components/TabBar.test.tsx | 86 +++++++++++++------ src/renderer/components/TabBar.tsx | 10 +-- 2 files changed, 67 insertions(+), 29 deletions(-) diff --git a/src/__tests__/renderer/components/TabBar.test.tsx b/src/__tests__/renderer/components/TabBar.test.tsx index 94115a13..cae12cf7 100644 --- a/src/__tests__/renderer/components/TabBar.test.tsx +++ b/src/__tests__/renderer/components/TabBar.test.tsx @@ -164,8 +164,9 @@ describe('TabBar', () => { beforeEach(() => { vi.useFakeTimers(); vi.clearAllMocks(); - // Mock scrollTo + // Mock scrollTo and scrollIntoView Element.prototype.scrollTo = vi.fn(); + Element.prototype.scrollIntoView = vi.fn(); // Mock clipboard Object.assign(navigator, { clipboard: { @@ -1430,13 +1431,13 @@ describe('TabBar', () => { }); describe('scroll behavior', () => { - it('scrolls to center active tab when activeTabId changes', async () => { + it('scrolls active tab into view when activeTabId changes', async () => { // Mock requestAnimationFrame const rafSpy = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0; }); - const scrollToSpy = vi.fn(); + const scrollIntoViewSpy = vi.fn(); const tabs = [ createTab({ id: 'tab-1', name: 'Tab 1' }), @@ -1454,9 +1455,11 @@ describe('TabBar', () => { /> ); - // Mock scrollTo on the container - const tabBarContainer = container.firstChild as HTMLElement; - tabBarContainer.scrollTo = scrollToSpy; + // Mock scrollIntoView on the tab elements + const tabElements = container.querySelectorAll('[data-tab-id]'); + tabElements.forEach((el) => { + (el as HTMLElement).scrollIntoView = scrollIntoViewSpy; + }); // Change active tab rerender( @@ -1470,19 +1473,29 @@ describe('TabBar', () => { /> ); - // scrollTo should have been called via requestAnimationFrame - expect(scrollToSpy).toHaveBeenCalled(); + // Re-mock scrollIntoView on tab elements after rerender + const newTabElements = container.querySelectorAll('[data-tab-id]'); + newTabElements.forEach((el) => { + (el as HTMLElement).scrollIntoView = scrollIntoViewSpy; + }); + + // scrollIntoView should have been called via requestAnimationFrame + expect(scrollIntoViewSpy).toHaveBeenCalledWith({ + inline: 'nearest', + behavior: 'smooth', + block: 'nearest', + }); rafSpy.mockRestore(); }); - it('scrolls to center active tab when showUnreadOnly filter is toggled off', async () => { + it('scrolls active tab into view when showUnreadOnly filter is toggled off', async () => { // Mock requestAnimationFrame const rafSpy = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0; }); - const scrollToSpy = vi.fn(); + const scrollIntoViewSpy = vi.fn(); const tabs = [ createTab({ id: 'tab-1', name: 'Tab 1' }), @@ -1502,12 +1515,14 @@ describe('TabBar', () => { /> ); - // Mock scrollTo on the container - const tabBarContainer = container.firstChild as HTMLElement; - tabBarContainer.scrollTo = scrollToSpy; + // Mock scrollIntoView on the tab elements + const tabElements = container.querySelectorAll('[data-tab-id]'); + tabElements.forEach((el) => { + (el as HTMLElement).scrollIntoView = scrollIntoViewSpy; + }); // Clear initial calls - scrollToSpy.mockClear(); + scrollIntoViewSpy.mockClear(); // Toggle filter off - this should trigger scroll to active tab rerender( @@ -1522,19 +1537,29 @@ describe('TabBar', () => { /> ); - // scrollTo should have been called when filter was toggled - expect(scrollToSpy).toHaveBeenCalled(); + // Re-mock scrollIntoView on tab elements after rerender + const newTabElements = container.querySelectorAll('[data-tab-id]'); + newTabElements.forEach((el) => { + (el as HTMLElement).scrollIntoView = scrollIntoViewSpy; + }); + + // scrollIntoView should have been called when filter was toggled + expect(scrollIntoViewSpy).toHaveBeenCalledWith({ + inline: 'nearest', + behavior: 'smooth', + block: 'nearest', + }); rafSpy.mockRestore(); }); - it('scrolls to center file tab when activeFileTabId changes', async () => { + it('scrolls file tab into view when activeFileTabId changes', async () => { // Mock requestAnimationFrame const rafSpy = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0; }); - const scrollToSpy = vi.fn(); + const scrollIntoViewSpy = vi.fn(); const tabs = [createTab({ id: 'tab-1', name: 'Tab 1' })]; const fileTab: FilePreviewTab = { @@ -1563,12 +1588,14 @@ describe('TabBar', () => { /> ); - // Mock scrollTo on the container - const tabBarContainer = container.firstChild as HTMLElement; - tabBarContainer.scrollTo = scrollToSpy; + // Mock scrollIntoView on the tab elements + const tabElements = container.querySelectorAll('[data-tab-id]'); + tabElements.forEach((el) => { + (el as HTMLElement).scrollIntoView = scrollIntoViewSpy; + }); // Clear initial calls - scrollToSpy.mockClear(); + scrollIntoViewSpy.mockClear(); // Select the file tab - this should trigger scroll to file tab rerender( @@ -1586,8 +1613,18 @@ describe('TabBar', () => { /> ); - // scrollTo should have been called when file tab was selected - expect(scrollToSpy).toHaveBeenCalled(); + // Re-mock scrollIntoView on tab elements after rerender + const newTabElements = container.querySelectorAll('[data-tab-id]'); + newTabElements.forEach((el) => { + (el as HTMLElement).scrollIntoView = scrollIntoViewSpy; + }); + + // scrollIntoView should have been called when file tab was selected + expect(scrollIntoViewSpy).toHaveBeenCalledWith({ + inline: 'nearest', + behavior: 'smooth', + block: 'nearest', + }); rafSpy.mockRestore(); }); @@ -1878,6 +1915,7 @@ describe('TabBar', () => { querySelector: vi.fn().mockReturnValue({ offsetLeft: 100, offsetWidth: 80, + scrollIntoView: vi.fn(), }), scrollTo: vi.fn(), }), diff --git a/src/renderer/components/TabBar.tsx b/src/renderer/components/TabBar.tsx index 010395eb..12b7c72c 100644 --- a/src/renderer/components/TabBar.tsx +++ b/src/renderer/components/TabBar.tsx @@ -1599,7 +1599,7 @@ function TabBarInner({ const tabRefs = useRef>(new Map()); const [isOverflowing, setIsOverflowing] = useState(false); - // Center the active tab in the scrollable area when activeTabId or activeFileTabId changes, or filter is toggled + // Ensure the active tab is fully visible (including close button) when activeTabId or activeFileTabId changes, or filter is toggled useEffect(() => { requestAnimationFrame(() => { const container = tabBarRef.current; @@ -1609,10 +1609,10 @@ function TabBarInner({ `[data-tab-id="${targetTabId}"]` ) as HTMLElement | null; if (container && tabElement) { - // Calculate scroll position to center the tab - const scrollLeft = - tabElement.offsetLeft - container.clientWidth / 2 + tabElement.offsetWidth / 2; - container.scrollTo({ left: scrollLeft, behavior: 'smooth' }); + // Use scrollIntoView with 'nearest' to ensure the full tab is visible + // This scrolls minimally - only if the tab is partially or fully out of view + // The 'end' option ensures the right edge (with close button) is visible + tabElement.scrollIntoView({ inline: 'nearest', behavior: 'smooth', block: 'nearest' }); } }); }, [activeTabId, activeFileTabId, showUnreadOnly]);