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 @@ -50,6 +50,7 @@ Legends:

### Fixed

- Fixed screen reader primary-action activation to move focus into interactive transcript activities, in PR [#5867](https://github.com/microsoft/BotFramework-WebChat/pull/5867), by [@ahonamatata](https://github.com/ahonamatata)
- 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
97 changes: 97 additions & 0 deletions __tests__/html2/part-grouping/primaryAction.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
<!doctype html>
<html lang="en-US">
<head>
<title>Part grouping: screen reader primary action</title>
<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 defer 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 createActivity = (id, position) => ({
entities: [
{
'@context': 'https://schema.org',
'@id': '',
'@type': 'Message',
abstract: `Activity ${position}`,
author: { name: 'Research' },
isPartOf: { '@id': '_:g-00001', '@type': 'HowTo' },
keywords: ['AIGeneratedContent', 'AnalysisMessage'],
position,
type: 'https://schema.org/Message'
}
],
id,
type: 'message'
});

directLine.emulateIncomingActivity(createActivity('group-activity-1', 1));
directLine.emulateIncomingActivity(createActivity('group-activity-2', 2));

await pageConditions.numActivitiesShown(2);
await pageObjects.focusTranscript();
await host.sendKeys('ARROW_UP', 'ARROW_UP');

const activeDescendant = () =>
document.getElementById(pageElements.transcript().getAttribute('aria-activedescendant'));
const groupButton = () => document.querySelector('.collapsible-grouping__header .activity-button');

// WHEN: Narrator invokes the grouped activity's primary action.
activeDescendant().click();

// THEN: Focus should move to the group's first interactive control.
await pageConditions.became(
'focus is on the group expand or collapse button after screen reader primary action',
() => document.activeElement === groupButton(),
1000
);

// WHEN: Escape is pressed from inside the group.
await host.sendKeys('ESCAPE');

// THEN: Focus should return to the transcript with the group active.
await pageConditions.became(
'focus returned to the transcript with the group active',
() =>
document.activeElement === pageElements.transcript() &&
!!activeDescendant() &&
!pageElements.focusedActivity(),
1000
);

// WHEN: Plain Enter is pressed.
await host.sendKeys('ENTER');

// THEN: Plain Enter should continue to move focus to the group button.
await pageConditions.became(
'focus is on the group expand or collapse button after plain Enter',
() => document.activeElement === groupButton(),
1000
);

// WHEN: Escape is pressed again.
await host.sendKeys('ESCAPE');

// THEN: Focus should return to the transcript with the group active.
await pageConditions.became(
'focus returned to the transcript after plain Enter on a group',
() =>
document.activeElement === pageElements.transcript() &&
!!activeDescendant() &&
!pageElements.focusedActivity(),
1000
);
});
</script>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<!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 () {
WebChat.renderWebChat(
{
directLine: testHelpers.createDirectLineWithTranscript(
testHelpers.transcriptNavigation.generateTranscript()
),
store: testHelpers.createStore()
},
document.getElementById('webchat')
);

await pageConditions.uiConnected();
await pageConditions.numActivitiesShown(32);
await pageConditions.scrollToBottomCompleted();

await pageObjects.focusSendBoxTextBox();
await host.sendShiftTab(3);
await host.sendKeys('ARROW_UP', 'ARROW_UP');
await pageConditions.scrollStabilized();
await pageConditions.focusedActivityInView();

const firstCardInput = () => document.querySelector('.ac-input, .ac-textInput');
const activeDescendant = () =>
document.getElementById(pageElements.transcript().getAttribute('aria-activedescendant'));

// WHEN: Narrator invokes the active descendant's primary action.
activeDescendant().click();

// THEN: Focus should move to the first interactive control.
await pageConditions.became(
'focus is on the first text box in the input card after screen reader primary action',
() => document.activeElement === firstCardInput(),
1000
);

// WHEN: Escape is pressed from inside the activity.
await host.sendKeys('ESCAPE');

// THEN: Focus should return to the transcript with the same activity active.
await pageConditions.became(
'focus returned to the transcript after screen reader primary action',
() => document.activeElement === pageElements.transcript() && !!activeDescendant(),
1000
);

// WHEN: NVDA invokes the primary action from content inside the active descendant.
activeDescendant().firstElementChild.click();

// THEN: Focus should move to the first interactive control.
await pageConditions.became(
'focus is on the first text box in the input card after NVDA primary action',
() => document.activeElement === firstCardInput(),
1000
);

// WHEN: Escape is pressed, followed by plain Enter.
await host.sendKeys('ESCAPE');
await host.sendKeys('ENTER');

// THEN: Plain Enter should continue to move focus to the first interactive control.
await pageConditions.became(
'focus is on the first text box in the input card after plain Enter',
() => document.activeElement === firstCardInput(),
1000
);

// WHEN: Escape is pressed again.
await host.sendKeys('ESCAPE');

// THEN: Focus should return to the transcript.
await pageConditions.became(
'focus returned to the transcript after plain Enter',
() => document.activeElement === pageElements.transcript() && !!activeDescendant(),
1000
);
});
</script>
</body>
</html>
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { reactNode, validateProps } from '@msinternal/botframework-webchat-react
import { useStyles } from '@msinternal/botframework-webchat-styles/react';
import { hooks } from 'botframework-webchat-api';
import { getOrgSchemaMessage, type WebChatActivity } from 'botframework-webchat-core';
import React, { Fragment, memo, useCallback, useMemo, useState, type MouseEventHandler } from 'react';
import React, { Fragment, memo, useCallback, useMemo, useRef, useState, type MouseEventHandler } from 'react';
import cx from 'classnames';
import { array, custom, is, minLength, object, optional, pipe, readonly, string, type InferOutput } from 'valibot';

Expand Down Expand Up @@ -67,6 +67,7 @@ const FocusablePartGroupingActivity = memo(function FocusablePartGroupingActivit
const { activity, children, className, groupKey } = validateProps(partGroupingFocusableActivityPropsSchema, props);

const [activeDescendantId] = useActiveDescendantId();
const focusTrapTargetRef = useRef<HTMLDivElement>();

const getGroupDescendantIdByActivityKey = useGetGroupDescendantIdByActivityKey();
const focusByGroupKey = useFocusByGroupKey();
Expand All @@ -85,6 +86,9 @@ const FocusablePartGroupingActivity = memo(function FocusablePartGroupingActivit
// When receive Escape key from descendant, focus back to the group.
const handleLeaveFocusTrap = useCallback(() => focusSelf(), [focusSelf]);

// Screen readers dispatch a click to the active descendant when users invoke its primary action.
const handleScreenReaderPrimaryAction = useCallback(() => focusTrapTargetRef.current?.focus(), [focusTrapTargetRef]);

// When the user press UP/DOWN arrow keys, we put a visual focus indicator around the focused group.
// We should do the same for mouse, when the user click on the activity, we should also put a visual focus indicator around the activity.
// We are doing it in event capture phase to prevent descendants from stopping event propagation to us.
Expand Down Expand Up @@ -115,11 +119,17 @@ const FocusablePartGroupingActivity = memo(function FocusablePartGroupingActivit
onFocus={handleDescendantFocus}
onLeave={handleLeaveFocusTrap}
targetClassName="webchat__basic-transcript__group-focus-target"
targetRef={focusTrapTargetRef}
>
{children}
</FocusTrap>
<TranscriptFocusContentOverlay>
{!android && <TranscriptFocusContentActiveDescendant id={groupingActivityDescendantId} />}
{!android && (
<TranscriptFocusContentActiveDescendant
id={groupingActivityDescendantId}
onClick={handleScreenReaderPrimaryAction}
/>
)}
<TranscriptFocusIndicator type="content" />
</TranscriptFocusContentOverlay>
</TranscriptFocusContent>
Expand Down
6 changes: 6 additions & 0 deletions packages/component/src/Transcript/ActivityRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const ActivityRow = forwardRef<HTMLElement, ActivityRowProps>(({ activity, child
const [readActivityKeys] = useActivityKeysByRead();
const activityRef = useRefFrom(activity);
const bodyRef = useRef<HTMLDivElement>();
const focusTrapTargetRef = useRef<HTMLDivElement>();
const focusByActivityKey = useFocusByActivityKey();
const getKeyByActivity = useGetKeyByActivity();
// TODO: [P2] #2858 We should use core/definitions/speakingActivity for this predicate instead
Expand Down Expand Up @@ -59,6 +60,9 @@ const ActivityRow = forwardRef<HTMLElement, ActivityRowProps>(({ activity, child
// When receive Escape key from descendant, focus back to the activity.
const handleLeaveFocusTrap = useCallback(() => focusSelf(), [focusSelf]);

// Screen readers dispatch a click to the active descendant when users invoke its primary action.
const handleScreenReaderPrimaryAction = useCallback(() => focusTrapTargetRef.current?.focus(), [focusTrapTargetRef]);

// When the user press UP/DOWN arrow keys, we put a visual focus indicator around the focused activity.
// We should do the same for mouse, when the user click on the activity, we should also put a visual focus indicator around the activity.
// We are doing it in event capture phase to prevent descendants from stopping event propagation to us.
Expand Down Expand Up @@ -146,6 +150,7 @@ const ActivityRow = forwardRef<HTMLElement, ActivityRowProps>(({ activity, child
onFocus={handleDescendantFocus}
onLeave={handleLeaveFocusTrap}
targetClassName="webchat__basic-transcript__activity-focus-target"
targetRef={focusTrapTargetRef}
>
{focusTrapChildren}
</FocusTrap>
Expand All @@ -160,6 +165,7 @@ const ActivityRow = forwardRef<HTMLElement, ActivityRowProps>(({ activity, child
className="webchat__basic-transcript__activity-active-descendant"
// "id" is required for "aria-labelledby"
id={descendantId}
onClick={handleScreenReaderPrimaryAction}
role="article"
>
<ScreenReaderText aria-hidden={true} id={descendantLabelId} text={accessibleName} />
Expand Down
7 changes: 5 additions & 2 deletions packages/component/src/Transcript/FocusTrap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import React, {
type FocusEvent,
type KeyboardEventHandler,
type ReactNode,
type Ref,
useCallback,
useRef
} from 'react';
Expand All @@ -16,12 +17,14 @@ const FocusTrap = ({
children,
onFocus,
onLeave,
targetClassName
targetClassName,
targetRef
}: Readonly<{
children: ReactNode;
onFocus: () => void;
onLeave: () => void;
targetClassName?: string | undefined;
targetRef?: Ref<HTMLDivElement> | undefined;
}>) => {
const bodyRef = useRef<HTMLDivElement>();
const lastFocused = useRef<HTMLElement>();
Expand Down Expand Up @@ -122,7 +125,7 @@ const FocusTrap = ({
>
{children}
</div>
<div aria-hidden="true" className={targetClassName} onFocus={handleTrapFocus} tabIndex={-1} />
<div aria-hidden="true" className={targetClassName} onFocus={handleTrapFocus} ref={targetRef} tabIndex={-1} />
</Fragment>
);
};
Expand Down