Skip to content

fix: a11y continuous improvements#3632

Merged
isekovanic merged 9 commits into
developfrom
fix/a11y-fixes
Jun 8, 2026
Merged

fix: a11y continuous improvements#3632
isekovanic merged 9 commits into
developfrom
fix/a11y-fixes

Conversation

@isekovanic

@isekovanic isekovanic commented Jun 6, 2026

Copy link
Copy Markdown
Contributor

🎯 Goal

Cross platform a11y fixes across the message surface (message row, footer, reactions, replies) and the channel preview list. VoiceOver / TalkBack now read each surface with proper labels, no misleading affordances, and correct focus behavior.

🛠 Implementation details

  • Poll message rotor actions - iOS announced custom rotor actions that couldn't actually be activated. Removed the composite rotor; poll bubbles now drill in so each option is a focus stop with proper radio/checkbox role.
  • Reply composer focus - initiating a reply landed VoiceOver focus on the navigation back button. Reply now focuses the composer's text input when a screen reader is active, and announces "Replying to {user}" + "Editing message" via useAnnounceOnShow so the user knows the composer entered reply/edit mode.
  • Channel preview verbosity - list rows previously announced "Avatar of X" per member, bare timestamps ("12:14 AM"), bare unread counts ("3"). Each leaf now carries a curated label: avatar reads "Direct chat with {name}" / "Channel with N members", date reads "Last message {date}", unread badge reads "{count} unread messages", muted indicator reads "Muted", delivery-status icon reads "Read, sent by you" / "Delivered, sent by you" / etc.
  • Channel preview delivery status not read - the status View had accessibilityLabel but no accessible={true}, so VO walked past it. Fixed by adding accessible so the composed delivery-status label is
    announced.
  • Message footer delivery state silent - the checkmark icon was a silent SVG; VO read only "11:05 AM". Footer is now accessible + accessibilityRole='text' (cross-platform compose), with MessageStatus
    rendering a HiddenA11yText for the localized status string. Footer announces as one stop: "Read, 11:05 AM".
  • Reaction list "actions available" misleading affordance — VO promised rotor actions that didn't exist. Clustered reactions are now one labeled button via the Pressable's auto compose (emoji names + "you reacted" tag for own + "and N more reactions" suffix), with hint "Double tap to view reactions". Segmented reactions stay as per-item Pressables with the same "you reacted" tagging.
  • Message rich content can't be activated - message rows with images, files, quoted replies, or shared location couldn't drill into the inner content. Generalized the poll only accessible={false} to cover any interactive children (attachments, quoted_message, poll, shared_location). Plain text messages keep their single focus stop behavior; rich messages drill in.

New shared components

  • CompositeAccessibilityProbe (package/src/components/Accessibility/) - wraps non Text visual content (avatars, icons, badges) so it announces as one focus stop with a curated label. Internally renders a hidden Text sibling + a decorative marked View around children. Used by ChannelAvatar, ChannelPreviewMutedStatus, ChannelMessagePreviewDeliveryStatus. Solves the iOS vs Android composition asymmetry where Android's importantForAccessibility='no-hide-descendants' leaks through nested accessible={true} descendants.
  • HiddenA11yText (package/src/components/Accessibility/) - visually invisible Text whose only job is to splice extra info into a parent's compose loop (e.g. "you reacted", "and N more reactions", localized delivery status). Different concern from the probe - this is for adding text to a composition chain, not for collapsing a subtree into one stop.

Both are exported from the SDK so consumers building their own custom message / preview UIs can reuse the patterns.

🎨 UI Changes

iOS
Before After
Android
Before After

🧪 Testing

☑️ Checklist

  • I have signed the Stream CLA (required)
  • PR targets the develop branch
  • Documentation is updated
  • New code is tested in main example apps, including all possible scenarios
    • SampleApp iOS and Android
    • Expo iOS and Android

@isekovanic isekovanic requested review from oliverlaz and szuperaz June 6, 2026 04:37
@Stream-SDK-Bot

Stream-SDK-Bot commented Jun 6, 2026

Copy link
Copy Markdown
Contributor

SDK Size

title develop branch diff status
js_bundle_size 1725 KB 1727 KB +2373 B 🔴

@isekovanic isekovanic merged commit c43d9f7 into develop Jun 8, 2026
9 of 10 checks passed
@isekovanic isekovanic deleted the fix/a11y-fixes branch June 8, 2026 08:27
@isekovanic isekovanic mentioned this pull request Jun 9, 2026
6 tasks
isekovanic added a commit that referenced this pull request Jun 9, 2026
## 🎯 Goal
Follow up `a11y` pass on top of #3632. Covers the message row, the
attachment thumbnail gallery, the full screen image/video gallery
overlay, the reactions UI, the message cell annotations and date
separators, the channel preview date, and the poll option row. VoiceOver
and TalkBack now read each surface with proper labels, units and focus
behavior and the gallery traps focus while open.
## 🛠 Implementation details
- Message row sender label - the bubble's `Pressable` now announces
"Message from {sender}" / "Message from you". Messages with interactive
children (poll, quoted message, attachments, shared location) opt out of
being a single focus stop so VO/TalkBack can drill into the children; an
iOS-only absolute-fill
`View` carries the sender label so the speaker is still announced when
focus lands inside the inner content.
- `hasInteractiveAccessibilityContent` override - new field on
`AccessibilityConfig` that receives the SDK's baseline boolean and lets
integrators extend the "is this message interactive?" decision without
losing the defaults. Resolved upstream in `useCreateMessageContext` and
exposed as a stable boolean on
`MessageContext`.
- Android compose gate on text bodies - `MessageTextContainer` and the
`Poll` header now set `accessible accessibilityRole='text'` so TalkBack
auto-composes the descendant `<Text>` content. Conditional on
`hasInteractiveAccessibilityContent` for the message text to avoid
double-announce on plain messages.
- Gallery thumbnails - the thumbnail `Pressable` now carries an
`a11y/Gallery Image` or `a11y/Gallery Video` label (added across all 13
locales), explicit `role='button'`, and the existing `a11y/Double tap to
open` hint. Announces as "Gallery video, button. Double tap to open."
- Full-screen gallery modal trap - `accessibilityViewIsModal` on the
`ImageGallery` root and the `MessageOverlayHostLayer` host on iOS.
Android gets a new `OverlayA11yShield` sibling wrapper that flips
`importantForAccessibility='no-hide-descendants'` when either the
gallery or the message context menu is active.
- Full-screen gallery adjustable cycling - the gallery backdrop is now
the focus target with `role='adjustable'`, `value={ text: 'N of M' }`,
increment/decrement actions, and an `accessibilityLabel` of "Image
Gallery". Swipe up/down cycles through assets.
- Gallery focus return on dismiss - the image gallery state store gained
a `requesterNode` field. `Gallery.tsx` captures the thumbnail's node
handle via `findNodeHandle` and passes it through `openImageGallery`; on
unmount the gallery restores focus via
`AccessibilityInfo.setAccessibilityFocus` inside an rAF.
- Reaction announcements - `ReactionListItem` gains `role='button'`
(trips the Android compose gate so TalkBack reads the emoji's CLDR name
instead of the reaction `type` string) and `state.selected`.
`ReactionButton` drops a broken `accessibilityLabelKey` that was
literally untranslated across every locale.
- Thread reply indicator - the `MessageReplies` `Pressable` now has
`role='button'`, an `accessibilityLabel` reading the reply count ("1
Reply" / "N Replies"), and an `accessibilityHint` of "Double tap to view
thread" (new i18n key across all 13 locales). Participant avatars in the
indicator are hidden from the
a11y tree so they no longer announce one-by-one.
- Decorative dot separators - the middle dots in `SentToChannelHeader`
("Replied to a thread · View") and `MessageReminderHeader` ("Reminder
set · 5 minutes left") are now marked decorative so focus moves directly
from the label to the action.
- Date announcements (iOS) - VoiceOver was reading numeric dates
literally ("zero four slash zero eight..."). Added a
`getDateStringForA11y` helper that substitutes `LL` for the calendar
`sameElse` slot, preserving locale relative words like "Today" /
"Yesterday". Wired into `InlineDateSeparator` (announces
"April 8, 2026") and `ChannelPreviewStatus` (announces "10:30 AM" for
same-day messages via a `sameDay: 'LT'` override). TalkBack honors the
override too, no platform branching needed.
- Poll option vote count - the bare count `Text` on each poll option row
now carries an `accessibilityLabel` using the existing `{{count}} votes`
plural key. Announces "0 votes" / "1 vote" / "N votes" instead of just
the bare number.
### New shared components and exports
- `OverlayA11yShield` (`package/src/components/Accessibility/`) -
Android-only focus-trap shield. Subscribes to the overlay context and
the message-overlay store to compute `isAnyOverlayActive`, then wraps
`{children}` in a `View` that flips
`importantForAccessibility='no-hide-descendants'` and
`accessibilityElementsHidden` while an overlay is active. On iOS it's a
pure passthrough selected at module load.
- `getDateStringForA11y` (`package/src/utils/i18n/getDateString.ts`) -
sibling helper to `getDateString`. Produces a TTS-friendly calendar
string by substituting `LL` for the `sameElse` slot in the locale's
calendar formats, with an optional `calendarFormatOverrides` parameter
for callers whose visible format
diverges from the locale defaults.
- `AccessibilityConfig.hasInteractiveAccessibilityContent` - new
override hook for integrators (see above).
- `MessageContext.hasInteractiveAccessibilityContent` - new stable
boolean field consumed by `MessageContent` and `MessageTextContainer`.
- `ImageGalleryState.requesterNode` and the matching `openImageGallery({
requesterNode? })` argument.

## 🎨 UI Changes

<!-- Add relevant screenshots -->

<details>
<summary>iOS</summary>


<table>
    <thead>
        <tr>
            <td>Before</td>
            <td>After</td>
        </tr>
    </thead>
    <tbody>
        <tr>
            <td>
                <!--<img src="" /> -->
            </td>
            <td>
                <!--<img src="" /> -->
            </td>
        </tr>
    </tbody>
</table>
</details>


<details>
<summary>Android</summary>

<table>
    <thead>
        <tr>
            <td>Before</td>
            <td>After</td>
        </tr>
    </thead>
    <tbody>
        <tr>
            <td>
                <!--<img src="" /> -->
            </td>
            <td>
                <!--<img src="" /> -->
            </td>
        </tr>
    </tbody>
</table>
</details>

## 🧪 Testing

<!-- Explain how this change can be tested (or why it can't be tested)
-->

## ☑️ Checklist

- [ ] I have signed the [Stream
CLA](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform)
(required)
- [ ] PR targets the `develop` branch
- [ ] Documentation is updated
- [ ] New code is tested in main example apps, including all possible
scenarios
  - [ ] SampleApp iOS and Android
  - [ ] Expo iOS and Android
@github-actions github-actions Bot mentioned this pull request Jun 10, 2026
6 tasks
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.

3 participants