|
2 | 2 | // Licensed under the MIT License. |
3 | 3 |
|
4 | 4 | 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'; |
6 | 6 | import { GridLayoutStyles } from '.'; |
7 | 7 | import { Announcer } from './Announcer'; |
8 | 8 | import { useEffect } from 'react'; |
@@ -92,6 +92,10 @@ export const MAX_PINNED_REMOTE_VIDEO_TILES = 4; |
92 | 92 | export interface VideoGalleryStrings { |
93 | 93 | /** String to notify that local user is sharing their screen */ |
94 | 94 | 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; |
95 | 99 | /** String to show when remote screen share stream is loading */ |
96 | 100 | screenShareLoadingMessage: string; |
97 | 101 | /** String to show when local screen share stream is loading */ |
@@ -656,20 +660,24 @@ export const VideoGallery = (props: VideoGalleryProps): JSX.Element => { |
656 | 660 | ); |
657 | 661 |
|
658 | 662 | const [announcementString, setAnnouncementString] = React.useState<string>(''); |
| 663 | + const [announcerAriaLive, setAnnouncerAriaLive] = React.useState<'polite' | 'assertive'>('polite'); |
659 | 664 | /** |
660 | 665 | * 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) |
661 | 668 | */ |
662 | 669 | const toggleAnnouncerString = useCallback( |
663 | | - (announcement: string) => { |
| 670 | + (announcement: string, ariaLive: 'polite' | 'assertive' = 'polite') => { |
664 | 671 | setAnnouncementString(announcement); |
| 672 | + setAnnouncerAriaLive(ariaLive); |
665 | 673 | /** |
666 | 674 | * Clears the announcer string after VideoGallery action allowing it to be re-announced. |
667 | 675 | */ |
668 | 676 | setTimeout(() => { |
669 | 677 | setAnnouncementString(''); |
670 | 678 | }, 3000); |
671 | 679 | }, |
672 | | - [setAnnouncementString] |
| 680 | + [setAnnouncementString, setAnnouncerAriaLive] |
673 | 681 | ); |
674 | 682 |
|
675 | 683 | const defaultOnRenderVideoTile = useCallback( |
@@ -771,6 +779,35 @@ export const VideoGallery = (props: VideoGalleryProps): JSX.Element => { |
771 | 779 | ); |
772 | 780 |
|
773 | 781 | 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 | + |
774 | 811 | const localScreenShareStreamComponent = ( |
775 | 812 | <LocalScreenShare |
776 | 813 | localParticipant={localParticipant} |
@@ -917,7 +954,7 @@ export const VideoGallery = (props: VideoGalleryProps): JSX.Element => { |
917 | 954 | className={mergeStyles(videoGalleryOuterDivStyle, styles?.root, unselectable)} |
918 | 955 | > |
919 | 956 | {videoGalleryLayout} |
920 | | - <Announcer announcementString={announcementString} ariaLive="polite" /> |
| 957 | + <Announcer announcementString={announcementString} ariaLive={announcerAriaLive} /> |
921 | 958 | </div> |
922 | 959 | ); |
923 | 960 | }; |
0 commit comments