🩹(frontend) fix tooltip positioning in cross-document rendering (PiP)

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 <c.gromoff@gmail.com>
This commit is contained in:
lebaudantoine
2026-05-22 15:44:32 +02:00
committed by aleb_the_flash
parent dee1e46173
commit a9ef134210
5 changed files with 111 additions and 25 deletions
@@ -4,6 +4,7 @@ import { UNSAFE_PortalProvider } from '@react-aria/overlays'
import { documentPictureInPictureStore } from '@/stores/documentPictureInPicture' import { documentPictureInPictureStore } from '@/stores/documentPictureInPicture'
import { useSnapshot } from 'valtio' import { useSnapshot } from 'valtio'
import { useEffect, useMemo } from 'react' import { useEffect, useMemo } from 'react'
import { CrossDocumentOverlaysContext } from '@/primitives/CrossDocumentOverlaysContext'
const InternalPortal = ({ children }: { children: React.ReactNode }) => { const InternalPortal = ({ children }: { children: React.ReactNode }) => {
const pipStoreSnap = useSnapshot(documentPictureInPictureStore) const pipStoreSnap = useSnapshot(documentPictureInPictureStore)
@@ -32,7 +33,11 @@ const InternalPortal = ({ children }: { children: React.ReactNode }) => {
* instead. * instead.
*/ */
<UNSAFE_PortalProvider getContainer={() => container}> <UNSAFE_PortalProvider getContainer={() => container}>
{children} {/*React Aria computes overlay position based on the main window size, so*/}
{/*we must disable it to position overlays correctly across documents.*/}
<CrossDocumentOverlaysContext.Provider value={true}>
{children}
</CrossDocumentOverlaysContext.Provider>
</UNSAFE_PortalProvider>, </UNSAFE_PortalProvider>,
container container
) )
@@ -1,9 +1,6 @@
import { Menu as RACMenu, MenuItem } from 'react-aria-components' import { Menu as RACMenu, MenuItem } from 'react-aria-components'
import { PictureInPictureMenuItem } from '@/features/rooms/livekit/components/controls/Options/PictureInPictureMenuItem' import { PictureInPictureMenuItem } from '@/features/rooms/livekit/components/controls/Options/PictureInPictureMenuItem'
import { import { CollapsibleControl, CollapsibleControls } from '../PipControlBar'
CollapsibleControl,
CollapsibleControls,
} from '../PipControlBar'
import { RiArrowUpLine, RiEmotionLine, RiHand } from '@remixicon/react' import { RiArrowUpLine, RiEmotionLine, RiHand } from '@remixicon/react'
import { menuRecipe } from '@/primitives/menuRecipe.ts' import { menuRecipe } from '@/primitives/menuRecipe.ts'
import { useReactionsToolbar } from '@/features/reactions/hooks/useReactionsToolbar' import { useReactionsToolbar } from '@/features/reactions/hooks/useReactionsToolbar'
@@ -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)
+10 -3
View File
@@ -6,6 +6,8 @@ import {
type TooltipProps, type TooltipProps,
} from 'react-aria-components' } from 'react-aria-components'
import { styled } from '@/styled-system/jsx' import { styled } from '@/styled-system/jsx'
import { useCrossDocumentOverlays } from '@/primitives/CrossDocumentOverlaysContext'
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
export type TooltipWrapperProps = { export type TooltipWrapperProps = {
tooltip?: string tooltip?: string
@@ -24,13 +26,18 @@ export const TooltipWrapper = ({
}: { }: {
children: ReactNode children: ReactNode
} & TooltipWrapperProps) => { } & TooltipWrapperProps) => {
return tooltip ? ( const isCrossDocumentOverlays = useCrossDocumentOverlays()
if (!tooltip) return children
if (isCrossDocumentOverlays)
return <VisualOnlyTooltip tooltip={tooltip}>{children}</VisualOnlyTooltip>
return (
<TooltipTrigger delay={tooltipType === 'instant' ? 150 : 1000}> <TooltipTrigger delay={tooltipType === 'instant' ? 150 : 1000}>
{children} {children}
<Tooltip>{tooltip}</Tooltip> <Tooltip>{tooltip}</Tooltip>
</TooltipTrigger> </TooltipTrigger>
) : (
children
) )
} }
@@ -1,15 +1,18 @@
import { import {
type ReactElement,
cloneElement, cloneElement,
isValidElement, isValidElement,
useLayoutEffect,
useMemo,
useRef, useRef,
useState, useState,
ReactNode,
} from 'react' } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { useUNSAFE_PortalContext } from '@react-aria/overlays'
export type VisualOnlyTooltipProps = { export type VisualOnlyTooltipProps = {
children: ReactElement children: ReactNode
tooltip: string tooltip: string
ariaLabel?: string ariaLabel?: string
tooltipPosition?: 'top' | 'bottom' tooltipPosition?: 'top' | 'bottom'
@@ -32,19 +35,29 @@ export const VisualOnlyTooltip = ({
tooltipPosition = 'top', tooltipPosition = 'top',
}: VisualOnlyTooltipProps) => { }: VisualOnlyTooltipProps) => {
const [isVisible, setIsVisible] = useState(false) const [isVisible, setIsVisible] = useState(false)
const { getContainer } = useUNSAFE_PortalContext()
const wrapperRef = useRef<HTMLDivElement>(null) const wrapperRef = useRef<HTMLDivElement>(null)
const tooltipRef = useRef<HTMLDivElement>(null)
const [position, setPosition] = useState<{ const [position, setPosition] = useState<{
top: number top: number
left: number left: number
} | null>(null) } | null>(null)
const [computedStyle, setComputedStyle] = useState<{
left: number
arrowLeft: number
} | null>(null)
const isBottom = tooltipPosition === 'bottom' const [effectiveBottom, setEffectiveBottom] = useState(
tooltipPosition === 'bottom'
)
const showTooltip = () => { const showTooltip = () => {
if (!wrapperRef.current) return if (!wrapperRef.current) return
const rect = wrapperRef.current.getBoundingClientRect() const rect = wrapperRef.current.getBoundingClientRect()
const preferBottom = tooltipPosition === 'bottom'
setEffectiveBottom(preferBottom)
setPosition({ setPosition({
top: isBottom ? rect.bottom + 8 : rect.top - 8, top: preferBottom ? rect.bottom + 8 : rect.top - 8,
left: rect.left + rect.width / 2, left: rect.left + rect.width / 2,
}) })
setIsVisible(true) setIsVisible(true)
@@ -53,15 +66,67 @@ export const VisualOnlyTooltip = ({
const hideTooltip = () => { const hideTooltip = () => {
setIsVisible(false) setIsVisible(false)
setPosition(null) 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) const wrappedChild = isValidElement(children)
? cloneElement(children, { ? cloneElement(children, {
...(ariaLabel ? { 'aria-label': ariaLabel } : {}), ...(ariaLabel ? { 'aria-label': ariaLabel } : {}),
}) })
: children : children
const translateY = effectiveBottom ? 'translateY(0)' : 'translateY(-100%)'
const translateXY = effectiveBottom
? 'translate(-50%, 0)'
: 'translate(-50%, -100%)'
const tooltipInlineStyle: React.CSSProperties & Record<string, string> = {
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 ( return (
<> <>
<div <div
@@ -73,11 +138,14 @@ export const VisualOnlyTooltip = ({
> >
{wrappedChild} {wrappedChild}
</div> </div>
{tooltipData && {isVisible &&
position &&
portalContainer &&
createPortal( createPortal(
<div <div
aria-hidden="true" aria-hidden="true"
role="presentation" role="presentation"
ref={tooltipRef}
className={css({ className={css({
position: 'fixed', position: 'fixed',
padding: '2px 8px', padding: '2px 8px',
@@ -87,15 +155,15 @@ export const VisualOnlyTooltip = ({
fontSize: 14, fontSize: 14,
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
pointerEvents: 'none', pointerEvents: 'none',
zIndex: 9999, zIndex: 100001,
boxShadow: '0 8px 20px rgba(0 0 0 / 0.1)', boxShadow: '0 8px 20px rgba(0 0 0 / 0.1)',
'&::after': { '&::after': {
content: '""', content: '""',
position: 'absolute', position: 'absolute',
left: '50%', left: 'var(--tooltip-arrow-left, 50%)',
transform: 'translateX(-50%)', transform: 'translateX(-50%)',
border: '4px solid transparent', border: '4px solid transparent',
...(isBottom ...(effectiveBottom
? { ? {
bottom: '100%', bottom: '100%',
borderBottomColor: 'primaryDark.100', borderBottomColor: 'primaryDark.100',
@@ -106,17 +174,11 @@ export const VisualOnlyTooltip = ({
}), }),
}, },
})} })}
style={{ style={tooltipInlineStyle}
top: `${tooltipData.position.top}px`,
left: `${tooltipData.position.left}px`,
transform: isBottom
? 'translate(-50%, 0)'
: 'translate(-50%, -100%)',
}}
> >
{tooltip} {tooltip}
</div>, </div>,
document.body portalContainer
)} )}
</> </>
) )