callstack / callstack/react-native-paper

refactor(banner): make hidden content inert and fix live region semantics

Open
#5,055 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
TypeScript
Stars
14.5k
Forks
2.2k
Avg merge
5d 23h
Merged PRs (30d)
12

Description

Part of #4990

Clean up Banner accessibility so hidden content is actually hidden/inert, live regions behave consistently across platforms, and animation callbacks only fire when a real show/hide transition happens.

Banner doesn't exist in MD3 anymore, but it's still a supported Paper extension, so this is mostly about making its behaviour internally sane and accessible rather than matching the Material spec exactly.

**Problems**

- Hidden Banner content is still mounted and reachable. It's only translated off-screen and clipped — screen readers can still see it and action buttons stay tabbable. Verified in the browser a11y tree: with `visible={false}`, both actions still have `tabIndex: 0` and can receive focus.
- `role="alert"` + `aria-live="polite"` doesn't really make sense. `alert` normally implies assertive + atomic, while Chrome currently ends up with `alert atomic live="polite"`.
- The live region currently does nothing on Android. RN maps `aria-live` to `accessibilityLiveRegion` on `View`, but not on `Text`, so the prop is effectively ignored there. It only works on web today.
- iOS has no live-region equivalent, so the message isn't announced there either.
- `actions` is unlimited and actions always sit below the message. Material allows max two.
- If a focused action disappears after the actions array changes, focus gets dumped at the top of the page.
- `onShowAnimationFinished` / `onHideAnimationFinished` fire on mount and again when `theme.animation.scale` changes, even though no visibility transition happened. Existing tests already call this out as probably a bug.

**Focus Areas**

- Make hidden content properly inert: no pointer interaction, no a11y exposure, no focus.
- Put the live region on a `View` so Android actually supports it, and keep it scoped to the message so action labels don't cause the whole banner to re-announce.
- Make `role` and `aria-live` agree: polite by default, assertive when explicitly requested.
- Announce manually on iOS without double-announcing on Android/web.
- Limit actions to two and let them reflow depending on available width.
- Restore focus if the currently focused action disappears.
- Run show/hide animations and callbacks only for actual `visible` transitions.

**Proposed API**

One new prop, everything else is behavioural:

```jsx

Your payment failed.

```

- `urgent?: boolean`
- `false` (default): `role="status"` + `aria-live="polite"`
- `true`: `role="alert"` + `aria-live="assertive"`; on iOS it interrupts instead of queueing

**Breaking changes**

- Banner children are unmounted once fully hidden, so local child state is lost after hiding.
- More than two actions are ignored, with a dev warning.

**Notes**

- #5016 retokenizes Banner (motion tokens + easing), but doesn't touch any of this accessibility behaviour.
- This should be checked separately with VoiceOver, TalkBack and the browser accessibility tree. They're different enough that testing only one isn't very meaningful.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.