Center newly opened tabs at end, keep close controls visible

- Add end padding to tab strip to preserve close button visibility against fade
- Center scroll position for newly revealed end tabs, avoiding hard scroll-to-end
- Only pin-to-end for new tabs that are also active; restored mid-strip tabs stay in place
This commit is contained in:
Jinjing
2026-08-13 10:12:44 -07:00
parent 1d2b8c7f42
commit db8b8bb6cf
4 changed files with 299 additions and 6 deletions
@@ -163,6 +163,8 @@ export function renderTabBarSurface({
.join(' ')}
>
{renderedItems}
{/* Why: a short end inset keeps a last tab's close control off the chevron and fade. */}
<div data-tab-strip-end-pad="" aria-hidden className="pointer-events-none shrink-0" />
</div>
<TabStripScrollIndicator metrics={tabStripOverflowState} />
</div>
@@ -0,0 +1,169 @@
// @vitest-environment happy-dom
import { describe, expect, it } from 'vitest'
import {
computeTabStripEndInset,
computeTabStripScrollLeft,
findLastTabStripTab,
isLastTabStripTab,
syncTabStripEndPad,
TAB_STRIP_REVEAL_INSET_PX
} from './tab-strip-active-tab-scroll'
function mockBox(
el: HTMLElement,
box: {
offsetLeft?: number
offsetWidth?: number
clientWidth?: number
scrollWidth?: number
}
): void {
Object.defineProperties(el, {
offsetLeft: { configurable: true, value: box.offsetLeft ?? 0 },
offsetWidth: { configurable: true, value: box.offsetWidth ?? 0 },
clientWidth: { configurable: true, value: box.clientWidth ?? 0 },
scrollWidth: { configurable: true, value: box.scrollWidth ?? 0 }
})
}
describe('computeTabStripEndInset', () => {
it('is zero when tabs already fit', () => {
expect(
computeTabStripEndInset({
stripClientWidth: 400,
lastTabWidth: 180,
contentWidth: 360
})
).toBe(0)
})
it('uses the fade-sized inset when the last tab overflows', () => {
expect(
computeTabStripEndInset({
stripClientWidth: 400,
lastTabWidth: 180,
contentWidth: 900
})
).toBe(TAB_STRIP_REVEAL_INSET_PX)
})
it('shrinks the inset when the last tab almost fills the strip', () => {
expect(
computeTabStripEndInset({
stripClientWidth: 200,
lastTabWidth: 190,
contentWidth: 400
})
).toBe(10)
})
it('is zero when the last tab is at least as wide as the strip', () => {
expect(
computeTabStripEndInset({
stripClientWidth: 180,
lastTabWidth: 180,
contentWidth: 400
})
).toBe(0)
})
})
describe('computeTabStripScrollLeft', () => {
it('centers a last tab when trailing pad makes that scroll reachable', () => {
expect(
computeTabStripScrollLeft({
stripScrollWidth: 1010,
stripClientWidth: 400,
stripScrollLeft: 0,
tabOffsetLeft: 720,
tabWidth: 180,
inline: 'center'
})
).toBe(610)
})
it('clamps a last-tab center to the end when there is no trailing pad', () => {
expect(
computeTabStripScrollLeft({
stripScrollWidth: 900,
stripClientWidth: 400,
stripScrollLeft: 0,
tabOffsetLeft: 720,
tabWidth: 180,
inline: 'center'
})
).toBe(500)
})
it('keeps a fully visible middle tab in place', () => {
expect(
computeTabStripScrollLeft({
stripScrollWidth: 900,
stripClientWidth: 400,
stripScrollLeft: 200,
tabOffsetLeft: 280,
tabWidth: 120,
inline: 'nearest'
})
).toBe(200)
})
it('scrolls far enough that a clipped trailing tab clears the fade inset', () => {
expect(
computeTabStripScrollLeft({
stripScrollWidth: 900,
stripClientWidth: 400,
stripScrollLeft: 0,
tabOffsetLeft: 320,
tabWidth: 180,
inline: 'nearest'
})
).toBe(320 + 180 - 400 + TAB_STRIP_REVEAL_INSET_PX)
})
})
describe('tab strip last-tab helpers', () => {
it('treats the last data-tab-id node as the last tab and ignores the end pad', () => {
const strip = document.createElement('div')
const first = document.createElement('div')
first.setAttribute('data-tab-id', 'one')
const last = document.createElement('div')
last.setAttribute('data-tab-id', 'two')
const pad = document.createElement('div')
pad.setAttribute('data-tab-strip-end-pad', '')
strip.append(first, last, pad)
expect(findLastTabStripTab(strip)).toBe(last)
expect(isLastTabStripTab(strip, last)).toBe(true)
expect(isLastTabStripTab(strip, first)).toBe(false)
})
it('sizes the end pad to the fade inset when the last tab overflows', () => {
const strip = document.createElement('div')
const last = document.createElement('div')
last.setAttribute('data-tab-id', 'end')
const pad = document.createElement('div')
pad.setAttribute('data-tab-strip-end-pad', '')
strip.append(last, pad)
mockBox(strip, { clientWidth: 400 })
mockBox(last, { offsetLeft: 720, offsetWidth: 180 })
expect(syncTabStripEndPad(strip)).toBe(TAB_STRIP_REVEAL_INSET_PX)
expect(pad.style.width).toBe(`${TAB_STRIP_REVEAL_INSET_PX}px`)
})
it('clears the end pad when every tab already fits', () => {
const strip = document.createElement('div')
const last = document.createElement('div')
last.setAttribute('data-tab-id', 'end')
const pad = document.createElement('div')
pad.setAttribute('data-tab-strip-end-pad', '')
pad.style.width = '110px'
strip.append(last, pad)
mockBox(strip, { clientWidth: 400 })
mockBox(last, { offsetLeft: 0, offsetWidth: 180 })
expect(syncTabStripEndPad(strip)).toBe(0)
expect(pad.style.width).toBe('0px')
})
})
@@ -0,0 +1,100 @@
export const TAB_STRIP_TAB_ID_SELECTOR = '[data-tab-id]'
export const TAB_STRIP_END_PAD_ATTR = 'data-tab-strip-end-pad'
// Why: matches the 1.5rem fade mask so nearest-reveal keeps the close control out of the fade.
export const TAB_STRIP_REVEAL_INSET_PX = 24
export function findLastTabStripTab(strip: ParentNode): HTMLElement | null {
const tabs = strip.querySelectorAll<HTMLElement>(TAB_STRIP_TAB_ID_SELECTOR)
return [...tabs].at(-1) ?? null
}
export function isLastTabStripTab(strip: ParentNode, tab: Element): boolean {
return findLastTabStripTab(strip) === tab
}
export function computeTabStripEndInset({
stripClientWidth,
lastTabWidth,
contentWidth
}: {
stripClientWidth: number
lastTabWidth: number
contentWidth: number
}): number {
if (stripClientWidth <= 0 || lastTabWidth <= 0 || contentWidth <= stripClientWidth) {
return 0
}
// Why: a half-viewport pad centered the last tab but hid earlier tabs and
// created a drop dead-zone; a fade-sized inset keeps the close control clear.
return Math.min(TAB_STRIP_REVEAL_INSET_PX, Math.max(0, stripClientWidth - lastTabWidth))
}
export function computeTabStripScrollLeft({
stripScrollWidth,
stripClientWidth,
stripScrollLeft,
tabOffsetLeft,
tabWidth,
inline
}: {
stripScrollWidth: number
stripClientWidth: number
stripScrollLeft: number
tabOffsetLeft: number
tabWidth: number
inline: 'nearest' | 'center'
}): number {
const maxScrollLeft = Math.max(0, stripScrollWidth - stripClientWidth)
if (inline === 'center') {
return clamp(tabOffsetLeft + tabWidth / 2 - stripClientWidth / 2, 0, maxScrollLeft)
}
const tabStart = tabOffsetLeft
const tabEnd = tabOffsetLeft + tabWidth
const viewStart = stripScrollLeft + TAB_STRIP_REVEAL_INSET_PX
const viewEnd = stripScrollLeft + stripClientWidth - TAB_STRIP_REVEAL_INSET_PX
if (tabStart >= viewStart && tabEnd <= viewEnd) {
return stripScrollLeft
}
if (tabEnd > viewEnd) {
return clamp(tabEnd - stripClientWidth + TAB_STRIP_REVEAL_INSET_PX, 0, maxScrollLeft)
}
return clamp(tabStart - TAB_STRIP_REVEAL_INSET_PX, 0, maxScrollLeft)
}
export function syncTabStripEndPad(strip: HTMLElement): number {
const lastTab = findLastTabStripTab(strip)
const pad = computeTabStripEndInset({
stripClientWidth: strip.clientWidth,
lastTabWidth: lastTab?.offsetWidth ?? 0,
contentWidth: lastTab ? lastTab.offsetLeft + lastTab.offsetWidth : 0
})
const spacer = strip.querySelector<HTMLElement>(`[${TAB_STRIP_END_PAD_ATTR}]`)
if (spacer) {
const nextWidth = `${pad}px`
if (spacer.style.width !== nextWidth) {
spacer.style.width = nextWidth
}
}
return pad
}
export function scrollTabStripTabIntoView(
strip: HTMLElement,
tab: HTMLElement,
inline: 'nearest' | 'center'
): void {
strip.scrollLeft = computeTabStripScrollLeft({
stripScrollWidth: strip.scrollWidth,
stripClientWidth: strip.clientWidth,
stripScrollLeft: strip.scrollLeft,
tabOffsetLeft: tab.offsetLeft,
tabWidth: tab.offsetWidth,
inline
})
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value))
}
@@ -6,6 +6,11 @@ import {
type TabStripScrollMetrics
} from './tab-strip-scroll-metrics'
import { isTabStripPointerGestureActive } from './tab-strip-pointer-gesture'
import {
findLastTabStripTab,
scrollTabStripTabIntoView,
syncTabStripEndPad
} from './tab-strip-active-tab-scroll'
const TAB_STRIP_SCROLL_FRACTION = 0.75
const TAB_STRIP_MIN_SCROLL_STEP_PX = 120
@@ -51,6 +56,8 @@ export function useTabStripOverflowNavigation({
const tabStripRef = useRef<HTMLDivElement>(null)
const prevStripLenRef = useRef<{ worktreeId: string; len: number } | null>(null)
const stickToEndRef = useRef(false)
const activeVisibleTabIdRef = useRef(activeVisibleTabId)
activeVisibleTabIdRef.current = activeVisibleTabId
const [tabStripOverflowState, setTabStripOverflowState] = useState<TabStripScrollMetrics>(
EMPTY_TAB_STRIP_OVERFLOW_STATE
)
@@ -109,6 +116,7 @@ export function useTabStripOverflowNavigation({
onScroll()
const handleStripResize = (): void => {
syncTabStripEndPad(el)
updateTabStripOverflowState()
// If the user is pinned to the right edge, keep it pinned even as tab
// labels (e.g. "Terminal 5" -> branch name) expand and change scrollWidth.
@@ -138,6 +146,7 @@ export function useTabStripOverflowNavigation({
}
if (!prev || prev.worktreeId !== worktreeId) {
prevStripLenRef.current = { worktreeId, len: tabCount }
syncTabStripEndPad(strip)
updateTabStripOverflowState()
return
}
@@ -148,6 +157,7 @@ export function useTabStripOverflowNavigation({
if (!el) {
return
}
syncTabStripEndPad(el)
el.scrollLeft = Math.max(0, el.scrollWidth - el.clientWidth)
updateTabStripOverflowState()
}
@@ -155,17 +165,28 @@ export function useTabStripOverflowNavigation({
requestAnimationFrame(scrollToEnd)
}
if (tabCount > prev.len && !pointerGestureActive) {
const scrollToEnd = (): void => {
const revealNewEndTab = (): void => {
const el = tabStripRef.current
if (!el) {
return
}
el.scrollLeft = Math.max(0, el.scrollWidth - el.clientWidth)
stickToEndRef.current = true
syncTabStripEndPad(el)
const lastTab = findLastTabStripTab(el)
const activeId = activeVisibleTabIdRef.current
const activeTab =
activeId == null
? null
: el.querySelector<HTMLElement>(`[data-tab-id="${CSS.escape(activeId)}"]`)
// Why: only a newly opened last tab should pin to the end; a restored
// mid-strip tab must not yank the viewport to the last tab.
if (lastTab && (activeTab == null || lastTab === activeTab)) {
scrollTabStripTabIntoView(el, lastTab, 'center')
stickToEndRef.current = true
}
updateTabStripOverflowState()
}
scrollToEnd()
requestAnimationFrame(scrollToEnd)
revealNewEndTab()
requestAnimationFrame(revealNewEndTab)
}
prevStripLenRef.current = { worktreeId, len: tabCount }
updateTabStripOverflowState()
@@ -189,7 +210,8 @@ export function useTabStripOverflowNavigation({
requestAnimationFrame(updateTabStripOverflowState)
return
}
activeTab.scrollIntoView({ block: 'nearest', inline: 'nearest' })
syncTabStripEndPad(strip)
scrollTabStripTabIntoView(strip, activeTab, 'nearest')
requestAnimationFrame(updateTabStripOverflowState)
}, [activeVisibleTabId, updateTabStripOverflowState])