From 8b8f9eae92f94ed35d2d37db14ac69ea1c271b28 Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Wed, 20 May 2026 19:15:22 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(frontend)=20add=20an=20options=20menu?= =?UTF-8?q?=20to=20the=20PiP=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../features/pip/components/PipControlBar.tsx | 2 + .../components/controls/PipOptionsMenu.tsx | 125 ++++++++++++++++++ .../controls/PipOptionsMenuItems.tsx | 15 +++ .../features/pip/hooks/useDismissOnEscape.ts | 28 ++++ .../toolbar/ReactionButtonsContainer.tsx | 2 +- 5 files changed, 171 insertions(+), 1 deletion(-) create mode 100644 src/frontend/src/features/pip/components/controls/PipOptionsMenu.tsx create mode 100644 src/frontend/src/features/pip/components/controls/PipOptionsMenuItems.tsx create mode 100644 src/frontend/src/features/pip/hooks/useDismissOnEscape.ts diff --git a/src/frontend/src/features/pip/components/PipControlBar.tsx b/src/frontend/src/features/pip/components/PipControlBar.tsx index 4bf8a973..bcee1ad6 100644 --- a/src/frontend/src/features/pip/components/PipControlBar.tsx +++ b/src/frontend/src/features/pip/components/PipControlBar.tsx @@ -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 && } + diff --git a/src/frontend/src/features/pip/components/controls/PipOptionsMenu.tsx b/src/frontend/src/features/pip/components/controls/PipOptionsMenu.tsx new file mode 100644 index 00000000..6a76a983 --- /dev/null +++ b/src/frontend/src/features/pip/components/controls/PipOptionsMenu.tsx @@ -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 `` 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(null) + const triggerRef = useRef(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 ( +
+ + {isOpen && ( +
+ {/* eslint-disable-next-line jsx-a11y/no-autofocus */} + + + + + +
+ )} +
+ ) +} diff --git a/src/frontend/src/features/pip/components/controls/PipOptionsMenuItems.tsx b/src/frontend/src/features/pip/components/controls/PipOptionsMenuItems.tsx new file mode 100644 index 00000000..690248f4 --- /dev/null +++ b/src/frontend/src/features/pip/components/controls/PipOptionsMenuItems.tsx @@ -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 ( + + + + ) +} diff --git a/src/frontend/src/features/pip/hooks/useDismissOnEscape.ts b/src/frontend/src/features/pip/hooks/useDismissOnEscape.ts new file mode 100644 index 00000000..b5964442 --- /dev/null +++ b/src/frontend/src/features/pip/hooks/useDismissOnEscape.ts @@ -0,0 +1,28 @@ +import { useEffect, useRef, type RefObject } from 'react' + +export const useDismissOnEscape = ( + ref: RefObject, + 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]) +} diff --git a/src/frontend/src/features/reactions/components/toolbar/ReactionButtonsContainer.tsx b/src/frontend/src/features/reactions/components/toolbar/ReactionButtonsContainer.tsx index 6a14bad3..76955187 100644 --- a/src/frontend/src/features/reactions/components/toolbar/ReactionButtonsContainer.tsx +++ b/src/frontend/src/features/reactions/components/toolbar/ReactionButtonsContainer.tsx @@ -64,7 +64,7 @@ const SCROLL_AMOUNT = 120 // roughly 3 buttons export const ReactionButtonsContainer = ({ children, - adjustedCentering, + adjustedCentering = true, }: { children: React.ReactNode adjustedCentering?: boolean