fix(app): fill vertical tab hover gaps (#54866)

This commit is contained in:
Kit Langton
2026-10-13 13:15:45 -07:00
committed by GitHub
Unverified
parent 573fd296d0
commit 1163e6cd91
4 changed files with 193 additions and 12 deletions
+135
View File
@@ -202,6 +202,141 @@ test("vertical tabs resize, scroll, show shortcut hints, and navigate", async ({
await expectPath(page, sessionHref(b.id))
})
test("vertical tab gaps hover and select the nearest row", async ({ page }) => {
await mockWorkspace(page, {
name: "Tabs",
sessions: [a, b, c],
seed: { settings: { appearance: { tabLayout: "vertical" } } },
})
await page.goto(sessionHref(a.id))
const sidebar = page.locator('[data-slot="vertical-tabs-sidebar"]')
const rows = sidebar.locator("[data-titlebar-tab]")
const tabB = rows.filter({ has: page.locator(`a[href="${sessionHref(b.id)}"]`) })
const tabC = rows.filter({ has: page.locator(`a[href="${sessionHref(c.id)}"]`) })
await expect(rows.locator("[data-titlebar-tab-title]")).toHaveText([a.title, b.title, c.title])
const upper = await tabB.boundingBox()
const lower = await tabC.boundingBox()
if (!upper || !lower) throw new Error("vertical tabs have no bounding boxes")
expect(lower.y - upper.y - upper.height).toBe(4)
const middle = upper.y + upper.height + 2
// The two halves belong to their nearest row; an exact tie belongs to the next row.
for (const [offset, id] of [
[-1, b.id],
[0, c.id],
] as const) {
await page.mouse.move(upper.x + upper.width / 2, middle + offset)
await expect(sidebar.locator("[data-titlebar-tab]:hover [data-titlebar-tab-link]")).toHaveAttribute(
"href",
sessionHref(id),
)
}
await page.mouse.click(upper.x + upper.width / 2, middle + 1)
await expectPath(page, sessionHref(c.id))
await page.mouse.move(upper.x + upper.width / 2, middle - 1)
await page.mouse.down()
await page.mouse.move(upper.x + upper.width / 2, lower.y + lower.height - 2, { steps: 8 })
await page.mouse.up()
await expect(rows.locator("[data-titlebar-tab-title]")).toHaveText([a.title, c.title, b.title])
await tabB.getByRole("button", { name: "Close tab", exact: true }).click()
await expect(rows.locator("[data-titlebar-tab-title]")).toHaveText([a.title, c.title])
await expectPath(page, sessionHref(c.id))
const list = await sidebar.locator("[data-titlebar-tab-list]").boundingBox()
if (!list) throw new Error("vertical tab list has no bounding box")
for (const [x, y] of [
[list.x + list.width / 2, list.y - 1],
[list.x + list.width / 2, list.y + list.height + 1],
[list.x - 1, list.y + list.height / 2],
[list.x + list.width + 1, list.y + list.height / 2],
]) {
await page.mouse.move(x, y)
await expect(sidebar.locator("[data-titlebar-tab]:hover")).toHaveCount(0)
}
})
test("tab preview opens cold, switches immediately between tabs, and closes outside the strip", async ({ page }) => {
const d = { id: "ses_tab_d", title: "Library" }
await mockWorkspace(page, {
name: "Tabs",
sessions: [a, b, c, d],
seed: { settings: { appearance: { tabLayout: "vertical" } } },
})
await page.goto(sessionHref(a.id))
const sidebar = page.locator('[data-slot="vertical-tabs-sidebar"]')
const rows = sidebar.locator("[data-titlebar-tab]")
const tabB = rows.filter({ has: page.locator(`a[href="${sessionHref(b.id)}"]`) })
const tabC = rows.filter({ has: page.locator(`a[href="${sessionHref(c.id)}"]`) })
const preview = page.locator('[data-component="session-tab-popover"]')
await expect(rows.locator("[data-titlebar-tab-title]")).toHaveText([a.title, b.title, c.title, d.title])
await page.clock.install()
await page.clock.pauseAt(new Date())
await tabB.hover()
await page.clock.runFor(700)
await expect(preview).toHaveCount(0)
await page.clock.runFor(50)
await expect(preview).toContainText(b.title)
await page.clock.runFor(10_000)
await expect(preview).toContainText(b.title)
const box = await tabB.boundingBox()
if (!box) throw new Error("vertical tab has no bounding box")
await page.mouse.move(box.x + box.width / 2, box.y + box.height + 1)
await page.clock.runFor(1000)
await expect(preview).toContainText(b.title)
await tabB.getByRole("button", { name: "Close tab", exact: true }).hover()
await page.clock.runFor(1000)
await expect(preview).toContainText(b.title)
await tabC.hover({ position: { x: box.width - 1, y: box.height / 2 } })
await page.clock.runFor(20)
await expect(preview).toHaveCount(1)
await expect(preview).toContainText(c.title)
await expect(preview).toHaveCSS("pointer-events", "none")
await rows.filter({ has: page.locator(`a[href="${sessionHref(d.id)}"]`) }).hover()
await page.clock.runFor(20)
await expect(preview).toHaveCount(1)
await expect(preview).toContainText(d.title)
const card = await preview.boundingBox()
if (!card) throw new Error("tab preview has no bounding box")
// The card is informational: entering its painted area leaves the tab strip.
await page.mouse.move(card.x + card.width / 2, card.y + card.height / 2)
await page.clock.runFor(20)
await expect(preview).toHaveCount(0)
await tabB.hover()
await page.clock.runFor(800)
await expect(preview).toContainText(b.title)
await page.mouse.down()
await page.mouse.move(box.x + box.width / 2, box.y + box.height + 20, { steps: 8 })
await page.clock.runFor(1000)
await expect(preview).toHaveCount(0)
await page.mouse.up()
// Removing an open tab must not leave the shared preview state permanently warm.
await tabB.hover()
await page.clock.runFor(800)
await expect(preview).toContainText(b.title)
await tabB.getByRole("button", { name: "Close tab", exact: true }).click()
await page.mouse.move(900, 500)
await page.clock.runFor(600)
await tabC.hover()
await page.clock.runFor(700)
await expect(preview).toHaveCount(0)
await page.clock.runFor(50)
await expect(preview).toContainText(c.title)
})
test("closing the active server's last tab opens the remaining server tab", async ({ page }) => {
const sessionA = session({ id: "ses_server_a", directory: "C:/server-a", title: "Server A session" })
const sessionB = session({ id: "ses_server_b", directory: "/home/server-b", title: "Server B session" })
+29 -4
View File
@@ -97,6 +97,31 @@
gap: 4px;
}
/* Each link owns half the gap, without expanding its painted background or close button. */
[data-slot="vertical-tabs-sidebar"] [data-titlebar-tab] {
--tab-hit-before: 0px;
--tab-hit-after: 0px;
--tab-link-inset: 6px;
overflow: visible;
padding-inline: 0;
}
[data-slot="vertical-tabs-sidebar"] [data-titlebar-tab-link] {
align-self: flex-start;
height: calc(100% + var(--tab-hit-before) + var(--tab-hit-after));
margin-block-start: calc(-1 * var(--tab-hit-before));
padding-block: var(--tab-hit-before) var(--tab-hit-after);
padding-inline: var(--tab-link-inset);
}
[data-slot="vertical-tabs-sidebar"] [data-titlebar-tab-slot] + [data-titlebar-tab-slot] [data-titlebar-tab] {
--tab-hit-before: 2px;
}
[data-slot="vertical-tabs-sidebar"] [data-titlebar-tab-slot]:has(+ [data-titlebar-tab-slot]) [data-titlebar-tab] {
--tab-hit-after: 2px;
}
[data-titlebar-tab-slot] {
--tab-separator: var(--v2-background-bg-layer-03);
position: relative;
@@ -133,7 +158,7 @@
[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]) [data-slot="tab-link"],
[data-titlebar-tab]:is(:hover, [data-active="true"]):not([data-editing="true"]) [data-slot="tab-link"] {
--tab-title-fade-offset: 4px;
--tab-title-fade-offset: calc(4px + var(--tab-link-inset, 0px));
-webkit-mask-image: linear-gradient(
to right,
black 0,
@@ -169,12 +194,12 @@
}
[data-titlebar-tab]:is(:hover, [data-active="true"]):not([data-editing="true"]) [data-slot="tab-link"] {
--tab-title-fade-offset: 24px;
--tab-title-fade-offset: calc(24px + var(--tab-link-inset, 0px));
}
[data-titlebar-tab][data-title-overflow="true"]:not(:hover):not([data-active="true"]):not([data-editing="true"])
[data-slot="tab-link"] {
padding-inline-end: 0;
padding-inline-end: var(--tab-link-inset, 0px);
}
[data-titlebar-tab] [data-slot="tab-title"] {
@@ -202,7 +227,7 @@
[data-titlebar-tab]:not([data-editing="true"]) [data-slot="tab-link"],
[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]) [data-slot="tab-link"] {
--tab-title-fade-offset: 24px;
--tab-title-fade-offset: calc(24px + var(--tab-link-inset, 0px));
--tab-title-fade-direction: to right;
-webkit-mask-image: linear-gradient(
var(--tab-title-fade-direction),
@@ -21,7 +21,8 @@
color: var(--v2-text-text-base);
font-variant-numeric: tabular-nums;
user-select: none;
pointer-events: none;
/* Kobalte's dismissable layer writes an inline pointer-events value. */
pointer-events: none !important;
transform-origin: var(--kb-hovercard-content-transform-origin);
/* Entrance: instant (trying no animation — may revert to 120ms ease-out). */
@@ -1,5 +1,5 @@
import { HoverCard } from "@kobalte/core/hover-card"
import { createSignal, Show, type JSXElement } from "solid-js"
import { createEffect, createSignal, onCleanup, Show, type JSXElement } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import { createMediaQuery } from "@solid-primitives/media"
import "./tab-popover.css"
@@ -7,7 +7,6 @@ import "./tab-popover.css"
// Initial hover delay before the preview appears, per design.
const OPEN_DELAY = 750
// Mouse-out delay: begin closing immediately (a brief exit animation plays).
const CLOSE_DELAY = 0
// After a preview closes, hovering a neighbouring tab within this window skips
@@ -17,6 +16,8 @@ const SKIP_WINDOW = 500
let lastClosedAt = 0
let activePreview: (() => void) | undefined
export interface TabPreviewData {
projectName?: string
title?: string
@@ -39,14 +40,34 @@ export function TabPreviewPopover(props: {
// "cold" preview animates. Mirrors how browsers reuse one tab tooltip.
const [instant, setInstant] = createSignal(false)
const warm = () => Date.now() - lastClosedAt < SKIP_WINDOW
const warm = () => !!activePreview || Date.now() - lastClosedAt < SKIP_WINDOW
const open = () => props.open && !mobile()
const close = () => props.onOpenChange(false)
// Observe accepted, controlled state, including drag/menu suppression in TabNav.
createEffect(() => {
if (open()) {
if (activePreview !== close) activePreview?.()
activePreview = close
return
}
if (activePreview !== close) return
activePreview = undefined
lastClosedAt = Date.now()
})
onCleanup(() => {
if (activePreview !== close) return
activePreview = undefined
lastClosedAt = Date.now()
})
// Kobalte reads openDelay lazily when the pointer enters the trigger, so this
// resolves the skip window per-hover.
const resolveOpenDelay = () => (warm() ? 0 : OPEN_DELAY)
const handleOpenChange = (open: boolean) => {
if (open) setInstant(warm())
else lastClosedAt = Date.now()
props.onOpenChange(open)
}
@@ -57,12 +78,11 @@ export function TabPreviewPopover(props: {
return (
<HoverCard
open={props.open && !mobile()}
open={open()}
onOpenChange={handleOpenChange}
openDelay={resolveOpenDelay()}
closeDelay={CLOSE_DELAY}
// The preview is non-interactive (pointer-events: none), so there is no
// safe area to traverse — leaving the tab hides it immediately.
// Informational previews belong to their tab, not to a hoverable safe area.
ignoreSafeArea
placement={placement()}
gutter={6}