Skip to content

fix: Screen Reader: Many Pages: There is no dialog role and title announced#85221

Merged
amyevans merged 42 commits intoExpensify:mainfrom
TaduJR:fix-Screen-Reader-Many-Pages-There-is-no-dialog-role-and-title-announced
Apr 7, 2026
Merged

fix: Screen Reader: Many Pages: There is no dialog role and title announced#85221
amyevans merged 42 commits intoExpensify:mainfrom
TaduJR:fix-Screen-Reader-Many-Pages-There-is-no-dialog-role-and-title-announced

Conversation

@TaduJR
Copy link
Copy Markdown
Contributor

@TaduJR TaduJR commented Mar 13, 2026

Explanation of Change

When opening RHP pages, screen readers (JAWS, VoiceOver) don't announce the dialog role or title, violating WCAG 4.1.2 (Name, Role, Value).

This PR adds dialog semantics to the RHP container and moves focus to the first interactive element for screen reader announcement.

What's changed

  • DialogLabelContext - A ref-based label stack that communicates the screen title from Header to the RHP container. Uses direct DOM mutation (setAttribute) to update aria-label, avoiding React state updates during animation. Split into data and actions contexts per lint rules.

  • RHP Animated.View - Gets role="dialog", aria-modal="true", and aria-label (via DOM mutation) on wide layouts. Small screens already get dialog semantics from createModalStackNavigator. DialogLabelProvider wraps the Stack.Navigator inside the Animated.View.

  • useDialogLabelRegistration - Hook that encapsulates the label lifecycle (push on mount, pop on unmount). Only registers when isScreenHeader is true, preventing nested Header components (e.g., inside PDF modals) from overwriting the dialog label.

  • useDialogContainerFocus - Platform-specific hook (web only, no-op on native) that focuses the first interactive element (typically the back button) inside the dialog after the screen transition completes. Uses InteractionManager + requestAnimationFrame to defer past useAutoFocusInput's async focus chain, and preventScroll: true to avoid layout recalculation during close animation. Skips focus if another element already has focus. Filters out stacked screens via aria-hidden="true".

  • Header - Accepts isScreenHeader prop (set by HeaderWithBackButton) to gate dialog label registration and focus. Only screen-level headers participate in the dialog system.

  • HeaderWithBackButton - Passes isScreenHeader to Header. For avatar-header routes (e.g., MoneyReportHeader) that skip Header, registers report?.reportName as the dialog label directly.

Fixed Issues

$ #76944
PROPOSAL: #76944 (comment)

Tests

Setup: Use web (wide layout). Enable a screen reader (VoiceOver on macOS, or JAWS/NVDA on Windows).

Accessibility verification:

  1. Open Settings > About
  2. Navigate using Tab to "App download links" and press Enter to activate it
  3. Verify the screen reader announces the dialog role
  4. Verify focus moves to the back button inside the RHP
  5. Press Tab verify focus moves to the next interactive element
  6. Press Shift+Tab verify focus moves to the Back button
  7. Press Back verify no flickering and focus is not lost

Regression tests (screen reader can be off):

#85013 Sluggish RHP close animation:

  1. Open a workspace chat, create two expenses, open the report
  2. Open any expense > click Amount
  3. Click outside the RHP to close it
  4. Repeat steps 2-3 several times
  5. Verify RHP closes smoothly each time (no sluggishness)

#84960 Amount input field loses focus:

  1. FAB > Create Expense
  2. Verify the amount input field has focus and you can type immediately

#84958 Name field auto throws error:

  1. Go to workspace settings > Reports > Add field > Name
  2. Verify the Name field does not auto-throw an error on open

#84957 Blank page on Clear cache and restart:

  1. Go to Account > Troubleshoot > Clear cache and restart
  2. Verify the "Are you sure" modal appears (no blank page)

#84966 Blank page when deleting expense:

  1. Open a workspace chat, create an expense
  2. Right-click the preview > Delete
  3. Verify the app does not crash
  4. Open the report > More > Delete
  5. Verify the delete modal opens (no blank page)

#84972 Blank page on disabling Approvals:

  1. Go to workspace settings > Workflows > enable Approvals > disable Approvals
  2. Verify the warning popup appears (no blank page)

#84975 Long click on Inbox chats:

  1. Go to Inbox, long-click a chat > Pin or Mark as unread
  2. Verify the action is carried out

#84995 Blank page after importing Per diem CSV:

  1. Go to workspace > More Features > enable Per diem > import a valid CSV
  2. Verify the workspace loads with imported settings (no blank page)

#85007 Removing a member crashes the page:

  1. Go to workspaces > Members > click a user > Remove from workspace
  2. Verify the user is removed (no blank page)

#85017 Blank page on deleting contact method:

  1. Go to Account > Profile > Contact Method > click a method > 3-dot menu > Remove
  2. Verify redirect to Contact methods RHP (no blank page)

#85019 Cancel payment leads to blank page:

  1. Submit and pay an expense in a workspace chat
  2. Open the report > More > Cancel payment
  3. Verify the payment is canceled (no blank page)

#85033 Delete bank account blank page:

  1. Go to Wallet > 3-dot menu on a bank account > Delete
  2. Verify the confirmation modal appears (no blank page)

#85045 Sign out offline shows blank page:

  1. Toggle Force offline (Ctrl+D), then Account > Sign out
  2. Verify the "Are you sure?" confirmation appears (no blank page)
  • Verify that no errors appear in the JS console

Offline tests

Same as tests

QA Steps

// TODO: These must be filled out, or the issue title must include "[No QA]."
Same as tests

  • Verify that no errors appear in the JS console

PR Author Checklist

  • I linked the correct issue in the ### Fixed Issues section above
  • I wrote clear testing steps that cover the changes made in this PR
    • I added steps for local testing in the Tests section
    • I added steps for the expected offline behavior in the Offline steps section
    • I added steps for Staging and/or Production testing in the QA steps section
    • I added steps to cover failure scenarios (i.e. verify an input displays the correct error message if the entered data is not correct)
    • I turned off my network connection and tested it while offline to ensure it matches the expected behavior (i.e. verify the default avatar icon is displayed if app is offline)
    • I tested this PR with a High Traffic account against the staging or production API to ensure there are no regressions (e.g. long loading states that impact usability).
  • I included screenshots or videos for tests on all platforms
  • I ran the tests on all platforms & verified they passed on:
    • Android: Native
    • Android: mWeb Chrome
    • iOS: Native
    • iOS: mWeb Safari
    • MacOS: Chrome / Safari
    • MacOS: Desktop
  • I verified there are no console errors (if there's a console error not related to the PR, report it or open an issue for it to be fixed)
  • I verified there are no new alerts related to the canBeMissing param for useOnyx
  • I followed proper code patterns (see Reviewing the code)
    • I verified that any callback methods that were added or modified are named for what the method does and never what callback they handle (i.e. toggleReport and not onIconClick)
    • I verified that comments were added to code that is not self explanatory
    • I verified that any new or modified comments were clear, correct English, and explained "why" the code was doing something instead of only explaining "what" the code was doing.
    • I verified any copy / text shown in the product is localized by adding it to src/languages/* files and using the translation method
      • If any non-english text was added/modified, I used JaimeGPT to get English > Spanish translation. I then posted it in #expensify-open-source and it was approved by an internal Expensify engineer. Link to Slack message:
    • I verified all numbers, amounts, dates and phone numbers shown in the product are using the localization methods
    • I verified any copy / text that was added to the app is grammatically correct in English. It adheres to proper capitalization guidelines (note: only the first word of header/labels should be capitalized), and is either coming verbatim from figma or has been approved by marketing (in order to get marketing approval, ask the Bug Zero team member to add the Waiting for copy label to the issue)
    • I verified proper file naming conventions were followed for any new files or renamed files. All non-platform specific files are named after what they export and are not named "index.js". All platform-specific files are named for the platform the code supports as outlined in the README.
    • I verified the JSDocs style guidelines (in STYLE.md) were followed
  • If a new code pattern is added I verified it was agreed to be used by multiple Expensify engineers
  • I followed the guidelines as stated in the Review Guidelines
  • I tested other components that can be impacted by my changes (i.e. if the PR modifies a shared library or component like Avatar, I verified the components using Avatar are working as expected)
  • I verified all code is DRY (the PR doesn't include any logic written more than once, with the exception of tests)
  • I verified any variables that can be defined as constants (ie. in CONST.ts or at the top of the file that uses the constant) are defined as such
  • I verified that if a function's arguments changed that all usages have also been updated correctly
  • If any new file was added I verified that:
    • The file has a description of what it does and/or why is needed at the top of the file if the code is not self explanatory
  • If a new CSS style is added I verified that:
    • A similar style doesn't already exist
    • The style can't be created with an existing StyleUtils function (i.e. StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))
  • If new assets were added or existing ones were modified, I verified that:
    • The assets are optimized and compressed (for SVG files, run npm run compress-svg)
    • The assets load correctly across all supported platforms.
  • If the PR modifies code that runs when editing or sending messages, I tested and verified there is no unexpected behavior for all supported markdown - URLs, single line code, code blocks, quotes, headings, bold, strikethrough, and italic.
  • If the PR modifies a generic component, I tested and verified that those changes do not break usages of that component in the rest of the App (i.e. if a shared library or component like Avatar is modified, I verified that Avatar is working as expected in all cases)
  • If the PR modifies a component related to any of the existing Storybook stories, I tested and verified all stories for that component are still working as expected.
  • If the PR modifies a component or page that can be accessed by a direct deeplink, I verified that the code functions as expected when the deeplink is used - from a logged in and logged out account.
  • If the PR modifies the UI (e.g. new buttons, new UI components, changing the padding/spacing/sizing, moving components, etc) or modifies the form input styles:
    • I verified that all the inputs inside a form are aligned with each other.
    • I added Design label and/or tagged @Expensify/design so the design team can review the changes.
  • If a new page is added, I verified it's using the ScrollView component to make it scrollable when more elements are added to the page.
  • I added unit tests for any new feature or bug fix in this PR to help automatically prevent regressions in this user flow.
  • If the main branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to the Test steps.

Screenshots/Videos

Android: Native
Android: mWeb Chrome
iOS: Native
iOS: mWeb Safari
MacOS: Chrome / Safari
Mac-Chrome.1.mp4

@TaduJR TaduJR requested review from a team as code owners March 13, 2026 11:43
@melvin-bot melvin-bot bot requested review from mkhutornyi and removed request for a team March 13, 2026 11:43
@melvin-bot
Copy link
Copy Markdown

melvin-bot bot commented Mar 13, 2026

@mkhutornyi 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]

@melvin-bot melvin-bot bot requested review from heyjennahay and removed request for a team March 13, 2026 11:43
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 43d1d07094

ℹ️ 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".

@codecov
Copy link
Copy Markdown

codecov bot commented Mar 13, 2026

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.

Files with missing lines Coverage Δ
src/CONST/index.ts 93.65% <ø> (ø)
src/components/Header.tsx 100.00% <100.00%> (ø)
src/components/HeaderWithBackButton/index.tsx 91.95% <100.00%> (+0.09%) ⬆️
src/hooks/useDialogContainerFocus/index.native.ts 100.00% <100.00%> (ø)
...on/AppNavigator/Navigators/Overlay/BaseOverlay.tsx 0.00% <ø> (ø)
src/hooks/useDialogLabelRegistration.ts 83.33% <83.33%> (ø)
src/components/DialogLabelContext.tsx 88.88% <88.88%> (ø)
src/hooks/useDialogContainerFocus/index.ts 0.00% <0.00%> (ø)
...on/AppNavigator/Navigators/RightModalNavigator.tsx 5.31% <0.00%> (-0.18%) ⬇️
... and 11 files with indirect coverage changes

@mkhutornyi
Copy link
Copy Markdown
Contributor

@TaduJR TaduJR marked this pull request as draft March 13, 2026 12:00
@TaduJR TaduJR marked this pull request as ready for review March 13, 2026 14:19
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 95126f75b4

ℹ️ 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".

@TaduJR
Copy link
Copy Markdown
Contributor Author

TaduJR commented Mar 13, 2026

Ready for review.

You can ignore the Jest, it's failing on main.

@mkhutornyi
Copy link
Copy Markdown
Contributor

Please summarize new fixes with root cause

@rushatgabhane
Copy link
Copy Markdown
Member

@MelvinBot please help with #85221 (comment)

@MelvinBot
Copy link
Copy Markdown
Contributor

Summary of Changes and Root Causes

Original Issue (#76944)

Problem: When opening Right-Hand Panel (RHP) pages, screen readers (JAWS, VoiceOver, NVDA) do not announce the dialog role or title, violating WCAG 4.1.2 (Name, Role, Value).

Root Cause: The RHP container (RightModalNavigator) renders as a plain Animated.View with no ARIA semantics. It has no role="dialog", no aria-modal, and no aria-label. Screen readers therefore treat it as generic content rather than a dialog, and don't announce the title when it opens.

Fix Breakdown

1. RHPDialogContainer + DialogLabelProvider (in RightModalNavigator.tsx)

  • Wraps the Stack.Navigator in a <View role="dialog" aria-modal> so screen readers recognize the RHP as a modal dialog.
  • DialogLabelProvider manages the aria-label for this container, ensuring it always reflects the current screen's title.

2. DialogLabelContext.tsx — ref-based label stack

  • Uses a ref-based stack (not React state) to track which screen title is active. Each Header pushes its title on mount and pops on unmount.
  • Updates the container's aria-label via direct DOM mutation (node.setAttribute), bypassing React's render cycle entirely.
  • Why refs instead of state: The initial implementation used React state, which triggered re-renders of the dialog container during RHP slide-in/slide-out animations. This caused a re-render cascade that made the JS-driven animation sluggish (regression [Due for payment 2026-03-19] Expense - Sluggish animation when RHP closes #85013). By using refs + direct DOM mutation, the label updates happen synchronously without triggering any React re-renders, keeping animations smooth.

3. useDialogContainerFocus (web-only, no-op on native)

  • After the screen transition completes (didScreenTransitionEnd), programmatically focuses the dialog heading so screen readers announce it.
  • Skips focus if an interactive element (<input>, <textarea>, <select>) already has focus — this preserves auto-focus behavior on form screens (preventing regressions like [$250] Expense - Amount input field loses focus #84960 where the amount input field would lose focus).
  • Uses claimInitialFocus() — a one-shot flag that ensures only the first Header to mount in a dialog claims focus, preventing focus fights between nested headers.

4. useDialogLabelRegistration hook (extracted from Header)

  • Encapsulates the push/pop label lifecycle into a dedicated hook, keeping Header.tsx focused on rendering.
  • Reads ScreenWrapperStatusContext to determine isTransitionReady, ensuring focus isn't moved until the screen transition animation has completed.

5. Header.tsx changes

  • Registers its title with the dialog context on mount.
  • Adds tabIndex={-1} to the title <Text> when inside a dialog, making it programmatically focusable without adding it to the tab order.
  • Applies styles.noOutline to prevent a visible focus ring on the heading.

Why this approach avoids the regression issues

The many regression tests (#84957, #84958, #84960, #84966, #84972, etc. — blank pages, lost focus, sluggish animation) all relate to the same concern: the accessibility additions must not interfere with existing navigation and rendering behavior. The key design decisions that prevent regressions:

Decision Prevents
Ref-based label stack (no React state) Re-render cascade during RHP animation (#85013)
Skip focus if input already focused Stealing focus from form fields (#84960, #84958)
Focus only after transition ends Focus-related blank pages during navigation (#84957, #84966, #84972, etc.)
claimInitialFocus one-shot flag Focus fights between multiple Headers
role="dialog" on always-mounted container No conditional mounting/unmounting that could break navigation state

…Reader-Many-Pages-There-is-no-dialog-role-and-title-announced
@TaduJR
Copy link
Copy Markdown
Contributor Author

TaduJR commented Mar 13, 2026

Jest fail is gone.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3dfef9c6f2

ℹ️ 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".

@mkhutornyi
Copy link
Copy Markdown
Contributor

Performance regression while testing #85007:

this branch: popover is not opened immediately, checkbox is not unchecked immediately

Screen.Recording.2026-03-15.at.2.45.05.am.mov

main:

Screen.Recording.2026-03-15.at.2.47.02.am.mov

@TaduJR
Copy link
Copy Markdown
Contributor Author

TaduJR commented Mar 15, 2026

main:

staging or dev?

@mkhutornyi
Copy link
Copy Markdown
Contributor

main:

staging or dev?

main branch

@TaduJR
Copy link
Copy Markdown
Contributor Author

TaduJR commented Apr 6, 2026

@mkhutornyi

Could you share a video?

@mkhutornyi
Copy link
Copy Markdown
Contributor

Video doesn't help because accessibility is broken while screen recording is on

Screen.Recording.2026-04-06.at.9.41.27.pm.mov

@TaduJR

This comment was marked as outdated.

@TaduJR
Copy link
Copy Markdown
Contributor Author

TaduJR commented Apr 6, 2026

Ok I see. Its very confusing

@TaduJR
Copy link
Copy Markdown
Contributor Author

TaduJR commented Apr 6, 2026

@rushatgabhane

Could you give it a try? After the back button announcement there should be an announcement that announces its in dialog

@TaduJR
Copy link
Copy Markdown
Contributor Author

TaduJR commented Apr 6, 2026

Feel free to test @amyevans

TaduJR added 3 commits April 7, 2026 04:39
…Reader-Many-Pages-There-is-no-dialog-role-and-title-announced
…Reader-Many-Pages-There-is-no-dialog-role-and-title-announced

# Conflicts:
#	src/components/HeaderWithBackButton/index.tsx
@melvin-bot melvin-bot bot requested a review from amyevans April 7, 2026 09:47
@amyevans amyevans merged commit 1276520 into Expensify:main Apr 7, 2026
33 checks passed
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 7, 2026

🚧 @amyevans has triggered a test Expensify/App build. You can view the workflow run here.

@OSBotify
Copy link
Copy Markdown
Contributor

OSBotify commented Apr 7, 2026

✋ This PR was not deployed to staging yet because QA is ongoing. It will be automatically deployed to staging after the next production release.

@mkhutornyi
Copy link
Copy Markdown
Contributor

@TaduJR please check this. Back button is highlighted after refresh app

Screen.Recording.2026-04-07.at.6.05.44.pm.mov

@TaduJR
Copy link
Copy Markdown
Contributor Author

TaduJR commented Apr 7, 2026

Sure. We will handle it in the next child issue, or should I create a separate PR.

LMK!

@mkhutornyi
Copy link
Copy Markdown
Contributor

Sure. We will handle it in the next child issue, or should I create a separate PR.

Maybe separate PR. Please fix as priority. QA might report this as blocker.

@TaduJR
Copy link
Copy Markdown
Contributor Author

TaduJR commented Apr 7, 2026

Maybe separate PR. Please fix as priority. QA might report this as blocker.

On it now.

@TaduJR
Copy link
Copy Markdown
Contributor Author

TaduJR commented Apr 7, 2026

Maybe separate PR. Please fix as priority. QA might report this as blocker.

PR is ready.

@OSBotify
Copy link
Copy Markdown
Contributor

OSBotify commented Apr 8, 2026

🚀 Deployed to staging by https://github.com/amyevans in version: 9.3.54-0 🚀

platform result
🕸 web 🕸 success ✅
🤖 android 🤖 success ✅
🍎 iOS 🍎 success ✅

Bundle Size Analysis (Sentry):

@MelvinBot
Copy link
Copy Markdown
Contributor

No help site changes are required for this PR.

This PR adds screen reader accessibility (ARIA dialog role, aria-modal, aria-label) to the Right Hand Panel container — a purely technical accessibility fix (WCAG 4.1.2). It doesn't introduce new features, settings, UI labels, or navigation paths that would need documentation updates under docs/articles/.

@mitarachim
Copy link
Copy Markdown

Deploy Blocker #87304 was identified to be related to this PR.

@OSBotify
Copy link
Copy Markdown
Contributor

OSBotify commented Apr 9, 2026

🚀 Deployed to production by https://github.com/roryabraham in version: 9.3.54-7 🚀

platform result
🕸 web 🕸 success ✅
🤖 android 🤖 success ✅
🍎 iOS 🍎 success ✅

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants