From 9084d3b1654f7b33671c73d1516c5e23409680df Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Mon, 19 Oct 2026 12:21:35 -0700 Subject: [PATCH 1/5] Use generic containers instead of articles in the live region Screen readers announced "article" before every new message and attachment read from the live region. The live region copy of a message and its attachments are now generic containers; aria-atomic and the text are kept. --- CHANGELOG.md | 1 + .../attachment/genericWrappers.html | 95 +++++++++++++++++++ .../AdaptiveCardAttachment.js | 4 +- .../RichCardAttachment.js | 4 +- .../src/LiveRegion/LiveRegionActivity.tsx | 4 +- .../AudioAttachment.js | 2 +- .../FileAttachment.js | 2 +- .../ImageAttachment.js | 2 +- .../TextAttachment.js | 2 +- .../VideoAttachment.js | 2 +- 10 files changed, 107 insertions(+), 11 deletions(-) create mode 100644 __tests__/html2/accessibility/liveRegion/attachment/genericWrappers.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 152d426838..b815c0921e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,7 @@ Legends: - Fixed screen readers announcing a single audio, video, or animation in a card as a list item, such as "1 of 1", fixes [#5722](https://github.com/microsoft/BotFramework-WebChat/issues/5722), in PR [#5903](https://github.com/microsoft/BotFramework-WebChat/pull/5903), by [@OEvgeny](https://github.com/OEvgeny) - Fixed video and animation cards disappearing in production builds when the video URL is relative or invalid, in PR [#5889](https://github.com/microsoft/BotFramework-WebChat/pull/5889), by [@OEvgeny](https://github.com/OEvgeny) - Fixed timestamps always using the 12-hour clock, such as "4:41 PM" instead of "16:41" in German, in PR [#5920](https://github.com/microsoft/BotFramework-WebChat/pull/5920), by [@OEvgeny](https://github.com/OEvgeny) +- Fixed screen readers saying "article" before new messages and their attachments in the live region, in PR [#XXXX](https://github.com/microsoft/BotFramework-WebChat/pull/XXXX), by [@OEvgeny](https://github.com/OEvgeny) ## [4.19.2] - 2026-10-08 diff --git a/__tests__/html2/accessibility/liveRegion/attachment/genericWrappers.html b/__tests__/html2/accessibility/liveRegion/attachment/genericWrappers.html new file mode 100644 index 0000000000..b1edf86c9e --- /dev/null +++ b/__tests__/html2/accessibility/liveRegion/attachment/genericWrappers.html @@ -0,0 +1,95 @@ + + + + + + + + + +
+ + + + diff --git a/packages/bundle/src/adaptiveCards/AttachmentForScreenReader/AdaptiveCardAttachment.js b/packages/bundle/src/adaptiveCards/AttachmentForScreenReader/AdaptiveCardAttachment.js index 8b38b0f1f0..64cce4785a 100644 --- a/packages/bundle/src/adaptiveCards/AttachmentForScreenReader/AdaptiveCardAttachment.js +++ b/packages/bundle/src/adaptiveCards/AttachmentForScreenReader/AdaptiveCardAttachment.js @@ -117,7 +117,7 @@ const AdaptiveCardAttachment = ({ content }) => { const cardLabel = localize('ATTACHMENT_CARD', card.speak || '', '', ''); return ( -
+
{cardLabel}
{inputs.map((input, index) => input instanceof ChoiceSetInput ? ( @@ -157,7 +157,7 @@ const AdaptiveCardAttachment = ({ content }) => { false ) )} -
+ ); }; diff --git a/packages/bundle/src/adaptiveCards/AttachmentForScreenReader/RichCardAttachment.js b/packages/bundle/src/adaptiveCards/AttachmentForScreenReader/RichCardAttachment.js index 582e6430b5..25354b07b4 100644 --- a/packages/bundle/src/adaptiveCards/AttachmentForScreenReader/RichCardAttachment.js +++ b/packages/bundle/src/adaptiveCards/AttachmentForScreenReader/RichCardAttachment.js @@ -16,7 +16,7 @@ const RichCardAttachment = ({ content = {} }) => { const cardLabel = localize('ATTACHMENT_CARD', title || '', subtitle || '', text || ''); return ( -
+
{cardLabel}
{!!image && !!image.alt && {image.alt}} {!!images && !!images.length && images.map(({ alt }, index) => {alt})} @@ -68,7 +68,7 @@ const RichCardAttachment = ({ content = {} }) => { ))}
)} -
+ ); }; diff --git a/packages/component/src/LiveRegion/LiveRegionActivity.tsx b/packages/component/src/LiveRegion/LiveRegionActivity.tsx index 46a1630ae1..8e56f47f84 100644 --- a/packages/component/src/LiveRegion/LiveRegionActivity.tsx +++ b/packages/component/src/LiveRegion/LiveRegionActivity.tsx @@ -57,7 +57,7 @@ function LiveRegionActivity(props: LiveRegionActivityProps) { const validFallbackText = fallbackText && typeof fallbackText === 'string'; return ( -
+
{greetingAlt}
{validFallbackText ? (
{fallbackText}
@@ -74,7 +74,7 @@ function LiveRegionActivity(props: LiveRegionActivityProps) { )} )} -
+ ); } diff --git a/packages/component/src/Middleware/AttachmentForScreenReader/AudioAttachment.js b/packages/component/src/Middleware/AttachmentForScreenReader/AudioAttachment.js index b34f4a44e3..866e7f5f3d 100644 --- a/packages/component/src/Middleware/AttachmentForScreenReader/AudioAttachment.js +++ b/packages/component/src/Middleware/AttachmentForScreenReader/AudioAttachment.js @@ -8,7 +8,7 @@ const AudioAttachment = () => { const label = localize('ATTACHMENT_AUDIO'); - return
{label}
; + return
{label}
; }; export default AudioAttachment; diff --git a/packages/component/src/Middleware/AttachmentForScreenReader/FileAttachment.js b/packages/component/src/Middleware/AttachmentForScreenReader/FileAttachment.js index 3d01ff2b9c..7b9e5788f7 100644 --- a/packages/component/src/Middleware/AttachmentForScreenReader/FileAttachment.js +++ b/packages/component/src/Middleware/AttachmentForScreenReader/FileAttachment.js @@ -9,7 +9,7 @@ const FileAttachment = ({ attachment: { name = '' } = {} }) => { const label = localize('ATTACHMENT_FILE', name); - return
{label}
; + return
{label}
; }; FileAttachment.propTypes = { diff --git a/packages/component/src/Middleware/AttachmentForScreenReader/ImageAttachment.js b/packages/component/src/Middleware/AttachmentForScreenReader/ImageAttachment.js index 454c371535..cbebad14b9 100644 --- a/packages/component/src/Middleware/AttachmentForScreenReader/ImageAttachment.js +++ b/packages/component/src/Middleware/AttachmentForScreenReader/ImageAttachment.js @@ -8,7 +8,7 @@ const ImageAttachment = () => { const label = localize('ATTACHMENT_IMAGE'); - return
{label}
; + return
{label}
; }; export default ImageAttachment; diff --git a/packages/component/src/Middleware/AttachmentForScreenReader/TextAttachment.js b/packages/component/src/Middleware/AttachmentForScreenReader/TextAttachment.js index 20ce747505..0c5164ffc2 100644 --- a/packages/component/src/Middleware/AttachmentForScreenReader/TextAttachment.js +++ b/packages/component/src/Middleware/AttachmentForScreenReader/TextAttachment.js @@ -9,7 +9,7 @@ const TextAttachment = ({ attachment: { content = '' } = {} }) => { const label = localize('ATTACHMENT_TEXT', content); - return
{label}
; + return
{label}
; }; TextAttachment.propTypes = { diff --git a/packages/component/src/Middleware/AttachmentForScreenReader/VideoAttachment.js b/packages/component/src/Middleware/AttachmentForScreenReader/VideoAttachment.js index c41f8c9117..b37da1040a 100644 --- a/packages/component/src/Middleware/AttachmentForScreenReader/VideoAttachment.js +++ b/packages/component/src/Middleware/AttachmentForScreenReader/VideoAttachment.js @@ -8,7 +8,7 @@ const VideoAttachment = () => { const label = localize('ATTACHMENT_VIDEO'); - return
{label}
; + return
{label}
; }; export default VideoAttachment; From a3ba84e9125cf81a25c72fc8fc922cf455b383b8 Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Mon, 19 Oct 2026 12:22:07 -0700 Subject: [PATCH 2/5] Use InferReactProps for live region props --- packages/component/src/LiveRegion/LiveRegionActivity.tsx | 6 +++--- .../src/LiveRegion/private/LiveRegionSuggestedActions.tsx | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/component/src/LiveRegion/LiveRegionActivity.tsx b/packages/component/src/LiveRegion/LiveRegionActivity.tsx index 8e56f47f84..6d1ddf146f 100644 --- a/packages/component/src/LiveRegion/LiveRegionActivity.tsx +++ b/packages/component/src/LiveRegion/LiveRegionActivity.tsx @@ -1,10 +1,10 @@ /* eslint no-magic-numbers: ["error", { "ignore": [2] }] */ import { hooks } from 'botframework-webchat-api'; -import { validateProps } from '@msinternal/botframework-webchat-react-valibot'; +import { validateProps, type InferReactProps } from '@msinternal/botframework-webchat-react-valibot'; import classNames from 'classnames'; import React, { Fragment, useMemo } from 'react'; -import { any, object, pipe, readonly, type InferInput } from 'valibot'; +import { any, object, pipe, readonly } from 'valibot'; import { useStyleToEmotionObject } from '../hooks/internal/styleToEmotionObject'; import useRenderMarkdownAsHTML from '../hooks/useRenderMarkdownAsHTML'; @@ -34,7 +34,7 @@ const liveRegionActivityPropsSchema = pipe( readonly() ); -type LiveRegionActivityProps = InferInput; +type LiveRegionActivityProps = InferReactProps; function LiveRegionActivity(props: LiveRegionActivityProps) { const { activity } = validateProps(liveRegionActivityPropsSchema, props); diff --git a/packages/component/src/LiveRegion/private/LiveRegionSuggestedActions.tsx b/packages/component/src/LiveRegion/private/LiveRegionSuggestedActions.tsx index 5e6369cc50..3f5d8016a9 100644 --- a/packages/component/src/LiveRegion/private/LiveRegionSuggestedActions.tsx +++ b/packages/component/src/LiveRegion/private/LiveRegionSuggestedActions.tsx @@ -1,6 +1,6 @@ -import { validateProps } from '@msinternal/botframework-webchat-react-valibot'; +import { validateProps, type InferReactProps } from '@msinternal/botframework-webchat-react-valibot'; import React, { memo } from 'react'; -import { any, array, object, pipe, readonly, type InferInput } from 'valibot'; +import { any, array, object, pipe, readonly } from 'valibot'; import computeSuggestedActionText from '../../Utils/computeSuggestedActionText'; @@ -17,7 +17,7 @@ const liveRegionSuggestedActionsPropSchema = pipe( readonly() ); -type LiveRegionSuggestedActionsProps = InferInput; +type LiveRegionSuggestedActionsProps = InferReactProps; function LiveRegionSuggestedActions(props: LiveRegionSuggestedActionsProps) { const { suggestedActions } = validateProps(liveRegionSuggestedActionsPropSchema, props); From f897f5ed4ddfe86932f72fa48651b72a1a578668 Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Mon, 19 Oct 2026 13:41:40 -0700 Subject: [PATCH 3/5] Hide line containers in the live region from the accessibility tree Screen readers on Windows read "group" before every line of a new message: generic
and

are exposed as UIA Group. Lines and containers in the live region copy are now role="none"; only the aria-atomic message, its text, and the interactive copies (buttons, inputs) remain. --- CHANGELOG.md | 1 + .../genericWrappers.html => noRoleWords.html} | 32 +++++++++++++------ docs/ACCESSIBILITY.md | 1 + .../AdaptiveCardAttachment.js | 8 ++--- .../RichCardAttachment.js | 22 ++++++------- .../src/LiveRegion/LiveRegionActivity.tsx | 8 +++-- .../private/LiveRegionAttachments.tsx | 7 ++-- .../private/LiveRegionSuggestedActions.tsx | 2 +- .../AudioAttachment.js | 2 +- .../FileAttachment.js | 2 +- .../ImageAttachment.js | 2 +- .../TextAttachment.js | 2 +- .../VideoAttachment.js | 2 +- 13 files changed, 56 insertions(+), 35 deletions(-) rename __tests__/html2/accessibility/liveRegion/{attachment/genericWrappers.html => noRoleWords.html} (66%) diff --git a/CHANGELOG.md b/CHANGELOG.md index b815c0921e..711607b1ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,7 @@ Legends: - Fixed video and animation cards disappearing in production builds when the video URL is relative or invalid, in PR [#5889](https://github.com/microsoft/BotFramework-WebChat/pull/5889), by [@OEvgeny](https://github.com/OEvgeny) - Fixed timestamps always using the 12-hour clock, such as "4:41 PM" instead of "16:41" in German, in PR [#5920](https://github.com/microsoft/BotFramework-WebChat/pull/5920), by [@OEvgeny](https://github.com/OEvgeny) - Fixed screen readers saying "article" before new messages and their attachments in the live region, in PR [#XXXX](https://github.com/microsoft/BotFramework-WebChat/pull/XXXX), by [@OEvgeny](https://github.com/OEvgeny) +- Fixed screen readers saying "article" and "group" before new messages and each of their lines in the live region, in PR [#XXXX](https://github.com/microsoft/BotFramework-WebChat/pull/XXXX), by [@OEvgeny](https://github.com/OEvgeny) ## [4.19.2] - 2026-10-08 diff --git a/__tests__/html2/accessibility/liveRegion/attachment/genericWrappers.html b/__tests__/html2/accessibility/liveRegion/noRoleWords.html similarity index 66% rename from __tests__/html2/accessibility/liveRegion/attachment/genericWrappers.html rename to __tests__/html2/accessibility/liveRegion/noRoleWords.html index b1edf86c9e..faeddcae69 100644 --- a/__tests__/html2/accessibility/liveRegion/attachment/genericWrappers.html +++ b/__tests__/html2/accessibility/liveRegion/noRoleWords.html @@ -32,7 +32,7 @@ await pageConditions.uiConnected(); - // WHEN: Bot sends a message with an Adaptive Card, a hero card, and a file. + // WHEN: Bot sends a message with an Adaptive Card, a hero card, a file, and a suggested action. await directLine.emulateIncomingActivity({ attachments: [ { @@ -52,23 +52,29 @@ { contentType: 'application/octet-stream', name: 'report.zip' } ], from: { role: 'bot' }, + suggestedActions: { actions: [{ title: 'Help', type: 'imBack', value: 'help' }] }, text: 'Here are 2 cards and a file.', type: 'message' }); await pageConditions.numActivitiesShown(1); - await waitFor(() => - expect(pageElements.liveRegion().querySelector('.webchat__live-region-activity')).toBeTruthy() - ); + const liveRegionActivity = await waitFor(() => { + const element = pageElements.liveRegion().querySelector('.webchat__live-region-activity'); + + expect(element).toBeTruthy(); - const liveRegionActivity = pageElements.liveRegion().querySelector('.webchat__live-region-activity'); + return element; + }); - // THEN: The live region should contain the text of the message, the cards, and the file. + // THEN: The live region should contain the text of the message, the suggested action, the cards, and the file. expect(liveRegionActivity.innerText).toBe( [ 'Bot WC said:', 'Here are 2 cards and a file.', + '', // The suggested actions are in a `

`, `innerText` puts a blank line around it. + 'Help', + '', 'A card: Card 1', 'OK', 'A card: Card 2 Hero text', @@ -80,15 +86,23 @@ // THEN: The message should still be announced as a whole. expect(liveRegionActivity.getAttribute('aria-atomic')).toBe('true'); - // THEN: The message and its attachments should not be articles, screen readers would read "article" before each of them. const accessibilityNodes = await Promise.all( [liveRegionActivity, ...liveRegionActivity.querySelectorAll('*')].map(element => testHelpers.getAccessibilityNode(element) ) ); - expect(accessibilityNodes.map(({ role }) => role?.value)).not.toContain('article'); - expect(liveRegionActivity.querySelectorAll('article')).toHaveLength(0); + // THEN: The message should be a generic container, not an article, screen readers would read "article" before it. + expect(accessibilityNodes[0].role?.value).toBe('generic'); + + // THEN: Inside the message, only the copies of the buttons should be in the accessibility tree, besides the text. + // On Windows, every other element is a "group" (including generic `

` and `

`), screen readers would read "group" before each line. + expect( + accessibilityNodes + .slice(1) // The message itself keeps `aria-atomic`, which requires an element in the accessibility tree. + .filter(({ ignored }) => !ignored) + .map(({ name, role }) => `${role?.value} "${name?.value}"`) + ).toEqual(['button "Help"', 'button "OK"', 'button "Go"']); }); diff --git a/docs/ACCESSIBILITY.md b/docs/ACCESSIBILITY.md index 3f2db34d0b..3b89e2528d 100644 --- a/docs/ACCESSIBILITY.md +++ b/docs/ACCESSIBILITY.md @@ -279,6 +279,7 @@ To make the live region more consistent across browsers and easier to control, w - Screen reader only reads the chat history marked as live region - Web Chat does not narrate attachment contents: attachments can be customized and the DOM tree could be very complex with interactive elements - Empty messages will be narrated if they have suggested actions + - Lines and containers in the copy are `role="none"`, otherwise screen readers on Windows read "group" before every line; custom `attachmentForScreenReaderMiddleware` should do the same - The live region contains activities that were recently - When the DOM element appear in the live region, the screen reader will compute the alternative text and queue it for narration in a first-come-first-serve manner - The screen reader will keep the alternative text in the queue even after the DOM element is removed from the live region diff --git a/packages/bundle/src/adaptiveCards/AttachmentForScreenReader/AdaptiveCardAttachment.js b/packages/bundle/src/adaptiveCards/AttachmentForScreenReader/AdaptiveCardAttachment.js index 64cce4785a..c1d4395ebd 100644 --- a/packages/bundle/src/adaptiveCards/AttachmentForScreenReader/AdaptiveCardAttachment.js +++ b/packages/bundle/src/adaptiveCards/AttachmentForScreenReader/AdaptiveCardAttachment.js @@ -32,7 +32,7 @@ const AdaptiveCardChoiceSetInput = ({ input: { choices, defaultValue, label } }) const defaultChoice = choices.find(({ value }) => defaultValue === value || (!defaultValue && !value)); return ( -

+
) : input instanceof OpenUrlAction || input instanceof ShowCardAction || input instanceof SubmitAction ? ( -
+
diff --git a/packages/bundle/src/adaptiveCards/AttachmentForScreenReader/RichCardAttachment.js b/packages/bundle/src/adaptiveCards/AttachmentForScreenReader/RichCardAttachment.js index 25354b07b4..16e6b5db0c 100644 --- a/packages/bundle/src/adaptiveCards/AttachmentForScreenReader/RichCardAttachment.js +++ b/packages/bundle/src/adaptiveCards/AttachmentForScreenReader/RichCardAttachment.js @@ -16,8 +16,8 @@ const RichCardAttachment = ({ content = {} }) => { const cardLabel = localize('ATTACHMENT_CARD', title || '', subtitle || '', text || ''); return ( -
-
{cardLabel}
+
+
{cardLabel}
{!!image && !!image.alt && {image.alt}} {!!images && !!images.length && images.map(({ alt }, index) => {alt})} {!!facts && !!facts.length && ( @@ -35,32 +35,32 @@ const RichCardAttachment = ({ content = {} }) => { {items.map(({ image, price, quantity, subtitle, text, title }, index) => (
  • {!!image && !!image.alt && {image.alt}} - {!!title &&

    {title}

    } - {!!subtitle &&

    {subtitle}

    } - {!!text &&

    {text}

    } - {!!quantity &&

    {quantity}

    } - {!!price &&

    {price}

    } + {!!title &&

    {title}

    } + {!!subtitle &&

    {subtitle}

    } + {!!text &&

    {text}

    } + {!!quantity &&

    {quantity}

    } + {!!price &&

    {price}

    }
  • ))} )} {!!vat && ( -

    +

    {vatLabel} {vat}

    )} {!!tax && ( -

    +

    {taxLabel} {tax}

    )} {!!total && ( -

    +

    {totalLabel} {total}

    )} {!!buttons && !!buttons.length && ( -
    +
    {buttons.map(({ title }, index) => (