From 29f3dc0809899010fd1175edcc395cd968ffea4b Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Fri, 10 Jul 2026 04:00:32 -0500 Subject: [PATCH] fix(desktop): stop Tip from sticking open and blocking clicks Radix's hoverable-content grace area can leave tips stuck over Electron drag regions; disable it and make tip content pointer-events-none so open state tracks the trigger only. --- .../src/components/ui/tooltip.test.tsx | 56 +++++++++++++++++++ apps/desktop/src/components/ui/tooltip.tsx | 29 ++++++++-- 2 files changed, 79 insertions(+), 6 deletions(-) create mode 100644 apps/desktop/src/components/ui/tooltip.test.tsx diff --git a/apps/desktop/src/components/ui/tooltip.test.tsx b/apps/desktop/src/components/ui/tooltip.test.tsx new file mode 100644 index 00000000000..06087a69623 --- /dev/null +++ b/apps/desktop/src/components/ui/tooltip.test.tsx @@ -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( + + + + ) + + 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( + + + + ) + + 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( + + + + ) + + expect(screen.getByRole('button', { name: 'bare' })).toBeTruthy() + expect(screen.queryByRole('tooltip')).toBeNull() + }) +}) diff --git a/apps/desktop/src/components/ui/tooltip.tsx b/apps/desktop/src/components/ui/tooltip.tsx index 6b596b0ce77..d1053585869 100644 --- a/apps/desktop/src/components/ui/tooltip.tsx +++ b/apps/desktop/src/components/ui/tooltip.tsx @@ -3,8 +3,23 @@ import * as React from 'react' import { cn } from '@/lib/utils' -function TooltipProvider({ delayDuration = 0, ...props }: React.ComponentProps) { - return +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) { + return ( + + ) } function Tooltip({ ...props }: React.ComponentProps) { @@ -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{children} } return ( - - + + {children} {label}