Skip to content

Commit 34f5c6d

Browse files
committed
fix(core): fade the strip out at its own edge, not past it
The scroll box is a ring bleed wider than the TabList on each inline side -- that is what keeps the first and last tab's focus ring from being clipped -- so the fade ran to the bleed edge and the strip painted tabs outside the component: past a divider rail, and past the scroll arrow that caps that edge, which left a sliver of tab showing beyond the arrow. Measured against the demo container in Chromium across direction, scroll position and both edges: ink up to 19/255 outside the box in five of eight configurations, none in any of them now. Masking the bleed costs nothing, because it is only masked at an edge that is scrolled away from, and a stop at such an edge never holds focus -- the reveal keeps the focused stop a fade width clear of both edges. The fade's opaque point and that reveal inset are the same distance for the same reason, so they are now the same constant.
1 parent 4d85fec commit 34f5c6d

2 files changed

Lines changed: 44 additions & 12 deletions

File tree

packages/core/src/TabList/TabList.test.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1182,6 +1182,26 @@ describe('TabList overflow (scroll)', () => {
11821182
expect(scrollBy.mock.calls[0][0].left).toBeCloseTo(40);
11831183
});
11841184

1185+
it('fades out at the strip’s own edge, not at the bleed edge', () => {
1186+
// The scroll box is a ring-bleed wider than the TabList on each side, so a
1187+
// fade that ran to the box edge would paint tabs outside the component and
1188+
// past the arrow that caps that edge.
1189+
const bleed =
1190+
'calc(var(--focus-outline-width) + var(--focus-outline-offset))';
1191+
const {strip} = renderStrip();
1192+
strip.scrollBy = vi.fn();
1193+
1194+
fakeScrollBox(strip, {scrollWidth: 600, clientWidth: 300});
1195+
fireEvent.scroll(strip);
1196+
expect(getComputedStyle(strip).maskImage).toContain(`transparent ${bleed}`);
1197+
1198+
fakeScrollBox(strip, {scrollWidth: 600, clientWidth: 300, scrollLeft: 150});
1199+
fireEvent.scroll(strip);
1200+
expect(getComputedStyle(strip).maskImage).toContain(
1201+
`transparent calc(100% - ${bleed})`,
1202+
);
1203+
});
1204+
11851205
it('does not hand focus to an arrow, which is hidden from assistive tech', () => {
11861206
const {container, strip} = renderStrip();
11871207
fakeScrollBox(strip, {scrollWidth: 600, clientWidth: 300});

packages/core/src/TabList/TabList.tsx

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -141,13 +141,25 @@ const BLOCK_END_BLEED = `max(${RING_BLEED}, ${INDICATOR_BLEED})`;
141141
const FADE_WIDTH = spacingVars['--spacing-8'];
142142

143143
/**
144-
* Keeps a tab scrolled into view clear of the faded edge and the bleed
145-
* padding. Declared as scroll-padding so the browser's own focus scrolling
146-
* uses it too, and read back from the computed style so the arithmetic below
147-
* has one source of truth with the CSS.
144+
* Where the fade turns fully opaque, and — the same distance, for the same
145+
* reason — how far a revealed stop is kept clear of the edge. Declared as
146+
* scroll-padding so the browser's own focus scrolling uses it too, and read
147+
* back from the computed style so the arithmetic below has one source of
148+
* truth with the CSS.
148149
*/
149150
const SCROLL_EDGE_INSET = `calc(${RING_BLEED} + ${FADE_WIDTH})`;
150151

152+
/**
153+
* The fade reaches transparent at the strip's *own* edge, not at the bleed
154+
* edge — otherwise the scroll container paints tabs `RING_BLEED` past the
155+
* TabList's box, past a divider rail, and past the scroll arrow that caps
156+
* that edge. Masking the bleed costs nothing: only a faded edge is masked,
157+
* and a stop at a faded edge never holds focus, so the bleed is still there
158+
* when the ring needs it.
159+
*/
160+
const FADE_FROM_START = `linear-gradient(to right, transparent ${RING_BLEED}, black ${SCROLL_EDGE_INSET})`;
161+
const FADE_FROM_END = `linear-gradient(to left, transparent ${RING_BLEED}, black ${SCROLL_EDGE_INSET})`;
162+
151163
const styles = stylex.create({
152164
nav: {
153165
display: 'flex',
@@ -213,18 +225,18 @@ const styles = stylex.create({
213225
},
214226
fadeStart: {
215227
maskImage: {
216-
default: `linear-gradient(to right, transparent, black ${FADE_WIDTH})`,
217-
':is([dir="rtl"] *)': `linear-gradient(to left, transparent, black ${FADE_WIDTH})`,
228+
default: FADE_FROM_START,
229+
':is([dir="rtl"] *)': FADE_FROM_END,
218230
},
219231
},
220232
fadeEnd: {
221233
maskImage: {
222-
default: `linear-gradient(to left, transparent, black ${FADE_WIDTH})`,
223-
':is([dir="rtl"] *)': `linear-gradient(to right, transparent, black ${FADE_WIDTH})`,
234+
default: FADE_FROM_END,
235+
':is([dir="rtl"] *)': FADE_FROM_START,
224236
},
225237
},
226238
fadeBoth: {
227-
maskImage: `linear-gradient(to right, transparent, black ${FADE_WIDTH}, black calc(100% - ${FADE_WIDTH}), transparent 100%)`,
239+
maskImage: `linear-gradient(to right, transparent ${RING_BLEED}, black ${SCROLL_EDGE_INSET}, black calc(100% - ${SCROLL_EDGE_INSET}), transparent calc(100% - ${RING_BLEED}))`,
228240
},
229241
arrow: {
230242
position: 'absolute',
@@ -365,10 +377,10 @@ export function TabList({
365377
return;
366378
}
367379
const stripBox = strip.getBoundingClientRect();
368-
const tabBox = stop.getBoundingClientRect();
380+
const stopBox = stop.getBoundingClientRect();
369381
const inset = parseFloat(getComputedStyle(strip).scrollPaddingLeft) || 0;
370-
const pastEnd = tabBox.right - (stripBox.right - inset);
371-
const pastStart = tabBox.left - (stripBox.left + inset);
382+
const pastEnd = stopBox.right - (stripBox.right - inset);
383+
const pastStart = stopBox.left - (stripBox.left + inset);
372384
const delta = pastEnd > 0 ? pastEnd : pastStart < 0 ? pastStart : 0;
373385
if (delta !== 0) {
374386
// Not an animation: the strip has to arrive already showing the right

0 commit comments

Comments
 (0)