fix(desktop): stop empty mispositioned tooltip on terminal rail hover

A block-level label child (e.g. `flex`) collapses TooltipContent's inline
`box-decoration-clone` wrapper, so Radix measures a zero-size chip and parks
an empty black rectangle in the panel corner instead of by the trigger
(#62022). The terminal rail's hotkey labels and the preview row's two-line
label both hit this.

Harden the shared wrapper (`[&>*]:!inline-flex`) so any call site's direct
child renders inline-flex, add a reusable `TipHintLabel` for the common
text+hotkey label, and keep the preview row's label explicitly inline-flex.

Salvages #62139 (shared-component hardening + TipHintLabel) and #62073
(inline-flex call-site fixes + rail/preview coverage).

Co-authored-by: alelpoan <alelpoan@proton.me>
Co-authored-by: zapabob <1920071390@campus.ouj.ac.jp>
This commit is contained in:
Brooklyn Nicholson 2026-07-12 02:53:03 -04:00
parent 964ecef401
commit be2b73e253
6 changed files with 139 additions and 37 deletions

View file

@ -0,0 +1,30 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { PreviewStatusRow } from './preview-row'
describe('PreviewStatusRow', () => {
afterEach(() => {
cleanup()
})
it('keeps the preview tooltip label inline inside the portaled decoration', async () => {
const view = render(
<PreviewStatusRow
item={{ cwd: 'C:\\repo', id: 'preview.html', label: 'preview.html', target: 'preview.html' }}
onDismiss={() => undefined}
/>
)
fireEvent.pointerMove(screen.getByText('preview.html'), { pointerType: 'mouse' })
await screen.findByRole('tooltip')
const content = document.querySelector<HTMLElement>('[data-slot="tooltip-content"]')
const label = content?.firstElementChild?.firstElementChild
expect(content).not.toBeNull()
expect(view.container.contains(content)).toBe(false)
expect(label?.classList.contains('inline-flex')).toBe(true)
expect(label?.classList.contains('flex')).toBe(false)
})
})

View file

@ -113,7 +113,9 @@ export const PreviewStatusRow = memo(function PreviewStatusRow({ item, onDismiss
>
<Tip
label={
<span className="flex flex-col gap-0.5">
// inline-flex (not flex): a block child collapses Tip's decoration
// wrapper geometry and mis-positions the tooltip (#62022).
<span className="inline-flex flex-col gap-0.5">
<span>{item.target}</span>
<span className="opacity-70">{t.preview.linkHint}</span>
</span>

View file

@ -0,0 +1,36 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { $bindings } from '@/store/keybinds'
import { TerminalRail } from './rail'
import { $activeTerminalId, $terminals } from './terminals'
describe('TerminalRail', () => {
beforeEach(() => {
$terminals.set([{ auto: true, cwd: 'C:\\repo', id: 'term-1', kind: 'user', title: 'PowerShell' }])
$activeTerminalId.set('term-1')
$bindings.set({ ...$bindings.get(), 'view.showTerminal': ['ctrl+`'] })
})
afterEach(() => {
cleanup()
$terminals.set([])
$activeTerminalId.set(null)
})
it('keeps a hotkey label inline inside the portaled tooltip decoration', async () => {
const view = render(<TerminalRail />)
fireEvent.pointerMove(screen.getByRole('tab', { name: '1. PowerShell' }), { pointerType: 'mouse' })
await screen.findByRole('tooltip')
const content = document.querySelector<HTMLElement>('[data-slot="tooltip-content"]')
const label = content?.firstElementChild?.firstElementChild
expect(content).not.toBeNull()
expect(view.container.contains(content)).toBe(false)
expect(label?.classList.contains('inline-flex')).toBe(true)
expect(label?.classList.contains('flex')).toBe(false)
})
})

View file

@ -8,7 +8,7 @@ import {
ContextMenuSeparator,
ContextMenuTrigger
} from '@/components/ui/context-menu'
import { Tip } from '@/components/ui/tooltip'
import { Tip, TipHintLabel } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { formatCombo } from '@/lib/keybinds/combo'
import { cn } from '@/lib/utils'
@ -30,18 +30,6 @@ import {
const RAIL_ACTION =
'grid size-6 place-items-center rounded text-(--ui-text-tertiary) transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring [-webkit-app-region:no-drag]'
/** Tooltip label with a trailing hotkey hint (the user's live binding). */
function hintLabel(text: string, combo?: string) {
return combo ? (
<span className="flex items-center gap-2">
<span>{text}</span>
<span className="opacity-55">{formatCombo(combo)}</span>
</span>
) : (
text
)
}
/** Thin icon "bookmark" strip blended into the terminal surface, shown whenever a
* terminal exists. Each square is a tab (name + hotkey on hover); close via the
* shell's `exit`, middle-click, or the context menu. */
@ -78,7 +66,10 @@ export function TerminalRail() {
/>
))}
<li className="flex w-full justify-center">
<Tip label={hintLabel(t.rightSidebar.terminalNew, newHint)} side="left">
<Tip
label={<TipHintLabel hint={newHint && formatCombo(newHint)} text={t.rightSidebar.terminalNew} />}
side="left"
>
<button
aria-label={t.rightSidebar.terminalNew}
className={cn(RAIL_ACTION, 'size-7 text-(--ui-text-quaternary)')}
@ -129,7 +120,7 @@ function TerminalRailItem({ active, canCloseOthers, index, term, toggleHint }: T
className="absolute inset-y-0.5 right-0 w-0.5 rounded-l-sm bg-(--ui-stroke-primary)"
/>
)}
<Tip label={hintLabel(label, toggleHint)} side="left">
<Tip label={<TipHintLabel hint={toggleHint && formatCombo(toggleHint)} text={label} />} side="left">
<button
aria-label={label}
aria-selected={active}

View file

@ -1,7 +1,7 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { Tip } from './tooltip'
import { Tip, TipHintLabel } from './tooltip'
describe('Tip', () => {
afterEach(() => {
@ -18,9 +18,7 @@ describe('Tip', () => {
const trigger = screen.getByRole('button', { name: 'layout' })
fireEvent.pointerMove(trigger, { pointerType: 'mouse' })
expect((await screen.findByRole('tooltip')).textContent).toContain(
'Layout editor — ⌘-click resets the layout'
)
expect((await screen.findByRole('tooltip')).textContent).toContain('Layout editor — ⌘-click resets the layout')
fireEvent.pointerLeave(trigger)
await waitFor(() => {
@ -28,21 +26,6 @@ describe('Tip', () => {
})
})
it('never captures pointer events on the tip surface', async () => {
render(
<Tip label="Blocked?">
<button type="button">target</button>
</Tip>
)
fireEvent.pointerMove(screen.getByRole('button', { name: 'target' }), { pointerType: 'mouse' })
const tip = await screen.findByRole('tooltip')
// Role lives on the visually-hidden a11y node; the portaled content root
// is the data-slot wrapper that must stay click-through.
const content = tip.closest('[data-slot="tooltip-content"]') ?? tip.parentElement
expect(content?.className).toMatch(/pointer-events-none/)
})
it('renders the child alone when label is empty', () => {
render(
<Tip label="">
@ -53,4 +36,39 @@ describe('Tip', () => {
expect(screen.getByRole('button', { name: 'bare' })).toBeTruthy()
expect(screen.queryByRole('tooltip')).toBeNull()
})
it('guards a block-level label child via the decoration wrapper class', async () => {
render(
<Tip label={<span className="flex items-center gap-2">broken label</span>}>
<button type="button">trigger</button>
</Tip>
)
fireEvent.pointerMove(screen.getByRole('button', { name: 'trigger' }), { pointerType: 'mouse' })
await screen.findByRole('tooltip')
// jsdom applies no real Tailwind, so assert the guarding class is present on
// the decoration wrapper — that's what forces any direct child inline-flex
// in a browser (#62022).
const decoration = document.querySelector<HTMLElement>('[data-slot="tooltip-content"]')?.firstElementChild
expect(decoration?.className).toMatch(/\[&>\*\]:!inline-flex/)
})
})
describe('TipHintLabel', () => {
afterEach(() => {
cleanup()
})
it('renders inline-flex with a hint and plain text without one', () => {
const { rerender } = render(<TipHintLabel hint="Ctrl+`" text="PowerShell" />)
const withHint = screen.getByText('PowerShell').parentElement
expect(withHint?.classList.contains('inline-flex')).toBe(true)
expect(withHint?.classList.contains('flex')).toBe(false)
rerender(<TipHintLabel text="PowerShell" />)
expect(screen.getByText('PowerShell').tagName).not.toBe('SPAN')
})
})

View file

@ -53,7 +53,11 @@ function TooltipContent({
{/* bg-foreground/text-background auto-inverts per theme. leading-normal
keeps lines readable; py-1 makes the cloned line-boxes overlap just
enough to read as one continuous fill (no gaps between lines). */}
<span className="box-decoration-clone inline bg-foreground px-1.5 py-1 text-[11px] font-bold leading-normal text-background [font-family:Arial,sans-serif]">
{/* [&>*]:!inline-flex: a block-level label child (e.g. `flex`) collapses
this inline decoration's geometry, so Radix measures a zero-size chip
and parks an empty rectangle in the corner (#62022). Force any direct
child inline-flex so every call site stays safe. */}
<span className="box-decoration-clone inline bg-foreground px-1.5 py-1 text-[11px] font-bold leading-normal text-background [font-family:Arial,sans-serif] [&>*]:!inline-flex">
{children}
</span>
</TooltipPrimitive.Content>
@ -86,4 +90,25 @@ function Tip({ label, children, delayDuration = 0, ...props }: TipProps) {
)
}
export { Tip, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
interface TipHintLabelProps {
text: string
hint?: string
}
/** Tooltip label with an optional trailing hotkey hint. Uses `inline-flex` so it
* stays safe inside Tip's decoration wrapper prefer this over a bespoke
* flex/gap span at the call site (see #62022). */
function TipHintLabel({ text, hint }: TipHintLabelProps) {
if (!hint) {
return <>{text}</>
}
return (
<span className="inline-flex items-center gap-2">
<span>{text}</span>
<span className="opacity-55">{hint}</span>
</span>
)
}
export { Tip, TipHintLabel, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }