From db188075af2c2bebb10b3c9a88226c7f89dac6e0 Mon Sep 17 00:00:00 2001 From: Cyril Date: Thu, 11 Dec 2025 11:02:15 +0100 Subject: [PATCH] =?UTF-8?q?=E2=99=BF=EF=B8=8F(frontend)=20improve=20meetin?= =?UTF-8?q?g=20a11y:=20blur,=20focus,=20hover,=20sr=20announcements?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit enhances keyboard nav and screen reader support for meeting interface Signed-off-by: Cyril --- .../livekit/components/ParticipantName.tsx | 1 + .../livekit/components/ParticipantTile.tsx | 37 ++++++++++++++++++- .../components/ParticipantTileFocus.tsx | 11 ++++-- 3 files changed, 44 insertions(+), 5 deletions(-) diff --git a/src/frontend/src/features/rooms/livekit/components/ParticipantName.tsx b/src/frontend/src/features/rooms/livekit/components/ParticipantName.tsx index 2c595802..85739656 100644 --- a/src/frontend/src/features/rooms/livekit/components/ParticipantName.tsx +++ b/src/frontend/src/features/rooms/livekit/components/ParticipantName.tsx @@ -35,6 +35,7 @@ export const ParticipantName = ({ style={{ paddingBottom: '0.1rem', }} + aria-hidden="true" > {displayedName} diff --git a/src/frontend/src/features/rooms/livekit/components/ParticipantTile.tsx b/src/frontend/src/features/rooms/livekit/components/ParticipantTile.tsx index dabe9203..b19c6f33 100644 --- a/src/frontend/src/features/rooms/livekit/components/ParticipantTile.tsx +++ b/src/frontend/src/features/rooms/livekit/components/ParticipantTile.tsx @@ -29,6 +29,8 @@ import { ParticipantPlaceholder } from './ParticipantPlaceholder' import { ParticipantTileFocus } from './ParticipantTileFocus' import { FullScreenShareWarning } from './FullScreenShareWarning' import { ParticipantName } from './ParticipantName' +import { getParticipantName } from '@/features/rooms/utils/getParticipantName' +import { useTranslation } from 'react-i18next' export function TrackRefContextIfNeeded( props: React.PropsWithChildren<{ @@ -102,9 +104,37 @@ export const ParticipantTile: ( }) const isScreenShare = trackReference.source != Track.Source.Camera + const [hasKeyboardFocus, setHasKeyboardFocus] = React.useState(false) + + const participantName = getParticipantName(trackReference.participant) + const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' }) + + // Avoid double announcements: LiveKit's useParticipantTile may set its own + // aria-label / aria-labelledby / aria-describedby. We strip them here + // and provide a single, explicit label for the focusable tile container. + const { 'aria-label': _ignoredAriaLabel, ...safeElementProps } = elementProps + void _ignoredAriaLabel + + const interactiveProps = { + ...safeElementProps, + // Ensure the tile is focusable to expose contextual controls to keyboard users. + tabIndex: 0, + 'aria-label': t('containerLabel', { name: participantName }), + onFocus: (event: React.FocusEvent) => { + elementProps.onFocus?.(event) + setHasKeyboardFocus(true) + }, + onBlur: (event: React.FocusEvent) => { + elementProps.onBlur?.(event) + const nextTarget = event.relatedTarget as Node | null + if (!event.currentTarget.contains(nextTarget)) { + setHasKeyboardFocus(false) + } + }, + } return ( -
+
@@ -195,7 +225,10 @@ export const ParticipantTile: ( )} {!disableMetadata && ( - + )} diff --git a/src/frontend/src/features/rooms/livekit/components/ParticipantTileFocus.tsx b/src/frontend/src/features/rooms/livekit/components/ParticipantTileFocus.tsx index b7dc092f..605314ca 100644 --- a/src/frontend/src/features/rooms/livekit/components/ParticipantTileFocus.tsx +++ b/src/frontend/src/features/rooms/livekit/components/ParticipantTileFocus.tsx @@ -131,8 +131,10 @@ const MOUSE_IDLE_TIME = 3000 export const ParticipantTileFocus = ({ trackRef, + hasKeyboardFocus, }: { trackRef: TrackReferenceOrPlaceholder + hasKeyboardFocus: boolean }) => { const [hovered, setHovered] = useState(false) const [opacity, setOpacity] = useState(0) @@ -140,8 +142,10 @@ export const ParticipantTileFocus = ({ const idleTimerRef = useRef(null) const [isIdleRef, setIsIdleRef] = useState(false) + const isVisible = hasKeyboardFocus || (hovered && !isIdleRef) + useEffect(() => { - if (hovered && !isIdleRef) { + if (isVisible) { // Wait for next frame to ensure element is mounted requestAnimationFrame(() => { setOpacity(0.6) @@ -149,7 +153,7 @@ export const ParticipantTileFocus = ({ } else { setOpacity(0) } - }, [hovered, isIdleRef]) + }, [isVisible]) const handleMouseMove = () => { if (idleTimerRef.current) { @@ -180,11 +184,12 @@ export const ParticipantTileFocus = ({ width: '100%', height: '100%', })} + aria-hidden={!isVisible} onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)} onMouseMove={handleMouseMove} > - {hovered && ( + {isVisible && (