Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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" and "group" before new messages and each of their lines in the live region, in PR [#5921](https://github.com/microsoft/BotFramework-WebChat/pull/5921), by [@OEvgeny](https://github.com/OEvgeny)

## [4.19.2] - 2026-10-08

Expand Down
117 changes: 117 additions & 0 deletions __tests__/html2/accessibility/liveRegion/noRoleWords.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
<!doctype html>
<html lang="en-US">
<head>
<link href="/assets/index.css" rel="stylesheet" type="text/css" />
<!-- Shows the live region, so the snapshot shows the copy sent to screen readers. -->
<link href="/assets/accessibility.liveRegionAttachment.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 type="importmap">
{
"imports": {
"@testduet/wait-for": "https://esm.sh/@testduet/wait-for"
}
}
</script>
<script type="module">
import { waitFor } from '@testduet/wait-for';

run(async function () {
const { directLine, store } = testHelpers.createDirectLineEmulator();

// GIVEN: Web Chat keeps the live region copy of new messages, so it can be seen in the snapshot.
WebChat.renderWebChat(
{
directLine,
store,
styleOptions: { botAvatarInitials: 'WC', hideSendBox: true, internalLiveRegionFadeAfter: 60000 }
},
document.getElementById('webchat')
);

await pageConditions.uiConnected();

// WHEN: Bot sends a message with an Adaptive Card, a hero card, a file, and a suggested action.
await directLine.emulateIncomingActivity({
attachments: [
{
content: {
actions: [{ title: 'OK', type: 'Action.Submit' }],
body: [{ text: 'Card 1', type: 'TextBlock' }],
speak: 'Card 1',
type: 'AdaptiveCard',
version: '1.3'
},
contentType: 'application/vnd.microsoft.card.adaptive'
},
{
content: { buttons: [{ title: 'Go', type: 'imBack', value: 'go' }], text: 'Hero text', title: 'Card 2' },
contentType: 'application/vnd.microsoft.card.hero'
},
{ 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);

const liveRegionActivity = await waitFor(() => {
const element = pageElements.liveRegion().querySelector('.webchat__live-region-activity');

expect(element).toBeTruthy();

return element;
});

// 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 `<p>`, `innerText` puts a blank line around it.
'Help',
'',
'A card: Card 1',
'OK',
'A card: Card 2 Hero text',
'Go',
'A file: report.zip'
].join('\n')
);

// THEN: The message should still be announced as a whole.
expect(liveRegionActivity.getAttribute('aria-atomic')).toBe('true');

const accessibilityNodes = await Promise.all(
[liveRegionActivity, ...liveRegionActivity.querySelectorAll('*')].map(element =>
testHelpers.getAccessibilityNode(element)
)
);

// 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 `<div>` and `<p>`), 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"']);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

TODO: add snapshots with the live region visualized via a CSS provided in the tests instrumentation


await pageConditions.liveRegionStabilized();

// THEN: The live region (top) shows the message, "Help", "A card: Card 1", "OK", "A card: Card 2 Hero text", "Go" and "A file: report.zip", with no role words such as "article" or "group".
await host.snapshot('local');
});
</script>
</body>
</html>
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions docs/ACCESSIBILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ const AdaptiveCardChoiceSetInput = ({ input: { choices, defaultValue, label } })
const defaultChoice = choices.find(({ value }) => defaultValue === value || (!defaultValue && !value));

return (
<div>
<div role="none">
<select
aria-label={label}
aria-labelledby={!label && defaultChoice ? labelId : undefined}
Expand Down Expand Up @@ -117,8 +117,8 @@ const AdaptiveCardAttachment = ({ content }) => {
const cardLabel = localize('ATTACHMENT_CARD', card.speak || '', '', '');

return (
<article>
<div>{cardLabel}</div>
<div role="none">
<div role="none">{cardLabel}</div>
{inputs.map((input, index) =>
input instanceof ChoiceSetInput ? (
<AdaptiveCardChoiceSetInput input={input} key={index} />
Expand All @@ -133,7 +133,7 @@ const AdaptiveCardAttachment = ({ content }) => {
<input placeholder={input.placeholder} tabIndex={-1} type="number" />
</label>
) : input instanceof OpenUrlAction || input instanceof ShowCardAction || input instanceof SubmitAction ? (
<div key={index}>
<div key={index} role="none">
<button tabIndex={-1} type="button">
{input.title}
</button>
Expand All @@ -157,7 +157,7 @@ const AdaptiveCardAttachment = ({ content }) => {
false
)
)}
</article>
</div>
);
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ const RichCardAttachment = ({ content = {} }) => {
const cardLabel = localize('ATTACHMENT_CARD', title || '', subtitle || '', text || '');

return (
<article>
<div>{cardLabel}</div>
<div role="none">
<div role="none">{cardLabel}</div>
{!!image && !!image.alt && <img alt={image.alt} />}
{!!images && !!images.length && images.map(({ alt }, index) => <img alt={alt} key={index} />)}
{!!facts && !!facts.length && (
Expand All @@ -35,40 +35,40 @@ const RichCardAttachment = ({ content = {} }) => {
{items.map(({ image, price, quantity, subtitle, text, title }, index) => (
<li key={index}>
{!!image && !!image.alt && <img alt={image.alt} />}
{!!title && <p>{title}</p>}
{!!subtitle && <p>{subtitle}</p>}
{!!text && <p>{text}</p>}
{!!quantity && <p>{quantity}</p>}
{!!price && <p>{price}</p>}
{!!title && <p role="none">{title}</p>}
{!!subtitle && <p role="none">{subtitle}</p>}
{!!text && <p role="none">{text}</p>}
{!!quantity && <p role="none">{quantity}</p>}
{!!price && <p role="none">{price}</p>}
</li>
))}
</ul>
)}
{!!vat && (
<p>
<p role="none">
{vatLabel} {vat}
</p>
)}
{!!tax && (
<p>
<p role="none">
{taxLabel} {tax}
</p>
)}
{!!total && (
<p>
<p role="none">
{totalLabel} {total}
</p>
)}
{!!buttons && !!buttons.length && (
<div>
<div role="none">
{buttons.map(({ title }, index) => (
<button key={index} tabIndex={-1} type="button">
{title}
</button>
))}
</div>
)}
</article>
</div>
);
};

Expand Down
18 changes: 10 additions & 8 deletions packages/component/src/LiveRegion/LiveRegionActivity.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -34,7 +34,7 @@ const liveRegionActivityPropsSchema = pipe(
readonly()
);

type LiveRegionActivityProps = InferInput<typeof liveRegionActivityPropsSchema>;
type LiveRegionActivityProps = InferReactProps<typeof liveRegionActivityPropsSchema>;

function LiveRegionActivity(props: LiveRegionActivityProps) {
const { activity } = validateProps(liveRegionActivityPropsSchema, props);
Expand All @@ -56,14 +56,16 @@ function LiveRegionActivity(props: LiveRegionActivityProps) {
).replace(/\s{2,}/gu, ' ');
const validFallbackText = fallbackText && typeof fallbackText === 'string';

// Lines are `role="none"`: on Windows, generic `<div>` and `<p>` are exposed as "group", screen readers would read "group" before each line.
// The message itself stays in the accessibility tree, as required by `aria-atomic`.
return (
<article aria-atomic={true} className={classNames('webchat__live-region-activity', rootClassName)}>
<div>{greetingAlt}</div>
<div aria-atomic={true} className={classNames('webchat__live-region-activity', rootClassName)}>
<div role="none">{greetingAlt}</div>
{validFallbackText ? (
<div>{fallbackText}</div>
<div role="none">{fallbackText}</div>
) : (
<Fragment>
<div>{textAlt}</div>
<div role="none">{textAlt}</div>
{type === 'message' && (
<Fragment>
{!!activity.suggestedActions && (
Expand All @@ -74,7 +76,7 @@ function LiveRegionActivity(props: LiveRegionActivityProps) {
)}
</Fragment>
)}
</article>
</div>
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,13 @@ const LiveRegionAttachments = ({ activity }: LiveRegionAttachmentsProps) => {
<Fragment>
{attachmentForScreenReaderRenderers.map((render, index) => (
// Direct Line does not have key for attachment other than index.
// Like the lines of the message, `role="none"` keeps "group" from being read, see `LiveRegionActivity`.
// eslint-disable-next-line react/no-array-index-key
<div key={index}>{typeof render === 'function' && render()}</div>
<div key={index} role="none">
{typeof render === 'function' && render()}
</div>
))}
{numAttachmentsAlt && <p>{numAttachmentsAlt}</p>}
{numAttachmentsAlt && <p role="none">{numAttachmentsAlt}</p>}
</Fragment>
);
};
Expand Down
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -17,14 +17,14 @@ const liveRegionSuggestedActionsPropSchema = pipe(
readonly()
);

type LiveRegionSuggestedActionsProps = InferInput<typeof liveRegionSuggestedActionsPropSchema>;
type LiveRegionSuggestedActionsProps = InferReactProps<typeof liveRegionSuggestedActionsPropSchema>;

function LiveRegionSuggestedActions(props: LiveRegionSuggestedActionsProps) {
const { suggestedActions } = validateProps(liveRegionSuggestedActionsPropSchema, props);

return (
suggestedActions.actions?.length && (
<p className="webchat__live-region-activity__suggested-actions">
<p className="webchat__live-region-activity__suggested-actions" role="none">
{suggestedActions.actions.map((action, index) => (
// Direct Line schema does not have key other than index.
// eslint-disable-next-line react/no-array-index-key
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const AudioAttachment = () => {

const label = localize('ATTACHMENT_AUDIO');

return <article>{label}</article>;
return <div role="none">{label}</div>;
};

export default AudioAttachment;
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const FileAttachment = ({ attachment: { name = '' } = {} }) => {

const label = localize('ATTACHMENT_FILE', name);

return <article>{label}</article>;
return <div role="none">{label}</div>;
};

FileAttachment.propTypes = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const ImageAttachment = () => {

const label = localize('ATTACHMENT_IMAGE');

return <article>{label}</article>;
return <div role="none">{label}</div>;
};

export default ImageAttachment;
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const TextAttachment = ({ attachment: { content = '' } = {} }) => {

const label = localize('ATTACHMENT_TEXT', content);

return <article>{label}</article>;
return <div role="none">{label}</div>;
};

TextAttachment.propTypes = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const VideoAttachment = () => {

const label = localize('ATTACHMENT_VIDEO');

return <article>{label}</article>;
return <div role="none">{label}</div>;
};

export default VideoAttachment;
Loading