mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-22 11:20:15 +01:00
📣 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:
parent
598e2be225
commit
cebb3751c1
6 changed files with 152 additions and 105 deletions
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue