diff --git a/packages/react/src/tabs/indicator/TabsIndicator.test.tsx b/packages/react/src/tabs/indicator/TabsIndicator.test.tsx index 48c1c532211..c582f3519b1 100644 --- a/packages/react/src/tabs/indicator/TabsIndicator.test.tsx +++ b/packages/react/src/tabs/indicator/TabsIndicator.test.tsx @@ -48,17 +48,35 @@ describe('', () => { ) { const tabRect = activeTab.getBoundingClientRect(); const tabListRect = tabList.getBoundingClientRect(); - const { width: tabWidth, height: tabHeight } = getCssDimensions(activeTab); const { width: tabListWidth, height: tabListHeight } = getCssDimensions(tabList); const scaleX = tabListWidth > 0 ? tabListRect.width / tabListWidth : 1; const scaleY = tabListHeight > 0 ? tabListRect.height / tabListHeight : 1; - - const relativeLeft = - (tabRect.left - tabListRect.left) / scaleX + tabList.scrollLeft - tabList.clientLeft; - const relativeTop = - (tabRect.top - tabListRect.top) / scaleY + tabList.scrollTop - tabList.clientTop; - const relativeRight = tabList.scrollWidth - relativeLeft - tabWidth; - const relativeBottom = tabList.scrollHeight - relativeTop - tabHeight; + const devicePixelRatio = window.devicePixelRatio || 1; + const snap = (value: number) => Math.round(value * devicePixelRatio) / devicePixelRatio; + const isVertical = tabList.getAttribute('aria-orientation') === 'vertical'; + const tabLeft = + isVertical || Math.abs(tabRect.left - tabListRect.left) < 0.01 + ? tabRect.left + : snap(tabRect.left); + const tabRight = + isVertical || Math.abs(tabRect.right - tabListRect.right) < 0.01 + ? tabRect.right + : snap(tabRect.right); + const tabTop = + !isVertical || Math.abs(tabRect.top - tabListRect.top) < 0.01 + ? tabRect.top + : snap(tabRect.top); + const tabBottom = + !isVertical || Math.abs(tabRect.bottom - tabListRect.bottom) < 0.01 + ? tabRect.bottom + : snap(tabRect.bottom); + + const left = (tabLeft - tabListRect.left) / scaleX + tabList.scrollLeft - tabList.clientLeft; + const top = (tabTop - tabListRect.top) / scaleY + tabList.scrollTop - tabList.clientTop; + const width = (tabRight - tabLeft) / scaleX; + const height = (tabBottom - tabTop) / scaleY; + const relativeRight = tabList.scrollWidth - left - width; + const relativeBottom = tabList.scrollHeight - top - height; const bubbleComputedStyle = window.getComputedStyle(bubble); const actualLeft = bubbleComputedStyle.getPropertyValue('--active-tab-left'); @@ -68,14 +86,172 @@ describe('', () => { const actualWidth = bubbleComputedStyle.getPropertyValue('--active-tab-width'); const actualHeight = bubbleComputedStyle.getPropertyValue('--active-tab-height'); - assertSize(actualLeft, relativeLeft); + assertSize(actualLeft, left); assertSize(actualRight, relativeRight); - assertSize(actualTop, relativeTop); + assertSize(actualTop, top); assertSize(actualBottom, relativeBottom); - assertSize(actualWidth, tabWidth); - assertSize(actualHeight, tabHeight); + assertSize(actualWidth, width); + assertSize(actualHeight, height); } + function getAbsoluteIndicatorEdges(bubble: HTMLElement, tabList: HTMLElement) { + const bubbleComputedStyle = window.getComputedStyle(bubble); + const left = parseFloat(bubbleComputedStyle.getPropertyValue('--active-tab-left')); + const top = parseFloat(bubbleComputedStyle.getPropertyValue('--active-tab-top')); + const width = parseFloat(bubbleComputedStyle.getPropertyValue('--active-tab-width')); + const height = parseFloat(bubbleComputedStyle.getPropertyValue('--active-tab-height')); + const tabListRect = tabList.getBoundingClientRect(); + const { width: tabListWidth, height: tabListHeight } = getCssDimensions(tabList); + const scaleX = tabListWidth > 0 ? tabListRect.width / tabListWidth : 1; + const scaleY = tabListHeight > 0 ? tabListRect.height / tabListHeight : 1; + + // Convert the indicator CSS variables back to viewport coordinates. + return { + left: tabListRect.left + (left - tabList.scrollLeft + tabList.clientLeft) * scaleX, + right: tabListRect.left + (left + width - tabList.scrollLeft + tabList.clientLeft) * scaleX, + top: tabListRect.top + (top - tabList.scrollTop + tabList.clientTop) * scaleY, + bottom: tabListRect.top + (top + height - tabList.scrollTop + tabList.clientTop) * scaleY, + }; + } + + function assertDevicePixelSnapped(value: number) { + const devicePixelRatio = window.devicePixelRatio || 1; + const devicePixel = value * devicePixelRatio; + + expect(Math.abs(devicePixel - Math.round(devicePixel))).toBeLessThanOrEqual(0.01); + } + + function assertMainAxisEdgesAreSnapped(bubble: HTMLElement, tabList: HTMLElement) { + const edges = getAbsoluteIndicatorEdges(bubble, tabList); + + if (tabList.getAttribute('aria-orientation') === 'vertical') { + assertDevicePixelSnapped(edges.top); + assertDevicePixelSnapped(edges.bottom); + } else { + assertDevicePixelSnapped(edges.left); + assertDevicePixelSnapped(edges.right); + } + } + + it('snaps fractional tab edges to the device pixel grid', async () => { + const halfDevicePixel = 0.5 / (window.devicePixelRatio || 1); + + await render( +
+ + + + One + + + Two + + + Three + + + + +
, + ); + + const bubble = screen.getByTestId('bubble'); + const tabList = screen.getByTestId('tab-list'); + const activeTab = screen.getAllByRole('tab')[1]; + + await waitFor(() => assertBubblePositionVariables(bubble, tabList, activeTab)); + assertMainAxisEdgesAreSnapped(bubble, tabList); + }); + + it('keeps boundary tab edges flush with the tab list edge', async () => { + const halfDevicePixel = 0.5 / (window.devicePixelRatio || 1); + + await render( +
+ + + One + Two + + + +
, + ); + + const bubble = screen.getByTestId('bubble'); + const tabList = screen.getByTestId('tab-list'); + const activeTab = screen.getAllByRole('tab')[1]; + + await waitFor(() => assertBubblePositionVariables(bubble, tabList, activeTab)); + + const edges = getAbsoluteIndicatorEdges(bubble, tabList); + expect(Math.abs(edges.right - tabList.getBoundingClientRect().right)).toBeLessThanOrEqual( + 0.01, + ); + }); + + it('does not introduce a cross-axis scrollbar from main-axis snapping', async () => { + const halfDevicePixel = 0.5 / (window.devicePixelRatio || 1); + + await render( +
+ + + + One + + + Two + + + + +
, + ); + + const tabList = screen.getByTestId('tab-list'); + const activeTab = screen.getAllByRole('tab')[1]; + + await waitFor(() => + assertBubblePositionVariables(screen.getByTestId('bubble'), tabList, activeTab), + ); + + expect(tabList.scrollHeight).toBeLessThanOrEqual(tabList.clientHeight); + }); + + it('snaps fractional tab edges on the vertical axis for vertical orientation', async () => { + const halfDevicePixel = 0.5 / (window.devicePixelRatio || 1); + + await render( +
+ + + + One + + + Two + + + Three + + + + +
, + ); + + const bubble = screen.getByTestId('bubble'); + const tabList = screen.getByTestId('tab-list'); + const activeTab = screen.getAllByRole('tab')[1]; + + await waitFor(() => assertBubblePositionVariables(bubble, tabList, activeTab)); + assertMainAxisEdgesAreSnapped(bubble, tabList); + }); + it('should set CSS variables corresponding to the active tab', async () => { await render( diff --git a/packages/react/src/tabs/indicator/TabsIndicator.tsx b/packages/react/src/tabs/indicator/TabsIndicator.tsx index ef8ef0393b1..1132edaad42 100644 --- a/packages/react/src/tabs/indicator/TabsIndicator.tsx +++ b/packages/react/src/tabs/indicator/TabsIndicator.tsx @@ -1,5 +1,6 @@ 'use client'; import * as React from 'react'; +import { ownerWindow } from '@base-ui/utils/owner'; import { useForcedRerendering } from '@base-ui/utils/useForcedRerendering'; import { useRenderElement } from '../../internals/useRenderElement'; import { getCssDimensions } from '../../utils/getCssDimensions'; @@ -67,7 +68,6 @@ export const TabsIndicator = React.forwardRef(function TabsIndicator( isTabSelected = true; if (activeTab != null) { - const { width: computedWidth, height: computedHeight } = getCssDimensions(activeTab); const { width: tabListWidth, height: tabListHeight } = getCssDimensions(tabsListElement); const tabRect = activeTab.getBoundingClientRect(); const tabsListRect = tabsListElement.getBoundingClientRect(); @@ -77,18 +77,39 @@ export const TabsIndicator = React.forwardRef(function TabsIndicator( Math.abs(scaleX) > Number.EPSILON && Math.abs(scaleY) > Number.EPSILON; if (hasNonZeroScale) { - const tabLeftDelta = tabRect.left - tabsListRect.left; - const tabTopDelta = tabRect.top - tabsListRect.top; - - left = tabLeftDelta / scaleX + tabsListElement.scrollLeft - tabsListElement.clientLeft; - top = tabTopDelta / scaleY + tabsListElement.scrollTop - tabsListElement.clientTop; + const devicePixelRatio = ownerWindow(tabsListElement).devicePixelRatio || 1; + const snap = (edge: number) => Math.round(edge * devicePixelRatio) / devicePixelRatio; + const isAtStart = Math.abs(tabRect.left - tabsListRect.left) < 0.01; + const isAtEnd = Math.abs(tabRect.right - tabsListRect.right) < 0.01; + const isAtTop = Math.abs(tabRect.top - tabsListRect.top) < 0.01; + const isAtBottom = Math.abs(tabRect.bottom - tabsListRect.bottom) < 0.01; + // Snap only interior edges along the main axis. Boundary edges stay flush with + // the tab list so bordered indicators can connect to an adjacent panel border. + const isVertical = orientation === 'vertical'; + const tabLeft = isVertical || isAtStart ? tabRect.left : snap(tabRect.left); + const tabRight = isVertical || isAtEnd ? tabRect.right : snap(tabRect.right); + const tabTop = !isVertical || isAtTop ? tabRect.top : snap(tabRect.top); + const tabBottom = !isVertical || isAtBottom ? tabRect.bottom : snap(tabRect.bottom); + + left = + (tabLeft - tabsListRect.left) / scaleX + + tabsListElement.scrollLeft - + tabsListElement.clientLeft; + top = + (tabTop - tabsListRect.top) / scaleY + + tabsListElement.scrollTop - + tabsListElement.clientTop; + width = (tabRight - tabLeft) / scaleX; + height = (tabBottom - tabTop) / scaleY; } else { + const { width: computedWidth, height: computedHeight } = getCssDimensions(activeTab); + left = activeTab.offsetLeft; top = activeTab.offsetTop; + width = computedWidth; + height = computedHeight; } - width = computedWidth; - height = computedHeight; right = tabsListElement.scrollWidth - left - width; bottom = tabsListElement.scrollHeight - top - height; } diff --git a/packages/react/src/tabs/indicator/prehydrationScript.min.ts b/packages/react/src/tabs/indicator/prehydrationScript.min.ts index cf5e76947bd..2685735ce69 100644 --- a/packages/react/src/tabs/indicator/prehydrationScript.min.ts +++ b/packages/react/src/tabs/indicator/prehydrationScript.min.ts @@ -2,4 +2,4 @@ // To update it, modify the corresponding source file and run `pnpm inline-scripts`. // prettier-ignore -export const script = '!function(){const t=document.currentScript.previousElementSibling;if(!t)return;const e=t.closest(\'[role="tablist"]\');if(!e)return;const i=e.querySelector("[data-active]");if(!i)return;if(0===i.offsetWidth||0===e.offsetWidth)return;let o=0,n=0,h=0,l=0,r=0,f=0;function s(t){const e=getComputedStyle(t);let i=parseFloat(e.width)||0,o=parseFloat(e.height)||0;return(Math.round(i)!==t.offsetWidth||Math.round(o)!==t.offsetHeight)&&(i=t.offsetWidth,o=t.offsetHeight),{width:i,height:o}}if(null!=i&&null!=e){const{width:t,height:c}=s(i),{width:u,height:d}=s(e),a=i.getBoundingClientRect(),g=e.getBoundingClientRect(),p=u>0?g.width/u:1,b=d>0?g.height/d:1;if(Math.abs(p)>Number.EPSILON&&Math.abs(b)>Number.EPSILON){const t=a.left-g.left,i=a.top-g.top;o=t/p+e.scrollLeft-e.clientLeft,h=i/b+e.scrollTop-e.clientTop}else o=i.offsetLeft,h=i.offsetTop;r=t,f=c,n=e.scrollWidth-o-r,l=e.scrollHeight-h-f}function c(e,i){t.style.setProperty(`--active-tab-${e}`,`${i}px`)}c("left",o),c("right",n),c("top",h),c("bottom",l),c("width",r),c("height",f),r>0&&f>0&&t.removeAttribute("hidden")}();'; +export const script = '!function(){const t=document.currentScript.previousElementSibling;if(!t)return;const e=t.closest(\'[role="tablist"]\');if(!e)return;const i=e.querySelector("[data-active]");if(!i||0===i.offsetWidth||0===e.offsetWidth)return;function o(t){const e=getComputedStyle(t);let i=parseFloat(e.width)||0,o=parseFloat(e.height)||0;return(Math.round(i)!==t.offsetWidth||Math.round(o)!==t.offsetHeight)&&(i=t.offsetWidth,o=t.offsetHeight),{width:i,height:o}}const{width:h,height:r}=o(e),f=i.getBoundingClientRect(),n=e.getBoundingClientRect(),l=h>0?n.width/h:1,s=r>0?n.height/r:1;let c=i.offsetLeft,d=i.offsetTop,{width:u,height:a}=o(i);Math.abs(l)>Number.EPSILON&&Math.abs(s)>Number.EPSILON&&(c=(f.left-n.left)/l+e.scrollLeft-e.clientLeft,d=(f.top-n.top)/s+e.scrollTop-e.clientTop,u=f.width/l,a=f.height/s),[["left",c],["right",e.scrollWidth-c-u],["top",d],["bottom",e.scrollHeight-d-a],["width",u],["height",a]].forEach(([e,i])=>{t.style.setProperty(`--active-tab-${e}`,`${i}px`)}),u>0&&a>0&&t.removeAttribute("hidden")}();'; diff --git a/packages/react/src/tabs/indicator/prehydrationScript.template.js b/packages/react/src/tabs/indicator/prehydrationScript.template.js index b24f5cc37af..dbdae82a3b2 100644 --- a/packages/react/src/tabs/indicator/prehydrationScript.template.js +++ b/packages/react/src/tabs/indicator/prehydrationScript.template.js @@ -10,21 +10,10 @@ } const activeTab = tabsList.querySelector('[data-active]'); - if (!activeTab) { + if (!activeTab || activeTab.offsetWidth === 0 || tabsList.offsetWidth === 0) { return; } - if (activeTab.offsetWidth === 0 || tabsList.offsetWidth === 0) { - return; - } - - let left = 0; - let right = 0; - let top = 0; - let bottom = 0; - let width = 0; - let height = 0; - function getCssDimensions(element) { const css = getComputedStyle(element); let cssWidth = parseFloat(css.width) || 0; @@ -44,42 +33,34 @@ }; } - if (activeTab != null && tabsList != null) { - const { width: computedWidth, height: computedHeight } = getCssDimensions(activeTab); - const { width: tabsListWidth, height: tabsListHeight } = getCssDimensions(tabsList); - const tabRect = activeTab.getBoundingClientRect(); - const tabsListRect = tabsList.getBoundingClientRect(); - const scaleX = tabsListWidth > 0 ? tabsListRect.width / tabsListWidth : 1; - const scaleY = tabsListHeight > 0 ? tabsListRect.height / tabsListHeight : 1; - const hasNonZeroScale = Math.abs(scaleX) > Number.EPSILON && Math.abs(scaleY) > Number.EPSILON; + const { width: tabsListWidth, height: tabsListHeight } = getCssDimensions(tabsList); + const tabRect = activeTab.getBoundingClientRect(); + const tabsListRect = tabsList.getBoundingClientRect(); + const scaleX = tabsListWidth > 0 ? tabsListRect.width / tabsListWidth : 1; + const scaleY = tabsListHeight > 0 ? tabsListRect.height / tabsListHeight : 1; - if (hasNonZeroScale) { - const tabLeftDelta = tabRect.left - tabsListRect.left; - const tabTopDelta = tabRect.top - tabsListRect.top; + let left = activeTab.offsetLeft; + let top = activeTab.offsetTop; + let { width, height } = getCssDimensions(activeTab); - left = tabLeftDelta / scaleX + tabsList.scrollLeft - tabsList.clientLeft; - top = tabTopDelta / scaleY + tabsList.scrollTop - tabsList.clientTop; - } else { - left = activeTab.offsetLeft; - top = activeTab.offsetTop; - } - - width = computedWidth; - height = computedHeight; - right = tabsList.scrollWidth - left - width; - bottom = tabsList.scrollHeight - top - height; + if (Math.abs(scaleX) > Number.EPSILON && Math.abs(scaleY) > Number.EPSILON) { + // Hydration applies device-pixel snapping; this inline script stays positioning-only. + left = (tabRect.left - tabsListRect.left) / scaleX + tabsList.scrollLeft - tabsList.clientLeft; + top = (tabRect.top - tabsListRect.top) / scaleY + tabsList.scrollTop - tabsList.clientTop; + width = tabRect.width / scaleX; + height = tabRect.height / scaleY; } - function setProp(name, value) { + [ + ['left', left], + ['right', tabsList.scrollWidth - left - width], + ['top', top], + ['bottom', tabsList.scrollHeight - top - height], + ['width', width], + ['height', height], + ].forEach(([name, value]) => { indicator.style.setProperty(`--active-tab-${name}`, `${value}px`); - } - - setProp('left', left); - setProp('right', right); - setProp('top', top); - setProp('bottom', bottom); - setProp('width', width); - setProp('height', height); + }); if (width > 0 && height > 0) { indicator.removeAttribute('hidden');