From c795f118d0d6915edfe3624ccd4b596cc784d617 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sun, 3 May 2026 10:48:57 +0200 Subject: [PATCH] fix(ui): preserve tooltip positioning under zoom --- src/components/BreadcrumbBar.test.tsx | 18 +++++++++ src/components/BreadcrumbBar.tsx | 2 +- src/components/ui/overlayPresence.test.tsx | 45 ++++++++++++++++++++-- src/components/ui/tooltip.tsx | 19 +++++++-- src/hooks/useZoom.ts | 4 +- 5 files changed, 79 insertions(+), 9 deletions(-) diff --git a/src/components/BreadcrumbBar.test.tsx b/src/components/BreadcrumbBar.test.tsx index 575581e5..d870e426 100644 --- a/src/components/BreadcrumbBar.test.tsx +++ b/src/components/BreadcrumbBar.test.tsx @@ -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( + , + ) + + 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() diff --git a/src/components/BreadcrumbBar.tsx b/src/components/BreadcrumbBar.tsx index 684bc050..e9f8c514 100644 --- a/src/components/BreadcrumbBar.tsx +++ b/src/components/BreadcrumbBar.tsx @@ -120,7 +120,7 @@ function IconActionButton({ style, children, testId, - tooltipAlign, + tooltipAlign = 'end', }: { copy: ActionTooltipCopy onClick?: () => void diff --git a/src/components/ui/overlayPresence.test.tsx b/src/components/ui/overlayPresence.test.tsx index 53886710..78949a0e 100644 --- a/src/components/ui/overlayPresence.test.tsx +++ b/src/components/ui/overlayPresence.test.tsx @@ -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( @@ -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( + + + + + + Tooltip copy + + , + ) + + 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( diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx index 62345776..acc85d01 100644 --- a/src/components/ui/tooltip.tsx +++ b/src/components/ui/tooltip.tsx @@ -33,7 +33,9 @@ function TooltipTrigger({ function TooltipContent({ className, sideOffset = 0, + collisionPadding = 8, children, + style, ...props }: React.ComponentProps) { return ( @@ -41,14 +43,23 @@ function TooltipContent({ - {children} - +
+ {children} + +
) diff --git a/src/hooks/useZoom.ts b/src/hooks/useZoom.ts index 333af557..1b9bbdca 100644 --- a/src/hooks/useZoom.ts +++ b/src/hooks/useZoom.ts @@ -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 })