Conversation
Test `dismissableBackButton` alone instead of `dismissable || dismissableBackButton`, which short-circuited on `dismissable` so the prop only had an effect when `dismissable` was already `false`.
Add `role="dialog"` to the content `Surface` and accept an `aria-label`, so the role and the name sit on the element that actually is the dialog. Drop `aria-modal`: it was set on the full-screen wrapper rather than on the dialog, and this component does not make the content behind the modal inert, so claiming modality would have misdescribed it.
The scrim carries its own accessible name and closes the modal when it is pressed, but `importantForAccessibility="no"` kept it out of the screen reader's reach, so the one control that dismisses the modal was announced to nobody. Tie `accessible` to `dismissable` so the scrim is only announced when pressing it does something. A non-dismissible modal would otherwise offer a "Close modal" button that ignores every press.
`dismissable={false}` keeps the scrim from closing the modal, but the
iOS escape gesture closed it anyway, so a screen reader user could leave
a modal the interface was holding them in.
`aria-modal` was dropped from the component while reworking the modal's accessibility, leaving nothing to scope assistive technology to the dialog. On iOS it maps to `accessibilityViewIsModal`, which is the only mechanism that takes effect there: `role` and `aria-label` are both ignored by VoiceOver on native, so without it a screen reader could reach the content behind an open modal. Put it on the content `Surface` rather than the full-screen wrapper, so the boundary matches the element that is the dialog.
The role was hardcoded to `dialog`, which suits most modals but not one that interrupts the user with an urgent message. Accept a `role` prop, typed as React Native's `Role` and defaulting to `dialog`, so `alertdialog` and the other ARIA roles are reachable without forking the component. Note that native platforms ignore this: React Native maps every dialog-ish role to `UIAccessibilityTraitNone` on iOS, and TalkBack does not announce it either. It carries on react-native-web, where the role is honoured.
The scrim is a visual affordance, not a control worth announcing. Exposing it gave screen reader users a "Close modal" button that duplicates the escape gesture and the back button, and on Android it was announced as disabled whenever `dismissable` was `false`. Hide it with `aria-hidden`, which covers both platforms: React Native maps it to `accessibilityElementsHidden` on iOS and `importantForAccessibility="no-hide-descendants"` on Android. With the scrim out of the accessibility tree its label can no longer be read by anything, so `overlayAccessibilityLabel` is removed rather than left as a prop that does nothing. Tests locate the scrim through `overlayTestID`, which already existed for that purpose. BREAKING CHANGE: `overlayAccessibilityLabel` is removed from `Modal`. The scrim is no longer exposed to assistive technology, so the prop had no remaining effect. Use `overlayTestID` to target the scrim in tests.
There was a problem hiding this comment.
Now that Modal accepts aria-label/role, Dialog doesn't forward either to it - its Props type has no aria-label/role field at all. So after this merges, <Dialog> usage still has no accessible name. Will that be wired up in the Dialog-adoption follow-up task, or should it land here since the plumbing already exists?
There was a problem hiding this comment.
dismissable isn't read inside this effect anymore after the fix, You can drop it from the deps array
| aria-live="polite" | ||
| style={StyleSheet.absoluteFill} | ||
| onAccessibilityEscape={onDismissCallback} | ||
| onAccessibilityEscape={dismissable ? onDismissCallback : undefined} |
There was a problem hiding this comment.
Modal.tsx has missing focus-related code that is one of the acceptance criteria for this ticket.
- Initial focus - nothing moves focus into the
Surfacewhenvisiblebecomestrue - Focus trap - nothing intercepts Tab to keep focus inside the modal while open; a keyboard or switch-control user can tab straight into the content behind it
- Focus restore - nothing captures the pre-open focus target or restores it in
onDismissCallbackon close
Given the ticket calls this out as the hard, VoiceOver/TalkBack/keyboard/switch-control-verified part of the work, will there be another follow-up PR for this, or should it be included here? @satya164 what do You think about it?
There was a problem hiding this comment.
There will be a follow-up PR with initial focus / restore focus.
Regarding focus trap - #5126 this one covers it.
| > | ||
| <AnimatedPressable | ||
| aria-label={overlayAccessibilityLabel} | ||
| role="button" |
There was a problem hiding this comment.
The role="button" is now a no-op due to the aria-hidden prop added to AnimatedPressable, it can be removed now.
The scrim is hidden from assistive technology, so the role it declared no longer reaches anyone. The hardware back effect reads only dismissableBackButton now, so dismissable is no longer a dependency of it.
@k0ndee I believe there should be an adoption task for Dialog, but I can add this small change here instead. |
Actually, it looks like #5072 already forwards |
The surface stays a dialog, but the prop lands together with the Dialog wiring in callstack#5072 rather than half-done here.
|
Found potential problems with the pull request:
|
| aria-label={overlayAccessibilityLabel} | ||
| role="button" | ||
| disabled={!dismissable} | ||
| aria-hidden |
There was a problem hiding this comment.
Why is overlay hidden from a11y with aria-hidden?
If a dialog doesn't have a button to close itself, doesn't mean screen reader users and keyboard users won't be able to close it at all as compared to mouse users?
There was a problem hiding this comment.
@satya164 I made the decision to not make it accessible based on a few things:
- It was not accessible on android in the first place (via importantForAccessibility)
- The task suggested removing the label since it was not accessible
- https://m3.material.io/components/dialogs/guidelines 's anatomy shows that Dialog should have actions.
- https://m3.material.io/components/dialogs/accessibility says the initial focus should land on the first interactive element within dialog, not on scrim. And the scrim is just a visual dimmer.
- https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/ :
- "All elements required to operate the dialog are descendants of the element that has role dialog."
- "Tab and Shift + Tab do not move focus outside the dialog"
- "It is strongly recommended that the tab sequence of all dialogs include a visible element with role button that closes the dialog, such as a close icon or cancel button."
The screen reader users and keyboard users can still use Escape/Back to initiate close.
There was a problem hiding this comment.
Dialog should have actions.
It should, but material design guidelines are still just guidelines. They are not requirements the users must implement. Also this affects modals in general, not just dialogs.
says the initial focus should land on the first interactive element within dialog, not on scrim.
What's scrim? Overlay? That sounds right, but it could be achieved without making the overlay inaccessible.
And the scrim is just a visual dimmer.
But the overlay is not just a visual dimmer here, users can already interact with it using a mouse, which makes it an interactive element.
Every interactive element should have equivalent for screen readers. If it's not the overlay itself, then there needs to be a screen reader specific affordance to close the modal like a mouse user would.
"All elements required to operate the dialog are descendants of the element that has role dialog."
"It is strongly recommended that the tab sequence of all dialogs include a visible element with role button that closes the dialog, such as a close icon or cancel button."
Still, the problem is these are guidelines and recommendations. While we can implement the recommendations in the library itself, the user may not. We should still provide an accessible experience by default.
Prior design to this would be what React Aria does. It hides the overlay from a11y, but also provides a screen-reader only affordance.
Following is from Codex:
When outside-click dismissal is enabled, React Aria automatically adds a visually hidden dismiss button inside the modal. Screen reader users can find and activate it even if the app doesn't provide a close button. It has a localized label and tabIndex={-1}, so it doesn't add an extra Tab stop. Keyboard users can dismiss with Escape. (Modal implementation, dismiss button)
We could follow the same approach. If we hide the overlay from a11y, we should provide an equivalent dismiss button for screen reader users whenever clicking the overlay can close the modal. We also need Escape handling on web, which is currently missing.
There was a problem hiding this comment.
@satya164 Got it. I'll revert the changes and make overlay accessible again when modal is dismissable.
| * Accessibility label for the overlay. This is read by the screen reader when the user taps outside the modal. | ||
| * Accessible name for the modal. | ||
| */ | ||
| overlayAccessibilityLabel?: string; |
There was a problem hiding this comment.
with overlayAccessibilityLabel removed, how can user customize aria label for the overlay or access it in tests (there is test id, but better practice is to use a11y, same way your users will)?
There was a problem hiding this comment.
@satya164 please check my comment above. It will not be needed if we move forward with not making it accessible
There was a problem hiding this comment.
Restored the label
Motivation
This PR improves some aspects of Paper's Modal accessibility.
dismissableBackButtonis now honoured on its own - hardware back readdismissable || dismissableBackButton, sodismissableBackButton={false}did nothing unlessdismissablewas alsofalsearia-labelprop to Modal.roleprop should be added in this one instead refactor(dialog): MD3 updates, updated Dialog API #5072dismissable={false}dismissable={false}Related issue
Notion
Screenshots / Videos
Screen reader speech is shown on screen in each clip (VoiceOver caption panel / TalkBack "Display speech output").
main)dismissable={false}(iOS)escape-gesture-ignores-dismissable-before.mp4
escape-gesture-ignores-dismissable-after.mp4
dismissableBackButton={false}(Android)undismissable-back-button-before.mp4
undismissable-back-button-after.mp4
Test plan
yarn test,yarn lint,yarn typecheckpass. In the example app:aria-labeland role announced.