Skip to content

Commit fb9d46f

Browse files
OEvgenyEugene Olonov
andauthored
Read live region messages without article and group role words (#5921)
* 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. * Use InferReactProps for live region props * 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 <div> and <p> 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. * Add PR number to changelog * Show the live region copy in the no role words snapshot * Move the changelog entry to .changelog/ --------- Co-authored-by: Eugene Olonov <eugeneolonov@microsoft.com>
1 parent 1362d87 commit fb9d46f

14 files changed

Lines changed: 162 additions & 36 deletions

File tree

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
### Fixed
2+
3+
- 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)
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
<!doctype html>
2+
<html lang="en-US">
3+
<head>
4+
<link href="/assets/index.css" rel="stylesheet" type="text/css" />
5+
<!-- Shows the live region, so the snapshot shows the copy sent to screen readers. -->
6+
<link href="/assets/accessibility.liveRegionAttachment.css" rel="stylesheet" type="text/css" />
7+
<script crossorigin="anonymous" src="/test-harness.js"></script>
8+
<script crossorigin="anonymous" src="/test-page-object.js"></script>
9+
<script crossorigin="anonymous" src="/__dist__/webchat-es5.js"></script>
10+
</head>
11+
<body>
12+
<main id="webchat"></main>
13+
<script type="importmap">
14+
{
15+
"imports": {
16+
"@testduet/wait-for": "https://esm.sh/@testduet/wait-for"
17+
}
18+
}
19+
</script>
20+
<script type="module">
21+
import { waitFor } from '@testduet/wait-for';
22+
23+
run(async function () {
24+
const { directLine, store } = testHelpers.createDirectLineEmulator();
25+
26+
// GIVEN: Web Chat keeps the live region copy of new messages, so it can be seen in the snapshot.
27+
WebChat.renderWebChat(
28+
{
29+
directLine,
30+
store,
31+
styleOptions: { botAvatarInitials: 'WC', hideSendBox: true, internalLiveRegionFadeAfter: 60000 }
32+
},
33+
document.getElementById('webchat')
34+
);
35+
36+
await pageConditions.uiConnected();
37+
38+
// WHEN: Bot sends a message with an Adaptive Card, a hero card, a file, and a suggested action.
39+
await directLine.emulateIncomingActivity({
40+
attachments: [
41+
{
42+
content: {
43+
actions: [{ title: 'OK', type: 'Action.Submit' }],
44+
body: [{ text: 'Card 1', type: 'TextBlock' }],
45+
speak: 'Card 1',
46+
type: 'AdaptiveCard',
47+
version: '1.3'
48+
},
49+
contentType: 'application/vnd.microsoft.card.adaptive'
50+
},
51+
{
52+
content: { buttons: [{ title: 'Go', type: 'imBack', value: 'go' }], text: 'Hero text', title: 'Card 2' },
53+
contentType: 'application/vnd.microsoft.card.hero'
54+
},
55+
{ contentType: 'application/octet-stream', name: 'report.zip' }
56+
],
57+
from: { role: 'bot' },
58+
suggestedActions: { actions: [{ title: 'Help', type: 'imBack', value: 'help' }] },
59+
text: 'Here are 2 cards and a file.',
60+
type: 'message'
61+
});
62+
63+
await pageConditions.numActivitiesShown(1);
64+
65+
const liveRegionActivity = await waitFor(() => {
66+
const element = pageElements.liveRegion().querySelector('.webchat__live-region-activity');
67+
68+
expect(element).toBeTruthy();
69+
70+
return element;
71+
});
72+
73+
// THEN: The live region should contain the text of the message, the suggested action, the cards, and the file.
74+
expect(liveRegionActivity.innerText).toBe(
75+
[
76+
'Bot WC said:',
77+
'Here are 2 cards and a file.',
78+
'', // The suggested actions are in a `<p>`, `innerText` puts a blank line around it.
79+
'Help',
80+
'',
81+
'A card: Card 1',
82+
'OK',
83+
'A card: Card 2 Hero text',
84+
'Go',
85+
'A file: report.zip'
86+
].join('\n')
87+
);
88+
89+
// THEN: The message should still be announced as a whole.
90+
expect(liveRegionActivity.getAttribute('aria-atomic')).toBe('true');
91+
92+
const accessibilityNodes = await Promise.all(
93+
[liveRegionActivity, ...liveRegionActivity.querySelectorAll('*')].map(element =>
94+
testHelpers.getAccessibilityNode(element)
95+
)
96+
);
97+
98+
// THEN: The message should be a generic container, not an article, screen readers would read "article" before it.
99+
expect(accessibilityNodes[0].role?.value).toBe('generic');
100+
101+
// THEN: Inside the message, only the copies of the buttons should be in the accessibility tree, besides the text.
102+
// On Windows, every other element is a "group" (including generic `<div>` and `<p>`), screen readers would read "group" before each line.
103+
expect(
104+
accessibilityNodes
105+
.slice(1) // The message itself keeps `aria-atomic`, which requires an element in the accessibility tree.
106+
.filter(({ ignored }) => !ignored)
107+
.map(({ name, role }) => `${role?.value} "${name?.value}"`)
108+
).toEqual(['button "Help"', 'button "OK"', 'button "Go"']);
109+
110+
await pageConditions.liveRegionStabilized();
111+
112+
// 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".
113+
await host.snapshot('local');
114+
});
115+
</script>
116+
</body>
117+
</html>
32.6 KB
Loading

‎docs/ACCESSIBILITY.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,7 @@ To make the live region more consistent across browsers and easier to control, w
279279
- Screen reader only reads the chat history marked as live region
280280
- Web Chat does not narrate attachment contents: attachments can be customized and the DOM tree could be very complex with interactive elements
281281
- Empty messages will be narrated if they have suggested actions
282+
- 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
282283
- The live region contains activities that were recently
283284
- 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
284285
- The screen reader will keep the alternative text in the queue even after the DOM element is removed from the live region

‎packages/bundle/src/adaptiveCards/AttachmentForScreenReader/AdaptiveCardAttachment.js‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ const AdaptiveCardChoiceSetInput = ({ input: { choices, defaultValue, label } })
6565
const defaultChoice = choices.find(({ value }) => defaultValue === value || (!defaultValue && !value));
6666

6767
return (
68-
<div>
68+
<div role="none">
6969
<select
7070
aria-label={label}
7171
aria-labelledby={!label && defaultChoice ? labelId : undefined}
@@ -180,8 +180,8 @@ const AdaptiveCardAttachment = ({ content }) => {
180180
const cardLabel = localize('ATTACHMENT_CARD', card.speak || text, '', '');
181181

182182
return (
183-
<article>
184-
<div>{cardLabel}</div>
183+
<div role="none">
184+
<div role="none">{cardLabel}</div>
185185
{inputs.map((input, index) =>
186186
input instanceof ChoiceSetInput ? (
187187
<AdaptiveCardChoiceSetInput input={input} key={index} />
@@ -196,7 +196,7 @@ const AdaptiveCardAttachment = ({ content }) => {
196196
<input placeholder={input.placeholder} tabIndex={-1} type="number" />
197197
</label>
198198
) : input instanceof OpenUrlAction || input instanceof ShowCardAction || input instanceof SubmitAction ? (
199-
<div key={index}>
199+
<div key={index} role="none">
200200
<button tabIndex={-1} type="button">
201201
{input.title}
202202
</button>
@@ -220,7 +220,7 @@ const AdaptiveCardAttachment = ({ content }) => {
220220
false
221221
)
222222
)}
223-
</article>
223+
</div>
224224
);
225225
};
226226

‎packages/bundle/src/adaptiveCards/AttachmentForScreenReader/RichCardAttachment.js‎

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ const RichCardAttachment = ({ content = {} }) => {
1616
const cardLabel = localize('ATTACHMENT_CARD', title || '', subtitle || '', text || '');
1717

1818
return (
19-
<article>
20-
<div>{cardLabel}</div>
19+
<div role="none">
20+
<div role="none">{cardLabel}</div>
2121
{!!image && !!image.alt && <img alt={image.alt} />}
2222
{!!images && !!images.length && images.map(({ alt }, index) => <img alt={alt} key={index} />)}
2323
{!!facts && !!facts.length && (
@@ -35,40 +35,40 @@ const RichCardAttachment = ({ content = {} }) => {
3535
{items.map(({ image, price, quantity, subtitle, text, title }, index) => (
3636
<li key={index}>
3737
{!!image && !!image.alt && <img alt={image.alt} />}
38-
{!!title && <p>{title}</p>}
39-
{!!subtitle && <p>{subtitle}</p>}
40-
{!!text && <p>{text}</p>}
41-
{!!quantity && <p>{quantity}</p>}
42-
{!!price && <p>{price}</p>}
38+
{!!title && <p role="none">{title}</p>}
39+
{!!subtitle && <p role="none">{subtitle}</p>}
40+
{!!text && <p role="none">{text}</p>}
41+
{!!quantity && <p role="none">{quantity}</p>}
42+
{!!price && <p role="none">{price}</p>}
4343
</li>
4444
))}
4545
</ul>
4646
)}
4747
{!!vat && (
48-
<p>
48+
<p role="none">
4949
{vatLabel} {vat}
5050
</p>
5151
)}
5252
{!!tax && (
53-
<p>
53+
<p role="none">
5454
{taxLabel} {tax}
5555
</p>
5656
)}
5757
{!!total && (
58-
<p>
58+
<p role="none">
5959
{totalLabel} {total}
6060
</p>
6161
)}
6262
{!!buttons && !!buttons.length && (
63-
<div>
63+
<div role="none">
6464
{buttons.map(({ title }, index) => (
6565
<button key={index} tabIndex={-1} type="button">
6666
{title}
6767
</button>
6868
))}
6969
</div>
7070
)}
71-
</article>
71+
</div>
7272
);
7373
};
7474

‎packages/component/src/LiveRegion/LiveRegionActivity.tsx‎

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
/* eslint no-magic-numbers: ["error", { "ignore": [2] }] */
22

33
import { hooks } from 'botframework-webchat-api';
4-
import { validateProps } from '@msinternal/botframework-webchat-react-valibot';
4+
import { validateProps, type InferReactProps } from '@msinternal/botframework-webchat-react-valibot';
55
import classNames from 'classnames';
66
import React, { Fragment, useMemo } from 'react';
7-
import { any, object, pipe, readonly, type InferInput } from 'valibot';
7+
import { any, object, pipe, readonly } from 'valibot';
88

99
import { useStyleToEmotionObject } from '../hooks/internal/styleToEmotionObject';
1010
import useRenderMarkdownAsHTML from '../hooks/useRenderMarkdownAsHTML';
@@ -34,7 +34,7 @@ const liveRegionActivityPropsSchema = pipe(
3434
readonly()
3535
);
3636

37-
type LiveRegionActivityProps = InferInput<typeof liveRegionActivityPropsSchema>;
37+
type LiveRegionActivityProps = InferReactProps<typeof liveRegionActivityPropsSchema>;
3838

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

59+
// Lines are `role="none"`: on Windows, generic `<div>` and `<p>` are exposed as "group", screen readers would read "group" before each line.
60+
// The message itself stays in the accessibility tree, as required by `aria-atomic`.
5961
return (
60-
<article aria-atomic={true} className={classNames('webchat__live-region-activity', rootClassName)}>
61-
<div>{greetingAlt}</div>
62+
<div aria-atomic={true} className={classNames('webchat__live-region-activity', rootClassName)}>
63+
<div role="none">{greetingAlt}</div>
6264
{validFallbackText ? (
63-
<div>{fallbackText}</div>
65+
<div role="none">{fallbackText}</div>
6466
) : (
6567
<Fragment>
66-
<div>{textAlt}</div>
68+
<div role="none">{textAlt}</div>
6769
{type === 'message' && (
6870
<Fragment>
6971
{!!activity.suggestedActions && (
@@ -74,7 +76,7 @@ function LiveRegionActivity(props: LiveRegionActivityProps) {
7476
)}
7577
</Fragment>
7678
)}
77-
</article>
79+
</div>
7880
);
7981
}
8082

‎packages/component/src/LiveRegion/private/LiveRegionAttachments.tsx‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,13 @@ const LiveRegionAttachments = ({ activity }: LiveRegionAttachmentsProps) => {
3737
<Fragment>
3838
{attachmentForScreenReaderRenderers.map((render, index) => (
3939
// Direct Line does not have key for attachment other than index.
40+
// Like the lines of the message, `role="none"` keeps "group" from being read, see `LiveRegionActivity`.
4041
// eslint-disable-next-line react/no-array-index-key
41-
<div key={index}>{typeof render === 'function' && render()}</div>
42+
<div key={index} role="none">
43+
{typeof render === 'function' && render()}
44+
</div>
4245
))}
43-
{numAttachmentsAlt && <p>{numAttachmentsAlt}</p>}
46+
{numAttachmentsAlt && <p role="none">{numAttachmentsAlt}</p>}
4447
</Fragment>
4548
);
4649
};

‎packages/component/src/LiveRegion/private/LiveRegionSuggestedActions.tsx‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { validateProps } from '@msinternal/botframework-webchat-react-valibot';
1+
import { validateProps, type InferReactProps } from '@msinternal/botframework-webchat-react-valibot';
22
import React, { memo } from 'react';
3-
import { any, array, object, pipe, readonly, type InferInput } from 'valibot';
3+
import { any, array, object, pipe, readonly } from 'valibot';
44

55
import computeSuggestedActionText from '../../Utils/computeSuggestedActionText';
66

@@ -17,14 +17,14 @@ const liveRegionSuggestedActionsPropSchema = pipe(
1717
readonly()
1818
);
1919

20-
type LiveRegionSuggestedActionsProps = InferInput<typeof liveRegionSuggestedActionsPropSchema>;
20+
type LiveRegionSuggestedActionsProps = InferReactProps<typeof liveRegionSuggestedActionsPropSchema>;
2121

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

2525
return (
2626
suggestedActions.actions?.length && (
27-
<p className="webchat__live-region-activity__suggested-actions">
27+
<p className="webchat__live-region-activity__suggested-actions" role="none">
2828
{suggestedActions.actions.map((action, index) => (
2929
// Direct Line schema does not have key other than index.
3030
// eslint-disable-next-line react/no-array-index-key

‎packages/component/src/Middleware/AttachmentForScreenReader/AudioAttachment.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ const AudioAttachment = () => {
88

99
const label = localize('ATTACHMENT_AUDIO');
1010

11-
return <article>{label}</article>;
11+
return <div role="none">{label}</div>;
1212
};
1313

1414
export default AudioAttachment;

0 commit comments

Comments
 (0)