diff --git a/src/frontend/src/components/shared/Tooltip.tsx b/src/frontend/src/components/shared/Tooltip.tsx index f13b277e..b1a4d5bc 100644 --- a/src/frontend/src/components/shared/Tooltip.tsx +++ b/src/frontend/src/components/shared/Tooltip.tsx @@ -3,6 +3,7 @@ import { useLayoutEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { useMountEffect } from '../../hooks/useMountEffect'; +import { getTooltipViewportNudge } from '../../utils/tooltipViewportNudge'; interface TooltipProps { content: ReactNode; @@ -112,44 +113,25 @@ export function Tooltip({ return; } - const tooltipRect = tooltipRef.current.getBoundingClientRect(); - const viewportPadding = 6; + const nudge = getTooltipViewportNudge( + tooltipRef.current.getBoundingClientRect(), + window.innerWidth, + window.innerHeight, + ); - let deltaX = 0; - let deltaY = 0; - - if (tooltipRect.width + viewportPadding * 2 > window.innerWidth) { - deltaX = viewportPadding - tooltipRect.left; - } else { - if (tooltipRect.left < viewportPadding) { - deltaX = viewportPadding - tooltipRect.left; - } else if (tooltipRect.right > window.innerWidth - viewportPadding) { - deltaX = window.innerWidth - viewportPadding - tooltipRect.right; - } - } - - if (tooltipRect.height + viewportPadding * 2 > window.innerHeight) { - deltaY = viewportPadding - tooltipRect.top; - } else { - if (tooltipRect.top < viewportPadding) { - deltaY = viewportPadding - tooltipRect.top; - } else if (tooltipRect.bottom > window.innerHeight - viewportPadding) { - deltaY = window.innerHeight - viewportPadding - tooltipRect.bottom; - } - } - - if (deltaX !== 0 || deltaY !== 0) { + if (nudge.x !== 0 || nudge.y !== 0) { // Genuine measure-and-adjust: the tooltip must be laid out before we know // whether it overflows the viewport. The loop converges in one pass because - // the corrected position yields deltaX/deltaY of 0 on the next run. + // the corrected position measures as no nudge on the next run, which holds + // only because the nudge ignores sub-pixel overflow. // oxlint-disable-next-line react/set-state-in-effect setCoords((current) => { if (!current) { return current; } return { - top: current.top + deltaY, - left: current.left + deltaX, + top: current.top + nudge.y, + left: current.left + nudge.x, }; }); } diff --git a/src/frontend/src/tests/tooltipViewportNudge.test.ts b/src/frontend/src/tests/tooltipViewportNudge.test.ts new file mode 100644 index 00000000..7c0ee1dc --- /dev/null +++ b/src/frontend/src/tests/tooltipViewportNudge.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from 'vitest'; + +import { getTooltipViewportNudge } from '../utils/tooltipViewportNudge'; + +const box = (left: number, top: number, width: number, height: number) => ({ + left, + top, + width, + height, + right: left + width, + bottom: top + height, +}); + +describe('getTooltipViewportNudge', () => { + it('leaves a tooltip that fits where it is', () => { + expect(getTooltipViewportNudge(box(100, 100, 80, 30), 1600, 900)).toEqual({ x: 0, y: 0 }); + }); + + // The two measurements Firefox took of the "Stop download" tooltip in #1646, + // before and after the nudge, in a 1600x900 window. + it('moves a tooltip that runs past the right edge back inside', () => { + const rect = box(1536.2166748046875, 185, 67.566680908203125, 35.5); + const nudge = getTooltipViewportNudge(rect, 1600, 900); + expect(nudge.x).toBeCloseTo(1594 - rect.right, 10); + expect(nudge.y).toBe(0); + }); + + it('leaves a tooltip that measures a fraction of a pixel past the edge', () => { + const rect = box(1526.433349609375, 185, 67.56666564941406, 35.5); + expect(rect.right).toBeGreaterThan(1594); + expect(getTooltipViewportNudge(rect, 1600, 900)).toEqual({ x: 0, y: 0 }); + }); + + it('still moves a tooltip that is half a pixel over', () => { + expect(getTooltipViewportNudge(box(1514, 100, 80.5, 30), 1600, 900)).toEqual({ + x: -0.5, + y: 0, + }); + }); + + it('moves a tooltip past the left or top edge back inside', () => { + expect(getTooltipViewportNudge(box(-20, -10, 80, 30), 1600, 900)).toEqual({ x: 26, y: 16 }); + }); + + it('moves a tooltip past the bottom edge up', () => { + expect(getTooltipViewportNudge(box(100, 880, 80, 30), 1600, 900)).toEqual({ x: 0, y: -16 }); + }); + + it('pins a tooltip wider than the viewport to the left edge', () => { + expect(getTooltipViewportNudge(box(40, 100, 400, 30), 390, 900)).toEqual({ x: -34, y: 0 }); + }); +}); diff --git a/src/frontend/src/utils/tooltipViewportNudge.ts b/src/frontend/src/utils/tooltipViewportNudge.ts new file mode 100644 index 00000000..ce819966 --- /dev/null +++ b/src/frontend/src/utils/tooltipViewportNudge.ts @@ -0,0 +1,38 @@ +type MeasuredBox = Pick; + +interface ViewportNudge { + x: number; + y: number; +} + +const VIEWPORT_PADDING = 6; + +// Firefox measures a transformed box in single precision, so a tooltip already moved flush +// to the edge can read 0.00001px past it. Moving it by that much changes nothing, the next +// measurement shows the same overflow, and the tooltip's layout effect loops until React +// gives up with error #185 and the page goes blank (#1646). +const SUBPIXEL_TOLERANCE = 0.5; + +const nudgeAxis = (start: number, end: number, size: number, viewport: number): number => { + let delta = 0; + if (size + VIEWPORT_PADDING * 2 > viewport || start < VIEWPORT_PADDING) { + delta = VIEWPORT_PADDING - start; + } else if (end > viewport - VIEWPORT_PADDING) { + delta = viewport - VIEWPORT_PADDING - end; + } + return Math.abs(delta) < SUBPIXEL_TOLERANCE ? 0 : delta; +}; + +/** + * How far to move a tooltip so it stays inside the viewport, keeping a small gap + * from each edge. A tooltip bigger than the viewport is pinned to the top/left edge. + * Overflow under half a pixel counts as none. + */ +export const getTooltipViewportNudge = ( + rect: MeasuredBox, + viewportWidth: number, + viewportHeight: number, +): ViewportNudge => ({ + x: nudgeAxis(rect.left, rect.right, rect.width, viewportWidth), + y: nudgeAxis(rect.top, rect.bottom, rect.height, viewportHeight), +});