📣 a11y: Better Screen Reader Announcements (#3693)

* refactor: Improve LiveAnnouncer component

The LiveAnnouncer component in the client/src/a11y/LiveAnnouncer.tsx file has been refactored to improve its functionality. The component now processes text in chunks for better performance and adds a minimum announcement delay to prevent overlapping announcements. Additionally, the component now properly clears the announcement message and ID before setting a new one. These changes enhance the accessibility and user experience of the LiveAnnouncer component.

* refactor: manage only 2 LiveAnnouncer aria-live elements, queue assertive/polite together

* refactor: use localizations for event announcements

* refactor: update minimum announcement delay in LiveAnnouncer component

* refactor: replace *`_

* chore(useContentHandler): typing

* chore: more type fixes and safely announce final message
This commit is contained in:
Danny Avila 2024-08-19 03:51:17 -04:00 committed by GitHub
parent 598e2be225
commit cebb3751c1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 152 additions and 105 deletions

View file

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React from 'react';
import MessageBlock from './MessageBlock';
interface AnnouncerProps {
@ -8,45 +8,11 @@ interface AnnouncerProps {
assertiveMessageId: string;
}
const Announcer: React.FC<AnnouncerProps> = ({
politeMessage,
politeMessageId,
assertiveMessage,
assertiveMessageId,
}) => {
const [state, setState] = useState({
assertiveMessage1: '',
assertiveMessage2: '',
politeMessage1: '',
politeMessage2: '',
setAlternatePolite: false,
setAlternateAssertive: false,
});
useEffect(() => {
setState((prevState) => ({
...prevState,
politeMessage1: prevState.setAlternatePolite ? '' : politeMessage,
politeMessage2: prevState.setAlternatePolite ? politeMessage : '',
setAlternatePolite: !prevState.setAlternatePolite,
}));
}, [politeMessage, politeMessageId]);
useEffect(() => {
setState((prevState) => ({
...prevState,
assertiveMessage1: prevState.setAlternateAssertive ? '' : assertiveMessage,
assertiveMessage2: prevState.setAlternateAssertive ? assertiveMessage : '',
setAlternateAssertive: !prevState.setAlternateAssertive,
}));
}, [assertiveMessage, assertiveMessageId]);
const Announcer: React.FC<AnnouncerProps> = ({ politeMessage, assertiveMessage }) => {
return (
<div>
<MessageBlock aria-live="assertive" aria-atomic="true" message={state.assertiveMessage1} />
<MessageBlock aria-live="assertive" aria-atomic="true" message={state.assertiveMessage2} />
<MessageBlock aria-live="polite" aria-atomic="false" message={state.politeMessage1} />
<MessageBlock aria-live="polite" aria-atomic="false" message={state.politeMessage2} />
<MessageBlock aria-live="assertive" aria-atomic="true" message={assertiveMessage} />
<MessageBlock aria-live="polite" aria-atomic="false" message={politeMessage} />
</div>
);
};

View file

@ -1,23 +1,38 @@
import React, { useState, useCallback, useRef, useEffect } from 'react';
import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import { findLastSeparatorIndex } from 'librechat-data-provider';
import type { AnnounceOptions } from '~/Providers/AnnouncerContext';
import AnnouncerContext from '~/Providers/AnnouncerContext';
import useLocalize from '~/hooks/useLocalize';
import Announcer from './Announcer';
interface LiveAnnouncerProps {
children: React.ReactNode;
}
const LiveAnnouncer: React.FC<LiveAnnouncerProps> = ({ children }) => {
const [announcePoliteMessage, setAnnouncePoliteMessage] = useState('');
const [politeMessageId, setPoliteMessageId] = useState('');
const [announceAssertiveMessage, setAnnounceAssertiveMessage] = useState('');
const [assertiveMessageId, setAssertiveMessageId] = useState('');
interface AnnouncementItem {
message: string;
id: string;
isAssertive: boolean;
}
const CHUNK_SIZE = 50;
const MIN_ANNOUNCEMENT_DELAY = 400;
/** Regex to remove *, `, and _ from message text */
const replacementRegex = /[*`_]/g;
const LiveAnnouncer: React.FC<LiveAnnouncerProps> = ({ children }) => {
const [politeMessageId, setPoliteMessageId] = useState('');
const [assertiveMessageId, setAssertiveMessageId] = useState('');
const [announcePoliteMessage, setAnnouncePoliteMessage] = useState('');
const [announceAssertiveMessage, setAnnounceAssertiveMessage] = useState('');
const politeProcessedTextRef = useRef('');
const politeQueueRef = useRef<Array<{ message: string; id: string }>>([]);
const isAnnouncingRef = useRef(false);
const counterRef = useRef(0);
const isAnnouncingRef = useRef(false);
const politeProcessedTextRef = useRef('');
const queueRef = useRef<AnnouncementItem[]>([]);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const localize = useLocalize();
const generateUniqueId = (prefix: string) => {
counterRef.current += 1;
@ -26,29 +41,76 @@ const LiveAnnouncer: React.FC<LiveAnnouncerProps> = ({ children }) => {
const processChunks = (text: string, processedTextRef: React.MutableRefObject<string>) => {
const remainingText = text.slice(processedTextRef.current.length);
const separatorIndex = findLastSeparatorIndex(remainingText);
if (separatorIndex !== -1) {
const chunkText = remainingText.slice(0, separatorIndex + 1);
processedTextRef.current += chunkText;
return chunkText.trim();
}
return '';
};
const announceNextInQueue = useCallback(() => {
if (politeQueueRef.current.length > 0 && !isAnnouncingRef.current) {
isAnnouncingRef.current = true;
const nextAnnouncement = politeQueueRef.current.shift();
if (nextAnnouncement) {
setAnnouncePoliteMessage(nextAnnouncement.message);
setPoliteMessageId(nextAnnouncement.id);
setTimeout(() => {
isAnnouncingRef.current = false;
announceNextInQueue();
}, 100);
if (remainingText.length < CHUNK_SIZE) {
return ''; /* Not enough characters to process */
}
let separatorIndex = -1;
let startIndex = CHUNK_SIZE;
while (separatorIndex === -1 && startIndex <= remainingText.length) {
separatorIndex = findLastSeparatorIndex(remainingText.slice(startIndex));
if (separatorIndex !== -1) {
separatorIndex += startIndex; /* Adjust the index to account for the starting position */
} else {
startIndex += CHUNK_SIZE; /* Move the starting position by another CHUNK_SIZE characters */
}
}
}, []);
if (separatorIndex === -1) {
return ''; /* No separator found, wait for more text */
}
const chunkText = remainingText.slice(0, separatorIndex + 1);
processedTextRef.current += chunkText;
return chunkText.trim();
};
/** Localized event announcements, i.e., "the AI is replying, finished, etc." */
const events: Record<string, string | undefined> = useMemo(
() => ({ start: localize('com_a11y_start'), end: localize('com_a11y_end') }),
[localize],
);
const announceNextInQueue = useCallback(() => {
if (queueRef.current.length > 0 && !isAnnouncingRef.current) {
isAnnouncingRef.current = true;
const nextAnnouncement = queueRef.current.shift();
if (nextAnnouncement) {
const { message: _msg, id, isAssertive } = nextAnnouncement;
const setMessage = isAssertive ? setAnnounceAssertiveMessage : setAnnouncePoliteMessage;
const setMessageId = isAssertive ? setAssertiveMessageId : setPoliteMessageId;
setMessage('');
setMessageId('');
/* Force a re-render before setting the new message */
setTimeout(() => {
const message = (events[_msg] ?? _msg).replace(replacementRegex, '');
setMessage(message);
setMessageId(id);
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
isAnnouncingRef.current = false;
announceNextInQueue();
}, MIN_ANNOUNCEMENT_DELAY);
}, 0);
}
}
}, [events]);
const addToQueue = useCallback(
(item: AnnouncementItem) => {
queueRef.current.push(item);
announceNextInQueue();
},
[announceNextInQueue],
);
const announcePolite = useCallback(
({ message, id, isStream = false, isComplete = false }: AnnounceOptions) => {
@ -56,30 +118,29 @@ const LiveAnnouncer: React.FC<LiveAnnouncerProps> = ({ children }) => {
if (isStream) {
const chunk = processChunks(message, politeProcessedTextRef);
if (chunk) {
politeQueueRef.current.push({ message: chunk, id: announcementId });
announceNextInQueue();
addToQueue({ message: chunk, id: announcementId, isAssertive: false });
}
} else if (isComplete) {
const remainingText = message.slice(politeProcessedTextRef.current.length);
if (remainingText.trim()) {
politeQueueRef.current.push({ message: remainingText.trim(), id: announcementId });
announceNextInQueue();
addToQueue({ message: remainingText.trim(), id: announcementId, isAssertive: false });
}
politeProcessedTextRef.current = '';
} else {
politeQueueRef.current.push({ message, id: announcementId });
announceNextInQueue();
addToQueue({ message, id: announcementId, isAssertive: false });
politeProcessedTextRef.current = '';
}
},
[announceNextInQueue],
[addToQueue],
);
const announceAssertive = useCallback(({ message, id }: AnnounceOptions) => {
const announcementId = id ?? generateUniqueId('assertive');
setAnnounceAssertiveMessage(message);
setAssertiveMessageId(announcementId);
}, []);
const announceAssertive = useCallback(
({ message, id }: AnnounceOptions) => {
const announcementId = id ?? generateUniqueId('assertive');
addToQueue({ message, id: announcementId, isAssertive: true });
},
[addToQueue],
);
const contextValue = {
announcePolite,
@ -88,8 +149,11 @@ const LiveAnnouncer: React.FC<LiveAnnouncerProps> = ({ children }) => {
useEffect(() => {
return () => {
politeQueueRef.current = [];
queueRef.current = [];
isAnnouncingRef.current = false;
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);