mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-27 04:09:26 +00:00
✨(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:
committed by
aleb_the_flash
parent
4456137948
commit
8b8f9eae92
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user