(frontend) add an options menu to the PiP window

Adding a menu turned out to be tricky. Our usual menu primitive
is built on react-aria, which positions overlays based on the
`window` object. Since the JS runs in the main window, the
computed position refers to the main window's coordinates, and
the menu renders in the wrong place inside the PiP document.

After investigating, we couldn't find a clean way to make
react-aria target the PiP window's `window`/`document` without
significant rework. Agreed with @ovgodd to duplicate the menu in
the PiP window as a pragmatic workaround until a better approach
emerges.

Co-authored-by: Cyril <c.gromoff@gmail.com>
This commit is contained in:
lebaudantoine
2026-05-20 19:15:22 +02:00
committed by aleb_the_flash
parent 4456137948
commit 8b8f9eae92
5 changed files with 171 additions and 1 deletions
@@ -7,6 +7,7 @@ import { LeaveButton } from '@/features/rooms/livekit/components/controls/LeaveB
import { HandToggle } from '@/features/rooms/livekit/components/controls/HandToggle'
import { StartMediaButton } from '@/features/rooms/livekit/components/controls/StartMediaButton'
import { ReactionsToggle } from '@/features/reactions/components/ReactionsToggle'
import { PipOptionsMenu } from './controls/PipOptionsMenu'
export const PipControlBar = ({
showScreenShare,
@@ -30,6 +31,7 @@ export const PipControlBar = ({
{showScreenShare && <ScreenShareToggle />}
<HandToggle />
<StartMediaButton />
<PipOptionsMenu />
<LeaveButton />
</PipControlsCenter>
</PipControls>
@@ -0,0 +1,125 @@
import { useEffect, useRef, useState } from 'react'
import { RiMoreFill } from '@remixicon/react'
import { FocusScope } from '@react-aria/focus'
import { Box, Button } from '@/primitives'
import { css } from '@/styled-system/css'
import { useTranslation } from 'react-i18next'
import { PipOptionsMenuItems } from './PipOptionsMenuItems'
import { useDismissOnEscape } from '../../hooks/useDismissOnEscape'
/**
* PiP-native options menu.
*
* Why not use the shared `<Menu>` primitive (React Aria's MenuTrigger +
* Popover)?
*
* React Aria positions popovers by reading `window.innerWidth` and
* `window.innerHeight` to know where the viewport edges are. The problem:
* it reads them from the *module-global* `window`, which is always the
* main browser window — even when the trigger button lives inside the
* Picture-in-Picture window (a separate `document` with its own, smaller
* viewport). React Aria therefore thinks it has the full main-window
* space to work with, and places the popover using those coordinates.
* The result is a menu that appears off-screen, clipped, or in the wrong
* corner of the PiP.
*
* The same single-document assumption breaks focus: React Aria's focus
* management and outside-click detection listen on the main `document`,
* so when the user interacts in the PiP, the menu doesn't receive focus
* on open, Escape doesn't restore focus to the trigger, and clicking
* outside doesn't dismiss it.
*
* These aren't bugs we can fix from the outside — the `window` and
* `document` references are baked into React Aria internals, with no
* prop or context to override them.
*
* So in PiP we replace the primitive with this component.
*/
export const PipOptionsMenu = () => {
const { t } = useTranslation('rooms')
const wrapperRef = useRef<HTMLDivElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const [isOpen, setIsOpen] = useState(false)
const label = t('options.buttonLabel')
useDismissOnEscape(wrapperRef, isOpen, () => {
setIsOpen(false)
requestAnimationFrame(() => triggerRef.current?.focus())
})
useEffect(() => {
if (!isOpen) return
const doc = wrapperRef.current?.ownerDocument ?? document
const handleMenuItemClick = (event: MouseEvent) => {
const target = event.target as HTMLElement | null
const wrapper = wrapperRef.current
if (!wrapper || !target) return
if (wrapper.querySelector('button')?.contains(target)) return
if (target.closest('[role="menuitem"]')) {
requestAnimationFrame(() => {
setIsOpen(false)
triggerRef.current?.focus()
})
}
}
const handleOutsideClick = (event: MouseEvent) => {
const target = event.target as HTMLElement | null
const wrapper = wrapperRef.current
if (!wrapper || !target) return
if (wrapper.contains(target)) return
setIsOpen(false)
}
doc.addEventListener('click', handleMenuItemClick, true)
doc.addEventListener('mousedown', handleOutsideClick, true)
return () => {
doc.removeEventListener('click', handleMenuItemClick, true)
doc.removeEventListener('mousedown', handleOutsideClick, true)
}
}, [isOpen])
return (
<div
ref={wrapperRef}
className={css({
position: 'relative',
})}
>
<Button
ref={triggerRef}
id="room-options-trigger"
square
variant="primaryDark"
aria-label={label}
aria-haspopup="menu"
aria-expanded={isOpen}
tooltip={label}
onPress={() => setIsOpen(!isOpen)}
>
<RiMoreFill />
</Button>
{isOpen && (
<div
className={css({
position: 'absolute',
left: '50%',
bottom: 'calc(100% + 0.85rem)',
transform: 'translateX(-50%)',
zIndex: 10,
})}
>
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
<FocusScope autoFocus>
<Box size="sm" type="popover" variant="dark">
<PipOptionsMenuItems />
</Box>
</FocusScope>
</div>
)}
</div>
)
}
@@ -0,0 +1,15 @@
import { Menu as RACMenu } from 'react-aria-components'
import { PictureInPictureMenuItem } from '@/features/rooms/livekit/components/controls/Options/PictureInPictureMenuItem'
export const PipOptionsMenuItems = () => {
return (
<RACMenu
style={{
minWidth: '150px',
width: '300px',
}}
>
<PictureInPictureMenuItem />
</RACMenu>
)
}
@@ -0,0 +1,28 @@
import { useEffect, useRef, type RefObject } from 'react'
export const useDismissOnEscape = (
ref: RefObject<HTMLElement | null>,
isActive: boolean,
onDismiss: () => void
) => {
const latestOnDismiss = useRef(onDismiss)
useEffect(() => {
latestOnDismiss.current = onDismiss
})
useEffect(() => {
if (!isActive) return
const el = ref.current
if (!el) return
const handler = (event: KeyboardEvent) => {
if (event.key !== 'Escape' || event.defaultPrevented) return
event.preventDefault()
event.stopPropagation()
latestOnDismiss.current()
}
el.addEventListener('keydown', handler)
return () => el.removeEventListener('keydown', handler)
}, [ref, isActive])
}
@@ -64,7 +64,7 @@ const SCROLL_AMOUNT = 120 // roughly 3 buttons
export const ReactionButtonsContainer = ({
children,
adjustedCentering,
adjustedCentering = true,
}: {
children: React.ReactNode
adjustedCentering?: boolean