Keep inactive tabs clickable in narrow docked strips (#34881)

* Keep inactive tabs clickable when active dock crowds the strip

* Fix narrow tab dock state after drag cancellation

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
This commit is contained in:
OrcaWin
2026-10-28 13:51:55 -07:00
committed by GitHub
co-authored by m4air
parent 6e2b245ac6
commit dd0c4cf35e
5 changed files with 125 additions and 3 deletions
+5
View File
@@ -838,6 +838,11 @@ body[data-modal-overlay-open]
mask-image: linear-gradient(to right, #000 0, #000 calc(100% - 1.5rem), transparent);
}
.terminal-tab-strip[data-active-tab-dock-disabled] > [data-active-tab-dock] {
position: relative;
z-index: auto;
}
/* Why: tabs only draw a right border, so a tab docked at the end has no seam against the tab it covers. */
.terminal-tab-strip[data-active-tab-docked='end'] > [data-active-tab-dock]::before {
content: '';
@@ -4,9 +4,16 @@ import { vi } from 'vitest'
// Headless React: hooks are stubbed so TabBar can be invoked as a plain function.
export async function stubHeadlessReact(): Promise<Record<string, unknown>> {
const actual = await vi.importActual<typeof ReactModule>('react')
const contextDefaults = new WeakMap<object, unknown>()
return {
...actual,
memo: <T>(component: T) => component,
createContext: <T>(defaultValue: T) => {
const context = actual.createContext(defaultValue)
contextDefaults.set(context, defaultValue)
return context
},
useContext: (context: ReactModule.Context<unknown>) => contextDefaults.get(context),
useEffect: () => {},
useLayoutEffect: () => {},
useCallback: <T extends (...args: never[]) => unknown>(callback: T) => callback,
@@ -18,9 +18,11 @@ import {
getActiveTabDockSide,
readTabStripSlotIds,
revealTabStripSlot,
updateTabStripDockAvailability,
type ActiveTabDockSide
} from './tab-strip-slot-geometry'
import { motionSafeScrollBehavior } from '@/hooks/usePrefersReducedMotion'
import { useTabDragActive } from '../tab-group/tab-drag-context'
const TAB_STRIP_SCROLL_FRACTION = 0.75
const TAB_STRIP_MIN_SCROLL_STEP_PX = 120
@@ -69,6 +71,7 @@ export function useTabStripOverflowNavigation({
scrollTabStrip: (direction: 'start' | 'end', behavior?: ScrollBehavior) => void
subscribeToStripResize: (listener: () => void) => () => void
} {
const isTabDragActive = useTabDragActive()
const tabStripRef = useRef<HTMLDivElement>(null)
const stripResizeListenersRef = useRef<Set<() => void>>(new Set())
const prevStripRef = useRef<{ worktreeId: string; tabIds: ReadonlySet<string> } | null>(null)
@@ -94,6 +97,7 @@ export function useTabStripOverflowNavigation({
setTabStripOverflowState((previous) =>
sameTabStripOverflowState(previous, next) ? previous : next
)
updateTabStripDockAvailability(el)
setActiveTabDockSide(getActiveTabDockSide(el))
}, [])
const scrollTabStrip = useCallback(
@@ -291,10 +295,10 @@ export function useTabStripOverflowNavigation({
recordScrollAnchor()
}, [activeVisibleTabId, recordScrollAnchor, updateTabStripOverflowState])
// Why: moving the dock between slots shifts which edge it is drawn at with no scroll or resize.
// Dock ownership and drag transitions change sticky layout without scrolling or resizing.
useLayoutEffect(() => {
updateTabStripOverflowState()
}, [activeDockSlotId, updateTabStripOverflowState])
}, [activeDockSlotId, isTabDragActive, updateTabStripOverflowState])
// Why every render: the close flag belongs to the commit that set it, not a later tab switch.
useLayoutEffect(() => {
@@ -2,6 +2,7 @@
const SLOT_SELECTOR = '[data-tab-strip-slot]'
/** Marks the slot of the tab that renders active; it is sticky, so it docks to the edge it would scroll past. */
const DOCK_SELECTOR = '[data-active-tab-dock]'
const DOCK_DISABLED_ATTRIBUTE = 'data-active-tab-dock-disabled'
export type ActiveTabDockSide = 'start' | 'end'
@@ -14,7 +15,18 @@ function getSlotId(slot: HTMLElement): string {
}
function getActiveTabDock(strip: HTMLElement): HTMLElement | null {
return strip.querySelector<HTMLElement>(`:scope > ${DOCK_SELECTOR}`)
return strip.hasAttribute(DOCK_DISABLED_ATTRIBUTE)
? null
: strip.querySelector<HTMLElement>(`:scope > ${DOCK_SELECTOR}`)
}
export function updateTabStripDockAvailability(strip: HTMLElement): void {
const dock = strip.querySelector<HTMLElement>(`:scope > ${DOCK_SELECTOR}`)
// Leave room for another tab beside the dock, or inactive tabs become unreachable.
strip.toggleAttribute(
DOCK_DISABLED_ATTRIBUTE,
dock !== null && strip.clientWidth < dock.getBoundingClientRect().width * 2
)
}
export function findTabStripSlot(strip: HTMLElement, tabId: string): HTMLElement | undefined {
+94
View File
@@ -74,6 +74,100 @@ test.describe('Tab strip active-tab dock', () => {
await ensureTerminalVisible(orcaPage)
})
for (const edge of ['start', 'end'] as const) {
test(`keeps inactive tabs clickable in a strip too narrow for the ${edge} dock`, async ({
orcaPage
}) => {
const worktreeId = await waitForActiveWorktree(orcaPage)
for (let i = 0; i < 3; i++) {
await createBackgroundTerminalTab(orcaPage, worktreeId)
}
const ids = await readStripSlotIds(orcaPage)
const activeTabId = (edge === 'start' ? ids.at(0) : ids.at(-1))!
const inactiveTabId = (edge === 'start' ? ids.at(-1) : ids.at(0))!
const activeTab = orcaPage.locator(
`${slotSelector(activeTabId)} [data-testid="sortable-tab"]`
)
const inactiveTab = orcaPage.locator(
`${slotSelector(inactiveTabId)} [data-testid="sortable-tab"]`
)
await activeTab.click()
const strip = orcaPage.locator(STRIP).first()
await strip.evaluate((el) => {
el.style.maxWidth = '100px'
})
await expect(strip).toHaveAttribute('data-active-tab-dock-disabled', '')
await strip.evaluate((el, side) => {
el.scrollLeft = side === 'start' ? el.scrollWidth : 0
}, edge)
await expect.poll(() => isSlotInView(orcaPage, inactiveTabId)).toBe(true)
await inactiveTab.click()
await expect(inactiveTab).toHaveAttribute('data-active', 'true')
await activeTab.locator('[data-tab-close-button]').click()
await expect(activeTab).toHaveCount(0)
await strip.evaluate((el) => {
el.style.maxWidth = '200px'
})
await expect(strip).not.toHaveAttribute('data-active-tab-dock-disabled')
await strip.evaluate((el, side) => {
el.scrollLeft = side === 'start' ? 0 : el.scrollWidth
}, edge)
await expect(strip).toHaveAttribute(
'data-active-tab-docked',
edge === 'start' ? 'end' : 'start'
)
expect(await isSlotInView(orcaPage, inactiveTabId)).toBe(true)
})
}
test('keeps inactive tabs clickable after cancelling a drag that scrolled a narrow strip', async ({
orcaPage
}) => {
const worktreeId = await waitForActiveWorktree(orcaPage)
for (let i = 0; i < 3; i++) {
await createBackgroundTerminalTab(orcaPage, worktreeId)
}
const ids = await readStripSlotIds(orcaPage)
const activeSlot = orcaPage.locator(slotSelector(ids[0]))
const activeTab = activeSlot.locator('[data-testid="sortable-tab"]')
const inactiveTab = orcaPage.locator(`${slotSelector(ids[1])} [data-testid="sortable-tab"]`)
await activeTab.click()
const strip = orcaPage.locator(STRIP).first()
await strip.evaluate((el) => {
el.style.maxWidth = '100px'
})
await expect(strip).toHaveAttribute('data-active-tab-dock-disabled', '')
await strip.evaluate((el) => {
el.scrollLeft = 0
})
await expect.poll(() => isSlotInView(orcaPage, ids[0])).toBe(true)
const box = (await activeTab.boundingBox())!
const y = box.y + box.height / 2
await orcaPage.mouse.move(box.x + 20, y)
await orcaPage.mouse.down()
await orcaPage.mouse.move(box.x + 34, y)
await orcaPage.mouse.move(box.x + 38, y)
await expect(strip.locator('[data-active-tab-dock]')).toHaveCount(0)
// Hidden-window drag overlays can intercept native wheel input before it reaches the strip.
await strip.evaluate((el) => {
el.dispatchEvent(new WheelEvent('wheel', { deltaY: 72, cancelable: true }))
})
await expect.poll(() => strip.evaluate((el) => el.scrollLeft)).toBe(72)
await nextFrames(orcaPage, 2)
await orcaPage.evaluate(() => window.dispatchEvent(new Event('blur')))
await orcaPage.mouse.up()
await expect(activeSlot).toHaveAttribute('data-active-tab-dock', '')
await expect(strip).toHaveAttribute('data-active-tab-dock-disabled', '')
await inactiveTab.click()
await expect(inactiveTab).toHaveAttribute('data-active', 'true')
})
test('docks an offscreen active tab and reveals an arriving tab beside it without flicker', async ({
orcaPage
}) => {