From 021d7a7e066b98b43e1734f342a0bb79f67d6a36 Mon Sep 17 00:00:00 2001 From: Cyril Date: Wed, 28 Jan 2026 12:34:52 +0100 Subject: [PATCH] =?UTF-8?q?=E2=99=BF=EF=B8=8F(frontend)=20centralize=20ari?= =?UTF-8?q?a-live=20announcements=20in=20store?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit avoid per-feature live regions and reduce a11y duplication. --- CHANGELOG.md | 1 + .../components/RecordingStateToast.tsx | 25 ++---- .../components/IsIdleDisconnectModal.tsx | 32 ++++---- .../livekit/components/ReactionPortal.tsx | 19 +---- .../effects/EffectsConfiguration.tsx | 11 +-- .../rooms/livekit/prefabs/VideoConference.tsx | 16 ++-- .../src/hooks/useScreenReaderAnnounce.ts | 10 ++- .../src/primitives/ScreenReaderAnnouncer.tsx | 19 +++-- .../src/stores/screenReaderAnnouncer.ts | 77 ++++++++++++++++--- 9 files changed, 119 insertions(+), 91 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5755c3bb..c88d2e3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to - ♿️(frontend) adjust visual-only tooltip a11y labels #910 - ♿️(frontend) sr pin/unpin announcements with dedicated messages #898 - ♿(frontend) adjust sr announcements for idle disconnect timer #908 +- ♿️(frontend) add global screen reader announcer#922 ### Fixed diff --git a/src/frontend/src/features/recording/components/RecordingStateToast.tsx b/src/frontend/src/features/recording/components/RecordingStateToast.tsx index 4699e0d5..d672efc7 100644 --- a/src/frontend/src/features/recording/components/RecordingStateToast.tsx +++ b/src/frontend/src/features/recording/components/RecordingStateToast.tsx @@ -1,6 +1,6 @@ import { css } from '@/styled-system/css' import { useTranslation } from 'react-i18next' -import { useMemo, useRef, useState, useEffect } from 'react' +import { useMemo, useRef, useEffect } from 'react' import { Text } from '@/primitives' import { RecordingMode, @@ -13,6 +13,7 @@ import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel' import { useRoomMetadata } from '../hooks/useRoomMetadata' import { RecordingStatusIcon } from './RecordingStatusIcon' import { useIsRecording } from '@livekit/components-react' +import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce' export const RecordingStateToast = () => { const { t } = useTranslation('rooms', { @@ -21,8 +22,8 @@ export const RecordingStateToast = () => { const { openTranscript, openScreenRecording } = useSidePanel() - const [srMessage, setSrMessage] = useState('') const lastKeyRef = useRef('') + const announce = useScreenReaderAnnounce() const hasTranscriptAccess = useHasRecordingAccess( RecordingMode.Transcript, @@ -76,16 +77,9 @@ export const RecordingStateToast = () => { if (key && key !== lastKeyRef.current) { lastKeyRef.current = key const message = t(key) - setSrMessage(message) - - // Clear message after 3 seconds to prevent it from being announced again - const timer = setTimeout(() => { - setSrMessage('') - }, 3000) - - return () => clearTimeout(timer) + announce(message) } - }, [key, t]) + }, [announce, key, t]) if (!key) return null @@ -95,15 +89,6 @@ export const RecordingStateToast = () => { return ( <> - {/* Screen reader only message to announce state changes once */} -
- {srMessage} -
{/* Visual banner (without aria-live to avoid duplicate announcements) */}
{ const connectionObserverSnap = useSnapshot(connectionObserverStore) const [timeRemaining, setTimeRemaining] = useState(IDLE_DISCONNECT_TIMEOUT_MS) - const [srMessage, setSrMessage] = useState('') const lastAnnouncementRef = useRef(null) const { t } = useTranslation('rooms', { keyPrefix: 'isIdleDisconnectModal' }) + const announce = useScreenReaderAnnounce() useEffect(() => { if (connectionObserverSnap.isIdleDisconnectModalOpen) { @@ -42,11 +43,10 @@ export const IsIdleDisconnectModal = () => { useEffect(() => { if (!connectionObserverSnap.isIdleDisconnectModalOpen) { lastAnnouncementRef.current = null - setSrMessage('') } }, [connectionObserverSnap.isIdleDisconnectModalOpen]) - const remainingSeconds = Math.ceil(timeRemaining / 1000) + const remainingSeconds = Math.floor(timeRemaining / 1000) const minutes = Math.floor(remainingSeconds / 60) const seconds = remainingSeconds % 60 const formattedTime = `${minutes}:${seconds.toString().padStart(2, '0')}` @@ -63,18 +63,18 @@ export const IsIdleDisconnectModal = () => { const message = t('countdownAnnouncement', { duration: humanizeDuration(remainingSeconds * 1000, { language: i18n.language, - round: true, + round: false, + largest: 2, }), }) - setSrMessage(message) - - const timer = setTimeout(() => { - setSrMessage('') - }, 3000) - - return () => clearTimeout(timer) + announce(message, 'assertive', 'idle') } - }, [connectionObserverSnap.isIdleDisconnectModalOpen, remainingSeconds, t]) + }, [ + announce, + connectionObserverSnap.isIdleDisconnectModalOpen, + remainingSeconds, + t, + ]) return ( { {({ close }) => { return (
+
{ > {formattedTime}
-
- {srMessage} -
{t('title')} diff --git a/src/frontend/src/features/rooms/livekit/components/ReactionPortal.tsx b/src/frontend/src/features/rooms/livekit/components/ReactionPortal.tsx index 4c34d7e9..8d041700 100644 --- a/src/frontend/src/features/rooms/livekit/components/ReactionPortal.tsx +++ b/src/frontend/src/features/rooms/livekit/components/ReactionPortal.tsx @@ -8,6 +8,7 @@ import { Reaction } from '@/features/rooms/livekit/components/controls/Reactions import { getEmojiLabel } from '@/features/rooms/livekit/utils/reactionUtils' import { accessibilityStore } from '@/stores/accessibility' import { useSnapshot } from 'valtio' +import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce' export const ANIMATION_DURATION = 3000 export const ANIMATION_DISTANCE = 300 @@ -146,15 +147,14 @@ export function ReactionPortal({ export const ReactionPortals = ({ reactions }: { reactions: Reaction[] }) => { const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' }) const { announceReactions } = useSnapshot(accessibilityStore) - const [announcement, setAnnouncement] = useState(null) const [lastAnnouncedId, setLastAnnouncedId] = useState(null) + const announce = useScreenReaderAnnounce() const latestReaction = reactions.length > 0 ? reactions[reactions.length - 1] : undefined useEffect(() => { if (!announceReactions) { - setAnnouncement(null) return } if (!latestReaction) return @@ -166,12 +166,9 @@ export const ReactionPortals = ({ reactions }: { reactions: Reaction[] }) => { ? t('you') : latestReaction.participant?.name?.trim() || t('someone', { defaultValue: 'Someone' }) - setAnnouncement(t('announce', { name: participantName, emoji: emojiLabel })) + announce(t('announce', { name: participantName, emoji: emojiLabel })) setLastAnnouncedId(latestReaction.id) - - const timer = setTimeout(() => setAnnouncement(null), 1200) - return () => clearTimeout(timer) - }, [latestReaction, lastAnnouncedId, announceReactions, t]) + }, [announce, latestReaction, lastAnnouncedId, announceReactions, t]) return ( <> @@ -182,14 +179,6 @@ export const ReactionPortals = ({ reactions }: { reactions: Reaction[] }) => { participant={instance.participant} /> ))} -
- {announcement ?? ''} -
) } diff --git a/src/frontend/src/features/rooms/livekit/components/effects/EffectsConfiguration.tsx b/src/frontend/src/features/rooms/livekit/components/effects/EffectsConfiguration.tsx index 09c9c27f..9f301eae 100644 --- a/src/frontend/src/features/rooms/livekit/components/effects/EffectsConfiguration.tsx +++ b/src/frontend/src/features/rooms/livekit/components/effects/EffectsConfiguration.tsx @@ -18,6 +18,7 @@ import { Loader } from '@/primitives/Loader' import { useSyncAfterDelay } from '@/hooks/useSyncAfterDelay' import { FunnyEffects } from './FunnyEffects' import { useHasFunnyEffectsAccess } from '../../hooks/useHasFunnyEffectsAccess' +import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce' enum BlurRadius { NONE = 0, @@ -56,7 +57,7 @@ export const EffectsConfiguration = ({ const [processorPending, setProcessorPending] = useState(false) const processorPendingReveal = useSyncAfterDelay(processorPending) const hasFunnyEffectsAccess = useHasFunnyEffectsAccess() - const [effectStatusMessage, setEffectStatusMessage] = useState('') + const announce = useScreenReaderAnnounce() const effectAnnouncementTimeout = useRef | null>(null) @@ -104,12 +105,9 @@ export const EffectsConfiguration = ({ clearTimeout(effectAnnouncementTimeout.current) } - // Clear the region first so screen readers drop queued announcements. - setEffectStatusMessage('') - effectAnnouncementTimeout.current = setTimeout(() => { if (currentId !== effectAnnouncementId.current) return - setEffectStatusMessage(message) + announce(message) }, 80) } @@ -423,9 +421,6 @@ export const EffectsConfiguration = ({
-
- {effectStatusMessage} -
(null) const lastPinnedParticipantIdentityRef = useRef(null) - const [pinAnnouncement, setPinAnnouncement] = useState('') const { t } = useTranslation('rooms', { keyPrefix: 'pinAnnouncements' }) const { t: tRooms } = useTranslation('rooms') const room = useRoomContext() + const announce = useScreenReaderAnnounce() const getAnnouncementName = useCallback( (participant?: Participant | null) => { @@ -148,7 +149,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) { : room.remoteParticipants.get(lastIdentity) const announcementName = getAnnouncementName(lastParticipant) - setPinAnnouncement( + announce( lastParticipant?.isLocal ? t('self.unpin') : t('unpin', { @@ -172,10 +173,11 @@ export function VideoConference({ ...props }: VideoConferenceProps) { lastPinnedParticipantIdentityRef.current = participant.identity - setPinAnnouncement( + announce( participant.isLocal ? t('self.pin') : t('pin', { name: participantName }) ) }, [ + announce, focusTrack, getAnnouncementName, room.localParticipant, @@ -257,14 +259,6 @@ export function VideoConference({ ...props }: VideoConferenceProps) { value={layoutContext} // onPinChange={handleFocusStateChange} > -
- {pinAnnouncement} -
setIsShareErrorVisible(false)} diff --git a/src/frontend/src/hooks/useScreenReaderAnnounce.ts b/src/frontend/src/hooks/useScreenReaderAnnounce.ts index 50382734..3fcf6ae7 100644 --- a/src/frontend/src/hooks/useScreenReaderAnnounce.ts +++ b/src/frontend/src/hooks/useScreenReaderAnnounce.ts @@ -2,14 +2,18 @@ import { useCallback } from 'react' import { announceToScreenReader, type Politeness, + type ScreenReaderChannel, } from '@/stores/screenReaderAnnouncer' export const useScreenReaderAnnounce = () => { return useCallback( - (message: string, politeness: Politeness = 'polite') => { - announceToScreenReader(message, politeness) + ( + message: string, + politeness: Politeness = 'polite', + channel: ScreenReaderChannel = 'global' + ) => { + announceToScreenReader(message, politeness, channel) }, [] ) } - diff --git a/src/frontend/src/primitives/ScreenReaderAnnouncer.tsx b/src/frontend/src/primitives/ScreenReaderAnnouncer.tsx index 0f43fcc8..08883f56 100644 --- a/src/frontend/src/primitives/ScreenReaderAnnouncer.tsx +++ b/src/frontend/src/primitives/ScreenReaderAnnouncer.tsx @@ -1,20 +1,29 @@ import { useSnapshot } from 'valtio' -import { screenReaderAnnouncerStore } from '@/stores/screenReaderAnnouncer' +import { + screenReaderAnnouncerStore, + type ScreenReaderChannel, +} from '@/stores/screenReaderAnnouncer' -export const ScreenReaderAnnouncer = () => { - const { announcement } = useSnapshot(screenReaderAnnouncerStore) +export const ScreenReaderAnnouncer = ({ + channel = 'global', +}: { + channel?: ScreenReaderChannel +}) => { + const { announcements } = useSnapshot(screenReaderAnnouncerStore) + const announcement = announcements[channel] + + if (!announcement) return null return (
{announcement.message}
) } - diff --git a/src/frontend/src/stores/screenReaderAnnouncer.ts b/src/frontend/src/stores/screenReaderAnnouncer.ts index 97f7ddb2..3a53077f 100644 --- a/src/frontend/src/stores/screenReaderAnnouncer.ts +++ b/src/frontend/src/stores/screenReaderAnnouncer.ts @@ -1,6 +1,7 @@ import { proxy } from 'valtio' export type Politeness = 'polite' | 'assertive' +export type ScreenReaderChannel = 'global' | 'idle' type ScreenReaderAnnouncement = { message: string @@ -9,25 +10,77 @@ type ScreenReaderAnnouncement = { } type ScreenReaderAnnouncerState = { - announcement: ScreenReaderAnnouncement + announcements: Record } export const screenReaderAnnouncerStore = proxy({ - announcement: { - message: '', - politeness: 'polite', - id: 0, + announcements: { + global: { + message: '', + politeness: 'polite', + id: 0, + }, + idle: { + message: '', + politeness: 'polite', + id: 0, + }, }, }) +const channels: ScreenReaderChannel[] = ['global', 'idle'] +const announcementTokens: Record = { + global: 0, + idle: 0, +} +const announcementTimers: Record< + ScreenReaderChannel, + ReturnType | null +> = { + global: null, + idle: null, +} +const lastAnnouncementTimes: Record = { + global: 0, + idle: 0, +} +const MIN_ANNOUNCEMENT_INTERVAL = 300 // Minimum 300ms between announcements + export const announceToScreenReader = ( message: string, - politeness: Politeness = 'polite' + politeness: Politeness = 'polite', + channel: ScreenReaderChannel = 'global' ) => { - screenReaderAnnouncerStore.announcement = { - message, - politeness, - id: screenReaderAnnouncerStore.announcement.id + 1, - } -} + if (!channels.includes(channel)) return + const now = Date.now() + const timeSinceLastAnnouncement = now - lastAnnouncementTimes[channel] + + announcementTokens[channel] += 1 + const currentToken = announcementTokens[channel] + + if (announcementTimers[channel]) { + clearTimeout(announcementTimers[channel]!) + } + + const delay = Math.max( + 150, // Minimum delay for clear + set sequence + MIN_ANNOUNCEMENT_INTERVAL - timeSinceLastAnnouncement + ) + + screenReaderAnnouncerStore.announcements[channel] = { + message: '', + politeness, + id: currentToken, + } + + announcementTimers[channel] = setTimeout(() => { + if (currentToken !== announcementTokens[channel]) return + screenReaderAnnouncerStore.announcements[channel] = { + message, + politeness, + id: currentToken, + } + lastAnnouncementTimes[channel] = Date.now() + }, delay) +}