mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-15 14:22:43 +00:00
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.
This commit is contained in:
parent
a9f3f08700
commit
29f3dc0809
2 changed files with 79 additions and 6 deletions
56
apps/desktop/src/components/ui/tooltip.test.tsx
Normal file
56
apps/desktop/src/components/ui/tooltip.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue