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