Announce dynamic content for screen readers (search suggestions + timer)#84963
Announce dynamic content for screen readers (search suggestions + timer)#84963
Conversation
…+ timer countdown) Add accessibility live regions and announcements for two categories of dynamic content: 1. Search suggestions: Add visually-hidden live region text in BaseSelectionList that announces result count (debounced) when filtering changes. Add accessibilityLiveRegion on BaseAutoCompleteSuggestions container with count text. 2. Timer countdown: Add AccessibilityInfo.announceForAccessibility calls in ValidateCodeCountdown at countdown start/reset and expiration (not every second). Wrap timer output in View with accessibilityLiveRegion="polite" for web/Android. Addresses WCAG 4.1.3 (Status Messages). Co-authored-by: Situ Chandra Shil <situchan@users.noreply.github.com>
🦜 Polyglot Parrot! 🦜Squawk! Looks like you added some shiny new English strings. Allow me to parrot them back to you in other tongues: View the translation diffdiff --git a/src/languages/de.ts b/src/languages/de.ts
index 4af8f696..80c79bb5 100644
--- a/src/languages/de.ts
+++ b/src/languages/de.ts
@@ -551,6 +551,8 @@ const translations: TranslationDeepObject<typeof en> = {
concierge: {sidePanelGreeting: 'Hallo, wie kann ich helfen?', showHistory: 'Verlauf anzeigen'},
duplicateReport: 'Duplizierten Bericht',
approver: 'Genehmiger',
+ resultsAvailable: (count: number) => `${count} ${count === 1 ? 'Ergebnis' : 'Ergebnisse'} verfügbar`,
+ suggestionsAvailable: (count: number) => `${count} ${count === 1 ? 'Vorschlag' : 'Vorschläge'} verfügbar`,
},
socials: {
podcast: 'Folgen Sie uns auf Podcast',
@@ -2688,6 +2690,8 @@ ${amount} für ${merchant} – ${date}`,
incorrectMagicCode: 'Falscher oder ungültiger Magic-Code. Bitte versuche es erneut oder fordere einen neuen Code an.',
pleaseFillTwoFactorAuth: 'Bitte gib deinen Zwei-Faktor-Authentifizierungscode ein',
},
+ timeRemainingAnnouncement: ({timeRemaining}: {timeRemaining: number}) => `Verbleibende Zeit: ${timeRemaining} Sekunden`,
+ timeExpiredAnnouncement: 'Die Zeit ist abgelaufen',
},
passwordForm: {
pleaseFillOutAllFields: 'Bitte füllen Sie alle Felder aus',
diff --git a/src/languages/fr.ts b/src/languages/fr.ts
index 693385c2..a37a6c1f 100644
--- a/src/languages/fr.ts
+++ b/src/languages/fr.ts
@@ -551,6 +551,8 @@ const translations: TranslationDeepObject<typeof en> = {
concierge: {sidePanelGreeting: 'Bonjour, comment puis-je vous aider ?', showHistory: 'Afficher l’historique'},
duplicateReport: 'Note de frais en double',
approver: 'Approbateur',
+ resultsAvailable: (count: number) => `${count} ${count === 1 ? 'résultat' : 'résultats'} disponible`,
+ suggestionsAvailable: (count: number) => `${count} ${count === 1 ? 'suggestion' : 'suggestions'} disponible`,
},
socials: {
podcast: 'Suivez-nous sur Podcast',
@@ -2693,6 +2695,8 @@ ${amount} pour ${merchant} - ${date}`,
incorrectMagicCode: 'Code magique incorrect ou non valide. Veuillez réessayer ou demander un nouveau code.',
pleaseFillTwoFactorAuth: 'Veuillez saisir votre code d’authentification à deux facteurs',
},
+ timeRemainingAnnouncement: ({timeRemaining}: {timeRemaining: number}) => `Temps restant : ${timeRemaining} secondes`,
+ timeExpiredAnnouncement: 'Le délai est expiré',
},
passwordForm: {
pleaseFillOutAllFields: 'Veuillez remplir tous les champs',
diff --git a/src/languages/it.ts b/src/languages/it.ts
index 03e31966..8dd1c2e4 100644
--- a/src/languages/it.ts
+++ b/src/languages/it.ts
@@ -551,6 +551,8 @@ const translations: TranslationDeepObject<typeof en> = {
concierge: {sidePanelGreeting: 'Ciao, come posso aiutarti?', showHistory: 'Mostra cronologia'},
duplicateReport: 'Report duplicato',
approver: 'Approvante',
+ resultsAvailable: (count: number) => `${count} ${count === 1 ? 'risultato' : 'risultati'} disponibili`,
+ suggestionsAvailable: (count: number) => `${count} ${count === 1 ? 'suggerimento' : 'suggerimenti'} disponibili`,
},
socials: {
podcast: 'Seguici su Podcast',
@@ -2682,6 +2684,8 @@ ${amount} per ${merchant} - ${date}`,
incorrectMagicCode: 'Codice magico errato o non valido. Riprova o richiedi un nuovo codice.',
pleaseFillTwoFactorAuth: 'Inserisci il tuo codice di autenticazione a due fattori',
},
+ timeRemainingAnnouncement: ({timeRemaining}: {timeRemaining: number}) => `Tempo rimanente: ${timeRemaining} secondi`,
+ timeExpiredAnnouncement: 'Il tempo è scaduto',
},
passwordForm: {
pleaseFillOutAllFields: 'Compila tutti i campi',
diff --git a/src/languages/ja.ts b/src/languages/ja.ts
index 3558d541..de31ef33 100644
--- a/src/languages/ja.ts
+++ b/src/languages/ja.ts
@@ -550,6 +550,8 @@ const translations: TranslationDeepObject<typeof en> = {
concierge: {sidePanelGreeting: 'こんにちは、どのようにお手伝いできますか?', showHistory: '履歴を表示'},
duplicateReport: 'レポートを複製',
approver: '承認者',
+ resultsAvailable: (count: number) => `利用可能:${count} ${count === 1 ? '結果' : '結果'}`,
+ suggestionsAvailable: (count: number) => `利用可能数: ${count} ${count === 1 ? '提案' : '提案'}`,
},
socials: {
podcast: 'ポッドキャストでフォロー',
@@ -2662,6 +2664,8 @@ ${date} の ${merchant} への ${amount}`,
incorrectMagicCode: '魔法コードが間違っているか無効です。もう一度お試しいただくか、新しいコードをリクエストしてください。',
pleaseFillTwoFactorAuth: '2 要素認証コードを入力してください',
},
+ timeRemainingAnnouncement: ({timeRemaining}: {timeRemaining: number}) => `残り時間:${timeRemaining}秒`,
+ timeExpiredAnnouncement: '時間が終了しました',
},
passwordForm: {
pleaseFillOutAllFields: 'すべての項目を入力してください',
diff --git a/src/languages/nl.ts b/src/languages/nl.ts
index e0abe0a1..69a3a737 100644
--- a/src/languages/nl.ts
+++ b/src/languages/nl.ts
@@ -550,6 +550,8 @@ const translations: TranslationDeepObject<typeof en> = {
concierge: {sidePanelGreeting: 'Hoi, waarmee kan ik je helpen?', showHistory: 'Geschiedenis weergeven'},
duplicateReport: 'Dubbel rapport',
approver: 'Fiatteur',
+ resultsAvailable: (count: number) => `${count} ${count === 1 ? 'resultaat' : 'resultaten'} beschikbaar`,
+ suggestionsAvailable: (count: number) => `${count} ${count === 1 ? 'suggestie' : 'suggesties'} beschikbaar`,
},
socials: {
podcast: 'Volg ons op Podcast',
@@ -2681,6 +2683,8 @@ ${amount} voor ${merchant} - ${date}`,
incorrectMagicCode: 'Onjuiste of ongeldige magische code. Probeer het opnieuw of vraag een nieuwe code aan.',
pleaseFillTwoFactorAuth: 'Voer je twee-factor-authenticatiecode in',
},
+ timeRemainingAnnouncement: ({timeRemaining}: {timeRemaining: number}) => `Resterende tijd: ${timeRemaining} seconden`,
+ timeExpiredAnnouncement: 'De tijd is verstreken',
},
passwordForm: {
pleaseFillOutAllFields: 'Vul alle velden in',
diff --git a/src/languages/pl.ts b/src/languages/pl.ts
index 01d78ca9..5c8c246c 100644
--- a/src/languages/pl.ts
+++ b/src/languages/pl.ts
@@ -550,6 +550,8 @@ const translations: TranslationDeepObject<typeof en> = {
concierge: {sidePanelGreeting: 'Cześć, w czym mogę pomóc?', showHistory: 'Pokaż historię'},
duplicateReport: 'Zduplikowany raport',
approver: 'Osoba zatwierdzająca',
+ resultsAvailable: (count: number) => `Dostępne: ${count} ${count === 1 ? 'wynik' : 'wyniki'}`,
+ suggestionsAvailable: (count: number) => `Dostępne: ${count} ${count === 1 ? 'sugestia' : 'sugestie'}`,
},
socials: {
podcast: 'Śledź nas na Podcast',
@@ -2674,6 +2676,8 @@ ${amount} dla ${merchant} - ${date}`,
incorrectMagicCode: 'Nieprawidłowy lub niepoprawny kod magiczny. Spróbuj ponownie lub poproś o nowy kod.',
pleaseFillTwoFactorAuth: 'Wprowadź swój kod uwierzytelniania dwuskładnikowego',
},
+ timeRemainingAnnouncement: ({timeRemaining}: {timeRemaining: number}) => `Pozostały czas: ${timeRemaining} sekund`,
+ timeExpiredAnnouncement: 'Czas minął',
},
passwordForm: {
pleaseFillOutAllFields: 'Proszę wypełnić wszystkie pola',
diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts
index c7ffffd4..31b21570 100644
--- a/src/languages/pt-BR.ts
+++ b/src/languages/pt-BR.ts
@@ -549,6 +549,8 @@ const translations: TranslationDeepObject<typeof en> = {
concierge: {sidePanelGreeting: 'Oi, como posso ajudar?', showHistory: 'Mostrar histórico'},
duplicateReport: 'Duplicar relatório',
approver: 'Aprovador',
+ resultsAvailable: (count: number) => `${count} ${count === 1 ? 'resultado' : 'resultados'} disponível`,
+ suggestionsAvailable: (count: number) => `${count} ${count === 1 ? 'sugestão' : 'sugestões'} disponível`,
},
socials: {
podcast: 'Siga-nos no Podcast',
@@ -2674,6 +2676,8 @@ ${amount} para ${merchant} - ${date}`,
incorrectMagicCode: 'Código mágico incorreto ou inválido. Tente novamente ou solicite um novo código.',
pleaseFillTwoFactorAuth: 'Insira seu código de autenticação de dois fatores',
},
+ timeRemainingAnnouncement: ({timeRemaining}: {timeRemaining: number}) => `Tempo restante: ${timeRemaining} segundos`,
+ timeExpiredAnnouncement: 'O tempo expirou',
},
passwordForm: {
pleaseFillOutAllFields: 'Por favor, preencha todos os campos',
diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts
index d9a523c8..e23af4af 100644
--- a/src/languages/zh-hans.ts
+++ b/src/languages/zh-hans.ts
@@ -546,6 +546,8 @@ const translations: TranslationDeepObject<typeof en> = {
concierge: {sidePanelGreeting: '你好,我能帮你做什么?', showHistory: '显示历史'},
duplicateReport: '重复报销单',
approver: '审批人',
+ resultsAvailable: (count: number) => `${count} ${count === 1 ? '结果' : '结果'} 可用`,
+ suggestionsAvailable: (count: number) => `${count} 个 ${count === 1 ? '建议' : '建议'} 可用`,
},
socials: {
podcast: '在播客上关注我们',
@@ -2622,6 +2624,8 @@ ${amount},商户:${merchant} - 日期:${date}`,
incorrectMagicCode: '魔术验证码不正确或无效。请重试或请求新的验证码。',
pleaseFillTwoFactorAuth: '请输入您的双重身份验证代码',
},
+ timeRemainingAnnouncement: ({timeRemaining}: {timeRemaining: number}) => `剩余时间:${timeRemaining} 秒`,
+ timeExpiredAnnouncement: '时间已过期',
},
passwordForm: {
pleaseFillOutAllFields: '请填写所有字段',
Note You can apply these changes to your branch by copying the patch to your clipboard, then running |
Add missing sentryLabel prop to PressableWithFeedback in BaseAutoCompleteSuggestions and fix import ordering in BaseSelectionList to satisfy Prettier. Co-authored-by: Situ Chandra Shil <situchan@users.noreply.github.com>
|
Fixed the failing checks:
|
Add resultsAvailable, suggestionsAvailable, timeRemainingAnnouncement, and timeExpiredAnnouncement to de, fr, it, ja, nl, pl, pt-BR, and zh-hans language files to fix TypeScript check failures. Co-authored-by: Situ Chandra Shil <situchan@users.noreply.github.com>
|
Fixed the failing TypeScript check by adding missing translation keys ( These keys were added to Note: The |
The hasVisibleReportFieldViolations function referenced allReportsViolations but it was never declared or connected to Onyx, causing a TypeScript error. Added the module-level Onyx.connect for REPORT_VIOLATIONS collection, following the same pattern as other collection connections in the file. Co-authored-by: Situ Chandra Shil <situchan@users.noreply.github.com>
|
Fixed the failing typecheck. There were two issues:
|
Codecov Report❌ Looks like you've decreased code coverage for some files. Please write tests to increase, or at least maintain, the existing level of code coverage. See our documentation here for how to interpret this table.
|
…eldViolations The module-level Onyx.connect approach for allReportsViolations was triggering a CI-specific ESLint no-unused-vars false positive. Reverting to the original pattern where reportViolations is passed as a parameter from reportAttributes.ts, which matches the main branch approach and avoids the lint error. Co-authored-by: Situ Chandra Shil <situchan@users.noreply.github.com>
|
Fixed the failing The previous commit added a module-level Reverted to the original parameter-based approach: |
|
The failing checks were caused by a reference to an undefined variable The latest CI run confirms all checks are now passing:
|
|
@situchan FYI https://reactnative.dev/docs/accessibilityinfo#announceforaccessibilitywithoptions, might be a good idea to pass |
|
@MelvinBot pull main, fix conflict |
Co-authored-by: Situ Chandra Shil <situchan@users.noreply.github.com>
|
Merged main and resolved conflicts in:
|
Co-authored-by: Situ Chandra Shil <situchan@users.noreply.github.com>
|
Fixed the failing Prettier check: ran prettier on |
The reportAttributes config added REPORT_VIOLATIONS as a dependency, increasing the array from 11 to 12 elements. The test's compute() calls need the matching element count. Co-authored-by: Situ Chandra Shil <situchan@users.noreply.github.com>
|
Fixed the failing typecheck: the |
|
The failing checks from the previous run have been addressed:
No additional code changes are needed. The current CI run on the latest commit should pass (barring unrelated flaky tests). |
| // Announce countdown start/reset and expiration for screen readers (iOS) | ||
| useEffect(() => { | ||
| if (timeRemaining === CONST.REQUEST_CODE_DELAY && previousTimeRemainingRef.current !== CONST.REQUEST_CODE_DELAY) { | ||
| // Countdown was reset | ||
| AccessibilityInfo.announceForAccessibility(translate('validateCodeForm.timeRemainingAnnouncement', {timeRemaining: CONST.REQUEST_CODE_DELAY})); | ||
| } else if (timeRemaining === 0) { | ||
| AccessibilityInfo.announceForAccessibility(translate('validateCodeForm.timeExpiredAnnouncement')); | ||
| } | ||
| previousTimeRemainingRef.current = timeRemaining; | ||
| }, [timeRemaining, translate]); | ||
|
|
||
| // Announce on initial mount | ||
| useEffect(() => { | ||
| AccessibilityInfo.announceForAccessibility(translate('validateCodeForm.timeRemainingAnnouncement', {timeRemaining: CONST.REQUEST_CODE_DELAY})); | ||
| // eslint-disable-next-line react-hooks/exhaustive-deps | ||
| }, []); |
There was a problem hiding this comment.
@MelvinBot This entire logic is not necessary. We can use existing useAccessibilityAnnouncement hook.
Once time remaining is announced first time, no need to announce more until expired.
There was a problem hiding this comment.
Replaced the manual AccessibilityInfo.announceForAccessibility logic with two useAccessibilityAnnouncement hook calls:
- One for the countdown start/reset (timeRemaining === CONST.REQUEST_CODE_DELAY)
- One for expiration (timeRemaining === 0)
Removed previousTimeRemainingRef, the two manual useEffect blocks, and the AccessibilityInfo import.
Co-authored-by: Situ Chandra Shil <situchan@users.noreply.github.com>
|
The previously failing checks have been fixed and are now passing:
All checks are now green (typecheck, all 8 test jobs, ESLint, prettier, builds, etc.). The only remaining 'failure' is the PR Reviewer Checklist, which is expected. |
|
@MelvinBot implement #74840 and #77259 separately in different PRs as they have completely different root cause.
For #77259: once time remaining message is announced on first render, no need to announce more (every second) until it's expired. |
|
Created two separate PRs as requested, using the
Both PRs are drafts. Please mark them as "Ready for review" when ready. |
|
@MelvinBot close this PR as dupe of #85296 |
|
Closing this PR as a duplicate of #85296. |
Explanation of Change
This PR addresses two accessibility issues where screen reader users are not informed when dynamic content appears or updates:
Search suggestions not announced (SR: Settings - Security: The availability of search suggestions is not announced #74840): When typing in autocomplete fields (address, pronouns, country, currency, etc.), suggestions appear visually but screen readers stay silent. Fix: Added a visually-hidden
<Text>withaccessibilityLiveRegion="polite"inBaseSelectionListthat announces the result count (debounced at 300ms to avoid noise). AddedaccessibilityLiveRegion="polite"on theBaseAutoCompleteSuggestionscontainer with a hidden text announcing suggestion count.Timer countdown not announced (Screen Reader: Many Pages: The user is not informed about displayed time and when it disappears #77259): Magic code countdown timer ticks down visually but VoiceOver/TalkBack never announces it. Fix: Added
AccessibilityInfo.announceForAccessibilitycalls inValidateCodeCountdownat two key moments only — countdown start/reset and expiration (not every second). Wrapped the timer output in<View accessibilityLiveRegion="polite">for web/Android coverage.Both fixes follow existing codebase patterns (dual
accessibilityLiveRegion+announceForAccessibilityapproach fromFormHelpMessage). All announcement text is localized (en + es).Fixed Issues
$ #84311
PROPOSAL: #84311 (comment)
Tests
SelectionListthat has a text input (e.g., Country selector, Currency selector, Pronouns)@to trigger mention suggestionsOffline tests
N/A — These are purely UI accessibility announcements that don't involve network requests.
QA Steps
@and verify suggestion count is announcedPR Author Checklist
### Fixed Issuessection aboveTestssectionOffline stepssectionQA stepssectiontoggleReportand notonIconClick)src/languages/*files and using the translation methodSTYLE.md) were followedAvatar, I verified the components usingAvatarare working as expected)StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))npm run compress-svg)Avataris modified, I verified thatAvataris working as expected in all cases)Designlabel and/or tagged@Expensify/designso the design team can review the changes.ScrollViewcomponent to make it scrollable when more elements are added to the page.mainbranch was merged into this PR after a review, I tested again and verified the outcome was still expected according to theTeststeps.Screenshots/Videos
Android: Native
N/A — Changes are accessibility attribute additions only (no visual UI changes).
Android: mWeb Chrome
N/A — Changes are accessibility attribute additions only (no visual UI changes).
iOS: Native
N/A — Changes are accessibility attribute additions only (no visual UI changes).
iOS: mWeb Safari
N/A — Changes are accessibility attribute additions only (no visual UI changes).
MacOS: Chrome / Safari
N/A — Changes are accessibility attribute additions only (no visual UI changes).