diff --git a/change-beta/@azure-communication-react-screenshare-announcer-a1b2c3d4.json b/change-beta/@azure-communication-react-screenshare-announcer-a1b2c3d4.json
new file mode 100644
index 00000000000..a47fdcd723a
--- /dev/null
+++ b/change-beta/@azure-communication-react-screenshare-announcer-a1b2c3d4.json
@@ -0,0 +1,9 @@
+{
+ "type": "patch",
+ "area": "fix",
+ "workstream": "Accessibility",
+ "comment": "Add screen reader announcements when a remote participant starts or stops sharing their screen",
+ "packageName": "@azure/communication-react",
+ "email": "miguelgamis@microsoft.com",
+ "dependentChangeType": "patch"
+}
diff --git a/change/@azure-communication-react-screenshare-announcer-a1b2c3d4.json b/change/@azure-communication-react-screenshare-announcer-a1b2c3d4.json
new file mode 100644
index 00000000000..a47fdcd723a
--- /dev/null
+++ b/change/@azure-communication-react-screenshare-announcer-a1b2c3d4.json
@@ -0,0 +1,9 @@
+{
+ "type": "patch",
+ "area": "fix",
+ "workstream": "Accessibility",
+ "comment": "Add screen reader announcements when a remote participant starts or stops sharing their screen",
+ "packageName": "@azure/communication-react",
+ "email": "miguelgamis@microsoft.com",
+ "dependentChangeType": "patch"
+}
diff --git a/packages/communication-react/review/beta/communication-react.api.md b/packages/communication-react/review/beta/communication-react.api.md
index bd76472137d..4b0ef516cf7 100644
--- a/packages/communication-react/review/beta/communication-react.api.md
+++ b/packages/communication-react/review/beta/communication-react.api.md
@@ -5758,6 +5758,8 @@ export interface VideoGalleryStrings {
pinParticipantMenuItemAriaLabel: string;
screenIsBeingSharedMessage: string;
screenShareLoadingMessage: string;
+ screenShareStartedAnnouncementAriaLabel: string;
+ screenShareStoppedAnnouncementAriaLabel: string;
spotlightLimitReachedMenuTitle: string;
startSpotlightVideoTileMenuLabel: string;
stopSpotlightOnSelfVideoTileMenuLabel: string;
diff --git a/packages/communication-react/review/stable/communication-react.api.md b/packages/communication-react/review/stable/communication-react.api.md
index 565a4dda81f..5959da18001 100644
--- a/packages/communication-react/review/stable/communication-react.api.md
+++ b/packages/communication-react/review/stable/communication-react.api.md
@@ -5161,6 +5161,8 @@ export interface VideoGalleryStrings {
pinParticipantMenuItemAriaLabel: string;
screenIsBeingSharedMessage: string;
screenShareLoadingMessage: string;
+ screenShareStartedAnnouncementAriaLabel: string;
+ screenShareStoppedAnnouncementAriaLabel: string;
spotlightLimitReachedMenuTitle: string;
startSpotlightVideoTileMenuLabel: string;
stopSpotlightOnSelfVideoTileMenuLabel: string;
diff --git a/packages/react-components/src/components/Announcer.tsx b/packages/react-components/src/components/Announcer.tsx
index 12a1fe2eb2b..6df9bbb6453 100644
--- a/packages/react-components/src/components/Announcer.tsx
+++ b/packages/react-components/src/components/Announcer.tsx
@@ -20,14 +20,20 @@ export type AnnouncerProps = {
export const Announcer = (props: AnnouncerProps): JSX.Element => {
const { announcementString, ariaLive } = props;
+ // Use role="alert" for assertive announcements as it's more reliably announced by screen readers
+ // even when focus is on interactive elements
+ const role = ariaLive === 'assertive' ? 'alert' : 'status';
+
return (
+ >
+ {announcementString}
+
);
};
diff --git a/packages/react-components/src/components/InputBoxComponent.test.tsx b/packages/react-components/src/components/InputBoxComponent.test.tsx
index 1ac92066e6b..954968d5f47 100644
--- a/packages/react-components/src/components/InputBoxComponent.test.tsx
+++ b/packages/react-components/src/components/InputBoxComponent.test.tsx
@@ -75,9 +75,9 @@ describe('InputBoxComponent should show mention popover', () => {
const checkExpectedSuggestions = async (): Promise => {
for (const suggestion of suggestions) {
- // Check that all suggestions are presented
- const contextMenuItem = await screen.findByText(suggestion.displayText);
- expect(contextMenuItem.classList.contains('ms-Persona-primaryText')).toBe(true);
+ // Check that all suggestions are presented (use selector to exclude announcer elements)
+ const contextMenuItem = await screen.findByText(suggestion.displayText, { selector: '.ms-Persona-primaryText' });
+ expect(contextMenuItem).toBeTruthy();
}
};
@@ -154,8 +154,9 @@ describe('InputBoxComponent should show mention popover for a custom trigger', (
const checkExpectedSuggestions = async (): Promise => {
for (const suggestion of suggestions) {
- const contextMenuItem = await screen.findByText(suggestion?.displayText);
- expect(contextMenuItem.classList.contains('ms-Persona-primaryText')).toBe(true);
+ // Use selector to exclude announcer elements
+ const contextMenuItem = await screen.findByText(suggestion?.displayText, { selector: '.ms-Persona-primaryText' });
+ expect(contextMenuItem).toBeTruthy();
}
};
@@ -266,7 +267,8 @@ describe('InputBoxComponent should hide mention popover', () => {
const checkSuggestionsNotShown = (): void => {
for (const suggestion of suggestions) {
- const contextMenuItem = screen.queryByText(suggestion?.displayText);
+ // Use selector to exclude announcer elements
+ const contextMenuItem = screen.queryByText(suggestion?.displayText, { selector: '.ms-Persona-primaryText' });
expect(contextMenuItem).toBeNull();
}
};
@@ -276,8 +278,8 @@ describe('InputBoxComponent should hide mention popover', () => {
if (!firstSuggestionText) {
throw new Error('Suggestion text is not defined');
}
- const firstSuggestionMenuItem = await screen.findByText(firstSuggestionText);
- expect(firstSuggestionMenuItem.classList.contains('ms-Persona-primaryText')).toBe(true);
+ // Use selector to exclude announcer elements
+ const firstSuggestionMenuItem = await screen.findByText(firstSuggestionText, { selector: '.ms-Persona-primaryText' });
return firstSuggestionMenuItem;
};
diff --git a/packages/react-components/src/components/MessageThread.test.tsx b/packages/react-components/src/components/MessageThread.test.tsx
index 6a91f206eab..a2b94587be0 100644
--- a/packages/react-components/src/components/MessageThread.test.tsx
+++ b/packages/react-components/src/components/MessageThread.test.tsx
@@ -396,13 +396,13 @@ describe('Message should display Mention correctly', () => {
await userEvent.keyboard(' @');
});
- // Check that Everyone is an option
- const everyoneMentionContextMenuItem = await screen.findByText('Everyone');
- expect(everyoneMentionContextMenuItem.classList.contains('ms-Persona-primaryText')).toBe(true);
+ // Check that Everyone is an option (use selector to exclude announcer elements)
+ const everyoneMentionContextMenuItem = await screen.findByText('Everyone', { selector: '.ms-Persona-primaryText' });
+ expect(everyoneMentionContextMenuItem).toBeTruthy();
- // Check that user1Name is an option
- const user1MentionContextMenuItem = await screen.findByText(user1Name);
- expect(user1MentionContextMenuItem.classList.contains('ms-Persona-primaryText')).toBe(true);
+ // Check that user1Name is an option (use selector to exclude announcer elements)
+ const user1MentionContextMenuItem = await screen.findByText(user1Name, { selector: '.ms-Persona-primaryText' });
+ expect(user1MentionContextMenuItem).toBeTruthy();
// Select mention from popover for user1Name, verify plain text not contain mention html tag
fireEvent.click(user1MentionContextMenuItem);
diff --git a/packages/react-components/src/components/SendBox.test.tsx b/packages/react-components/src/components/SendBox.test.tsx
index 525c1ddad21..a06448bbdda 100644
--- a/packages/react-components/src/components/SendBox.test.tsx
+++ b/packages/react-components/src/components/SendBox.test.tsx
@@ -66,8 +66,8 @@ describe('SendBox should return correct value with a selected mention', () => {
if (!suggestions[0]) {
throw new Error('No suggestions found');
}
- const contextMenuItem = await screen.findByText(suggestions[0].displayText);
- expect(contextMenuItem.classList.contains('ms-Persona-primaryText')).toBe(true);
+ // Use selector to exclude announcer elements
+ const contextMenuItem = await screen.findByText(suggestions[0].displayText, { selector: '.ms-Persona-primaryText' });
contextMenuItem && fireEvent.click(contextMenuItem);
};
@@ -161,8 +161,8 @@ describe('Clicks/Touch should select mention', () => {
if (!suggestions[0]) {
throw new Error('No suggestions found');
}
- const contextMenuItem = await screen.findByText(suggestions[0].displayText);
- expect(contextMenuItem.classList.contains('ms-Persona-primaryText')).toBe(true);
+ // Use selector to exclude announcer elements
+ const contextMenuItem = await screen.findByText(suggestions[0].displayText, { selector: '.ms-Persona-primaryText' });
fireEvent.click(contextMenuItem);
};
@@ -421,8 +421,8 @@ describe('Keyboard events should be handled for mentions', () => {
if (!suggestion) {
throw new Error('Suggestion not found');
}
- const contextMenuItem = await screen.findByText(suggestion.displayText);
- expect(contextMenuItem.classList.contains('ms-Persona-primaryText')).toBe(true);
+ // Use selector to exclude announcer elements
+ const contextMenuItem = await screen.findByText(suggestion.displayText, { selector: '.ms-Persona-primaryText' });
fireEvent.click(contextMenuItem);
};
diff --git a/packages/react-components/src/components/VideoGallery.tsx b/packages/react-components/src/components/VideoGallery.tsx
index 7dc9ea96da9..6ca2be8b9b1 100644
--- a/packages/react-components/src/components/VideoGallery.tsx
+++ b/packages/react-components/src/components/VideoGallery.tsx
@@ -2,7 +2,7 @@
// Licensed under the MIT License.
import { concatStyleSets, IStyle, mergeStyles, Stack } from '@fluentui/react';
-import React, { useCallback, useMemo, useRef } from 'react';
+import React, { useCallback, useLayoutEffect, useMemo, useRef } from 'react';
import { GridLayoutStyles } from '.';
import { Announcer } from './Announcer';
import { useEffect } from 'react';
@@ -92,6 +92,10 @@ export const MAX_PINNED_REMOTE_VIDEO_TILES = 4;
export interface VideoGalleryStrings {
/** String to notify that local user is sharing their screen */
screenIsBeingSharedMessage: string;
+ /** Aria label to announce when a remote participant starts sharing their screen */
+ screenShareStartedAnnouncementAriaLabel: string;
+ /** Aria label to announce when a remote participant stops sharing their screen */
+ screenShareStoppedAnnouncementAriaLabel: string;
/** String to show when remote screen share stream is loading */
screenShareLoadingMessage: string;
/** String to show when local screen share stream is loading */
@@ -656,12 +660,16 @@ export const VideoGallery = (props: VideoGalleryProps): JSX.Element => {
);
const [announcementString, setAnnouncementString] = React.useState('');
+ const [announcerAriaLive, setAnnouncerAriaLive] = React.useState<'polite' | 'assertive'>('polite');
/**
* sets the announcement string for VideoGallery actions so that the screenreader will trigger
+ * @param announcement - The message to announce
+ * @param ariaLive - The aria-live mode ('polite' for non-urgent, 'assertive' for important events)
*/
const toggleAnnouncerString = useCallback(
- (announcement: string) => {
+ (announcement: string, ariaLive: 'polite' | 'assertive' = 'polite') => {
setAnnouncementString(announcement);
+ setAnnouncerAriaLive(ariaLive);
/**
* Clears the announcer string after VideoGallery action allowing it to be re-announced.
*/
@@ -669,7 +677,7 @@ export const VideoGallery = (props: VideoGalleryProps): JSX.Element => {
setAnnouncementString('');
}, 3000);
},
- [setAnnouncementString]
+ [setAnnouncementString, setAnnouncerAriaLive]
);
const defaultOnRenderVideoTile = useCallback(
@@ -771,6 +779,35 @@ export const VideoGallery = (props: VideoGalleryProps): JSX.Element => {
);
const screenShareParticipant = remoteParticipants.find((participant) => participant.screenShareStream?.isAvailable);
+
+ // Track the previous screen share participant to detect when screen sharing starts or stops
+ const previousScreenShareParticipantRef = useRef(undefined);
+
+ // Announce when a remote participant starts or stops sharing their screen for screen reader accessibility
+ // Use useLayoutEffect to trigger announcement synchronously before browser paint,
+ // which should queue the announcement before any focus-shift induced announcements
+ useLayoutEffect(() => {
+ const previousParticipant = previousScreenShareParticipantRef.current;
+
+ if (screenShareParticipant && previousParticipant?.userId !== screenShareParticipant.userId) {
+ // Screen share started (or switched to a different participant)
+ const participantName = screenShareParticipant.displayName || strings.displayNamePlaceholder;
+ const announcementMessage = _formatString(strings.screenShareStartedAnnouncementAriaLabel, {
+ participant: participantName
+ });
+ toggleAnnouncerString(announcementMessage, 'assertive');
+ } else if (!screenShareParticipant && previousParticipant) {
+ // Screen share stopped
+ const participantName = previousParticipant.displayName || strings.displayNamePlaceholder;
+ const announcementMessage = _formatString(strings.screenShareStoppedAnnouncementAriaLabel, {
+ participant: participantName
+ });
+ toggleAnnouncerString(announcementMessage, 'assertive');
+ }
+
+ previousScreenShareParticipantRef.current = screenShareParticipant;
+ }, [screenShareParticipant, strings.displayNamePlaceholder, strings.screenShareStartedAnnouncementAriaLabel, strings.screenShareStoppedAnnouncementAriaLabel, toggleAnnouncerString]);
+
const localScreenShareStreamComponent = (
{
className={mergeStyles(videoGalleryOuterDivStyle, styles?.root, unselectable)}
>
{videoGalleryLayout}
-
+
);
};
diff --git a/packages/react-components/src/localization/locales/en-US/strings.json b/packages/react-components/src/localization/locales/en-US/strings.json
index 62ef8ca8ebd..aaa391c5922 100644
--- a/packages/react-components/src/localization/locales/en-US/strings.json
+++ b/packages/react-components/src/localization/locales/en-US/strings.json
@@ -666,6 +666,8 @@
},
"videoGallery": {
"screenIsBeingSharedMessage": "You are sharing your screen",
+ "screenShareStartedAnnouncementAriaLabel": "{participant} is sharing their screen",
+ "screenShareStoppedAnnouncementAriaLabel": "{participant} stopped sharing their screen",
"screenShareLoadingMessage": "Loading {participant}'s screen",
"localScreenShareLoadingMessage": "Loading your screen",
"localVideoLabel": "You",