fix(ui): preserve tooltip positioning under zoom

This commit is contained in:
lucaronin
2026-05-03 10:48:57 +02:00
parent d96d6efd57
commit c795f118d0
5 changed files with 79 additions and 9 deletions

View File

@@ -307,6 +307,24 @@ describe('BreadcrumbBar — action buttons always right-aligned', () => {
expect(widthActionGroup).toHaveClass('gap-2')
})
it('end-aligns toolbar action tooltips so zoomed windows keep them inside the right edge', async () => {
render(
<BreadcrumbBar
entry={baseEntry}
{...defaultProps}
onToggleFavorite={vi.fn()}
/>,
)
act(() => {
fireEvent.focus(screen.getByRole('button', { name: 'Add to favorites' }))
})
const tooltip = await screen.findByRole('tooltip')
expect(document.querySelector('[data-slot="tooltip-content"]')).toHaveAttribute('data-align', 'end')
expect(tooltip).toHaveTextContent('Add to favorites')
})
it('lets the title use the free space before the fixed drag gap', () => {
const { container } = render(<BreadcrumbBar entry={baseEntry} {...defaultProps} />)

View File

@@ -120,7 +120,7 @@ function IconActionButton({
style,
children,
testId,
tooltipAlign,
tooltipAlign = 'end',
}: {
copy: ActionTooltipCopy
onClick?: () => void

View File

@@ -1,11 +1,12 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { act, render, renderHook, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it } from 'vitest'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from './tooltip'
import { useZoom } from '@/hooks/useZoom'
import {
Popover,
PopoverContent,
@@ -15,7 +16,8 @@ import {
const PRESENCE_ANIMATION_CLASS_PARTS = [
'animate-',
'fade-',
'zoom-',
'zoom-in-',
'zoom-out-',
'slide-in-from',
]
@@ -30,6 +32,11 @@ function expectNoPresenceAnimationClasses(element: HTMLElement) {
}
describe('overlay presence stability', () => {
beforeEach(() => {
document.documentElement.style.removeProperty('--tolaria-overlay-zoom-factor')
document.documentElement.style.removeProperty('--tolaria-overlay-zoom-inverse')
})
it('keeps tooltip content free of Radix presence animation classes', () => {
render(
<TooltipProvider>
@@ -45,6 +52,38 @@ describe('overlay presence stability', () => {
expectNoPresenceAnimationClasses(screen.getByTestId('tooltip-content'))
})
it('publishes zoom variables for overlay portal positioning and visual scale', () => {
const { result } = renderHook(() => useZoom())
act(() => {
result.current.zoomIn()
})
expect(document.documentElement.style.getPropertyValue('--tolaria-overlay-zoom-factor')).toBe(String(110 / 100))
expect(document.documentElement.style.getPropertyValue('--tolaria-overlay-zoom-inverse')).toBe(String(100 / 110))
})
it('compensates tooltip portal positioning without cancelling content zoom', () => {
document.documentElement.style.setProperty('--tolaria-overlay-zoom-factor', '1.4')
document.documentElement.style.setProperty('--tolaria-overlay-zoom-inverse', String(1 / 1.4))
render(
<TooltipProvider>
<Tooltip open>
<TooltipTrigger asChild>
<button type="button">Tooltip trigger</button>
</TooltipTrigger>
<TooltipContent data-testid="tooltip-content">Tooltip copy</TooltipContent>
</Tooltip>
</TooltipProvider>,
)
const positionShell = document.querySelector('[data-slot="tooltip-content"]') as HTMLElement
const visualShell = document.querySelector('[data-slot="tooltip-visual-scale"]') as HTMLElement
expect(positionShell.className).toContain('[zoom:var(--tolaria-overlay-zoom-inverse,1)]')
expect(visualShell.className).toContain('[zoom:var(--tolaria-overlay-zoom-factor,1)]')
})
it('keeps popover content free of Radix presence animation classes', () => {
render(
<Popover open>

View File

@@ -33,7 +33,9 @@ function TooltipTrigger({
function TooltipContent({
className,
sideOffset = 0,
collisionPadding = 8,
children,
style,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
@@ -41,14 +43,23 @@ function TooltipContent({
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
collisionPadding={collisionPadding}
className={cn(
"bg-foreground text-background z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
"z-50 w-fit max-w-[min(var(--radix-tooltip-content-available-width,22rem),22rem)] origin-(--radix-tooltip-content-transform-origin) [zoom:var(--tolaria-overlay-zoom-inverse,1)]"
)}
style={style}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
<div
data-slot="tooltip-visual-scale"
className={cn(
"bg-foreground text-background max-w-[inherit] rounded-md px-3 py-1.5 text-xs text-balance [zoom:var(--tolaria-overlay-zoom-factor,1)]",
className
)}
>
{children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</div>
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)

View File

@@ -28,6 +28,8 @@ function loadPersistedZoom(): number {
function applyZoomToDocument(level: number): void {
document.documentElement.style.setProperty('zoom', `${level}%`)
document.documentElement.style.setProperty('--tolaria-overlay-zoom-factor', String(level / DEFAULT_ZOOM))
document.documentElement.style.setProperty('--tolaria-overlay-zoom-inverse', String(DEFAULT_ZOOM / level))
window.dispatchEvent(new Event('laputa-zoom-change'))
}
@@ -40,7 +42,7 @@ export function useZoom() {
const level = loadPersistedZoom()
// Apply zoom synchronously during init so child components (e.g. CodeMirror)
// measure the correct scale factor in their own effects.
document.documentElement.style.setProperty('zoom', `${level}%`)
applyZoomToDocument(level)
return level
})