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(
{tooltip}
,
- document.body
+ portalContainer
)}
>
)