From 96edb3725a844fcff9b04972fc7eba7c1153a4d4 Mon Sep 17 00:00:00 2001 From: Cyril Date: Thu, 23 Apr 2026 19:18:56 +0200 Subject: [PATCH] =?UTF-8?q?=E2=99=BF=EF=B8=8F(frontend)=20PiP=20accessibil?= =?UTF-8?q?ity=20and=20cross-document=20focus=20foundation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cross-doc focus helpers, Escape stack, lang/title sync, landmarks, SR announce. --- .../pip/components/DocumentPiPPortal.tsx | 53 ++++++++++++++++--- .../features/pip/components/PipControlBar.tsx | 6 ++- .../src/features/pip/components/PipView.tsx | 33 ++++++++++-- .../pip/components/layouts/PipStage.tsx | 18 +++++-- .../features/pip/hooks/useEscapeDismiss.ts | 28 ++++++++++ .../features/pip/hooks/usePipRestoreFocus.ts | 41 ++++++++++++++ src/frontend/src/utils/dom.ts | 24 +++++++-- 7 files changed, 182 insertions(+), 21 deletions(-) create mode 100644 src/frontend/src/features/pip/hooks/useEscapeDismiss.ts create mode 100644 src/frontend/src/features/pip/hooks/usePipRestoreFocus.ts diff --git a/src/frontend/src/features/pip/components/DocumentPiPPortal.tsx b/src/frontend/src/features/pip/components/DocumentPiPPortal.tsx index afbdbda5..52326e65 100644 --- a/src/frontend/src/features/pip/components/DocumentPiPPortal.tsx +++ b/src/frontend/src/features/pip/components/DocumentPiPPortal.tsx @@ -1,6 +1,9 @@ import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react' import { createPortal } from 'react-dom' +import { useTranslation } from 'react-i18next' import { useDocumentPiP } from '../hooks/useDocumentPiP' +import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce' +import { useRestoreFocus } from '@/hooks/useRestoreFocus' import { UNSAFE_PortalProvider } from '@react-aria/overlays' // Minimal base styles so the PiP window renders correctly on first paint. @@ -96,10 +99,9 @@ const syncCssVariables = (source: Document, target: Document) => { } /** - * React Portal that renders children into a Document Picture-in-Picture window. - * Handles PiP window lifecycle, style injection, React root management, and uses UNSAFE_PortalProvider - * to ensure React Aria overlays render correctly within the PiP window. - * Creates a fresh React root on reopen to prevent black screen issues. + * React portal into a Document Picture-in-Picture window. Handles window + * lifecycle, style/theme sync and routes React Aria overlays via + * `UNSAFE_PortalProvider` so they render inside the PiP document. */ export const DocumentPiPPortal = ({ isOpen, @@ -118,8 +120,13 @@ export const DocumentPiPPortal = ({ width, height, }) + const { t } = useTranslation('rooms', { + keyPrefix: 'options.items.pictureInPicture', + }) + const announce = useScreenReaderAnnounce() const [container, setContainer] = useState(null) const containerRef = useRef(null) + const prevOpenRef = useRef(false) useEffect(() => { if (!isOpen) { @@ -139,6 +146,10 @@ export const DocumentPiPPortal = ({ copyStyles(document, doc) syncThemeAttribute(document, doc) syncCssVariables(document, doc) + + doc.documentElement.setAttribute('lang', document.documentElement.lang) + doc.title = t('windowLabel') + const existingContainer = containerRef.current if (!existingContainer || existingContainer.ownerDocument !== doc) { const nextContainer = doc.createElement('div') @@ -161,10 +172,40 @@ export const DocumentPiPPortal = ({ } }, [closePiP, isOpen, isSupported, openPiP]) + // Focus stays on the trigger; PiP is announced as an auxiliary surface. + useEffect(() => { + const wasOpen = prevOpenRef.current + prevOpenRef.current = isOpen + + if (isOpen && !wasOpen) { + announce(t('opened'), 'polite') + } + if (!isOpen && wasOpen) { + announce(t('closed'), 'polite') + } + }, [isOpen, announce, t]) + + useRestoreFocus(isOpen, { restoreFocusRaf: true }) + + // Escape from either document closes PiP (unless a nested overlay handled it). + useEffect(() => { + if (!isOpen) return + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Escape' || event.defaultPrevented) return + event.preventDefault() + onClose?.() + } + document.addEventListener('keydown', handleKeyDown) + pipWindow?.document.addEventListener('keydown', handleKeyDown) + return () => { + document.removeEventListener('keydown', handleKeyDown) + pipWindow?.document.removeEventListener('keydown', handleKeyDown) + } + }, [isOpen, onClose, pipWindow]) + useEffect(() => { if (!pipWindow) return const handleClose = () => { - // Reset container so reopening PiP mounts a fresh root. containerRef.current = null setContainer(null) onClose?.() @@ -180,8 +221,6 @@ export const DocumentPiPPortal = ({ const portal = useMemo(() => { if (!container) return null return createPortal( - // "UNSAFE" because it bypasses react-aria's default portal container. - // We need it to target the PiP document; otherwise overlays render in the main window. container}> {children} , diff --git a/src/frontend/src/features/pip/components/PipControlBar.tsx b/src/frontend/src/features/pip/components/PipControlBar.tsx index 0f6534ae..9028838d 100644 --- a/src/frontend/src/features/pip/components/PipControlBar.tsx +++ b/src/frontend/src/features/pip/components/PipControlBar.tsx @@ -1,5 +1,6 @@ import { styled } from '@/styled-system/jsx' import { useRef, useMemo } from 'react' +import { useTranslation } from 'react-i18next' import { AudioDevicesControl } from '@/features/rooms/livekit/components/controls/Device/AudioDevicesControl' import { VideoDeviceControl } from '@/features/rooms/livekit/components/controls/Device/VideoDeviceControl' import { ScreenShareToggle } from '@/features/rooms/livekit/components/controls/ScreenShareToggle' @@ -54,6 +55,9 @@ export const PipControlBar = ({ }) => { const containerRef = useRef(null) const { width } = usePipElementSize(containerRef) + const { t } = useTranslation('rooms', { + keyPrefix: 'options.items.pictureInPicture', + }) const hidden = useMemo( () => getHiddenControls(width, showScreenShare), @@ -61,7 +65,7 @@ export const PipControlBar = ({ ) return ( - + diff --git a/src/frontend/src/features/pip/components/PipView.tsx b/src/frontend/src/features/pip/components/PipView.tsx index b161d919..91e0eb58 100644 --- a/src/frontend/src/features/pip/components/PipView.tsx +++ b/src/frontend/src/features/pip/components/PipView.tsx @@ -1,23 +1,46 @@ +import { useCallback, useRef } from 'react' import { supportsScreenSharing } from '@livekit/components-core' +import { useTranslation } from 'react-i18next' import { styled } from '@/styled-system/jsx' import { SidePanel } from '@/features/rooms/livekit/components/SidePanel' +import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel' import { pipLayoutStore } from '../stores/pipLayoutStore' +import { useEscapeDismiss } from '../hooks/useEscapeDismiss' +import { usePipRestoreFocus } from '../hooks/usePipRestoreFocus' import { PipControlBar } from './PipControlBar' import { PipReactionsToolbar } from './PipReactionsToolbar' import { PipStage } from './layouts/PipStage' - -// Composition shell for the Picture-in-Picture window. - export const PipView = () => { const browserSupportsScreenSharing = supportsScreenSharing() + const { t } = useTranslation('rooms', { + keyPrefix: 'options.items.pictureInPicture', + }) + const containerRef = useRef(null) + const { isSidePanelOpen, closePanel } = useSidePanel(pipLayoutStore) + + // Escape closes the side panel instead of the whole PiP window. + useEscapeDismiss(containerRef, isSidePanelOpen, closePanel) + + // 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 + }, + [] + ) + usePipRestoreFocus(containerRef, isSidePanelOpen, { resolveTrigger }) return ( - + - {/* Side panel (effects, settings, etc.) opens within PiP window. */} ) diff --git a/src/frontend/src/features/pip/components/layouts/PipStage.tsx b/src/frontend/src/features/pip/components/layouts/PipStage.tsx index da14b2e9..1a4dfbeb 100644 --- a/src/frontend/src/features/pip/components/layouts/PipStage.tsx +++ b/src/frontend/src/features/pip/components/layouts/PipStage.tsx @@ -1,4 +1,5 @@ -import { useMemo } from 'react' +import { useEffect, useMemo, useRef } from 'react' +import { useTranslation } from 'react-i18next' import { useTracks } from '@livekit/components-react' import { Track } from 'livekit-client' import { styled } from '@/styled-system/jsx' @@ -20,6 +21,9 @@ const FOCUS_MAX_TILES = 2 // Handles which layout to render inside the PiP stage. export const PipStage = () => { + const { t } = useTranslation('rooms', { + keyPrefix: 'options.items.pictureInPicture', + }) const tracks = useTracks( [ { source: Track.Source.Camera, withPlaceholder: true }, @@ -41,11 +45,19 @@ export const PipStage = () => { return [screenShareTrack, ...cameraTracks] }, [tracks, screenShareTrack]) + // avoid tabbing to the stage when it's not visible + const frameRef = useRef(null) + useEffect(() => { + frameRef.current?.setAttribute('inert', '') + }, []) + if (stageTracks.length === 0) return null + const stageLabel = t('stage') + if (stageTracks.length > FOCUS_MAX_TILES) { return ( - + ) @@ -60,7 +72,7 @@ export const PipStage = () => { : stageTracks.find((track) => track !== mainTrack) return ( - + ) diff --git a/src/frontend/src/features/pip/hooks/useEscapeDismiss.ts b/src/frontend/src/features/pip/hooks/useEscapeDismiss.ts new file mode 100644 index 00000000..9d857f2c --- /dev/null +++ b/src/frontend/src/features/pip/hooks/useEscapeDismiss.ts @@ -0,0 +1,28 @@ +import { useEffect, useRef, type RefObject } from 'react' + +export const useEscapeDismiss = ( + 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/pip/hooks/usePipRestoreFocus.ts b/src/frontend/src/features/pip/hooks/usePipRestoreFocus.ts new file mode 100644 index 00000000..7ac2223e --- /dev/null +++ b/src/frontend/src/features/pip/hooks/usePipRestoreFocus.ts @@ -0,0 +1,41 @@ +import { useEffect, useRef, type RefObject } from 'react' + +type Options = { + /** Remap the captured trigger (e.g. when it unmounts on click). */ + resolveTrigger?: (activeEl: HTMLElement | null) => HTMLElement | null +} + +/** + * `useRestoreFocus`: captures and restores focus via the PiP + * document instead of the main one. + */ +export const usePipRestoreFocus = ( + ref: RefObject, + isOpen: boolean, + { resolveTrigger }: Options = {} +) => { + const prevOpenRef = useRef(false) + const triggerRef = useRef(null) + + useEffect(() => { + const doc = ref.current?.ownerDocument + const wasOpen = prevOpenRef.current + prevOpenRef.current = isOpen + + if (!doc) return + + if (!wasOpen && isOpen) { + const activeEl = doc.activeElement as HTMLElement | null + triggerRef.current = resolveTrigger ? resolveTrigger(activeEl) : activeEl + return + } + + if (wasOpen && !isOpen) { + const trigger = triggerRef.current + triggerRef.current = null + if (trigger && doc.contains(trigger)) { + requestAnimationFrame(() => trigger.focus({ preventScroll: true })) + } + } + }, [ref, isOpen, resolveTrigger]) +} diff --git a/src/frontend/src/utils/dom.ts b/src/frontend/src/utils/dom.ts index c18bccfa..b8c5817a 100644 --- a/src/frontend/src/utils/dom.ts +++ b/src/frontend/src/utils/dom.ts @@ -1,6 +1,20 @@ +const FOCUSABLE_SELECTOR = + 'input, select, textarea, button, object, a, area[href], [tabindex]' + +/** + * Find the first focusable descendant of `root`. + * Works across documents (useful for the PiP window, which has its own + * `document`). Pass the result of `ownerDocument.getElementById(...)` to + * target an element in a specific document. + */ +export const findFirstFocusable = ( + root: HTMLElement | null | undefined +): HTMLElement | null => + root?.querySelector(FOCUSABLE_SELECTOR) ?? null + +/** + * Wrapper for the main document. Use `findFirstFocusable` when + * working with a non-main document (e.g. the PiP window). + */ export const getFirstControlBarFocusable = (id: string): HTMLElement | null => - document - .getElementById(id) - ?.querySelector( - 'input, select, textarea, button, object, a, area[href], [tabindex]' - ) ?? null + findFirstFocusable(document.getElementById(id))