From a9ef13421077164d1b294c13fd2905e309f5bfd7 Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Fri, 22 May 2026 15:44:32 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=A9=B9(frontend)=20fix=20tooltip=20positi?= =?UTF-8?q?oning=20in=20cross-document=20rendering=20(PiP)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit react-aria computes overlay positions against the main window's dimensions, which produces incorrect placement when the overlay is rendered into a PiP document. Tooltips were the most visible symptom, but the issue affects overlays in general. Introduce a context that lets our primitives know when they're rendering across documents. When set, the primitives use the host document's window for positioning instead of falling back to react-aria's default. Original work by @ovgod. No cleaner approach seems feasible in the short term, react-aria doesn't expose a clean way to override the positioning target, so this works around it at our primitive layer. Co-authored-by: Cyril --- .../pip/components/PictureInPicturePortal.tsx | 7 +- .../controls/PipOptionsMenuItems.tsx | 5 +- .../CrossDocumentOverlaysContext.tsx | 15 +++ .../src/primitives/TooltipWrapper.tsx | 13 ++- .../src/primitives/VisualOnlyTooltip.tsx | 96 +++++++++++++++---- 5 files changed, 111 insertions(+), 25 deletions(-) create mode 100644 src/frontend/src/primitives/CrossDocumentOverlaysContext.tsx diff --git a/src/frontend/src/features/pip/components/PictureInPicturePortal.tsx b/src/frontend/src/features/pip/components/PictureInPicturePortal.tsx index 00cb20b8..326e40a3 100644 --- a/src/frontend/src/features/pip/components/PictureInPicturePortal.tsx +++ b/src/frontend/src/features/pip/components/PictureInPicturePortal.tsx @@ -4,6 +4,7 @@ import { UNSAFE_PortalProvider } from '@react-aria/overlays' import { documentPictureInPictureStore } from '@/stores/documentPictureInPicture' import { useSnapshot } from 'valtio' import { useEffect, useMemo } from 'react' +import { CrossDocumentOverlaysContext } from '@/primitives/CrossDocumentOverlaysContext' const InternalPortal = ({ children }: { children: React.ReactNode }) => { const pipStoreSnap = useSnapshot(documentPictureInPictureStore) @@ -32,7 +33,11 @@ const InternalPortal = ({ children }: { children: React.ReactNode }) => { * instead. */ container}> - {children} + {/*React Aria computes overlay position based on the main window size, so*/} + {/*we must disable it to position overlays correctly across documents.*/} + + {children} + , container ) diff --git a/src/frontend/src/features/pip/components/controls/PipOptionsMenuItems.tsx b/src/frontend/src/features/pip/components/controls/PipOptionsMenuItems.tsx index a2834183..7991c066 100644 --- a/src/frontend/src/features/pip/components/controls/PipOptionsMenuItems.tsx +++ b/src/frontend/src/features/pip/components/controls/PipOptionsMenuItems.tsx @@ -1,9 +1,6 @@ import { Menu as RACMenu, MenuItem } from 'react-aria-components' import { PictureInPictureMenuItem } from '@/features/rooms/livekit/components/controls/Options/PictureInPictureMenuItem' -import { - CollapsibleControl, - CollapsibleControls, -} from '../PipControlBar' +import { CollapsibleControl, CollapsibleControls } from '../PipControlBar' import { RiArrowUpLine, RiEmotionLine, RiHand } from '@remixicon/react' import { menuRecipe } from '@/primitives/menuRecipe.ts' import { useReactionsToolbar } from '@/features/reactions/hooks/useReactionsToolbar' diff --git a/src/frontend/src/primitives/CrossDocumentOverlaysContext.tsx b/src/frontend/src/primitives/CrossDocumentOverlaysContext.tsx new file mode 100644 index 00000000..c52b9253 --- /dev/null +++ b/src/frontend/src/primitives/CrossDocumentOverlaysContext.tsx @@ -0,0 +1,15 @@ +import { createContext, useContext } from 'react' + +/** + * Signals that React Aria's default overlay positioning can't be trusted in + * this subtree because the trigger and the rendered overlay live in different + * documents (currently: picture-in-picture windows) — React Aria measures + * against the main window's viewport, so tooltips, popovers, and menus end up + * mispositioned in the host that actually displays them. When `true`, consumers + * should bypass React Aria's positioning and handle placement themselves + * (e.g. a visual-only tooltip); triggers should still carry an accessible name. + */ +export const CrossDocumentOverlaysContext = createContext(false) + +export const useCrossDocumentOverlays = () => + useContext(CrossDocumentOverlaysContext) diff --git a/src/frontend/src/primitives/TooltipWrapper.tsx b/src/frontend/src/primitives/TooltipWrapper.tsx index 86ac105f..c918e666 100644 --- a/src/frontend/src/primitives/TooltipWrapper.tsx +++ b/src/frontend/src/primitives/TooltipWrapper.tsx @@ -6,6 +6,8 @@ import { type TooltipProps, } from 'react-aria-components' import { styled } from '@/styled-system/jsx' +import { useCrossDocumentOverlays } from '@/primitives/CrossDocumentOverlaysContext' +import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip' export type TooltipWrapperProps = { tooltip?: string @@ -24,13 +26,18 @@ export const TooltipWrapper = ({ }: { children: ReactNode } & TooltipWrapperProps) => { - return tooltip ? ( + const isCrossDocumentOverlays = useCrossDocumentOverlays() + + if (!tooltip) return children + + if (isCrossDocumentOverlays) + return {children} + + return ( {children} {tooltip} - ) : ( - children ) } diff --git a/src/frontend/src/primitives/VisualOnlyTooltip.tsx b/src/frontend/src/primitives/VisualOnlyTooltip.tsx index 1d196f77..4f97bc11 100644 --- a/src/frontend/src/primitives/VisualOnlyTooltip.tsx +++ b/src/frontend/src/primitives/VisualOnlyTooltip.tsx @@ -1,15 +1,18 @@ import { - type ReactElement, cloneElement, isValidElement, + useLayoutEffect, + useMemo, useRef, useState, + ReactNode, } from 'react' import { createPortal } from 'react-dom' import { css } from '@/styled-system/css' +import { useUNSAFE_PortalContext } from '@react-aria/overlays' export type VisualOnlyTooltipProps = { - children: ReactElement + children: ReactNode tooltip: string ariaLabel?: string tooltipPosition?: 'top' | 'bottom' @@ -32,19 +35,29 @@ export const VisualOnlyTooltip = ({ tooltipPosition = 'top', }: VisualOnlyTooltipProps) => { const [isVisible, setIsVisible] = useState(false) + const { getContainer } = useUNSAFE_PortalContext() const wrapperRef = useRef(null) + const tooltipRef = useRef(null) const [position, setPosition] = useState<{ top: number left: number } | null>(null) + const [computedStyle, setComputedStyle] = useState<{ + left: number + arrowLeft: number + } | null>(null) - const isBottom = tooltipPosition === 'bottom' + const [effectiveBottom, setEffectiveBottom] = useState( + tooltipPosition === 'bottom' + ) const showTooltip = () => { if (!wrapperRef.current) return const rect = wrapperRef.current.getBoundingClientRect() + const preferBottom = tooltipPosition === 'bottom' + setEffectiveBottom(preferBottom) setPosition({ - top: isBottom ? rect.bottom + 8 : rect.top - 8, + top: preferBottom ? rect.bottom + 8 : rect.top - 8, left: rect.left + rect.width / 2, }) setIsVisible(true) @@ -53,15 +66,67 @@ export const VisualOnlyTooltip = ({ const hideTooltip = () => { setIsVisible(false) setPosition(null) + setComputedStyle(null) } - const tooltipData = isVisible && position ? { isVisible, position } : null + useLayoutEffect(() => { + if (!tooltipRef.current || !wrapperRef.current || !isVisible || !position) + return + const tooltipRect = tooltipRef.current.getBoundingClientRect() + const triggerRect = wrapperRef.current.getBoundingClientRect() + const doc = tooltipRef.current.ownerDocument + const viewportWidth = doc.defaultView?.innerWidth ?? globalThis.innerWidth + const padding = 8 + + // Vertical flip: if tooltip overflows the top, switch to bottom + if (!effectiveBottom && position.top - tooltipRect.height < 0) { + const flippedTop = triggerRect.bottom + 8 + setEffectiveBottom(true) + setPosition({ top: flippedTop, left: position.left }) + return + } + + // Horizontal clamping (both edges) + const desiredLeft = position.left - tooltipRect.width / 2 + const minLeft = padding + const maxLeft = viewportWidth - padding - tooltipRect.width + + if (desiredLeft >= minLeft && desiredLeft <= maxLeft) { + setComputedStyle(null) + return + } + + const clampedLeft = Math.max(minLeft, Math.min(maxLeft, desiredLeft)) + setComputedStyle({ + left: clampedLeft, + arrowLeft: position.left - clampedLeft, + }) + }, [isVisible, position, effectiveBottom]) + + const portalContainer = useMemo(() => { + if (getContainer) return getContainer() + return wrapperRef.current?.ownerDocument?.body ?? document.body + }, [getContainer]) const wrappedChild = isValidElement(children) ? cloneElement(children, { ...(ariaLabel ? { 'aria-label': ariaLabel } : {}), }) : children + const translateY = effectiveBottom ? 'translateY(0)' : 'translateY(-100%)' + const translateXY = effectiveBottom + ? 'translate(-50%, 0)' + : 'translate(-50%, -100%)' + + const tooltipInlineStyle: React.CSSProperties & Record = { + top: `${position?.top}px`, + left: computedStyle ? `${computedStyle.left}px` : `${position?.left}px`, + transform: computedStyle ? translateY : translateXY, + ...(computedStyle + ? { '--tooltip-arrow-left': `${computedStyle.arrowLeft}px` } + : null), + } + return ( <>
{wrappedChild}
- {tooltipData && + {isVisible && + position && + portalContainer && createPortal( , - document.body + portalContainer )} )