mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 00:03:47 +00:00
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:
@@ -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])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user