Announce timer countdown for screen readers#85742
Conversation
Use useAccessibilityAnnouncement hook in ValidateCodeCountdown to announce the countdown start (time remaining) and expiration for screen reader users. Add translation keys for both English and Spanish. 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 c61d6b1b..17bd2c0f 100644
--- a/src/languages/de.ts
+++ b/src/languages/de.ts
@@ -2673,6 +2673,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} ${timeRemaining === 1 ? 'Sekunde' : '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 b8a98326..43b0d038 100644
--- a/src/languages/fr.ts
+++ b/src/languages/fr.ts
@@ -2680,6 +2680,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} ${timeRemaining === 1 ? 'seconde' : '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 7f8f6e3d..91473ac0 100644
--- a/src/languages/it.ts
+++ b/src/languages/it.ts
@@ -2669,6 +2669,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} ${timeRemaining === 1 ? 'secondo' : 'secondi'}`,
+ timeExpiredAnnouncement: 'Il tempo è scaduto',
},
passwordForm: {
pleaseFillOutAllFields: 'Compila tutti i campi',
diff --git a/src/languages/ja.ts b/src/languages/ja.ts
index f0eb7872..e7ace5ac 100644
--- a/src/languages/ja.ts
+++ b/src/languages/ja.ts
@@ -2651,6 +2651,8 @@ ${date} の ${merchant} への ${amount}`,
incorrectMagicCode: '魔法コードが間違っているか無効です。もう一度お試しいただくか、新しいコードをリクエストしてください。',
pleaseFillTwoFactorAuth: '2 要素認証コードを入力してください',
},
+ timeRemainingAnnouncement: ({timeRemaining}: {timeRemaining: number}) => `残り時間:${timeRemaining} ${timeRemaining === 1 ? '秒' : '秒'}`,
+ timeExpiredAnnouncement: '時間切れです',
},
passwordForm: {
pleaseFillOutAllFields: 'すべての項目を入力してください',
diff --git a/src/languages/nl.ts b/src/languages/nl.ts
index e96c2a8a..f3668821 100644
--- a/src/languages/nl.ts
+++ b/src/languages/nl.ts
@@ -2667,6 +2667,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} ${timeRemaining === 1 ? 'seconde' : 'seconden'}`,
+ timeExpiredAnnouncement: 'De tijd is verlopen',
},
passwordForm: {
pleaseFillOutAllFields: 'Vul alle velden in',
diff --git a/src/languages/pl.ts b/src/languages/pl.ts
index bc7504d7..1201ce01 100644
--- a/src/languages/pl.ts
+++ b/src/languages/pl.ts
@@ -2661,6 +2661,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} ${timeRemaining === 1 ? 'sekunda' : 'sekundy'}`,
+ 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 c882118b..86b6568e 100644
--- a/src/languages/pt-BR.ts
+++ b/src/languages/pt-BR.ts
@@ -2660,6 +2660,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} ${timeRemaining === 1 ? 'segundo' : '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 b4670f8f..c0c5c249 100644
--- a/src/languages/zh-hans.ts
+++ b/src/languages/zh-hans.ts
@@ -2609,6 +2609,8 @@ ${amount},商户:${merchant} - 日期:${date}`,
incorrectMagicCode: '魔术验证码不正确或无效。请重试或请求新的验证码。',
pleaseFillTwoFactorAuth: '请输入您的双重身份验证代码',
},
+ timeRemainingAnnouncement: ({timeRemaining}: {timeRemaining: number}) => `剩余时间:${timeRemaining} ${timeRemaining === 1 ? '秒' : '秒'}`,
+ timeExpiredAnnouncement: '时间已过期',
},
passwordForm: {
pleaseFillOutAllFields: '请填写所有字段',
Note You can apply these changes to your branch by copying the patch to your clipboard, then running |
…nslations Added the two new translation keys to all remaining language files (de, fr, it, ja, nl, pl, pt-BR, zh-hans) that were missed in the original commit, causing typecheck failures. Co-authored-by: Situ Chandra Shil <situchan@users.noreply.github.com>
|
Fixed the failing typecheck: added the missing |
Codecov Report✅ Changes either increased or maintained existing code coverage, great job!
|
| // Announce countdown start/reset for screen readers | ||
| useAccessibilityAnnouncement(translate('validateCodeForm.timeRemainingAnnouncement', {timeRemaining: CONST.REQUEST_CODE_DELAY}), timeRemaining === CONST.REQUEST_CODE_DELAY, { | ||
| shouldAnnounceOnNative: true, | ||
| shouldAnnounceOnWeb: true, | ||
| }); | ||
|
|
||
| // Announce expiration for screen readers | ||
| useAccessibilityAnnouncement(translate('validateCodeForm.timeExpiredAnnouncement'), timeRemaining === 0, {shouldAnnounceOnNative: true, shouldAnnounceOnWeb: true}); |
There was a problem hiding this comment.
| // Announce countdown start/reset for screen readers | |
| useAccessibilityAnnouncement(translate('validateCodeForm.timeRemainingAnnouncement', {timeRemaining: CONST.REQUEST_CODE_DELAY}), timeRemaining === CONST.REQUEST_CODE_DELAY, { | |
| shouldAnnounceOnNative: true, | |
| shouldAnnounceOnWeb: true, | |
| }); | |
| // Announce expiration for screen readers | |
| useAccessibilityAnnouncement(translate('validateCodeForm.timeExpiredAnnouncement'), timeRemaining === 0, {shouldAnnounceOnNative: true, shouldAnnounceOnWeb: true}); | |
| // Announce countdown start/reset/expiration for screen readers | |
| useAccessibilityAnnouncement( | |
| timeRemaining === 1 ? translate('validateCodeForm.timeExpiredAnnouncement') : translate('validateCodeForm.timeRemainingAnnouncement', {timeRemaining: timeRemaining - 1}), | |
| timeRemaining % 10 === 1, | |
| { | |
| shouldAnnounceOnNative: true, | |
| shouldAnnounceOnWeb: true, | |
| }, | |
| ); |
There was a problem hiding this comment.
Applied! Consolidated the two hook calls into one that announces every 10 seconds and at expiration. Pushed in 3b6145e.
Combine two separate useAccessibilityAnnouncement calls into one that announces every 10 seconds and at expiration, per reviewer feedback. Co-authored-by: Situ Chandra Shil <situchan@users.noreply.github.com>
| timeRemaining === 1 ? translate('validateCodeForm.timeExpiredAnnouncement') : translate('validateCodeForm.timeRemainingAnnouncement', {timeRemaining: timeRemaining - 1}), | ||
| timeRemaining % 10 === 1, |
There was a problem hiding this comment.
@MelvinBot add comment here so others don't think 10, 1 are magic numbers.
10 is to announce every 10 seconds
1 is for 1s ahead, especially when expired because this component is unmounted immediately on 0s remaining and expired message is not announced
There was a problem hiding this comment.
Added comments explaining both magic numbers. Pushed in cd5ccf3.
There was a problem hiding this comment.
Added explanatory comments in cd5ccf3 — the code now documents why we use timeRemaining === 1 (component unmounts at 0s so the announcement wouldn't be spoken) and timeRemaining % 10 === 1 (announce every 10 seconds to avoid overwhelming screen reader users).
|
The previously failing TypeScript Checks (missing |
Clarify why timeRemaining === 1 (not 0) is used for expiration check and why timeRemaining % 10 === 1 triggers announcements every 10s. Co-authored-by: Situ Chandra Shil <situchan@users.noreply.github.com>
|
@MelvinBot update Tests Steps based on latest codebase.
30s remaining is intentionally not announced because it interrupts other announcements. |
|
Updated the PR description — Test Steps and QA Steps now reflect the actual announcement behavior:
|
|
@MelvinBot mark as ready for review |
|
PR has been marked as ready for review! ✅ |
Reviewer Checklist
Screenshots/VideosAndroid: HybridAppTested on physical device Android: mWeb ChromeiOS: HybridAppTested on physical device iOS: mWeb SafariMacOS: Chrome / Safariweb.mov |
|
We did not find an internal engineer to review this PR, trying to assign a random engineer to #77259 as well as to this PR... Please reach out for help on Slack if no one gets assigned! |
|
@youssef-lr Please copy/paste the Reviewer Checklist from here into a new comment on this PR and complete it. If you have the K2 extension, you can simply click: [this button] |
|
Pert Tests failure is not related. Re-trigger might fix it. |
|
The failing check is unrelated to this PR. Failing check: Reassure Performance Tests — Why it's unrelated: This PR only modifies Evidence: The same Reassure Performance Test is also failing on multiple other branches today ( The only other failing check is PR Reviewer Checklist, which is expected for PRs pending review. |
|
The failing Analysis: The failure is in Evidence: The perf test measures render counts for This appears to be a flaky perf test. Re-running the workflow should resolve it. |
Code ReviewI reviewed the full diff, the SummaryThis PR adds screen reader announcements to the magic code countdown timer via the existing What looks good
Minor observations (non-blocking)
VerdictThe implementation is correct and minimal. The hook behavior is sound across all platforms, edge cases (reset, unmount timing) are handled, and all language files are updated consistently. The observations above are non-blocking. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: cd5ccf3c1b
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| timeRemaining === 1 ? translate('validateCodeForm.timeExpiredAnnouncement') : translate('validateCodeForm.timeRemainingAnnouncement', {timeRemaining: timeRemaining - 1}), | ||
| timeRemaining % 10 === 1, |
There was a problem hiding this comment.
Announce countdown value at the actual remaining second
The new announcement logic subtracts one from timeRemaining and triggers on timeRemaining % 10 === 1, so users hear 20 while the UI still shows 21, 10 while it shows 11, and even "time expired" while 1 second is still left. This creates a consistent off-by-one mismatch between what screen reader users hear and what the timer actually represents, which is especially confusing near expiry.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
It's fine. While saying "Time remaining:", 1 second is gone already.
trjExpensify
left a comment
There was a problem hiding this comment.
Accessibility project PR 👍
|
Re-running reassure |
|
The failing Analysis: The failure is in Evidence: Files changed in this PR: |
|
@MelvinBot pull main |
…Accessibility Co-authored-by: Situ Chandra Shil <situchan@users.noreply.github.com>
|
Merged |
|
The failing check is unrelated to this PR. Failing check: Analysis: The build fails with Swift compiler errors in Evidence: This PR only modifies TypeScript/translation files:
None of the changed files touch iOS native code. The Swift import access level error in |
|
@MelvinBot can you merge main |
…Accessibility Co-authored-by: Youssef Lourayad <youssef-lr@users.noreply.github.com>
|
Merged |
|
🚧 @carlosmiceli has triggered a test Expensify/App build. You can view the workflow run here. |
|
🧪🧪 Use the links below to test this adhoc build on Android, iOS, and Web. Happy testing! 🧪🧪
|
Explanation of Change
This PR adds screen reader announcements for the magic code countdown timer, addressing WCAG 4.1.3 (Status Messages).
Changes:
ValidateCodeCountdown: UsesuseAccessibilityAnnouncementhook to announce at key moments:timeRemainingAnnouncementandtimeExpiredAnnouncementto English and Spanish language files.The 30-second mark is intentionally not announced to avoid interrupting other screen reader announcements. Announcements are spaced every 10 seconds (at 20s and 10s remaining) plus expiration, providing useful information without being noisy.
Fixed Issues
$ #77259
Tests
Offline tests
N/A — These are purely UI accessibility announcements that don't involve network requests.
QA Steps
PR 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).