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
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,21 @@ export const RESOURCE_TAB_ICON_CLASS = 'size-[16px] text-[var(--text-icon)]'
/** Shared geometry for the resource header and controls positioned over it. */
export const RESOURCE_HEADER_CLASSES = {
layout:
'[--resource-header-controls-height:34px] [--resource-header-end-inset:16px] [--resource-header-fixed-reserve:54px] [--resource-header-toggle-size:30px]',
'[--resource-header-controls-height:43px] [--resource-header-end-inset:16px] [--resource-header-fixed-reserve:54px] [--resource-header-toggle-size:30px]',
/**
* Drives the tab strip from this header's own tokens rather than restating the
* strip's defaults, so the height the overlaid controls below are positioned
* against and the height the strip renders at cannot drift apart. Set on the
* strip itself, not an ancestor — the browser and terminal strips nested in
* this panel keep their own geometry.
*
* The `+ 1px` is the strip's own bottom border. The controls height is the
* CONTENT box both clusters centre in, so the strip's box has to be a pixel
* taller than it or the tabs would centre in 43px while the overlaid toggle
* centres in 44px, and the two rows would sit half a pixel apart.
*/
stripGeometry:
'[--tab-strip-height:var(--resource-header-controls-height)] [--tab-strip-inline-start:var(--resource-header-end-inset)] [--tab-strip-inline-end:var(--resource-header-fixed-reserve)]',
'[--tab-strip-height:calc(var(--resource-header-controls-height)_+_1px)] [--tab-strip-max-tab-width:160px] [--tab-strip-inline-start:var(--resource-header-end-inset)] [--tab-strip-inline-end:var(--resource-header-fixed-reserve)]',
/**
* Centred, matching the `floating` strip: its tabs and controls sit centred in
* the header band rather than hanging from the top, so an overlaid control has
Expand Down
25 changes: 24 additions & 1 deletion packages/emcn/src/components/tab-strip/tab-strip.dom.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,29 @@ describe('TabStrip interactions', () => {

act(() => root?.render(renderStrip(tabs.map((tab) => ({ ...tab, active: tab.id === 'two' })))))

expect(row.scrollTo).toHaveBeenCalledWith({ left: 180, behavior: 'smooth' })
// 280 - 100 would park the tab's right edge flush with the container's,
// which is exactly where the fade gradient sits — the tab would arrive
// half-faded. The extra 16px carries it clear of the gradient.
expect(row.scrollTo).toHaveBeenCalledWith({ left: 196, behavior: 'smooth' })
})

it('lets the last tab rest flush, since no gradient is drawn at a scroll extreme', () => {
mount(renderStrip(tabs))
const row = scrollRow()
Object.defineProperties(row, {
clientWidth: { configurable: true, value: 100 },
scrollWidth: { configurable: true, value: 300 },
scrollLeft: { configurable: true, value: 0, writable: true },
})
row.getBoundingClientRect = () => ({ left: 0, right: 100, width: 100 }) as DOMRect
const last = tabButton('two').parentElement as HTMLDivElement
// Flush against the end of the scrollable area.
last.getBoundingClientRect = () => ({ left: 200, right: 300, width: 100 }) as DOMRect
row.scrollTo = vi.fn()

act(() => root?.render(renderStrip(tabs.map((tab) => ({ ...tab, active: tab.id === 'two' })))))

// Wants 216; clamped to the 200 maximum rather than over-scrolling.
expect(row.scrollTo).toHaveBeenCalledWith({ left: 200, behavior: 'smooth' })
})
})
33 changes: 26 additions & 7 deletions packages/emcn/src/components/tab-strip/tab-strip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,13 @@ import { Tooltip } from '../tooltip/tooltip'
const DRAG_EDGE_ZONE = 40
const DRAG_SCROLL_SPEED = 8
const TITLE_TOOLTIP_HIDDEN_PX = 8
/**
* Width of the scroll-edge fades, and so the margin a tab has to clear to be
* genuinely visible. Keep in step with the `w-4` on the gradients below: a tab
* revealed flush against the container edge lands under its gradient and reads
* as half-faded, which is indistinguishable from "there is more to scroll".
*/
const EDGE_FADE_PX = 16
const TAB_TRANSITION = { duration: 0.1, ease: [0.2, 0, 0, 1] as const }

/**
Expand All @@ -42,7 +49,7 @@ const TAB_TRANSITION = { duration: 0.1, ease: [0.2, 0, 0, 1] as const }
*/
const TAB_WIDTH: Record<TabStripVariant, string> = {
attached: 'w-[156px] min-w-[96px] shrink',
floating: 'max-w-[200px] shrink-0',
floating: 'max-w-[var(--tab-strip-max-tab-width,200px)] shrink-0',
}

/** The resting shape of a tab that is not the active one. */
Expand Down Expand Up @@ -223,6 +230,8 @@ interface TabStripBaseProps {
* - `--tab-strip-band` (default `30px`) — the height of the tabs and the
* controls beside them, which is the band an overlaid control must match.
* - `--tab-strip-inline-start` / `--tab-strip-inline-end` (default `8px`).
* - `--tab-strip-max-tab-width` (default `200px`) — the width a `floating`
* tab's label ellipsizes at. `attached` is fixed-width and ignores it.
*/
className?: string
}
Expand Down Expand Up @@ -537,13 +546,22 @@ export function TabStrip({
const nodeRect = node.getBoundingClientRect()
const tabLeft = tabRect.left - nodeRect.left + node.scrollLeft
const tabRight = tabLeft + tabRect.width
const nextLeft =
tabLeft < node.scrollLeft
? tabLeft
: tabRight > node.scrollLeft + node.clientWidth
? tabRight - node.clientWidth
// Inset by the fade on both sides so the tab comes to rest clear of the
// gradient rather than beneath it.
const viewLeft = node.scrollLeft + EDGE_FADE_PX
const viewRight = node.scrollLeft + node.clientWidth - EDGE_FADE_PX
const maxScrollLeft = Math.max(0, node.scrollWidth - node.clientWidth)
const target =
tabLeft < viewLeft
? tabLeft - EDGE_FADE_PX
: tabRight > viewRight
? tabRight - node.clientWidth + EDGE_FADE_PX

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reveal scrolls wrong for wide tabs

Medium Severity

revealActiveTab always shrinks the visible band by EDGE_FADE_PX on both sides and prefers the left branch. When a tab’s left edge sits in the left fade zone but its right edge is past the real viewport (common for attached/floating tabs once the scrollport is only slightly wider than the tab), it scrolls left to clear the fade and leaves the tab more clipped on the right. The old physical-overflow check would have scrolled right instead.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b1672f6. Configure here.

: null
if (nextLeft === null) return
if (target === null) return
// The clamp is what lets the first and last tabs sit flush: there is no
// gradient at a scroll extreme, so no margin is needed to clear one.
const nextLeft = Math.max(0, Math.min(maxScrollLeft, target))
if (Math.abs(nextLeft - node.scrollLeft) < 1) return
const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false
node.scrollTo({ left: nextLeft, behavior: reduceMotion ? 'auto' : 'smooth' })
}, [activeRegularId, regularTabOrder])
Expand Down Expand Up @@ -846,6 +864,7 @@ export function TabStrip({
</AnimatePresence>
</div>
{canScrollLeft && (
/* w-4 — see EDGE_FADE_PX */
<div className='pointer-events-none absolute inset-y-0 left-0 z-20 w-4 bg-gradient-to-r from-[var(--bg)] to-transparent' />
)}
{canScrollRight && (
Expand Down
Loading