From 4216c2ea5531cefc3f2bba6c28135e8e56b4ff50 Mon Sep 17 00:00:00 2001 From: mgamis-msft Date: Fri, 13 Feb 2026 12:09:13 -0800 Subject: [PATCH 1/5] Added screen reader announcements when a remote participant starts or stops sharing their screen --- .../src/components/Announcer.tsx | 11 +++-- .../src/components/VideoGallery.tsx | 45 +++++++++++++++++-- .../localization/locales/en-US/strings.json | 2 + 3 files changed, 51 insertions(+), 7 deletions(-) diff --git a/packages/react-components/src/components/Announcer.tsx b/packages/react-components/src/components/Announcer.tsx index 12a1fe2eb2b..de9e952fb6f 100644 --- a/packages/react-components/src/components/Announcer.tsx +++ b/packages/react-components/src/components/Announcer.tsx @@ -20,14 +20,19 @@ 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/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", From c096c70a1ade95ca7ff00f8719a862da81e72dc7 Mon Sep 17 00:00:00 2001 From: mgamis-msft Date: Fri, 13 Feb 2026 13:02:22 -0800 Subject: [PATCH 2/5] Change files --- ...mmunication-react-screenshare-announcer-a1b2c3d4.json | 9 +++++++++ ...mmunication-react-screenshare-announcer-a1b2c3d4.json | 9 +++++++++ 2 files changed, 18 insertions(+) create mode 100644 change-beta/@azure-communication-react-screenshare-announcer-a1b2c3d4.json create mode 100644 change/@azure-communication-react-screenshare-announcer-a1b2c3d4.json 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" +} From c0b2e521468da86f50782be9a279fdd34bf88539 Mon Sep 17 00:00:00 2001 From: mgamis-msft Date: Fri, 13 Feb 2026 13:30:46 -0800 Subject: [PATCH 3/5] update stable API --- .../review/stable/communication-react.api.md | 2 ++ 1 file changed, 2 insertions(+) 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; From 77b65709ff171fe3d3ffc217530010a61e04df8d Mon Sep 17 00:00:00 2001 From: mgamis-msft Date: Fri, 13 Feb 2026 14:16:48 -0800 Subject: [PATCH 4/5] update beta api --- .../communication-react/review/beta/communication-react.api.md | 2 ++ 1 file changed, 2 insertions(+) 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; From cc334ed42a8dbf659cdb341bf391ddde7b7fdb33 Mon Sep 17 00:00:00 2001 From: mgamis-msft Date: Fri, 13 Feb 2026 16:46:06 -0800 Subject: [PATCH 5/5] Add data-testid to Announcer component and update tests to exclude announcer elements --- .../src/components/Announcer.tsx | 1 + .../src/components/InputBoxComponent.test.tsx | 18 ++++++++++-------- .../src/components/MessageThread.test.tsx | 12 ++++++------ .../src/components/SendBox.test.tsx | 12 ++++++------ 4 files changed, 23 insertions(+), 20 deletions(-) diff --git a/packages/react-components/src/components/Announcer.tsx b/packages/react-components/src/components/Announcer.tsx index de9e952fb6f..6df9bbb6453 100644 --- a/packages/react-components/src/components/Announcer.tsx +++ b/packages/react-components/src/components/Announcer.tsx @@ -26,6 +26,7 @@ export const Announcer = (props: AnnouncerProps): JSX.Element => { return ( { 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); };