Skip to content

fix: announce sending status to screen readers via live region#5781

Open
isherstneva wants to merge 20 commits intomicrosoft:mainfrom
isherstneva:fix/sending-live-region-announcement
Open

fix: announce sending status to screen readers via live region#5781
isherstneva wants to merge 20 commits intomicrosoft:mainfrom
isherstneva:fix/sending-live-region-announcement

Conversation

@isherstneva
Copy link

@isherstneva isherstneva commented Mar 25, 2026

When a user submits a message, the activity enters the "sending" state while awaiting server acknowledgement. The visual "Sending" indicator is rendered next to the activity but not inside an ARIA live region, so screen readers never announce it.

This PR adds a LiveRegionLongSend component (mirroring LiveRegionSendFailed) that watches for activities remaining in the "sending" state for longer than 3 seconds and queues the localized "Sending message." string into the polite live region. Fast sends that are acknowledged within 3 seconds stay silent to avoid noisy announcements. Also add TRANSCRIPT_LIVE_REGION_SEND_SENDING_ALT localization key and a corresponding integration test.

Bug ticket: https://msazure.visualstudio.com/CCI/_workitems/edit/36003607/

Fixes #

Changelog Entry

Description

Design

Specific Changes

  • I have added tests and executed them locally
  • I have updated CHANGELOG.md
  • I have updated documentation

Review Checklist

This section is for contributors to review your work.

  • Accessibility reviewed (tab order, content readability, alt text, color contrast)
  • Browser and platform compatibilities reviewed
  • CSS styles reviewed (minimal rules, no z-index)
  • Documents reviewed (docs, samples, live demo)
  • Internationalization reviewed (strings, unit formatting)
  • package.json and package-lock.json reviewed
  • Security reviewed (no data URIs, check for nonce leak)
  • Tests reviewed (coverage, legitimacy)

When a user submits a message, the activity enters the "sending" state
while awaiting server acknowledgement. The visual "Sending" indicator
was rendered next to the activity but not inside an ARIA live region,
so screen readers never announced it.

Adds LiveRegionSendSending component (mirroring LiveRegionSendFailed)
that watches for activities newly entering the "sending" state and
queues the localized "Sending message." string into the polite live
region. Also adds TRANSCRIPT_LIVE_REGION_SEND_SENDING_ALT localization
key and a corresponding integration test.
Copilot AI review requested due to automatic review settings March 25, 2026 18:35
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds ARIA live-region announcements for the “sending” state so screen readers narrate “Sending message.” when an outgoing activity is awaiting server acknowledgement.

Changes:

  • Added LiveRegionSendSending component and mounted it in LiveRegionTranscript.
  • Introduced new localization key TRANSCRIPT_LIVE_REGION_SEND_SENDING_ALT.
  • Added an accessibility integration test covering the new live-region narration.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
packages/component/src/Transcript/LiveRegionTranscript.tsx Mounts the new sending live-region component alongside the existing send-failed narration.
packages/component/src/Transcript/LiveRegion/SendSending.tsx Implements detection of activities newly entering the SENDING state and queues localized narration.
packages/api/src/localization/en-US.json Adds the localized “Sending message.” string and comment for translators.
tests/html2/accessibility/liveRegion/activityStatus.sendSending.html Adds an integration test asserting the live region narrates “Sending message.” for unacknowledged outgoing activities.
CHANGELOG.md Documents the accessibility fix in the release notes.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +46 to +63
const prevActivityKeysOfSending = usePrevious(activityKeysOfSending);

/** True, if one or more non-presentational activities newly entered the "sending" state, otherwise false. */
const hasNewSending = useMemo<boolean>(() => {
if (activityKeysOfSending === prevActivityKeysOfSending) {
return false;
}

for (const key of activityKeysOfSending.keys()) {
if (!prevActivityKeysOfSending.has(key)) {
return true;
}
}

return false;
}, [activityKeysOfSending, prevActivityKeysOfSending]);

useLiveRegion(() => hasNewSending && liveRegionSendSendingAlt, [hasNewSending, liveRegionSendSendingAlt]);
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

prevActivityKeysOfSending.has(key) will throw if usePrevious returns undefined on the initial render (common for “previous value” hooks). Consider defaulting prevActivityKeysOfSending to an empty Set (or handling the undefined case inside the memo) so the first render cannot crash. Also, useLiveRegion(() => hasNewSending && liveRegionSendSendingAlt, ...) returns false when not sending; if useLiveRegion expects a string/undefined, prefer returning undefined/null rather than a boolean to avoid accidental narration/queuing of a non-string value.

Copilot uses AI. Check for mistakes.
Comment on lines +32 to +39
() =>
Array.from(sendStatusByActivityKey).reduce(
(activityKeysOfSending, [key, sendStatus]) =>
sendStatus === SENDING && !isPresentational(getActivityByKey(key))
? activityKeysOfSending.add(key)
: activityKeysOfSending,
new Set<string>()
),
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

Array.from(sendStatusByActivityKey) allocates an intermediate array on every recomputation. Since sendStatusByActivityKey is already iterable, iterating it directly (e.g., a for...of loop) avoids the extra allocation and can reduce overhead for large transcripts.

Suggested change
() =>
Array.from(sendStatusByActivityKey).reduce(
(activityKeysOfSending, [key, sendStatus]) =>
sendStatus === SENDING && !isPresentational(getActivityByKey(key))
? activityKeysOfSending.add(key)
: activityKeysOfSending,
new Set<string>()
),
() => {
const activityKeysOfSending = new Set<string>();
for (const [key, sendStatus] of sendStatusByActivityKey) {
if (sendStatus === SENDING && !isPresentational(getActivityByKey(key))) {
activityKeysOfSending.add(key);
}
}
return activityKeysOfSending;
},

Copilot uses AI. Check for mistakes.
"_TRANSCRIPT_LIVE_REGION_SUGGESTED_ACTIONS_WITH_ACCESS_KEY_LABEL_ALT.comment": "$1 will be \"ACCESS_KEY_ALT\".",
"TRANSCRIPT_LIVE_REGION_SEND_FAILED_ALT": "Failed to send message.",
"TRANSCRIPT_LIVE_REGION_SEND_SENDING_ALT": "Sending message.",
"_TRANSCRIPT_LIVE_REGION_SEND_SENDING_ALT.comment": "This is for screen reader. When the user sends a message, the live region will announce this string to indicate the message is being sent.",
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

Correct the wording “screen reader” → “screen readers” for grammatical correctness in the translator comment.

Suggested change
"_TRANSCRIPT_LIVE_REGION_SEND_SENDING_ALT.comment": "This is for screen reader. When the user sends a message, the live region will announce this string to indicate the message is being sent.",
"_TRANSCRIPT_LIVE_REGION_SEND_SENDING_ALT.comment": "This is for screen readers. When the user sends a message, the live region will announce this string to indicate the message is being sent.",

Copilot uses AI. Check for mistakes.
Updated tests to include "Sending message." in expected live region output:
- activityStatus.sendFailed.html: prepend Sending message. to expected array
- activityStatus.sendSending.html: use toContain with proper accumulation
- suggestedActions.accessKey.html: add Sending message. to expected array
- suggestedActions.noAccessKey.html: add Sending message. to expected array
The onTelemetry exception event fires asynchronously after activities are
rendered. Add a pageConditions.became() wait before asserting on it to
avoid a race condition where the assertion runs before the event fires.
- fluentTheme/suggestedActions.html: add allImagesLoaded() before snapshot
  to ensure external CDN images are fully rendered before comparison
- updateActivity/sameActivityId.html: add scrollToBottomCompleted() before
  snap-1 to ensure scroll animation finishes before comparison

These waits were already used in similar tests but missing here, causing
flaky pixel differences in CI where the machine is slower.
…ends

Only narrate "Sending message." if the outgoing activity remains in the
sending state for at least 3 seconds. Fast sends that are acknowledged
quickly stay silent; only stalled or slow sends get announced.

Update tests accordingly:
- sendSending: increase became() timeout to 4000ms to accommodate the delay
- sendFailed: remove Sending message. (rejection happens in < 3s)
- suggestedActions: remove Sending message. (real DirectLine acks in < 3s)
Sending message. no longer appears in the live region for fast emulated
sends, so all liveRegion attachment and alertEmptyMessage snapshots that
previously captured it need to be updated.
- Rename LiveRegion/SendSending.tsx -> LiveRegion/LongSend.tsx
- Rename component LiveRegionSendSending -> LiveRegionLongSend
- Revert unknownActivity.html, fluentTheme/suggestedActions.html,
  updateActivity/sameActivityId.html to base (unrelated test fixes)
- Revert videoCard.html.snap-1.png to base (spurious 1-byte PNG artifact)
@isherstneva
Copy link
Author

@isherstneva please read the following Contributor License Agreement(CLA). If you agree with the CLA, please reply with the following information.

@microsoft-github-policy-service agree [company="{your company}"]

Options:

  • (default - no company specified) I have sole ownership of intellectual property rights to my Submissions and I am not making Submissions in the course of work for my employer.
@microsoft-github-policy-service agree
  • (when company given) I am making Submissions in the course of work for my employer (or my employer has intellectual property rights in my Submissions by contract or applicable law). I have permission from my employer to make Submissions and enter into this Agreement on behalf of my employer. By signing below, the defined term “You” includes me and my employer.
@microsoft-github-policy-service agree company="Microsoft"

Contributor License Agreement

@microsoft-github-policy-service agree company="Microsoft"

Copy link
Contributor

@compulim compulim left a comment

Choose a reason for hiding this comment

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

Don't duplicate code. Put the logic in the <SendFailed> component.

…cation

Both SendFailed and LongSend shared the same useMemo pattern for
filtering activities by send status and isPresentational. Extracted
into a shared hook useActivityKeysOfSendStatus(status).
@isherstneva
Copy link
Author

Don't duplicate code. Put the logic in the <SendFailed> component.

Extracted shared filtering logic into a useActivityKeysOfSendStatus hook, used by both SendFailed and LongSend to eliminate duplicated code.

Copy link
Contributor

Choose a reason for hiding this comment

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

The hook design doesn't match our convention. Please look at HOOKS.md to see how we design our hooks.

Copy link
Author

Choose a reason for hiding this comment

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

updated

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