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 (
(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)
+}