mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-30 20:29:07 +00:00
♿️(frontend) PiP accessibility and cross-document focus foundation
Cross-doc focus helpers, Escape stack, lang/title sync, landmarks, SR announce.
This commit is contained in:
@@ -1,6 +1,9 @@
|
|||||||
import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react'
|
import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useDocumentPiP } from '../hooks/useDocumentPiP'
|
import { useDocumentPiP } from '../hooks/useDocumentPiP'
|
||||||
|
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||||
|
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
|
||||||
import { UNSAFE_PortalProvider } from '@react-aria/overlays'
|
import { UNSAFE_PortalProvider } from '@react-aria/overlays'
|
||||||
|
|
||||||
// Minimal base styles so the PiP window renders correctly on first paint.
|
// 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.
|
* React portal into a Document Picture-in-Picture window. Handles window
|
||||||
* Handles PiP window lifecycle, style injection, React root management, and uses UNSAFE_PortalProvider
|
* lifecycle, style/theme sync and routes React Aria overlays via
|
||||||
* to ensure React Aria overlays render correctly within the PiP window.
|
* `UNSAFE_PortalProvider` so they render inside the PiP document.
|
||||||
* Creates a fresh React root on reopen to prevent black screen issues.
|
|
||||||
*/
|
*/
|
||||||
export const DocumentPiPPortal = ({
|
export const DocumentPiPPortal = ({
|
||||||
isOpen,
|
isOpen,
|
||||||
@@ -118,8 +120,13 @@ export const DocumentPiPPortal = ({
|
|||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
})
|
})
|
||||||
|
const { t } = useTranslation('rooms', {
|
||||||
|
keyPrefix: 'options.items.pictureInPicture',
|
||||||
|
})
|
||||||
|
const announce = useScreenReaderAnnounce()
|
||||||
const [container, setContainer] = useState<HTMLElement | null>(null)
|
const [container, setContainer] = useState<HTMLElement | null>(null)
|
||||||
const containerRef = useRef<HTMLElement | null>(null)
|
const containerRef = useRef<HTMLElement | null>(null)
|
||||||
|
const prevOpenRef = useRef(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
@@ -139,6 +146,10 @@ export const DocumentPiPPortal = ({
|
|||||||
copyStyles(document, doc)
|
copyStyles(document, doc)
|
||||||
syncThemeAttribute(document, doc)
|
syncThemeAttribute(document, doc)
|
||||||
syncCssVariables(document, doc)
|
syncCssVariables(document, doc)
|
||||||
|
|
||||||
|
doc.documentElement.setAttribute('lang', document.documentElement.lang)
|
||||||
|
doc.title = t('windowLabel')
|
||||||
|
|
||||||
const existingContainer = containerRef.current
|
const existingContainer = containerRef.current
|
||||||
if (!existingContainer || existingContainer.ownerDocument !== doc) {
|
if (!existingContainer || existingContainer.ownerDocument !== doc) {
|
||||||
const nextContainer = doc.createElement('div')
|
const nextContainer = doc.createElement('div')
|
||||||
@@ -161,10 +172,40 @@ export const DocumentPiPPortal = ({
|
|||||||
}
|
}
|
||||||
}, [closePiP, isOpen, isSupported, openPiP])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!pipWindow) return
|
if (!pipWindow) return
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
// Reset container so reopening PiP mounts a fresh root.
|
|
||||||
containerRef.current = null
|
containerRef.current = null
|
||||||
setContainer(null)
|
setContainer(null)
|
||||||
onClose?.()
|
onClose?.()
|
||||||
@@ -180,8 +221,6 @@ export const DocumentPiPPortal = ({
|
|||||||
const portal = useMemo(() => {
|
const portal = useMemo(() => {
|
||||||
if (!container) return null
|
if (!container) return null
|
||||||
return createPortal(
|
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.
|
|
||||||
<UNSAFE_PortalProvider getContainer={() => container}>
|
<UNSAFE_PortalProvider getContainer={() => container}>
|
||||||
{children}
|
{children}
|
||||||
</UNSAFE_PortalProvider>,
|
</UNSAFE_PortalProvider>,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { styled } from '@/styled-system/jsx'
|
import { styled } from '@/styled-system/jsx'
|
||||||
import { useRef, useMemo } from 'react'
|
import { useRef, useMemo } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import { AudioDevicesControl } from '@/features/rooms/livekit/components/controls/Device/AudioDevicesControl'
|
import { AudioDevicesControl } from '@/features/rooms/livekit/components/controls/Device/AudioDevicesControl'
|
||||||
import { VideoDeviceControl } from '@/features/rooms/livekit/components/controls/Device/VideoDeviceControl'
|
import { VideoDeviceControl } from '@/features/rooms/livekit/components/controls/Device/VideoDeviceControl'
|
||||||
import { ScreenShareToggle } from '@/features/rooms/livekit/components/controls/ScreenShareToggle'
|
import { ScreenShareToggle } from '@/features/rooms/livekit/components/controls/ScreenShareToggle'
|
||||||
@@ -54,6 +55,9 @@ export const PipControlBar = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
const { width } = usePipElementSize(containerRef)
|
const { width } = usePipElementSize(containerRef)
|
||||||
|
const { t } = useTranslation('rooms', {
|
||||||
|
keyPrefix: 'options.items.pictureInPicture',
|
||||||
|
})
|
||||||
|
|
||||||
const hidden = useMemo(
|
const hidden = useMemo(
|
||||||
() => getHiddenControls(width, showScreenShare),
|
() => getHiddenControls(width, showScreenShare),
|
||||||
@@ -61,7 +65,7 @@ export const PipControlBar = ({
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PipControls ref={containerRef}>
|
<PipControls ref={containerRef} id="pip-control-bar" role="toolbar" aria-label={t('controlBar')}>
|
||||||
<PipControlsCenter>
|
<PipControlsCenter>
|
||||||
<AudioDevicesControl hideMenu />
|
<AudioDevicesControl hideMenu />
|
||||||
<VideoDeviceControl hideMenu />
|
<VideoDeviceControl hideMenu />
|
||||||
|
|||||||
@@ -1,23 +1,46 @@
|
|||||||
|
import { useCallback, useRef } from 'react'
|
||||||
import { supportsScreenSharing } from '@livekit/components-core'
|
import { supportsScreenSharing } from '@livekit/components-core'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import { styled } from '@/styled-system/jsx'
|
import { styled } from '@/styled-system/jsx'
|
||||||
import { SidePanel } from '@/features/rooms/livekit/components/SidePanel'
|
import { SidePanel } from '@/features/rooms/livekit/components/SidePanel'
|
||||||
|
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||||
import { pipLayoutStore } from '../stores/pipLayoutStore'
|
import { pipLayoutStore } from '../stores/pipLayoutStore'
|
||||||
|
import { useEscapeDismiss } from '../hooks/useEscapeDismiss'
|
||||||
|
import { usePipRestoreFocus } from '../hooks/usePipRestoreFocus'
|
||||||
import { PipControlBar } from './PipControlBar'
|
import { PipControlBar } from './PipControlBar'
|
||||||
import { PipReactionsToolbar } from './PipReactionsToolbar'
|
import { PipReactionsToolbar } from './PipReactionsToolbar'
|
||||||
import { PipStage } from './layouts/PipStage'
|
import { PipStage } from './layouts/PipStage'
|
||||||
|
|
||||||
|
|
||||||
// Composition shell for the Picture-in-Picture window.
|
|
||||||
|
|
||||||
export const PipView = () => {
|
export const PipView = () => {
|
||||||
const browserSupportsScreenSharing = supportsScreenSharing()
|
const browserSupportsScreenSharing = supportsScreenSharing()
|
||||||
|
const { t } = useTranslation('rooms', {
|
||||||
|
keyPrefix: 'options.items.pictureInPicture',
|
||||||
|
})
|
||||||
|
const containerRef = useRef<HTMLDivElement>(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 (
|
return (
|
||||||
<PipContainer>
|
<PipContainer ref={containerRef} role="region" aria-label={t('windowLabel')}>
|
||||||
<PipStage />
|
<PipStage />
|
||||||
<PipReactionsToolbar />
|
<PipReactionsToolbar />
|
||||||
<PipControlBar showScreenShare={browserSupportsScreenSharing} />
|
<PipControlBar showScreenShare={browserSupportsScreenSharing} />
|
||||||
{/* Side panel (effects, settings, etc.) opens within PiP window. */}
|
|
||||||
<SidePanel store={pipLayoutStore} />
|
<SidePanel store={pipLayoutStore} />
|
||||||
</PipContainer>
|
</PipContainer>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 { useTracks } from '@livekit/components-react'
|
||||||
import { Track } from 'livekit-client'
|
import { Track } from 'livekit-client'
|
||||||
import { styled } from '@/styled-system/jsx'
|
import { styled } from '@/styled-system/jsx'
|
||||||
@@ -20,6 +21,9 @@ const FOCUS_MAX_TILES = 2
|
|||||||
// Handles which layout to render inside the PiP stage.
|
// Handles which layout to render inside the PiP stage.
|
||||||
|
|
||||||
export const PipStage = () => {
|
export const PipStage = () => {
|
||||||
|
const { t } = useTranslation('rooms', {
|
||||||
|
keyPrefix: 'options.items.pictureInPicture',
|
||||||
|
})
|
||||||
const tracks = useTracks(
|
const tracks = useTracks(
|
||||||
[
|
[
|
||||||
{ source: Track.Source.Camera, withPlaceholder: true },
|
{ source: Track.Source.Camera, withPlaceholder: true },
|
||||||
@@ -41,11 +45,19 @@ export const PipStage = () => {
|
|||||||
return [screenShareTrack, ...cameraTracks]
|
return [screenShareTrack, ...cameraTracks]
|
||||||
}, [tracks, screenShareTrack])
|
}, [tracks, screenShareTrack])
|
||||||
|
|
||||||
|
// avoid tabbing to the stage when it's not visible
|
||||||
|
const frameRef = useRef<HTMLDivElement>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
frameRef.current?.setAttribute('inert', '')
|
||||||
|
}, [])
|
||||||
|
|
||||||
if (stageTracks.length === 0) return null
|
if (stageTracks.length === 0) return null
|
||||||
|
|
||||||
|
const stageLabel = t('stage')
|
||||||
|
|
||||||
if (stageTracks.length > FOCUS_MAX_TILES) {
|
if (stageTracks.length > FOCUS_MAX_TILES) {
|
||||||
return (
|
return (
|
||||||
<StageFrame>
|
<StageFrame ref={frameRef} role="region" aria-label={stageLabel}>
|
||||||
<PipGridLayout tracks={stageTracks} />
|
<PipGridLayout tracks={stageTracks} />
|
||||||
</StageFrame>
|
</StageFrame>
|
||||||
)
|
)
|
||||||
@@ -60,7 +72,7 @@ export const PipStage = () => {
|
|||||||
: stageTracks.find((track) => track !== mainTrack)
|
: stageTracks.find((track) => track !== mainTrack)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StageFrame>
|
<StageFrame ref={frameRef} role="region" aria-label={stageLabel}>
|
||||||
<PipFocusLayout mainTrack={mainTrack} thumbnailTrack={thumbnailTrack} />
|
<PipFocusLayout mainTrack={mainTrack} thumbnailTrack={thumbnailTrack} />
|
||||||
</StageFrame>
|
</StageFrame>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { useEffect, useRef, type RefObject } from 'react'
|
||||||
|
|
||||||
|
export const useEscapeDismiss = (
|
||||||
|
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])
|
||||||
|
}
|
||||||
@@ -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<HTMLElement | null>,
|
||||||
|
isOpen: boolean,
|
||||||
|
{ resolveTrigger }: Options = {}
|
||||||
|
) => {
|
||||||
|
const prevOpenRef = useRef(false)
|
||||||
|
const triggerRef = useRef<HTMLElement | null>(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])
|
||||||
|
}
|
||||||
@@ -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<HTMLElement>(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 =>
|
export const getFirstControlBarFocusable = (id: string): HTMLElement | null =>
|
||||||
document
|
findFirstFocusable(document.getElementById(id))
|
||||||
.getElementById(id)
|
|
||||||
?.querySelector(
|
|
||||||
'input, select, textarea, button, object, a, area[href], [tabindex]'
|
|
||||||
) ?? null
|
|
||||||
|
|||||||
Reference in New Issue
Block a user