-
Notifications
You must be signed in to change notification settings - Fork 1.6k
fix: announce sending status to screen readers via live region #5781
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
385f011
6523036
98695bb
6b936e5
a37c870
c4a883b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| <!DOCTYPE html> | ||
| <html lang="en-US"> | ||
| <head> | ||
| <link href="/assets/index.css" rel="stylesheet" type="text/css" /> | ||
| <script crossorigin="anonymous" src="/test-harness.js"></script> | ||
| <script crossorigin="anonymous" src="/test-page-object.js"></script> | ||
| <script crossorigin="anonymous" src="/__dist__/webchat-es5.js"></script> | ||
| </head> | ||
| <body> | ||
| <main id="webchat"></main> | ||
| <script> | ||
| run(async function () { | ||
| const { directLine, store } = testHelpers.createDirectLineEmulator(); | ||
|
|
||
| WebChat.renderWebChat( | ||
| { | ||
| directLine, | ||
| store | ||
| }, | ||
| document.getElementById('webchat') | ||
| ); | ||
|
|
||
| await pageConditions.uiConnected(); | ||
|
|
||
| const { disconnect, flush } = pageObjects.observeLiveRegion(); | ||
|
|
||
| try { | ||
| // Emulate outgoing activity but do not acknowledge it, keeping it in "sending" state. | ||
| directLine.emulateOutgoingActivity('Hello, World!'); | ||
|
|
||
| await pageConditions.became( | ||
| 'live region narrated sending message', | ||
| () => { | ||
| try { | ||
| expect(flush()).toEqual(['You said:\nHello, World!', 'Sending message.']); | ||
|
|
||
| return true; | ||
| } catch { | ||
| return false; | ||
| } | ||
| }, | ||
| 1000 | ||
| ); | ||
| } finally { | ||
| disconnect(); | ||
| } | ||
| }); | ||
| </script> | ||
| </body> | ||
| </html> |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,70 @@ | ||||||||||||||||||||||||||||||||||||||||
| import { hooks } from 'botframework-webchat-api'; | ||||||||||||||||||||||||||||||||||||||||
| import { memo, useMemo } from 'react'; | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| import usePrevious from '../../hooks/internal/usePrevious'; | ||||||||||||||||||||||||||||||||||||||||
| import { useLiveRegion } from '../../providers/LiveRegionTwin'; | ||||||||||||||||||||||||||||||||||||||||
| import { SENDING } from '../../types/internal/SendStatus'; | ||||||||||||||||||||||||||||||||||||||||
| import isPresentational from './isPresentational'; | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| const { useGetActivityByKey, useLocalizer, useSendStatusByActivityKey } = hooks; | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||
| * React component to on-demand narrate "Sending message." at the end of the live region. | ||||||||||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||||||||||
| * When the user sends a message the activity enters the "sending" state before the server acknowledges it. | ||||||||||||||||||||||||||||||||||||||||
| * The visual "Sending" indicator is rendered next to the activity, but that text is not inside an ARIA | ||||||||||||||||||||||||||||||||||||||||
| * live region and is therefore not announced by screen readers. | ||||||||||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||||||||||
| * This component watches for activities that newly enter the `sending` state and queues the localized | ||||||||||||||||||||||||||||||||||||||||
| * "Sending message." string into the polite live region so assistive technologies announce it. | ||||||||||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||||||||||
| * Presentational activities (e.g. `event` or `typing`) are excluded to reduce noise. | ||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||
| const LiveRegionSendSending = () => { | ||||||||||||||||||||||||||||||||||||||||
| const [sendStatusByActivityKey] = useSendStatusByActivityKey(); | ||||||||||||||||||||||||||||||||||||||||
| const getActivityByKey = useGetActivityByKey(); | ||||||||||||||||||||||||||||||||||||||||
| const localize = useLocalizer(); | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||
| * Set of keys of outgoing and non-presentational activities that are currently being sent. | ||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||
| const activityKeysOfSending = useMemo<Set<string>>( | ||||||||||||||||||||||||||||||||||||||||
| () => | ||||||||||||||||||||||||||||||||||||||||
| Array.from(sendStatusByActivityKey).reduce( | ||||||||||||||||||||||||||||||||||||||||
| (activityKeysOfSending, [key, sendStatus]) => | ||||||||||||||||||||||||||||||||||||||||
| sendStatus === SENDING && !isPresentational(getActivityByKey(key)) | ||||||||||||||||||||||||||||||||||||||||
| ? activityKeysOfSending.add(key) | ||||||||||||||||||||||||||||||||||||||||
| : activityKeysOfSending, | ||||||||||||||||||||||||||||||||||||||||
| new Set<string>() | ||||||||||||||||||||||||||||||||||||||||
| ), | ||||||||||||||||||||||||||||||||||||||||
|
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>() | |
| ), | |
| () => { | |
| const activityKeysOfSending = new Set<string>(); | |
| for (const [key, sendStatus] of sendStatusByActivityKey) { | |
| if (sendStatus === SENDING && !isPresentational(getActivityByKey(key))) { | |
| activityKeysOfSending.add(key); | |
| } | |
| } | |
| return activityKeysOfSending; | |
| }, |
Copilot
AI
Mar 25, 2026
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.