💄(fix) align overlays in pip

route menus/tooltips to the pip document
This commit is contained in:
Cyril
2026-01-21 12:31:00 +01:00
parent 3fd265f291
commit cf37d636db
5 changed files with 90 additions and 12 deletions
+28 -2
View File
@@ -1,10 +1,16 @@
import { ReactNode } from 'react'
import { ReactNode, useMemo } from 'react'
import { MenuTrigger } from 'react-aria-components'
import { StyledPopover } from './Popover'
import { Box } from './Box'
import {
useOverlayBoundaryElement,
useOverlayPortalContainer,
} from './useOverlayPortalContainer'
/**
* a Menu is a tuple of a trigger component (most usually a Button) that toggles menu items in a tooltip around the trigger
*
* Uses UNSAFE_PortalProvider context automatically for portal container (no need for UNSTABLE_portalContainer).
*/
export const Menu = ({
children,
@@ -16,10 +22,30 @@ export const Menu = ({
placement?: 'bottom' | 'top' | 'left' | 'right'
}) => {
const [trigger, menu] = children
const boundaryElement = useOverlayBoundaryElement()
const portalContainer = useOverlayPortalContainer()
// Detect if we're in PiP: portal container is in a different document than the main window
const isInPiP = useMemo(
() =>
portalContainer &&
portalContainer.ownerDocument &&
portalContainer.ownerDocument !== document,
[portalContainer]
)
// Default placement: 'bottom' in PiP, 'top' elsewhere (to match existing behavior)
const defaultPlacement = isInPiP ? 'bottom' : 'top'
const shouldFlip = isInPiP ? false : undefined
return (
<MenuTrigger>
{trigger}
<StyledPopover placement={placement}>
<StyledPopover
placement={placement ?? defaultPlacement}
shouldFlip={shouldFlip}
boundaryElement={boundaryElement}
>
<Box size="sm" type="popover" variant={variant}>
{menu}
</Box>
+5 -1
View File
@@ -8,6 +8,7 @@ import {
} from 'react-aria-components'
import { styled } from '@/styled-system/jsx'
import { Box } from './Box'
import { useOverlayBoundaryElement } from './useOverlayPortalContainer'
export const StyledPopover = styled(RACPopover, {
base: {
@@ -65,6 +66,8 @@ const StyledOverlayArrow = styled(OverlayArrow, {
*
* Note: to show a list of actionable items, like a dropdown menu, prefer using a <Menu> or <Select>.
* This is here when needing to show unrestricted content in a box.
*
* Uses UNSAFE_PortalProvider context automatically for portal container (no need for UNSTABLE_portalContainer).
*/
export const Popover = ({
children,
@@ -82,10 +85,11 @@ export const Popover = ({
withArrow?: boolean
} & Omit<DialogProps, 'children'>) => {
const [trigger, popoverContent] = children
const boundaryElement = useOverlayBoundaryElement()
return (
<DialogTrigger>
{trigger}
<StyledPopover>
<StyledPopover boundaryElement={boundaryElement}>
{withArrow && (
<StyledOverlayArrow variant={variant}>
<svg width={12} height={12} viewBox="0 0 12 12">
+25 -6
View File
@@ -6,12 +6,17 @@ import {
type TooltipProps,
} from 'react-aria-components'
import { styled } from '@/styled-system/jsx'
import { useOverlayPortalContainer } from './useOverlayPortalContainer'
import { VisualOnlyTooltip } from './VisualOnlyTooltip'
export type TooltipWrapperProps = {
tooltip?: string
tooltipType?: 'instant' | 'delayed'
}
const INSTANT_TOOLTIP_DELAY_MS = 150
const DELAYED_TOOLTIP_DELAY_MS = 1000
/**
* Wrap a component you want to apply a tooltip on (for example a Button)
*
@@ -24,11 +29,25 @@ export const TooltipWrapper = ({
}: {
children: ReactNode
} & TooltipWrapperProps) => {
const portalContainer = useOverlayPortalContainer()
const isExternalDocument =
portalContainer && portalContainer.ownerDocument !== document
return tooltip ? (
<TooltipTrigger delay={tooltipType === 'instant' ? 150 : 1000}>
{children}
<Tooltip>{tooltip}</Tooltip>
</TooltipTrigger>
isExternalDocument ? (
<VisualOnlyTooltip tooltip={tooltip}>{children}</VisualOnlyTooltip>
) : (
<TooltipTrigger
delay={
tooltipType === 'instant'
? INSTANT_TOOLTIP_DELAY_MS
: DELAYED_TOOLTIP_DELAY_MS
}
>
{children}
<Tooltip>{tooltip}</Tooltip>
</TooltipTrigger>
)
) : (
children
)
@@ -53,11 +72,11 @@ const StyledTooltip = styled(RACTooltip, {
fontSize: 14,
transform: 'translate3d(0, 0, 0)',
'&[data-placement=top]': {
marginBottom: '8px',
marginBottom: '2px',
'--origin': 'translateY(4px)',
},
'&[data-placement=bottom]': {
marginTop: '8px',
marginTop: '2px',
'--origin': 'translateY(-4px)',
},
'&[data-placement=right]': {
@@ -2,11 +2,12 @@ import {
type ReactElement,
cloneElement,
isValidElement,
useRef,
useMemo, useRef,
useState,
} 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
@@ -32,6 +33,7 @@ export const VisualOnlyTooltip = ({
tooltipPosition = 'top',
}: VisualOnlyTooltipProps) => {
const [isVisible, setIsVisible] = useState(false)
const { getContainer } = useUNSAFE_PortalContext()
const wrapperRef = useRef<HTMLDivElement>(null)
const [position, setPosition] = useState<{
top: number
@@ -56,6 +58,11 @@ export const VisualOnlyTooltip = ({
}
const tooltipData = isVisible && position ? { isVisible, position } : null
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 } : {}),
@@ -73,7 +80,7 @@ export const VisualOnlyTooltip = ({
>
{wrappedChild}
</div>
{tooltipData &&
{tooltipData && portalContainer &&
createPortal(
<div
aria-hidden="true"
@@ -116,7 +123,7 @@ export const VisualOnlyTooltip = ({
>
{tooltip}
</div>,
document.body
portalContainer
)}
</>
)
@@ -0,0 +1,22 @@
import { useMemo } from 'react'
import { useUNSAFE_PortalContext } from '@react-aria/overlays'
export const useOverlayPortalContainer = () => {
const { getContainer } = useUNSAFE_PortalContext()
// Read the portal container provided by UNSAFE_PortalProvider.
// This is how overlays know which document/window they should render into.
// "UNSAFE" means we're overriding the library default container on purpose.
return useMemo(() => getContainer?.() ?? undefined, [getContainer])
}
export const useOverlayBoundaryElement = () => {
const portalContainer = useOverlayPortalContainer()
return useMemo(() => {
// Use the portal container as the positioning boundary.
// In PiP this keeps overlays positioned relative to the PiP window.
if (portalContainer) return portalContainer
return undefined
}, [portalContainer])
}