Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
Expand Up @@ -5758,6 +5758,8 @@ export interface VideoGalleryStrings {
pinParticipantMenuItemAriaLabel: string;
screenIsBeingSharedMessage: string;
screenShareLoadingMessage: string;
screenShareStartedAnnouncementAriaLabel: string;
screenShareStoppedAnnouncementAriaLabel: string;
spotlightLimitReachedMenuTitle: string;
startSpotlightVideoTileMenuLabel: string;
stopSpotlightOnSelfVideoTileMenuLabel: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5161,6 +5161,8 @@ export interface VideoGalleryStrings {
pinParticipantMenuItemAriaLabel: string;
screenIsBeingSharedMessage: string;
screenShareLoadingMessage: string;
screenShareStartedAnnouncementAriaLabel: string;
screenShareStoppedAnnouncementAriaLabel: string;
spotlightLimitReachedMenuTitle: string;
startSpotlightVideoTileMenuLabel: string;
stopSpotlightOnSelfVideoTileMenuLabel: string;
Expand Down
12 changes: 9 additions & 3 deletions packages/react-components/src/components/Announcer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Stack
aria-label={announcementString}
data-testid="announcer"
aria-live={ariaLive}
role="status"
role={role}
aria-atomic={true}
styles={announcerStyles}
></Stack>
>
{announcementString}
</Stack>
);
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,9 @@ describe('InputBoxComponent should show mention popover', () => {

const checkExpectedSuggestions = async (): Promise<void> => {
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();
}
};

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

const checkExpectedSuggestions = async (): Promise<void> => {
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();
}
};

Expand Down Expand Up @@ -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();
}
};
Expand All @@ -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;
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
12 changes: 6 additions & 6 deletions packages/react-components/src/components/SendBox.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
};

Expand Down Expand Up @@ -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);
};

Expand Down Expand Up @@ -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);
};

Expand Down
45 changes: 41 additions & 4 deletions packages/react-components/src/components/VideoGallery.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 */
Expand Down Expand Up @@ -656,20 +660,24 @@ export const VideoGallery = (props: VideoGalleryProps): JSX.Element => {
);

const [announcementString, setAnnouncementString] = React.useState<string>('');
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.
*/
setTimeout(() => {
setAnnouncementString('');
}, 3000);
},
[setAnnouncementString]
[setAnnouncementString, setAnnouncerAriaLive]
);

const defaultOnRenderVideoTile = useCallback(
Expand Down Expand Up @@ -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<VideoGalleryRemoteParticipant | undefined>(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 = (
<LocalScreenShare
localParticipant={localParticipant}
Expand Down Expand Up @@ -917,7 +954,7 @@ export const VideoGallery = (props: VideoGalleryProps): JSX.Element => {
className={mergeStyles(videoGalleryOuterDivStyle, styles?.root, unselectable)}
>
{videoGalleryLayout}
<Announcer announcementString={announcementString} ariaLive="polite" />
<Announcer announcementString={announcementString} ariaLive={announcerAriaLive} />
</div>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading