Skip to content

Commit b500080

Browse files
authored
Added announcements when a remote participant starts or stops sharing their screen (#6047)
* Added screen reader announcements when a remote participant starts or stops sharing their screen * Change files * update stable API
1 parent aa5eca6 commit b500080

10 files changed

Lines changed: 96 additions & 27 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"type": "patch",
3+
"area": "fix",
4+
"workstream": "Accessibility",
5+
"comment": "Add screen reader announcements when a remote participant starts or stops sharing their screen",
6+
"packageName": "@azure/communication-react",
7+
"email": "miguelgamis@microsoft.com",
8+
"dependentChangeType": "patch"
9+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"type": "patch",
3+
"area": "fix",
4+
"workstream": "Accessibility",
5+
"comment": "Add screen reader announcements when a remote participant starts or stops sharing their screen",
6+
"packageName": "@azure/communication-react",
7+
"email": "miguelgamis@microsoft.com",
8+
"dependentChangeType": "patch"
9+
}

packages/communication-react/review/beta/communication-react.api.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5758,6 +5758,8 @@ export interface VideoGalleryStrings {
57585758
pinParticipantMenuItemAriaLabel: string;
57595759
screenIsBeingSharedMessage: string;
57605760
screenShareLoadingMessage: string;
5761+
screenShareStartedAnnouncementAriaLabel: string;
5762+
screenShareStoppedAnnouncementAriaLabel: string;
57615763
spotlightLimitReachedMenuTitle: string;
57625764
startSpotlightVideoTileMenuLabel: string;
57635765
stopSpotlightOnSelfVideoTileMenuLabel: string;

packages/communication-react/review/stable/communication-react.api.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5161,6 +5161,8 @@ export interface VideoGalleryStrings {
51615161
pinParticipantMenuItemAriaLabel: string;
51625162
screenIsBeingSharedMessage: string;
51635163
screenShareLoadingMessage: string;
5164+
screenShareStartedAnnouncementAriaLabel: string;
5165+
screenShareStoppedAnnouncementAriaLabel: string;
51645166
spotlightLimitReachedMenuTitle: string;
51655167
startSpotlightVideoTileMenuLabel: string;
51665168
stopSpotlightOnSelfVideoTileMenuLabel: string;

packages/react-components/src/components/Announcer.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,20 @@ export type AnnouncerProps = {
2020
export const Announcer = (props: AnnouncerProps): JSX.Element => {
2121
const { announcementString, ariaLive } = props;
2222

23+
// Use role="alert" for assertive announcements as it's more reliably announced by screen readers
24+
// even when focus is on interactive elements
25+
const role = ariaLive === 'assertive' ? 'alert' : 'status';
26+
2327
return (
2428
<Stack
25-
aria-label={announcementString}
29+
data-testid="announcer"
2630
aria-live={ariaLive}
27-
role="status"
31+
role={role}
2832
aria-atomic={true}
2933
styles={announcerStyles}
30-
></Stack>
34+
>
35+
{announcementString}
36+
</Stack>
3137
);
3238
};
3339

packages/react-components/src/components/InputBoxComponent.test.tsx

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,9 @@ describe('InputBoxComponent should show mention popover', () => {
7575

7676
const checkExpectedSuggestions = async (): Promise<void> => {
7777
for (const suggestion of suggestions) {
78-
// Check that all suggestions are presented
79-
const contextMenuItem = await screen.findByText(suggestion.displayText);
80-
expect(contextMenuItem.classList.contains('ms-Persona-primaryText')).toBe(true);
78+
// Check that all suggestions are presented (use selector to exclude announcer elements)
79+
const contextMenuItem = await screen.findByText(suggestion.displayText, { selector: '.ms-Persona-primaryText' });
80+
expect(contextMenuItem).toBeTruthy();
8181
}
8282
};
8383

@@ -154,8 +154,9 @@ describe('InputBoxComponent should show mention popover for a custom trigger', (
154154

155155
const checkExpectedSuggestions = async (): Promise<void> => {
156156
for (const suggestion of suggestions) {
157-
const contextMenuItem = await screen.findByText(suggestion?.displayText);
158-
expect(contextMenuItem.classList.contains('ms-Persona-primaryText')).toBe(true);
157+
// Use selector to exclude announcer elements
158+
const contextMenuItem = await screen.findByText(suggestion?.displayText, { selector: '.ms-Persona-primaryText' });
159+
expect(contextMenuItem).toBeTruthy();
159160
}
160161
};
161162

@@ -266,7 +267,8 @@ describe('InputBoxComponent should hide mention popover', () => {
266267

267268
const checkSuggestionsNotShown = (): void => {
268269
for (const suggestion of suggestions) {
269-
const contextMenuItem = screen.queryByText(suggestion?.displayText);
270+
// Use selector to exclude announcer elements
271+
const contextMenuItem = screen.queryByText(suggestion?.displayText, { selector: '.ms-Persona-primaryText' });
270272
expect(contextMenuItem).toBeNull();
271273
}
272274
};
@@ -276,8 +278,8 @@ describe('InputBoxComponent should hide mention popover', () => {
276278
if (!firstSuggestionText) {
277279
throw new Error('Suggestion text is not defined');
278280
}
279-
const firstSuggestionMenuItem = await screen.findByText(firstSuggestionText);
280-
expect(firstSuggestionMenuItem.classList.contains('ms-Persona-primaryText')).toBe(true);
281+
// Use selector to exclude announcer elements
282+
const firstSuggestionMenuItem = await screen.findByText(firstSuggestionText, { selector: '.ms-Persona-primaryText' });
281283
return firstSuggestionMenuItem;
282284
};
283285

packages/react-components/src/components/MessageThread.test.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -396,13 +396,13 @@ describe('Message should display Mention correctly', () => {
396396
await userEvent.keyboard(' @');
397397
});
398398

399-
// Check that Everyone is an option
400-
const everyoneMentionContextMenuItem = await screen.findByText('Everyone');
401-
expect(everyoneMentionContextMenuItem.classList.contains('ms-Persona-primaryText')).toBe(true);
399+
// Check that Everyone is an option (use selector to exclude announcer elements)
400+
const everyoneMentionContextMenuItem = await screen.findByText('Everyone', { selector: '.ms-Persona-primaryText' });
401+
expect(everyoneMentionContextMenuItem).toBeTruthy();
402402

403-
// Check that user1Name is an option
404-
const user1MentionContextMenuItem = await screen.findByText(user1Name);
405-
expect(user1MentionContextMenuItem.classList.contains('ms-Persona-primaryText')).toBe(true);
403+
// Check that user1Name is an option (use selector to exclude announcer elements)
404+
const user1MentionContextMenuItem = await screen.findByText(user1Name, { selector: '.ms-Persona-primaryText' });
405+
expect(user1MentionContextMenuItem).toBeTruthy();
406406

407407
// Select mention from popover for user1Name, verify plain text not contain mention html tag
408408
fireEvent.click(user1MentionContextMenuItem);

packages/react-components/src/components/SendBox.test.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,8 @@ describe('SendBox should return correct value with a selected mention', () => {
6666
if (!suggestions[0]) {
6767
throw new Error('No suggestions found');
6868
}
69-
const contextMenuItem = await screen.findByText(suggestions[0].displayText);
70-
expect(contextMenuItem.classList.contains('ms-Persona-primaryText')).toBe(true);
69+
// Use selector to exclude announcer elements
70+
const contextMenuItem = await screen.findByText(suggestions[0].displayText, { selector: '.ms-Persona-primaryText' });
7171
contextMenuItem && fireEvent.click(contextMenuItem);
7272
};
7373

@@ -161,8 +161,8 @@ describe('Clicks/Touch should select mention', () => {
161161
if (!suggestions[0]) {
162162
throw new Error('No suggestions found');
163163
}
164-
const contextMenuItem = await screen.findByText(suggestions[0].displayText);
165-
expect(contextMenuItem.classList.contains('ms-Persona-primaryText')).toBe(true);
164+
// Use selector to exclude announcer elements
165+
const contextMenuItem = await screen.findByText(suggestions[0].displayText, { selector: '.ms-Persona-primaryText' });
166166
fireEvent.click(contextMenuItem);
167167
};
168168

@@ -421,8 +421,8 @@ describe('Keyboard events should be handled for mentions', () => {
421421
if (!suggestion) {
422422
throw new Error('Suggestion not found');
423423
}
424-
const contextMenuItem = await screen.findByText(suggestion.displayText);
425-
expect(contextMenuItem.classList.contains('ms-Persona-primaryText')).toBe(true);
424+
// Use selector to exclude announcer elements
425+
const contextMenuItem = await screen.findByText(suggestion.displayText, { selector: '.ms-Persona-primaryText' });
426426
fireEvent.click(contextMenuItem);
427427
};
428428

packages/react-components/src/components/VideoGallery.tsx

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// Licensed under the MIT License.
33

44
import { concatStyleSets, IStyle, mergeStyles, Stack } from '@fluentui/react';
5-
import React, { useCallback, useMemo, useRef } from 'react';
5+
import React, { useCallback, useLayoutEffect, useMemo, useRef } from 'react';
66
import { GridLayoutStyles } from '.';
77
import { Announcer } from './Announcer';
88
import { useEffect } from 'react';
@@ -92,6 +92,10 @@ export const MAX_PINNED_REMOTE_VIDEO_TILES = 4;
9292
export interface VideoGalleryStrings {
9393
/** String to notify that local user is sharing their screen */
9494
screenIsBeingSharedMessage: string;
95+
/** Aria label to announce when a remote participant starts sharing their screen */
96+
screenShareStartedAnnouncementAriaLabel: string;
97+
/** Aria label to announce when a remote participant stops sharing their screen */
98+
screenShareStoppedAnnouncementAriaLabel: string;
9599
/** String to show when remote screen share stream is loading */
96100
screenShareLoadingMessage: string;
97101
/** String to show when local screen share stream is loading */
@@ -656,20 +660,24 @@ export const VideoGallery = (props: VideoGalleryProps): JSX.Element => {
656660
);
657661

658662
const [announcementString, setAnnouncementString] = React.useState<string>('');
663+
const [announcerAriaLive, setAnnouncerAriaLive] = React.useState<'polite' | 'assertive'>('polite');
659664
/**
660665
* sets the announcement string for VideoGallery actions so that the screenreader will trigger
666+
* @param announcement - The message to announce
667+
* @param ariaLive - The aria-live mode ('polite' for non-urgent, 'assertive' for important events)
661668
*/
662669
const toggleAnnouncerString = useCallback(
663-
(announcement: string) => {
670+
(announcement: string, ariaLive: 'polite' | 'assertive' = 'polite') => {
664671
setAnnouncementString(announcement);
672+
setAnnouncerAriaLive(ariaLive);
665673
/**
666674
* Clears the announcer string after VideoGallery action allowing it to be re-announced.
667675
*/
668676
setTimeout(() => {
669677
setAnnouncementString('');
670678
}, 3000);
671679
},
672-
[setAnnouncementString]
680+
[setAnnouncementString, setAnnouncerAriaLive]
673681
);
674682

675683
const defaultOnRenderVideoTile = useCallback(
@@ -771,6 +779,35 @@ export const VideoGallery = (props: VideoGalleryProps): JSX.Element => {
771779
);
772780

773781
const screenShareParticipant = remoteParticipants.find((participant) => participant.screenShareStream?.isAvailable);
782+
783+
// Track the previous screen share participant to detect when screen sharing starts or stops
784+
const previousScreenShareParticipantRef = useRef<VideoGalleryRemoteParticipant | undefined>(undefined);
785+
786+
// Announce when a remote participant starts or stops sharing their screen for screen reader accessibility
787+
// Use useLayoutEffect to trigger announcement synchronously before browser paint,
788+
// which should queue the announcement before any focus-shift induced announcements
789+
useLayoutEffect(() => {
790+
const previousParticipant = previousScreenShareParticipantRef.current;
791+
792+
if (screenShareParticipant && previousParticipant?.userId !== screenShareParticipant.userId) {
793+
// Screen share started (or switched to a different participant)
794+
const participantName = screenShareParticipant.displayName || strings.displayNamePlaceholder;
795+
const announcementMessage = _formatString(strings.screenShareStartedAnnouncementAriaLabel, {
796+
participant: participantName
797+
});
798+
toggleAnnouncerString(announcementMessage, 'assertive');
799+
} else if (!screenShareParticipant && previousParticipant) {
800+
// Screen share stopped
801+
const participantName = previousParticipant.displayName || strings.displayNamePlaceholder;
802+
const announcementMessage = _formatString(strings.screenShareStoppedAnnouncementAriaLabel, {
803+
participant: participantName
804+
});
805+
toggleAnnouncerString(announcementMessage, 'assertive');
806+
}
807+
808+
previousScreenShareParticipantRef.current = screenShareParticipant;
809+
}, [screenShareParticipant, strings.displayNamePlaceholder, strings.screenShareStartedAnnouncementAriaLabel, strings.screenShareStoppedAnnouncementAriaLabel, toggleAnnouncerString]);
810+
774811
const localScreenShareStreamComponent = (
775812
<LocalScreenShare
776813
localParticipant={localParticipant}
@@ -917,7 +954,7 @@ export const VideoGallery = (props: VideoGalleryProps): JSX.Element => {
917954
className={mergeStyles(videoGalleryOuterDivStyle, styles?.root, unselectable)}
918955
>
919956
{videoGalleryLayout}
920-
<Announcer announcementString={announcementString} ariaLive="polite" />
957+
<Announcer announcementString={announcementString} ariaLive={announcerAriaLive} />
921958
</div>
922959
);
923960
};

packages/react-components/src/localization/locales/en-US/strings.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -666,6 +666,8 @@
666666
},
667667
"videoGallery": {
668668
"screenIsBeingSharedMessage": "You are sharing your screen",
669+
"screenShareStartedAnnouncementAriaLabel": "{participant} is sharing their screen",
670+
"screenShareStoppedAnnouncementAriaLabel": "{participant} stopped sharing their screen",
669671
"screenShareLoadingMessage": "Loading {participant}'s screen",
670672
"localScreenShareLoadingMessage": "Loading your screen",
671673
"localVideoLabel": "You",

0 commit comments

Comments
 (0)