From 8b1d2007404c7eeba62600974bb48586001a7c67 Mon Sep 17 00:00:00 2001 From: Cyril Date: Thu, 23 Apr 2026 19:19:52 +0200 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F(frontend)=20PiP=20options=20?= =?UTF-8?q?menu=20and=20reactions=20toolbar=20refactor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PiP-native options popover; split reactions, keyboard nav, and pagination. --- .../features/pip/components/PipControlBar.tsx | 7 +- .../pip/components/PipReactionsToolbar.tsx | 244 +++--------------- .../src/features/pip/components/PipView.tsx | 32 ++- .../components/controls/PipOptionsMenu.tsx | 41 ++- .../controls/PipOptionsMenuItems.tsx | 2 - .../pip/components/layouts/PipGridLayout.tsx | 5 +- .../pip/components/layouts/PipStage.tsx | 7 +- .../PipReactionsKeyboardNavigation.tsx | 65 +++++ .../components/reactions/PipReactionsPill.tsx | 160 ++++++++++++ .../pip/hooks/usePipFlipAnimations.ts | 5 +- .../src/features/pip/utils/pipGrid.ts | 6 +- .../pip/utils/pipReactionsPagination.ts | 62 +++++ 12 files changed, 393 insertions(+), 243 deletions(-) create mode 100644 src/frontend/src/features/pip/components/reactions/PipReactionsKeyboardNavigation.tsx create mode 100644 src/frontend/src/features/pip/components/reactions/PipReactionsPill.tsx create mode 100644 src/frontend/src/features/pip/utils/pipReactionsPagination.ts diff --git a/src/frontend/src/features/pip/components/PipControlBar.tsx b/src/frontend/src/features/pip/components/PipControlBar.tsx index 9028838d..19d9a01d 100644 --- a/src/frontend/src/features/pip/components/PipControlBar.tsx +++ b/src/frontend/src/features/pip/components/PipControlBar.tsx @@ -65,7 +65,12 @@ export const PipControlBar = ({ ) return ( - + diff --git a/src/frontend/src/features/pip/components/PipReactionsToolbar.tsx b/src/frontend/src/features/pip/components/PipReactionsToolbar.tsx index 0ceddab6..b684e37b 100644 --- a/src/frontend/src/features/pip/components/PipReactionsToolbar.tsx +++ b/src/frontend/src/features/pip/components/PipReactionsToolbar.tsx @@ -1,147 +1,54 @@ -import { useRef, useState, useEffect, useCallback, useMemo } from 'react' +import { useEffect, useRef } from 'react' +import { FocusScope } from '@react-aria/focus' import { styled } from '@/styled-system/jsx' -import { css } from '@/styled-system/css' -import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react' import { useSnapshot } from 'valtio' import { pipLayoutStore } from '../stores/pipLayoutStore' -import { ReactionButton } from '@/features/reactions/components/toolbar/ReactionButton' -import { Emoji } from '@/features/reactions/types' - -const EMOJIS = Object.values(Emoji) -const EMOJI_SLOT_WIDTH = 40 -const ARROW_SLOT_WIDTH = 32 -const PILL_HORIZONTAL_PADDING = 12 +import { useDelayUnmount } from '@/hooks/useDelayUnmount' +import { usePipElementSize } from '../hooks/usePipElementSize' +import { PipReactionsKeyboardNavigation } from './reactions/PipReactionsKeyboardNavigation' +import { PipReactionsPill } from './reactions/PipReactionsPill' +/** + * Reactions toolbar for the PiP window. Owns only the open/close orchestration; + * layout and pagination live in `PipReactionsPill`, keyboard nav in + * `PipReactionsKeyboardNavigation`. + */ export const PipReactionsToolbar = () => { const { showReactionsToolbar: isOpen } = useSnapshot(pipLayoutStore) + // Unmount content after the close transition so hidden emojis leave the tab order. + const renderContent = useDelayUnmount(isOpen, 500) + const contentRef = useRef(null) const wrapperRef = useRef(null) - const [isVisible, setIsVisible] = useState(false) - const [availableWidth, setAvailableWidth] = useState(0) - const [pageStart, setPageStart] = useState(0) + const { width: availableWidth } = usePipElementSize(wrapperRef) + // Mark the subtree inert during the fade-out so Tab can't land on it. useEffect(() => { - if (isOpen) { - const id = requestAnimationFrame(() => setIsVisible(true)) - return () => cancelAnimationFrame(id) - } else { - setIsVisible(false) - } - }, [isOpen]) - - const updateWidth = useCallback(() => { - const el = wrapperRef.current + const el = contentRef.current if (!el) return - setAvailableWidth(el.getBoundingClientRect().width) - }, []) - - useEffect(() => { - const el = wrapperRef.current - if (!el) return - - updateWidth() - - const RO = - el.ownerDocument.defaultView?.ResizeObserver ?? window.ResizeObserver - const observer = new RO(() => updateWidth()) - observer.observe(el) - - return () => observer.disconnect() - }, [updateWidth]) - - const { visibleEmojis, hasOverflow, canGoLeft, canGoRight } = useMemo(() => { - const maxWithoutArrows = Math.max( - 1, - Math.floor((availableWidth - PILL_HORIZONTAL_PADDING) / EMOJI_SLOT_WIDTH) - ) - - if (EMOJIS.length <= maxWithoutArrows) { - return { - visibleEmojis: EMOJIS, - hasOverflow: false, - canGoLeft: false, - canGoRight: false, - } - } - - const visibleCount = Math.max( - 1, - Math.floor( - (availableWidth - PILL_HORIZONTAL_PADDING - ARROW_SLOT_WIDTH * 2) / - EMOJI_SLOT_WIDTH - ) - ) - const clampedStart = Math.min(pageStart, EMOJIS.length - visibleCount) - - return { - visibleEmojis: EMOJIS.slice(clampedStart, clampedStart + visibleCount), - hasOverflow: true, - canGoLeft: clampedStart > 0, - canGoRight: clampedStart + visibleCount < EMOJIS.length, - } - }, [pageStart, availableWidth]) - - useEffect(() => { - if (!hasOverflow) { - setPageStart(0) - return - } - const visibleCount = Math.max( - 1, - Math.floor( - (availableWidth - PILL_HORIZONTAL_PADDING - ARROW_SLOT_WIDTH * 2) / - EMOJI_SLOT_WIDTH - ) - ) - const maxStart = Math.max(0, EMOJIS.length - visibleCount) - if (pageStart > maxStart) { - setPageStart(maxStart) - } - }, [hasOverflow, pageStart, availableWidth]) - - const paginate = useCallback((direction: 'left' | 'right') => { - setPageStart((current) => - direction === 'left' ? Math.max(0, current - 1) : current + 1 - ) - }, []) + if (isOpen) el.removeAttribute('inert') + else el.setAttribute('inert', '') + }, [isOpen, renderContent]) return ( - - - {hasOverflow && ( - - {canGoLeft && ( - paginate('left')} - aria-label="Previous reactions" - > - - - )} - - )} - - {visibleEmojis.map((emoji) => ( - - ))} - - {hasOverflow && ( - - {canGoRight && ( - paginate('right')} - aria-label="Next reactions" - > - - - )} - - )} - - + + {renderContent && ( +
+ {/* eslint-disable-next-line jsx-a11y/no-autofocus */} + + + + + +
+ )} +
) } -const ToolbarWrapper = styled('div', { +const Wrapper = styled('div', { base: { display: 'flex', justifyContent: 'center', @@ -160,82 +67,3 @@ const ToolbarWrapper = styled('div', { }, }, }) - -const ToolbarPill = styled('div', { - base: { - display: 'flex', - alignItems: 'center', - gap: '0.2rem', - borderRadius: '21px', - padding: '0.15rem', - backgroundColor: 'primaryDark.100', - maxWidth: '100%', - overflow: 'hidden', - width: 'fit-content', - opacity: 0, - transform: 'translateY(3.25rem)', - transition: 'opacity, transform', - transitionDuration: '0.5s', - transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)', - pointerEvents: 'none', - }, - variants: { - isVisible: { - true: { - opacity: 1, - transform: 'translateY(0)', - pointerEvents: 'auto', - }, - }, - }, -}) - -const EmojiRow = styled('div', { - base: { - display: 'flex', - gap: '0.2rem', - '& > *': { - flexShrink: 0, - }, - }, -}) - -const ArrowSlot = styled('div', { - base: { - width: '32px', - minWidth: '32px', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - }, -}) - -const ArrowButton = ({ - children, - ...props -}: React.ButtonHTMLAttributes) => ( - -) diff --git a/src/frontend/src/features/pip/components/PipView.tsx b/src/frontend/src/features/pip/components/PipView.tsx index 91e0eb58..e1facf03 100644 --- a/src/frontend/src/features/pip/components/PipView.tsx +++ b/src/frontend/src/features/pip/components/PipView.tsx @@ -24,20 +24,21 @@ export const PipView = () => { // Side panels open via a menu item that unmounts on click; fall back to the // options button so focus returns somewhere visible. - const resolveTrigger = useCallback( - (activeEl: HTMLElement | null) => { - if (activeEl?.tagName === 'DIV') { - const doc = containerRef.current?.ownerDocument ?? document - return doc.getElementById('room-options-trigger') - } - return activeEl - }, - [] - ) + const resolveTrigger = useCallback((activeEl: HTMLElement | null) => { + if (activeEl?.tagName === 'DIV') { + const doc = containerRef.current?.ownerDocument ?? document + return doc.getElementById('room-options-trigger') + } + return activeEl + }, []) usePipRestoreFocus(containerRef, isSidePanelOpen, { resolveTrigger }) return ( - + @@ -55,12 +56,21 @@ const PipContainer = styled('div', { backgroundColor: 'primaryDark.50', '& .lk-participant-tile': { height: '100%', + overflow: 'hidden', + backgroundColor: 'primaryDark.100', }, '& .lk-participant-media': { height: '100%', + overflow: 'hidden', + borderRadius: 0, + backgroundColor: 'primaryDark.100', }, '& .lk-participant-media-video': { + display: 'block', + width: '100%', height: '100%', + borderRadius: 0, + backgroundColor: 'primaryDark.100', objectFit: 'cover', }, '& .lk-grid-layout': { diff --git a/src/frontend/src/features/pip/components/controls/PipOptionsMenu.tsx b/src/frontend/src/features/pip/components/controls/PipOptionsMenu.tsx index 9d487f31..fea3d31d 100644 --- a/src/frontend/src/features/pip/components/controls/PipOptionsMenu.tsx +++ b/src/frontend/src/features/pip/components/controls/PipOptionsMenu.tsx @@ -1,9 +1,11 @@ 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 { PipOptionsMenuItems } from './PipOptionsMenuItems' import { useTranslation } from 'react-i18next' +import { PipOptionsMenuItems } from './PipOptionsMenuItems' +import { useEscapeDismiss } from '@/features/pip/hooks/useEscapeDismiss' import type { CollapsibleControl } from '../PipControlBar' type PipOptionsMenuProps = { @@ -11,16 +13,22 @@ type PipOptionsMenuProps = { } /** - * PiP-specific options menu with absolute positioning for correct alignment in PiP window. - * Renders locally (unlike standard Menu) and closes automatically on item click or outside click. - * Overflow controls from the toolbar are rendered as additional menu items. + * PiP-native options menu. The shared `Menu` primitive mis-positions its + * popover and loses focus across documents, so we drive open/close, focus + * and dismissal ourselves. */ export const PipOptionsMenu = ({ overflowControls }: PipOptionsMenuProps) => { const { t } = useTranslation('rooms') const wrapperRef = useRef(null) + const triggerRef = useRef(null) const [isOpen, setIsOpen] = useState(false) const label = t('options.buttonLabel') + useEscapeDismiss(wrapperRef, isOpen, () => { + setIsOpen(false) + requestAnimationFrame(() => triggerRef.current?.focus()) + }) + useEffect(() => { if (!isOpen) return const doc = wrapperRef.current?.ownerDocument ?? document @@ -29,19 +37,28 @@ export const PipOptionsMenu = ({ overflowControls }: PipOptionsMenuProps) => { 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]) @@ -53,10 +70,13 @@ export const PipOptionsMenu = ({ overflowControls }: PipOptionsMenuProps) => { })} >