diff --git a/packages/emcn/src/components/tab-strip/tab-strip.test.ts b/packages/emcn/src/components/tab-strip/tab-strip.test.ts index 9d817b39163..370185fcc7d 100644 --- a/packages/emcn/src/components/tab-strip/tab-strip.test.ts +++ b/packages/emcn/src/components/tab-strip/tab-strip.test.ts @@ -1,3 +1,4 @@ +import { readFileSync } from 'node:fs' import { describe, expect, it } from 'vitest' import { isTabTitleTruncated, type TabStripItem, tabDropIndex } from './tab-strip' @@ -78,3 +79,30 @@ describe('tab tooltips', () => { expect(tooltipFor(tab, false)).toBe('GitHub') }) }) + +describe('tab strip vertical overflow', () => { + const source = readFileSync(new URL('./tab-strip.tsx', import.meta.url), 'utf8') + /** Declarations only — the comments here discuss the very class they removed. */ + const markup = source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '') + + /** + * A source-level guard, which is weaker than asserting rendered geometry and is a + * deliberate compromise: this package's vitest runs in `node`, has no browser mode, and + * the repo's only Playwright harness drives Electron against static HTML fixtures, so + * nothing here can lay out a React tree against the compiled CSS. The behaviour was + * instead measured directly in a renderer — the row went from scrollHeight 30 against + * clientHeight 29 to 30 against 30 — and this guard pins the invariant that produced it. + */ + it('keeps every negative bottom margin outside the horizontally scrolling row', () => { + // The active tab has to hang one pixel over the strip's bottom border. Anything inside + // the row that does so overflows it, and `overflow-x: auto` computes the visible + // `overflow-y` to `auto` as well — so the strip became scrollable by that one pixel. + // Asserting the count, not just the row's own class, catches the regression wherever a + // descendant reintroduces it rather than only on the tab it came from originally. + const negativeBottomMargins = markup.match(/-mb-px/g) ?? [] + expect(negativeBottomMargins).toHaveLength(1) + + const scrollRow = markup.match(/className='([^']*overflow-x-auto[^']*)'/)?.[1] ?? '' + expect(scrollRow).toContain('-mb-px') + }) +}) diff --git a/packages/emcn/src/components/tab-strip/tab-strip.tsx b/packages/emcn/src/components/tab-strip/tab-strip.tsx index d2ff02ea8f1..d1fb18c7d6e 100644 --- a/packages/emcn/src/components/tab-strip/tab-strip.tsx +++ b/packages/emcn/src/components/tab-strip/tab-strip.tsx @@ -180,7 +180,7 @@ function Tab({ aria-current={tab.active ? 'page' : undefined} aria-label={tab.pinned ? tab.title : undefined} className={cn( - '-mb-px h-[30px] w-full select-none rounded-b-none border border-transparent border-b-0 bg-transparent py-0 font-normal text-caption', + 'h-[30px] w-full select-none rounded-b-none border border-transparent border-b-0 bg-transparent py-0 font-normal text-caption', tab.pinned ? 'justify-center px-0' : 'justify-start gap-1.5 px-2', closeable && !tab.pinned && 'pr-7', tab.active && @@ -317,8 +317,16 @@ export function TabStrip({ and scrolls horizontally instead of growing, which pins the button back at the right edge rather than pushing it out of view. */} + {/* + `-mb-px` sits on this row rather than on the tabs inside it. The active tab has to + extend one pixel past the strip to cover its bottom border, and while that pixel + came from the tab it overflowed THIS element — which is a scroll container, since + `overflow-x: auto` computes the visible `overflow-y` to `auto` as well. The result + was a tab strip you could scroll vertically by exactly one pixel. Pulling the whole + row down instead keeps the tabs flush inside it, so there is nothing to scroll. + */}
{ if (draggedIdRef.current) event.preventDefault() }}