Merge branch 'refactor/issue-921-generic-sr-announcer'
This commit is contained in:
@@ -13,6 +13,7 @@ and this project adheres to
|
|||||||
- ♿️(frontend) adjust visual-only tooltip a11y labels #910
|
- ♿️(frontend) adjust visual-only tooltip a11y labels #910
|
||||||
- ♿️(frontend) sr pin/unpin announcements with dedicated messages #898
|
- ♿️(frontend) sr pin/unpin announcements with dedicated messages #898
|
||||||
- ♿(frontend) adjust sr announcements for idle disconnect timer #908
|
- ♿(frontend) adjust sr announcements for idle disconnect timer #908
|
||||||
|
- ♿️(frontend) add global screen reader announcer#922
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useMemo, useRef, useState, useEffect } from 'react'
|
import { useMemo, useRef, useEffect } from 'react'
|
||||||
import { Text } from '@/primitives'
|
import { Text } from '@/primitives'
|
||||||
import {
|
import {
|
||||||
RecordingMode,
|
RecordingMode,
|
||||||
@@ -13,6 +13,7 @@ import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
|||||||
import { useRoomMetadata } from '../hooks/useRoomMetadata'
|
import { useRoomMetadata } from '../hooks/useRoomMetadata'
|
||||||
import { RecordingStatusIcon } from './RecordingStatusIcon'
|
import { RecordingStatusIcon } from './RecordingStatusIcon'
|
||||||
import { useIsRecording } from '@livekit/components-react'
|
import { useIsRecording } from '@livekit/components-react'
|
||||||
|
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||||
|
|
||||||
export const RecordingStateToast = () => {
|
export const RecordingStateToast = () => {
|
||||||
const { t } = useTranslation('rooms', {
|
const { t } = useTranslation('rooms', {
|
||||||
@@ -21,8 +22,8 @@ export const RecordingStateToast = () => {
|
|||||||
|
|
||||||
const { openTranscript, openScreenRecording } = useSidePanel()
|
const { openTranscript, openScreenRecording } = useSidePanel()
|
||||||
|
|
||||||
const [srMessage, setSrMessage] = useState('')
|
|
||||||
const lastKeyRef = useRef('')
|
const lastKeyRef = useRef('')
|
||||||
|
const announce = useScreenReaderAnnounce()
|
||||||
|
|
||||||
const hasTranscriptAccess = useHasRecordingAccess(
|
const hasTranscriptAccess = useHasRecordingAccess(
|
||||||
RecordingMode.Transcript,
|
RecordingMode.Transcript,
|
||||||
@@ -76,16 +77,9 @@ export const RecordingStateToast = () => {
|
|||||||
if (key && key !== lastKeyRef.current) {
|
if (key && key !== lastKeyRef.current) {
|
||||||
lastKeyRef.current = key
|
lastKeyRef.current = key
|
||||||
const message = t(key)
|
const message = t(key)
|
||||||
setSrMessage(message)
|
announce(message)
|
||||||
|
|
||||||
// Clear message after 3 seconds to prevent it from being announced again
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
setSrMessage('')
|
|
||||||
}, 3000)
|
|
||||||
|
|
||||||
return () => clearTimeout(timer)
|
|
||||||
}
|
}
|
||||||
}, [key, t])
|
}, [announce, key, t])
|
||||||
|
|
||||||
if (!key) return null
|
if (!key) return null
|
||||||
|
|
||||||
@@ -95,15 +89,6 @@ export const RecordingStateToast = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Screen reader only message to announce state changes once */}
|
|
||||||
<div
|
|
||||||
role="status"
|
|
||||||
aria-live="polite"
|
|
||||||
aria-atomic="true"
|
|
||||||
className="sr-only"
|
|
||||||
>
|
|
||||||
{srMessage}
|
|
||||||
</div>
|
|
||||||
{/* Visual banner (without aria-live to avoid duplicate announcements) */}
|
{/* Visual banner (without aria-live to avoid duplicate announcements) */}
|
||||||
<div
|
<div
|
||||||
className={css({
|
className={css({
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Button, Dialog, H, P } from '@/primitives'
|
import { Button, Dialog, H, P, ScreenReaderAnnouncer } from '@/primitives'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { useSnapshot } from 'valtio'
|
import { useSnapshot } from 'valtio'
|
||||||
@@ -8,18 +8,19 @@ import { useEffect, useRef, useState } from 'react'
|
|||||||
import { navigateTo } from '@/navigation/navigateTo'
|
import { navigateTo } from '@/navigation/navigateTo'
|
||||||
import humanizeDuration from 'humanize-duration'
|
import humanizeDuration from 'humanize-duration'
|
||||||
import i18n from 'i18next'
|
import i18n from 'i18next'
|
||||||
|
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||||
|
|
||||||
const IDLE_DISCONNECT_TIMEOUT_MS = 120000 // 2 minutes
|
const IDLE_DISCONNECT_TIMEOUT_MS = 120000 // 2 minutes
|
||||||
const COUNTDOWN_ANNOUNCEMENT_SECONDS = [120, 90, 60, 30]
|
const COUNTDOWN_ANNOUNCEMENT_SECONDS = [90, 60, 30]
|
||||||
const FINAL_COUNTDOWN_SECONDS = 10
|
const FINAL_COUNTDOWN_SECONDS = 10
|
||||||
|
|
||||||
export const IsIdleDisconnectModal = () => {
|
export const IsIdleDisconnectModal = () => {
|
||||||
const connectionObserverSnap = useSnapshot(connectionObserverStore)
|
const connectionObserverSnap = useSnapshot(connectionObserverStore)
|
||||||
const [timeRemaining, setTimeRemaining] = useState(IDLE_DISCONNECT_TIMEOUT_MS)
|
const [timeRemaining, setTimeRemaining] = useState(IDLE_DISCONNECT_TIMEOUT_MS)
|
||||||
const [srMessage, setSrMessage] = useState('')
|
|
||||||
const lastAnnouncementRef = useRef<number | null>(null)
|
const lastAnnouncementRef = useRef<number | null>(null)
|
||||||
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'isIdleDisconnectModal' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'isIdleDisconnectModal' })
|
||||||
|
const announce = useScreenReaderAnnounce()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (connectionObserverSnap.isIdleDisconnectModalOpen) {
|
if (connectionObserverSnap.isIdleDisconnectModalOpen) {
|
||||||
@@ -42,11 +43,10 @@ export const IsIdleDisconnectModal = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!connectionObserverSnap.isIdleDisconnectModalOpen) {
|
if (!connectionObserverSnap.isIdleDisconnectModalOpen) {
|
||||||
lastAnnouncementRef.current = null
|
lastAnnouncementRef.current = null
|
||||||
setSrMessage('')
|
|
||||||
}
|
}
|
||||||
}, [connectionObserverSnap.isIdleDisconnectModalOpen])
|
}, [connectionObserverSnap.isIdleDisconnectModalOpen])
|
||||||
|
|
||||||
const remainingSeconds = Math.ceil(timeRemaining / 1000)
|
const remainingSeconds = Math.floor(timeRemaining / 1000)
|
||||||
const minutes = Math.floor(remainingSeconds / 60)
|
const minutes = Math.floor(remainingSeconds / 60)
|
||||||
const seconds = remainingSeconds % 60
|
const seconds = remainingSeconds % 60
|
||||||
const formattedTime = `${minutes}:${seconds.toString().padStart(2, '0')}`
|
const formattedTime = `${minutes}:${seconds.toString().padStart(2, '0')}`
|
||||||
@@ -63,18 +63,18 @@ export const IsIdleDisconnectModal = () => {
|
|||||||
const message = t('countdownAnnouncement', {
|
const message = t('countdownAnnouncement', {
|
||||||
duration: humanizeDuration(remainingSeconds * 1000, {
|
duration: humanizeDuration(remainingSeconds * 1000, {
|
||||||
language: i18n.language,
|
language: i18n.language,
|
||||||
round: true,
|
round: false,
|
||||||
|
largest: 2,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
setSrMessage(message)
|
announce(message, 'assertive', 'idle')
|
||||||
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
setSrMessage('')
|
|
||||||
}, 3000)
|
|
||||||
|
|
||||||
return () => clearTimeout(timer)
|
|
||||||
}
|
}
|
||||||
}, [connectionObserverSnap.isIdleDisconnectModalOpen, remainingSeconds, t])
|
}, [
|
||||||
|
announce,
|
||||||
|
connectionObserverSnap.isIdleDisconnectModalOpen,
|
||||||
|
remainingSeconds,
|
||||||
|
t,
|
||||||
|
])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
@@ -89,6 +89,7 @@ export const IsIdleDisconnectModal = () => {
|
|||||||
{({ close }) => {
|
{({ close }) => {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
<ScreenReaderAnnouncer channel="idle" />
|
||||||
<div
|
<div
|
||||||
className={css({
|
className={css({
|
||||||
height: '50px',
|
height: '50px',
|
||||||
@@ -106,9 +107,6 @@ export const IsIdleDisconnectModal = () => {
|
|||||||
>
|
>
|
||||||
{formattedTime}
|
{formattedTime}
|
||||||
</div>
|
</div>
|
||||||
<div className="sr-only" aria-live="polite" aria-atomic="true">
|
|
||||||
{srMessage}
|
|
||||||
</div>
|
|
||||||
<H lvl={2} centered>
|
<H lvl={2} centered>
|
||||||
{t('title')}
|
{t('title')}
|
||||||
</H>
|
</H>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Reaction } from '@/features/rooms/livekit/components/controls/Reactions
|
|||||||
import { getEmojiLabel } from '@/features/rooms/livekit/utils/reactionUtils'
|
import { getEmojiLabel } from '@/features/rooms/livekit/utils/reactionUtils'
|
||||||
import { accessibilityStore } from '@/stores/accessibility'
|
import { accessibilityStore } from '@/stores/accessibility'
|
||||||
import { useSnapshot } from 'valtio'
|
import { useSnapshot } from 'valtio'
|
||||||
|
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||||
|
|
||||||
export const ANIMATION_DURATION = 3000
|
export const ANIMATION_DURATION = 3000
|
||||||
export const ANIMATION_DISTANCE = 300
|
export const ANIMATION_DISTANCE = 300
|
||||||
@@ -146,15 +147,14 @@ export function ReactionPortal({
|
|||||||
export const ReactionPortals = ({ reactions }: { reactions: Reaction[] }) => {
|
export const ReactionPortals = ({ reactions }: { reactions: Reaction[] }) => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
|
||||||
const { announceReactions } = useSnapshot(accessibilityStore)
|
const { announceReactions } = useSnapshot(accessibilityStore)
|
||||||
const [announcement, setAnnouncement] = useState<string | null>(null)
|
|
||||||
const [lastAnnouncedId, setLastAnnouncedId] = useState<number | null>(null)
|
const [lastAnnouncedId, setLastAnnouncedId] = useState<number | null>(null)
|
||||||
|
const announce = useScreenReaderAnnounce()
|
||||||
|
|
||||||
const latestReaction =
|
const latestReaction =
|
||||||
reactions.length > 0 ? reactions[reactions.length - 1] : undefined
|
reactions.length > 0 ? reactions[reactions.length - 1] : undefined
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!announceReactions) {
|
if (!announceReactions) {
|
||||||
setAnnouncement(null)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!latestReaction) return
|
if (!latestReaction) return
|
||||||
@@ -166,12 +166,9 @@ export const ReactionPortals = ({ reactions }: { reactions: Reaction[] }) => {
|
|||||||
? t('you')
|
? t('you')
|
||||||
: latestReaction.participant?.name?.trim() ||
|
: latestReaction.participant?.name?.trim() ||
|
||||||
t('someone', { defaultValue: 'Someone' })
|
t('someone', { defaultValue: 'Someone' })
|
||||||
setAnnouncement(t('announce', { name: participantName, emoji: emojiLabel }))
|
announce(t('announce', { name: participantName, emoji: emojiLabel }))
|
||||||
setLastAnnouncedId(latestReaction.id)
|
setLastAnnouncedId(latestReaction.id)
|
||||||
|
}, [announce, latestReaction, lastAnnouncedId, announceReactions, t])
|
||||||
const timer = setTimeout(() => setAnnouncement(null), 1200)
|
|
||||||
return () => clearTimeout(timer)
|
|
||||||
}, [latestReaction, lastAnnouncedId, announceReactions, t])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -182,14 +179,6 @@ export const ReactionPortals = ({ reactions }: { reactions: Reaction[] }) => {
|
|||||||
participant={instance.participant}
|
participant={instance.participant}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<div
|
|
||||||
role="status"
|
|
||||||
aria-live="polite"
|
|
||||||
aria-atomic="true"
|
|
||||||
className="sr-only"
|
|
||||||
>
|
|
||||||
{announcement ?? ''}
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { Loader } from '@/primitives/Loader'
|
|||||||
import { useSyncAfterDelay } from '@/hooks/useSyncAfterDelay'
|
import { useSyncAfterDelay } from '@/hooks/useSyncAfterDelay'
|
||||||
import { FunnyEffects } from './FunnyEffects'
|
import { FunnyEffects } from './FunnyEffects'
|
||||||
import { useHasFunnyEffectsAccess } from '../../hooks/useHasFunnyEffectsAccess'
|
import { useHasFunnyEffectsAccess } from '../../hooks/useHasFunnyEffectsAccess'
|
||||||
|
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||||
|
|
||||||
enum BlurRadius {
|
enum BlurRadius {
|
||||||
NONE = 0,
|
NONE = 0,
|
||||||
@@ -56,7 +57,7 @@ export const EffectsConfiguration = ({
|
|||||||
const [processorPending, setProcessorPending] = useState(false)
|
const [processorPending, setProcessorPending] = useState(false)
|
||||||
const processorPendingReveal = useSyncAfterDelay(processorPending)
|
const processorPendingReveal = useSyncAfterDelay(processorPending)
|
||||||
const hasFunnyEffectsAccess = useHasFunnyEffectsAccess()
|
const hasFunnyEffectsAccess = useHasFunnyEffectsAccess()
|
||||||
const [effectStatusMessage, setEffectStatusMessage] = useState('')
|
const announce = useScreenReaderAnnounce()
|
||||||
const effectAnnouncementTimeout = useRef<ReturnType<
|
const effectAnnouncementTimeout = useRef<ReturnType<
|
||||||
typeof setTimeout
|
typeof setTimeout
|
||||||
> | null>(null)
|
> | null>(null)
|
||||||
@@ -104,12 +105,9 @@ export const EffectsConfiguration = ({
|
|||||||
clearTimeout(effectAnnouncementTimeout.current)
|
clearTimeout(effectAnnouncementTimeout.current)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear the region first so screen readers drop queued announcements.
|
|
||||||
setEffectStatusMessage('')
|
|
||||||
|
|
||||||
effectAnnouncementTimeout.current = setTimeout(() => {
|
effectAnnouncementTimeout.current = setTimeout(() => {
|
||||||
if (currentId !== effectAnnouncementId.current) return
|
if (currentId !== effectAnnouncementId.current) return
|
||||||
setEffectStatusMessage(message)
|
announce(message)
|
||||||
}, 80)
|
}, 80)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -423,9 +421,6 @@ export const EffectsConfiguration = ({
|
|||||||
<BlurOnStrong />
|
<BlurOnStrong />
|
||||||
</ToggleButton>
|
</ToggleButton>
|
||||||
</div>
|
</div>
|
||||||
<div aria-live="polite" className="sr-only">
|
|
||||||
{effectStatusMessage}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={css({
|
className={css({
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import { CarouselLayout } from '../components/layout/CarouselLayout'
|
|||||||
import { GridLayout } from '../components/layout/GridLayout'
|
import { GridLayout } from '../components/layout/GridLayout'
|
||||||
import { IsIdleDisconnectModal } from '../components/IsIdleDisconnectModal'
|
import { IsIdleDisconnectModal } from '../components/IsIdleDisconnectModal'
|
||||||
import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
|
import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
|
||||||
|
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||||
|
|
||||||
const LayoutWrapper = styled(
|
const LayoutWrapper = styled(
|
||||||
'div',
|
'div',
|
||||||
@@ -93,10 +94,10 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
|||||||
const lastAutoFocusedScreenShareTrack =
|
const lastAutoFocusedScreenShareTrack =
|
||||||
useRef<TrackReferenceOrPlaceholder | null>(null)
|
useRef<TrackReferenceOrPlaceholder | null>(null)
|
||||||
const lastPinnedParticipantIdentityRef = useRef<string | null>(null)
|
const lastPinnedParticipantIdentityRef = useRef<string | null>(null)
|
||||||
const [pinAnnouncement, setPinAnnouncement] = useState('')
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'pinAnnouncements' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'pinAnnouncements' })
|
||||||
const { t: tRooms } = useTranslation('rooms')
|
const { t: tRooms } = useTranslation('rooms')
|
||||||
const room = useRoomContext()
|
const room = useRoomContext()
|
||||||
|
const announce = useScreenReaderAnnounce()
|
||||||
|
|
||||||
const getAnnouncementName = useCallback(
|
const getAnnouncementName = useCallback(
|
||||||
(participant?: Participant | null) => {
|
(participant?: Participant | null) => {
|
||||||
@@ -148,7 +149,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
|||||||
: room.remoteParticipants.get(lastIdentity)
|
: room.remoteParticipants.get(lastIdentity)
|
||||||
const announcementName = getAnnouncementName(lastParticipant)
|
const announcementName = getAnnouncementName(lastParticipant)
|
||||||
|
|
||||||
setPinAnnouncement(
|
announce(
|
||||||
lastParticipant?.isLocal
|
lastParticipant?.isLocal
|
||||||
? t('self.unpin')
|
? t('self.unpin')
|
||||||
: t('unpin', {
|
: t('unpin', {
|
||||||
@@ -172,10 +173,11 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
|||||||
|
|
||||||
lastPinnedParticipantIdentityRef.current = participant.identity
|
lastPinnedParticipantIdentityRef.current = participant.identity
|
||||||
|
|
||||||
setPinAnnouncement(
|
announce(
|
||||||
participant.isLocal ? t('self.pin') : t('pin', { name: participantName })
|
participant.isLocal ? t('self.pin') : t('pin', { name: participantName })
|
||||||
)
|
)
|
||||||
}, [
|
}, [
|
||||||
|
announce,
|
||||||
focusTrack,
|
focusTrack,
|
||||||
getAnnouncementName,
|
getAnnouncementName,
|
||||||
room.localParticipant,
|
room.localParticipant,
|
||||||
@@ -257,14 +259,6 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
|||||||
value={layoutContext}
|
value={layoutContext}
|
||||||
// onPinChange={handleFocusStateChange}
|
// onPinChange={handleFocusStateChange}
|
||||||
>
|
>
|
||||||
<div
|
|
||||||
id="pin-announcer"
|
|
||||||
aria-live="polite"
|
|
||||||
aria-atomic="true"
|
|
||||||
className="sr-only"
|
|
||||||
>
|
|
||||||
{pinAnnouncement}
|
|
||||||
</div>
|
|
||||||
<ScreenShareErrorModal
|
<ScreenShareErrorModal
|
||||||
isOpen={isShareErrorVisible}
|
isOpen={isShareErrorVisible}
|
||||||
onClose={() => setIsShareErrorVisible(false)}
|
onClose={() => setIsShareErrorVisible(false)}
|
||||||
|
|||||||
19
src/frontend/src/hooks/useScreenReaderAnnounce.ts
Normal file
19
src/frontend/src/hooks/useScreenReaderAnnounce.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { useCallback } from 'react'
|
||||||
|
import {
|
||||||
|
announceToScreenReader,
|
||||||
|
type Politeness,
|
||||||
|
type ScreenReaderChannel,
|
||||||
|
} from '@/stores/screenReaderAnnouncer'
|
||||||
|
|
||||||
|
export const useScreenReaderAnnounce = () => {
|
||||||
|
return useCallback(
|
||||||
|
(
|
||||||
|
message: string,
|
||||||
|
politeness: Politeness = 'polite',
|
||||||
|
channel: ScreenReaderChannel = 'global'
|
||||||
|
) => {
|
||||||
|
announceToScreenReader(message, politeness, channel)
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { Header } from './Header'
|
|||||||
import { layoutStore } from '@/stores/layout'
|
import { layoutStore } from '@/stores/layout'
|
||||||
import { useSnapshot } from 'valtio'
|
import { useSnapshot } from 'valtio'
|
||||||
import { Footer } from '@/layout/Footer'
|
import { Footer } from '@/layout/Footer'
|
||||||
|
import { ScreenReaderAnnouncer } from '@/primitives'
|
||||||
|
|
||||||
export type Layout = 'fullpage' | 'centered'
|
export type Layout = 'fullpage' | 'centered'
|
||||||
|
|
||||||
@@ -41,6 +42,7 @@ export const Layout = ({ children }: { children: ReactNode }) => {
|
|||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
|
<ScreenReaderAnnouncer />
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
29
src/frontend/src/primitives/ScreenReaderAnnouncer.tsx
Normal file
29
src/frontend/src/primitives/ScreenReaderAnnouncer.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { useSnapshot } from 'valtio'
|
||||||
|
import {
|
||||||
|
screenReaderAnnouncerStore,
|
||||||
|
type ScreenReaderChannel,
|
||||||
|
} from '@/stores/screenReaderAnnouncer'
|
||||||
|
|
||||||
|
export const ScreenReaderAnnouncer = ({
|
||||||
|
channel = 'global',
|
||||||
|
}: {
|
||||||
|
channel?: ScreenReaderChannel
|
||||||
|
}) => {
|
||||||
|
const { announcements } = useSnapshot(screenReaderAnnouncerStore)
|
||||||
|
const announcement = announcements[channel]
|
||||||
|
|
||||||
|
if (!announcement) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
aria-live={announcement.politeness}
|
||||||
|
aria-atomic="true"
|
||||||
|
className="sr-only"
|
||||||
|
data-announce-id={announcement.id}
|
||||||
|
data-announce-channel={channel}
|
||||||
|
>
|
||||||
|
{announcement.message}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ export { Menu } from './Menu'
|
|||||||
export { MenuList } from './MenuList'
|
export { MenuList } from './MenuList'
|
||||||
export { P } from './P'
|
export { P } from './P'
|
||||||
export { Popover } from './Popover'
|
export { Popover } from './Popover'
|
||||||
|
export { ScreenReaderAnnouncer } from './ScreenReaderAnnouncer'
|
||||||
export { Text } from './Text'
|
export { Text } from './Text'
|
||||||
export { ToggleButton } from './ToggleButton'
|
export { ToggleButton } from './ToggleButton'
|
||||||
export { Ul } from './Ul'
|
export { Ul } from './Ul'
|
||||||
|
|||||||
86
src/frontend/src/stores/screenReaderAnnouncer.ts
Normal file
86
src/frontend/src/stores/screenReaderAnnouncer.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { proxy } from 'valtio'
|
||||||
|
|
||||||
|
export type Politeness = 'polite' | 'assertive'
|
||||||
|
export type ScreenReaderChannel = 'global' | 'idle'
|
||||||
|
|
||||||
|
type ScreenReaderAnnouncement = {
|
||||||
|
message: string
|
||||||
|
politeness: Politeness
|
||||||
|
id: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScreenReaderAnnouncerState = {
|
||||||
|
announcements: Record<ScreenReaderChannel, ScreenReaderAnnouncement>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const screenReaderAnnouncerStore = proxy<ScreenReaderAnnouncerState>({
|
||||||
|
announcements: {
|
||||||
|
global: {
|
||||||
|
message: '',
|
||||||
|
politeness: 'polite',
|
||||||
|
id: 0,
|
||||||
|
},
|
||||||
|
idle: {
|
||||||
|
message: '',
|
||||||
|
politeness: 'polite',
|
||||||
|
id: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const channels: ScreenReaderChannel[] = ['global', 'idle']
|
||||||
|
const announcementTokens: Record<ScreenReaderChannel, number> = {
|
||||||
|
global: 0,
|
||||||
|
idle: 0,
|
||||||
|
}
|
||||||
|
const announcementTimers: Record<
|
||||||
|
ScreenReaderChannel,
|
||||||
|
ReturnType<typeof setTimeout> | null
|
||||||
|
> = {
|
||||||
|
global: null,
|
||||||
|
idle: null,
|
||||||
|
}
|
||||||
|
const lastAnnouncementTimes: Record<ScreenReaderChannel, number> = {
|
||||||
|
global: 0,
|
||||||
|
idle: 0,
|
||||||
|
}
|
||||||
|
const MIN_ANNOUNCEMENT_INTERVAL = 300 // Minimum 300ms between announcements
|
||||||
|
|
||||||
|
export const announceToScreenReader = (
|
||||||
|
message: string,
|
||||||
|
politeness: Politeness = 'polite',
|
||||||
|
channel: ScreenReaderChannel = 'global'
|
||||||
|
) => {
|
||||||
|
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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user