Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 6 additions & 15 deletions apps/desktop/src/app/right-sidebar/terminal/rail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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. */
Expand Down Expand Up @@ -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)')}
Expand Down Expand Up @@ -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}
Expand Down
56 changes: 23 additions & 33 deletions apps/desktop/src/components/ui/tooltip.test.tsx
Original file line number Diff line number Diff line change
@@ -1,56 +1,46 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'

import { Tip } from './tooltip'
import { Tip, TipHintLabel } from './tooltip'

describe('Tip', () => {
describe('TooltipContent', () => {
afterEach(() => {
cleanup()
})

it('shows on pointer enter and dismisses on pointer leave', async () => {
it('forces a block-level label child back to inline-flex so it cannot break the decoration geometry', async () => {
render(
<Tip label="Layout editor β€” ⌘-click resets the layout">
<button type="button">layout</button>
<Tip label={<span className="flex items-center gap-2">broken label</span>}>
<button type="button">trigger</button>
</Tip>
)

const trigger = screen.getByRole('button', { name: 'layout' })
fireEvent.pointerMove(screen.getByRole('button', { name: 'trigger' }), { pointerType: 'mouse' })
await screen.findByRole('tooltip')

fireEvent.pointerMove(trigger, { pointerType: 'mouse' })
expect((await screen.findByRole('tooltip')).textContent).toContain(
'Layout editor β€” ⌘-click resets the layout'
)
const content = document.querySelector<HTMLElement>('[data-slot="tooltip-content"]')
const decoration = content?.firstElementChild
const label = decoration?.firstElementChild as HTMLElement | null | undefined

fireEvent.pointerLeave(trigger)
await waitFor(() => {
expect(screen.queryByRole('tooltip')).toBeNull()
})
expect(label).not.toBeNull()
expect(label && getComputedStyle(label).display).toBe('inline')

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This expects inline, so it can pass without the new inline-flex selector taking effect. Please replace it with coverage that observes the effective inline-flex rule (or use a browser-level style test).

})

it('never captures pointer events on the tip surface', async () => {
it('keeps TipHintLabel inline by default when a hint is present', async () => {
render(
<Tip label="Blocked?">
<button type="button">target</button>
<Tip label={<TipHintLabel hint="Ctrl+`" text="PowerShell" />}>
<button type="button">trigger</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/)
})
fireEvent.pointerMove(screen.getByRole('button', { name: 'trigger' }), { pointerType: 'mouse' })
await screen.findByRole('tooltip')

it('renders the child alone when label is empty', () => {
render(
<Tip label="">
<button type="button">bare</button>
</Tip>
)
const content = document.querySelector<HTMLElement>('[data-slot="tooltip-content"]')
const decoration = content?.firstElementChild
const label = decoration?.firstElementChild

expect(screen.getByRole('button', { name: 'bare' })).toBeTruthy()
expect(screen.queryByRole('tooltip')).toBeNull()
expect(label?.classList.contains('inline-flex')).toBe(true)
expect(label?.classList.contains('flex')).toBe(false)
})
})
26 changes: 24 additions & 2 deletions apps/desktop/src/components/ui/tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ 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]">
<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>
Expand Down Expand Up @@ -86,4 +86,26 @@ function Tip({ label, children, delayDuration = 0, ...props }: TipProps) {
)
}

export { Tip, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
interface TipHintLabelProps {
text: string
hint?: string
}

/** Label with a trailing hotkey hint, safe to use inside Tip's box-decoration-clone
* wrapper β€” a block-level child here breaks the wrapper's intrinsic geometry and
* mis-positions the whole tooltip (see #62022). Use this instead of a bespoke
* flex/gap span at the call site. */
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 }
Loading