mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-14 03:56:55 +03:00
fix(app): fill vertical tab hover gaps (#54866)
This commit is contained in:
committed by
GitHub
Unverified
parent
573fd296d0
commit
1163e6cd91
@@ -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" })
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user