Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
56 changes: 56 additions & 0 deletions apps/desktop/src/components/ui/tooltip.test.tsx
Original file line number Diff line number Diff line change
@@ -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()
})
})
29 changes: 23 additions & 6 deletions apps/desktop/src/components/ui/tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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>) {
Expand All @@ -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}
Expand All @@ -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>
Expand Down
Loading