Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
200 changes: 188 additions & 12 deletions packages/react/src/tabs/indicator/TabsIndicator.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,17 +48,35 @@ describe('<Tabs.Indicator />', () => {
) {
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');
Expand All @@ -68,14 +86,172 @@ describe('<Tabs.Indicator />', () => {
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(
<div style={{ transform: `translate(${halfDevicePixel}px, ${halfDevicePixel}px)` }}>
<Tabs.Root value={2}>
<Tabs.List data-testid="tab-list" style={{ display: 'inline-flex' }}>
<Tabs.Tab value={1} style={{ flex: `0 0 ${50 + halfDevicePixel}px` }}>
One
</Tabs.Tab>
<Tabs.Tab value={2} style={{ flex: `0 0 ${69 + halfDevicePixel}px` }}>
Two
</Tabs.Tab>
<Tabs.Tab value={3} style={{ flex: '0 0 50px' }}>
Three
</Tabs.Tab>
<Tabs.Indicator data-testid="bubble" />
</Tabs.List>
</Tabs.Root>
</div>,
);

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(
<div style={{ transform: `translate(${halfDevicePixel}px, ${halfDevicePixel}px)` }}>
<Tabs.Root value={2}>
<Tabs.List data-testid="tab-list" style={{ display: 'inline-flex' }}>
<Tabs.Tab value={1}>One</Tabs.Tab>
<Tabs.Tab value={2}>Two</Tabs.Tab>
<Tabs.Indicator data-testid="bubble" />
</Tabs.List>
</Tabs.Root>
</div>,
);

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(
<div style={{ transform: `translate(${halfDevicePixel}px, ${halfDevicePixel}px)` }}>
<Tabs.Root value={2}>
<Tabs.List
data-testid="tab-list"
style={{ display: 'flex', overflowX: 'auto', height: `${32 + halfDevicePixel}px` }}
>
<Tabs.Tab value={1} style={{ flex: '0 0 80px' }}>
One
</Tabs.Tab>
<Tabs.Tab value={2} style={{ flex: '0 0 80px' }}>
Two
</Tabs.Tab>
<Tabs.Indicator data-testid="bubble" />
</Tabs.List>
</Tabs.Root>
</div>,
);

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(
<div style={{ transform: `translate(${halfDevicePixel}px, ${halfDevicePixel}px)` }}>
<Tabs.Root value={2} orientation="vertical">
<Tabs.List data-testid="tab-list" style={{ display: 'flex', flexDirection: 'column' }}>
<Tabs.Tab value={1} style={{ flex: `0 0 ${30 + halfDevicePixel}px` }}>
One
</Tabs.Tab>
<Tabs.Tab value={2} style={{ flex: `0 0 ${42 + halfDevicePixel}px` }}>
Two
</Tabs.Tab>
<Tabs.Tab value={3} style={{ flex: '0 0 30px' }}>
Three
</Tabs.Tab>
<Tabs.Indicator data-testid="bubble" />
</Tabs.List>
</Tabs.Root>
</div>,
);

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(
<Tabs.Root value={2}>
Expand Down
37 changes: 29 additions & 8 deletions packages/react/src/tabs/indicator/TabsIndicator.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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();
Expand All @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")}();';
67 changes: 24 additions & 43 deletions packages/react/src/tabs/indicator/prehydrationScript.template.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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');
Expand Down
Loading