mirror of
https://github.com/suitenumerique/meet.git
synced 2026-09-04 14:45:40 +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 { 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)
|
||||||
@@ -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
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user