Skip to content
Open
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 @@ -50,6 +50,7 @@ Legends:

### Fixed

- Fixed screen readers repeating the full bot response when focus enters message actions such as Copy, Like, or Dislike, resolving [#5849](https://github.com/microsoft/BotFramework-WebChat/issues/5849), by [@asalsa](https://github.com/asalsa)
- Fixed an error when a failed activity is present when Web Chat mounts, resolving [#5812](https://github.com/microsoft/BotFramework-WebChat/issues/5812), in PR [#5848](https://github.com/microsoft/BotFramework-WebChat/pull/5848), by [@OEvgeny](https://github.com/OEvgeny)

## [4.19.1] - 2026-06-09
Expand Down
145 changes: 145 additions & 0 deletions __tests__/html2/accessibility/messageActions.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
<!doctype html>
<html lang="en-US">
<head>
<link href="/assets/index.css" rel="stylesheet" type="text/css" />
<script crossorigin="anonymous" src="https://unpkg.com/@babel/standalone@7.8.7/babel.min.js"></script>
<script crossorigin="anonymous" src="https://unpkg.com/react@16.8.6/umd/react.production.min.js"></script>
<script crossorigin="anonymous" src="https://unpkg.com/react-dom@16.8.6/umd/react-dom.production.min.js"></script>
<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>
<script crossorigin="anonymous" src="/__dist__/botframework-webchat-fluent-theme.production.min.js"></script>
</head>
<body>
<main id="webchat"></main>
<script type="text/babel">
run(async function () {
const {
React,
ReactDOM: { render },
WebChat: { FluentThemeProvider, ReactWebChat, testIds }
} = window;

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

await host.sendDevToolsCommand('Browser.setPermission', {
permission: { name: 'clipboard-write' },
setting: 'granted'
});

render(
<FluentThemeProvider>
<ReactWebChat
directLine={directLine}
store={store}
styleOptions={{ feedbackActionsPlacement: 'activity-actions' }}
/>
</FluentThemeProvider>,
document.getElementById('webchat')
);

await pageConditions.uiConnected();

await directLine.emulateIncomingActivity({
entities: [
{
'@context': 'https://schema.org',
'@id': '',
'@type': 'Message',
keywords: ['AllowCopy'],
potentialAction: [
{
'@type': 'LikeAction',
actionStatus: 'PotentialActionStatus',
target: {
'@type': 'EntryPoint',
urlTemplate: 'ms-directline://postback?interaction=like'
}
},
{
'@type': 'DislikeAction',
actionStatus: 'PotentialActionStatus',
target: {
'@type': 'EntryPoint',
urlTemplate: 'ms-directline://postback?interaction=dislike'
}
}
],
type: 'https://schema.org/Message'
}
],
text: 'This is a deliberately long bot response. It contains multiple sentences so duplicate narration is easy to detect. The message action should be announced without repeating this response.',
type: 'message'
});

await pageConditions.numActivitiesShown(1);

const getAccessibilityNode = async expression => {
const {
result: { objectId }
} = await host.sendAndGetDevToolsCommand('Runtime.evaluate', { expression });
const {
node: { backendNodeId }
} = await host.sendAndGetDevToolsCommand('DOM.describeNode', { objectId });
const { nodes } = await host.sendAndGetDevToolsCommand('Accessibility.getPartialAXTree', {
backendNodeId,
fetchRelatives: false
});

return nodes.find(node => node.backendDOMNodeId === backendNodeId) || nodes[0];
};

const copyButton = document.querySelector(`[data-testid="${testIds.copyButton}"]`);
const feedbackButtons = [...document.querySelectorAll(`[data-testid="${testIds.feedbackButton}"]`)];
const messageGroup = copyButton.closest('[aria-roledescription="message"][role="group"]');
const responseText = 'This is a deliberately long bot response.';

copyButton.setAttribute('data-a11y-action', 'copy');
feedbackButtons.forEach((button, index) => button.setAttribute('data-a11y-action', `${index}`));

const actionAccessibilityNodes = await Promise.all([
getAccessibilityNode(`document.querySelector('[data-a11y-action="copy"]')`),
...feedbackButtons.map((_, index) =>
getAccessibilityNode(`document.querySelector('[data-a11y-action="${index}"]')`)
)
]);

expect(actionAccessibilityNodes.map(({ name }) => name.value)).toEqual(['Copy', 'Like', 'Dislike']);
expect(messageGroup.textContent).toContain(responseText);

const actionAncestors = [];

for (let element = copyButton.parentElement; element; element = element.parentElement) {
actionAncestors.push(element);
}

actionAncestors.forEach((element, index) => element.setAttribute('data-a11y-ancestor', `${index}`));

const ancestorAccessibilityNames = await Promise.all(
actionAncestors.map((_, index) =>
getAccessibilityNode(`document.querySelector('[data-a11y-ancestor="${index}"]')`).then(
({ name }) => name?.value || ''
)
)
);

expect(ancestorAccessibilityNames.some(name => name.includes(responseText))).toBe(false);
expect(messageGroup.getAttribute('role')).toBe('group');
expect(messageGroup.getAttribute('aria-roledescription')).toBe('message');

await host.clickAt(1, 1, pageElements.transcriptScrollable());

expect(pageElements.focusedActivity()).toBe(pageElements.activities()[0]);
expect(pageElements.activeActivity().contains(messageGroup)).toBe(true);

pageElements.activeActivity().setAttribute('data-a11y-transcript-article', '');

const transcriptArticleAccessibilityNode = await getAccessibilityNode(
`document.querySelector('[data-a11y-transcript-article]')`
);

expect(transcriptArticleAccessibilityNode.role.value).toBe('article');
});
</script>
</body>
</html>
15 changes: 1 addition & 14 deletions packages/component/src/Activity/StackedLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import ScreenReaderText from '../ScreenReaderText';
import isZeroOrPositive from '../Utils/isZeroOrPositive';
import getFirstBaseOfSoftwareSourceCode from '../Utils/orgSchema/getFirstBaseOfSoftwareSourceCode';
import textFormatToContentType from '../Utils/textFormatToContentType';
import useUniqueId from '../hooks/internal/useUniqueId';
import { useGetLogicalGroupKey } from '../providers/ActivityLogicalGrouping';
import AttachmentRow from './AttachmentRow';
import Bubble from './Bubble';
Expand All @@ -41,7 +40,6 @@ type StackedLayoutInnerProps = Readonly<{
hasAvatar: boolean;
hasDisplayText: boolean;
hasNub: boolean;
id: string;
renderAvatar?: false | (() => Exclude<ReactNode, boolean | null | undefined>) | undefined;
renderBubbleContent: (title?: string | undefined, showStatus?: boolean) => ReactNode;
showAvatar?: boolean | undefined;
Expand All @@ -56,7 +54,6 @@ const StackedLayoutInner = memo(
hasAvatar,
hasDisplayText,
hasNub,
id,
renderAvatar,
renderBubbleContent,
showAvatar,
Expand All @@ -74,14 +71,7 @@ const StackedLayoutInner = memo(
return (
<StackedLayoutMain avatar={showAvatar && renderAvatar && renderAvatar()}>
{!!(hasDisplayText || messageThing?.abstract[0]) && (
<div
aria-roledescription="message"
className={cx(classNames['stacked-layout__message-row'])}
// Disable "Prop `id` is forbidden on DOM Nodes" rule because we are using the ID prop for accessibility.
/* eslint-disable-next-line react/forbid-dom-props */
id={id}
role="group"
>
<div aria-roledescription="message" className={cx(classNames['stacked-layout__message-row'])} role="group">
<ScreenReaderText text={greetingAlt} />
<Bubble
className={classNames['stacked-layout__message']}
Expand Down Expand Up @@ -122,7 +112,6 @@ const StackedLayout = ({
const [styleOptions] = useStyleOptions();
const [{ initials: botInitials }] = useAvatarForBot();
const [{ initials: userInitials }] = useAvatarForUser();
const ariaLabelId = useUniqueId('webchat__stacked-layout__id');
const localize = useLocalizer();
const classNames = useStyles(styles);

Expand Down Expand Up @@ -280,7 +269,6 @@ const StackedLayout = ({

return (
<StackedLayoutRoot
ariaLabelId={activityDisplayText ? ariaLabelId : undefined}
extraTrailing={extraTrailing}
fromUser={fromUser}
hideAvatar={hasAvatar && !showAvatar}
Expand All @@ -297,7 +285,6 @@ const StackedLayout = ({
hasAvatar={hasAvatar}
hasDisplayText={!!activityDisplayText?.length || isCollapsible}
hasNub={hasNub}
id={ariaLabelId}
renderAvatar={renderAvatar}
renderBubbleContent={renderBubbleContent}
showAvatar={showAvatar}
Expand Down
5 changes: 1 addition & 4 deletions packages/component/src/Activity/StackedLayoutRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,12 @@ import { reactNode, validateProps } from '@msinternal/botframework-webchat-react
import { useStyles } from '@msinternal/botframework-webchat-styles/react';
import cx from 'classnames';
import React, { memo } from 'react';
import { boolean, object, optional, pipe, readonly, string, type InferInput } from 'valibot';
import { boolean, object, optional, pipe, readonly, type InferInput } from 'valibot';

import styles from './StackedLayout.module.css';

const stackedLayoutRootPropsSchema = pipe(
object({
ariaLabelId: optional(string()),
children: optional(reactNode()),
extraTrailing: optional(boolean()),
fromUser: optional(boolean()),
Expand All @@ -28,7 +27,6 @@ type StackedLayoutRootProps = InferInput<typeof stackedLayoutRootPropsSchema>;

const StackedLayoutRoot = memo((props: StackedLayoutRootProps) => {
const {
ariaLabelId,
children,
extraTrailing,
fromUser,
Expand All @@ -46,7 +44,6 @@ const StackedLayoutRoot = memo((props: StackedLayoutRootProps) => {

return (
<div
aria-labelledby={ariaLabelId}
className={cx(classNames['stacked-layout'], {
[classNames['stacked-layout--from-user']]: fromUser,
[classNames['stacked-layout--extra-trailing']]: extraTrailing,
Expand Down
1 change: 1 addition & 0 deletions packages/test/harness/src/browser/proxies/host.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export default function createHost() {
ready: () => {},
release: () => {},
sendAccessKey: () => {},
sendAndGetDevToolsCommand: () => {},
sendDevToolsCommand: () => {},
sendKeys: () => {},
sendShiftTab: () => {},
Expand Down
2 changes: 2 additions & 0 deletions packages/test/harness/src/host/common/host/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const pressAndHold = require('./pressAndHold');
const ready = require('./ready');
const release = require('./release');
const sendAccessKey = require('./sendAccessKey');
const sendAndGetDevToolsCommand = require('./sendAndGetDevToolsCommand');
const sendDevToolsCommand = require('./sendDevToolsCommand');
const sendKeys = require('./sendKeys');
const sendShiftTab = require('./sendShiftTab');
Expand Down Expand Up @@ -45,6 +46,7 @@ module.exports = function createHost(webDriver) {
readyPromise: readyWithResolvers.promise,
release: release(webDriver),
sendAccessKey: sendAccessKey(webDriver),
sendAndGetDevToolsCommand: sendAndGetDevToolsCommand(webDriver),
sendDevToolsCommand: sendDevToolsCommand(webDriver),
sendKeys: sendKeys(webDriver),
sendShiftTab: sendShiftTab(webDriver),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
module.exports = webDriver =>
function sendAndGetDevToolsCommand(command, options) {
return webDriver.sendAndGetDevToolsCommand(command, options);
};