️(frontend) improve meeting a11y: blur, focus, hover, sr announcements

enhances keyboard nav and screen reader support for meeting interface

Signed-off-by: Cyril <c.gromoff@gmail.com>
This commit is contained in:
Cyril
2025-12-11 11:02:15 +01:00
parent 98e568d63c
commit db188075af
3 changed files with 44 additions and 5 deletions

View File

@@ -35,6 +35,7 @@ export const ParticipantName = ({
style={{ style={{
paddingBottom: '0.1rem', paddingBottom: '0.1rem',
}} }}
aria-hidden="true"
> >
{displayedName} {displayedName}
</Text> </Text>

View File

@@ -29,6 +29,8 @@ import { ParticipantPlaceholder } from './ParticipantPlaceholder'
import { ParticipantTileFocus } from './ParticipantTileFocus' import { ParticipantTileFocus } from './ParticipantTileFocus'
import { FullScreenShareWarning } from './FullScreenShareWarning' import { FullScreenShareWarning } from './FullScreenShareWarning'
import { ParticipantName } from './ParticipantName' import { ParticipantName } from './ParticipantName'
import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
import { useTranslation } from 'react-i18next'
export function TrackRefContextIfNeeded( export function TrackRefContextIfNeeded(
props: React.PropsWithChildren<{ props: React.PropsWithChildren<{
@@ -102,9 +104,37 @@ export const ParticipantTile: (
}) })
const isScreenShare = trackReference.source != Track.Source.Camera 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<HTMLDivElement>) => {
elementProps.onFocus?.(event)
setHasKeyboardFocus(true)
},
onBlur: (event: React.FocusEvent<HTMLDivElement>) => {
elementProps.onBlur?.(event)
const nextTarget = event.relatedTarget as Node | null
if (!event.currentTarget.contains(nextTarget)) {
setHasKeyboardFocus(false)
}
},
}
return ( return (
<div ref={ref} style={{ position: 'relative' }} {...elementProps}> <div ref={ref} style={{ position: 'relative' }} {...interactiveProps}>
<TrackRefContextIfNeeded trackRef={trackReference}> <TrackRefContextIfNeeded trackRef={trackReference}>
<ParticipantContextIfNeeded participant={trackReference.participant}> <ParticipantContextIfNeeded participant={trackReference.participant}>
<FullScreenShareWarning trackReference={trackReference} /> <FullScreenShareWarning trackReference={trackReference} />
@@ -195,7 +225,10 @@ export const ParticipantTile: (
</> </>
)} )}
{!disableMetadata && ( {!disableMetadata && (
<ParticipantTileFocus trackRef={trackReference} /> <ParticipantTileFocus
trackRef={trackReference}
hasKeyboardFocus={hasKeyboardFocus}
/>
)} )}
</ParticipantContextIfNeeded> </ParticipantContextIfNeeded>
</TrackRefContextIfNeeded> </TrackRefContextIfNeeded>

View File

@@ -131,8 +131,10 @@ const MOUSE_IDLE_TIME = 3000
export const ParticipantTileFocus = ({ export const ParticipantTileFocus = ({
trackRef, trackRef,
hasKeyboardFocus,
}: { }: {
trackRef: TrackReferenceOrPlaceholder trackRef: TrackReferenceOrPlaceholder
hasKeyboardFocus: boolean
}) => { }) => {
const [hovered, setHovered] = useState(false) const [hovered, setHovered] = useState(false)
const [opacity, setOpacity] = useState(0) const [opacity, setOpacity] = useState(0)
@@ -140,8 +142,10 @@ export const ParticipantTileFocus = ({
const idleTimerRef = useRef<number | null>(null) const idleTimerRef = useRef<number | null>(null)
const [isIdleRef, setIsIdleRef] = useState(false) const [isIdleRef, setIsIdleRef] = useState(false)
const isVisible = hasKeyboardFocus || (hovered && !isIdleRef)
useEffect(() => { useEffect(() => {
if (hovered && !isIdleRef) { if (isVisible) {
// Wait for next frame to ensure element is mounted // Wait for next frame to ensure element is mounted
requestAnimationFrame(() => { requestAnimationFrame(() => {
setOpacity(0.6) setOpacity(0.6)
@@ -149,7 +153,7 @@ export const ParticipantTileFocus = ({
} else { } else {
setOpacity(0) setOpacity(0)
} }
}, [hovered, isIdleRef]) }, [isVisible])
const handleMouseMove = () => { const handleMouseMove = () => {
if (idleTimerRef.current) { if (idleTimerRef.current) {
@@ -180,11 +184,12 @@ export const ParticipantTileFocus = ({
width: '100%', width: '100%',
height: '100%', height: '100%',
})} })}
aria-hidden={!isVisible}
onMouseEnter={() => setHovered(true)} onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)} onMouseLeave={() => setHovered(false)}
onMouseMove={handleMouseMove} onMouseMove={handleMouseMove}
> >
{hovered && ( {isVisible && (
<div <div
className={css({ className={css({
backgroundColor: 'primaryDark.50', backgroundColor: 'primaryDark.50',