diff --git a/packages/mui-material/src/Tabs/Tabs.js b/packages/mui-material/src/Tabs/Tabs.js index ebc6a5663a6d21..ff1d82577ac58f 100644 --- a/packages/mui-material/src/Tabs/Tabs.js +++ b/packages/mui-material/src/Tabs/Tabs.js @@ -231,6 +231,15 @@ const TabsScrollbarSize = styled(ScrollbarSize)({ const defaultIndicatorStyle = {}; +// scroll-padding computes to 'auto', a , or a of the scrollport +function resolveScrollPadding(value, scrollportSize) { + const number = parseFloat(value); + if (Number.isNaN(number)) { + return 0; + } + return value.endsWith('%') ? (number / 100) * scrollportSize : number; +} + // Dev-only: tracks per-`Tabs` instance (keyed by its ref) whether the invalid-value warning was // already logged, so it isn't repeated across the several effects that call `getTabsMeta`. // Only referenced from `process.env.NODE_ENV !== 'production'` blocks; the `@__PURE__` annotation @@ -582,13 +591,26 @@ const Tabs = React.forwardRef(function Tabs(inProps, ref) { return; } - if (tabMeta[start] < tabsMeta[start]) { + const scrollerComputedStyle = ownerWindow(tabsRef.current).getComputedStyle(tabsRef.current); + const scrollportSize = tabsRef.current[clientSize]; + const scrollPaddingStart = resolveScrollPadding( + scrollerComputedStyle[vertical ? 'scrollPaddingTop' : 'scrollPaddingLeft'], + scrollportSize, + ); + const scrollPaddingEnd = resolveScrollPadding( + scrollerComputedStyle[vertical ? 'scrollPaddingBottom' : 'scrollPaddingRight'], + scrollportSize, + ); + + if (tabMeta[start] - scrollPaddingStart < tabsMeta[start]) { // left side of button is out of view - const nextScrollStart = tabsMeta[scrollStart] + (tabMeta[start] - tabsMeta[start]); + const nextScrollStart = + tabsMeta[scrollStart] + (tabMeta[start] - scrollPaddingStart - tabsMeta[start]); scroll(nextScrollStart, { animation }); - } else if (tabMeta[end] > tabsMeta[end]) { + } else if (tabMeta[end] + scrollPaddingEnd > tabsMeta[end]) { // right side of button is out of view - const nextScrollStart = tabsMeta[scrollStart] + (tabMeta[end] - tabsMeta[end]); + const nextScrollStart = + tabsMeta[scrollStart] + (tabMeta[end] + scrollPaddingEnd - tabsMeta[end]); scroll(nextScrollStart, { animation }); } }); diff --git a/packages/mui-material/src/Tabs/Tabs.test.js b/packages/mui-material/src/Tabs/Tabs.test.js index e7ba3244755fbc..b41741f23557e7 100644 --- a/packages/mui-material/src/Tabs/Tabs.test.js +++ b/packages/mui-material/src/Tabs/Tabs.test.js @@ -950,6 +950,137 @@ describe.skipIf(isSafari)('', () => { clock.tick(1000); expect(tablistContainer.scrollLeft).to.equal(0); }); + + // Firefox reports fractional `scrollLeft`/`scrollTop` in Vitest browser mode, so the exact + // integer assertions in the scroll-padding tests fail there. + // See https://github.com/vitest-dev/vitest/issues/9223 + it.skipIf(isFirefox)( + 'should account for scroll-padding-left when scrolling a tab into view on the left', + function test() { + const { forceUpdate } = render( + + + + + , + ); + const tablist = screen.getByRole('tablist'); + const tablistContainer = tablist.parentElement; + const tab = tablist.children[0]; + + tablistContainer.style.scrollPaddingLeft = '15px'; + Object.defineProperty(tablistContainer, 'clientWidth', { value: 100 }); + tablistContainer.scrollLeft = 100; + tablistContainer.getBoundingClientRect = () => ({ + left: 0, + right: 100, + }); + tab.getBoundingClientRect = () => ({ + left: 10, + width: 50, + right: 60, + }); + forceUpdate(); + clock.tick(1000); + expect(tablistContainer.scrollLeft).to.equal(95); + }, + ); + + it.skipIf(isFirefox)( + 'should account for scroll-padding-right when scrolling a tab into view on the right', + function test() { + const { forceUpdate } = render( + + + + + , + ); + const tablist = screen.getByRole('tablist'); + const tablistContainer = tablist.parentElement; + const tab = tablist.children[0]; + + tablistContainer.style.scrollPaddingRight = '20px'; + Object.defineProperty(tablistContainer, 'clientWidth', { value: 200 }); + tablistContainer.scrollLeft = 0; + tablistContainer.getBoundingClientRect = () => ({ + left: 0, + right: 100, + }); + tab.getBoundingClientRect = () => ({ + left: 100, + width: 50, + right: 150, + }); + forceUpdate(); + clock.tick(1000); + expect(tablistContainer.scrollLeft).to.equal(70); + }, + ); + + it.skipIf(isFirefox)( + 'should resolve a percentage scroll-padding against the scroller clientWidth', + function test() { + const { forceUpdate } = render( + + + + + , + ); + const tablist = screen.getByRole('tablist'); + const tablistContainer = tablist.parentElement; + const tab = tablist.children[0]; + + tablistContainer.style.scrollPaddingRight = '10%'; + Object.defineProperty(tablistContainer, 'clientWidth', { value: 200 }); + tablistContainer.scrollLeft = 0; + tablistContainer.getBoundingClientRect = () => ({ + left: 0, + right: 100, + }); + tab.getBoundingClientRect = () => ({ + left: 100, + width: 50, + right: 150, + }); + forceUpdate(); + clock.tick(1000); + expect(tablistContainer.scrollLeft).to.equal(70); + }, + ); + + it.skipIf(isFirefox)( + 'should account for scroll-padding-bottom when scrolling a vertical tab into view', + function test() { + const { forceUpdate } = render( + + + + + , + ); + const tablist = screen.getByRole('tablist'); + const tablistContainer = tablist.parentElement; + const tab = tablist.children[0]; + + tablistContainer.style.scrollPaddingBottom = '20px'; + Object.defineProperty(tablistContainer, 'clientHeight', { value: 200 }); + tablistContainer.scrollTop = 0; + tablistContainer.getBoundingClientRect = () => ({ + top: 0, + bottom: 100, + }); + tab.getBoundingClientRect = () => ({ + top: 100, + height: 50, + bottom: 150, + }); + forceUpdate(); + clock.tick(1000); + expect(tablistContainer.scrollTop).to.equal(70); + }, + ); }); describe('slotProps: indicator', () => {