Merge pull request #61973 from NousResearch/bb/desktop-tip-stuck

fix(desktop): stop Tip from sticking open and blocking clicks
This commit is contained in:
brooklyn! 2026-07-10 04:03:15 -05:00 committed by GitHub
commit caf557be5b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 79 additions and 6 deletions

View file

@ -0,0 +1,56 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { Tip } from './tooltip'
describe('Tip', () => {
afterEach(() => {
cleanup()
})
it('shows on pointer enter and dismisses on pointer leave', async () => {
render(
<Tip label="Layout editor — ⌘-click resets the layout">
<button type="button">layout</button>
</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'
)
fireEvent.pointerLeave(trigger)
await waitFor(() => {
expect(screen.queryByRole('tooltip')).toBeNull()
})
})
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="">
<button type="button">bare</button>
</Tip>
)
expect(screen.getByRole('button', { name: 'bare' })).toBeTruthy()
expect(screen.queryByRole('tooltip')).toBeNull()
})
})

View file

@ -3,8 +3,23 @@ import * as React from 'react'
import { cn } from '@/lib/utils'
function TooltipProvider({ delayDuration = 0, ...props }: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return <TooltipPrimitive.Provider data-slot="tooltip-provider" delayDuration={delayDuration} {...props} />
function TooltipProvider({
delayDuration = 0,
// Tips are labels, not interactive surfaces. Hoverable content + Radix's
// pointer-grace bridge is what leaves tips stuck open — especially over
// Electron `-webkit-app-region: drag` chrome where pointermove never fires
// to clear the grace area. Default off so open state tracks the trigger only.
disableHoverableContent = true,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
disableHoverableContent={disableHoverableContent}
{...props}
/>
)
}
function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
@ -28,7 +43,9 @@ function TooltipContent({
// span so `box-decoration-break: clone` gives a marker-style background
// that hugs EACH wrapped line (bg only on the text, ragged right — no
// rectangular dead space). Instant, no transition (delayDuration=0).
className={cn('z-[200] w-fit max-w-64 select-none', className)}
// pointer-events-none: the tip must never steal hover/clicks from the
// chrome underneath (titlebar tools, adjacent tabs, etc.).
className={cn('pointer-events-none z-[200] w-fit max-w-64 select-none', className)}
data-slot="tooltip-content"
sideOffset={sideOffset}
{...props}
@ -53,15 +70,15 @@ interface TipProps extends Omit<React.ComponentProps<typeof TooltipPrimitive.Con
// Drop-in replacement for native `title=`: wrap any single element. Instant,
// position-aware, themed. Self-contained (carries its own Provider) so it works
// anywhere without a provider ancestor. Renders the child untouched when label
// is falsy.
// is falsy. Open state is trigger-hover only — never sticky, never click-blocking.
function Tip({ label, children, delayDuration = 0, ...props }: TipProps) {
if (!label) {
return <>{children}</>
}
return (
<TooltipProvider delayDuration={delayDuration}>
<Tooltip>
<TooltipProvider delayDuration={delayDuration} disableHoverableContent>
<Tooltip disableHoverableContent>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent {...props}>{label}</TooltipContent>
</Tooltip>