mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-26 11:58:53 +00:00
🩹(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:
committed by
aleb_the_flash
parent
dee1e46173
commit
a9ef134210
@@ -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.
|
||||
*/
|
||||
<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>,
|
||||
container
|
||||
)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
@@ -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 <VisualOnlyTooltip tooltip={tooltip}>{children}</VisualOnlyTooltip>
|
||||
|
||||
return (
|
||||
<TooltipTrigger delay={tooltipType === 'instant' ? 150 : 1000}>
|
||||
{children}
|
||||
<Tooltip>{tooltip}</Tooltip>
|
||||
</TooltipTrigger>
|
||||
) : (
|
||||
children
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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<HTMLDivElement>(null)
|
||||
const tooltipRef = useRef<HTMLDivElement>(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<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 (
|
||||
<>
|
||||
<div
|
||||
@@ -73,11 +138,14 @@ export const VisualOnlyTooltip = ({
|
||||
>
|
||||
{wrappedChild}
|
||||
</div>
|
||||
{tooltipData &&
|
||||
{isVisible &&
|
||||
position &&
|
||||
portalContainer &&
|
||||
createPortal(
|
||||
<div
|
||||
aria-hidden="true"
|
||||
role="presentation"
|
||||
ref={tooltipRef}
|
||||
className={css({
|
||||
position: 'fixed',
|
||||
padding: '2px 8px',
|
||||
@@ -87,15 +155,15 @@ export const VisualOnlyTooltip = ({
|
||||
fontSize: 14,
|
||||
whiteSpace: 'nowrap',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 9999,
|
||||
zIndex: 100001,
|
||||
boxShadow: '0 8px 20px rgba(0 0 0 / 0.1)',
|
||||
'&::after': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
left: 'var(--tooltip-arrow-left, 50%)',
|
||||
transform: 'translateX(-50%)',
|
||||
border: '4px solid transparent',
|
||||
...(isBottom
|
||||
...(effectiveBottom
|
||||
? {
|
||||
bottom: '100%',
|
||||
borderBottomColor: 'primaryDark.100',
|
||||
@@ -106,17 +174,11 @@ export const VisualOnlyTooltip = ({
|
||||
}),
|
||||
},
|
||||
})}
|
||||
style={{
|
||||
top: `${tooltipData.position.top}px`,
|
||||
left: `${tooltipData.position.left}px`,
|
||||
transform: isBottom
|
||||
? 'translate(-50%, 0)'
|
||||
: 'translate(-50%, -100%)',
|
||||
}}
|
||||
style={tooltipInlineStyle}
|
||||
>
|
||||
{tooltip}
|
||||
</div>,
|
||||
document.body
|
||||
portalContainer
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user