🍎 refactor(a11y): Optimize Live Region Announcements for Apple VoiceOver (#3762)

* refactor: first pass rewrite

* refactor: update CLEAR_DELAY to 5000 milliseconds in LiveAnnouncer.tsx

* refactor: assertive messages to clear queue immediately, fix circular useCallback dependency issue

* chore: comment
This commit is contained in:
Danny Avila 2024-08-23 10:22:16 -04:00 committed by GitHub
parent f86e9dd04c
commit 967e8a1e92
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 79 additions and 58 deletions

View file

@ -1,18 +1,20 @@
// client/src/a11y/Announcer.tsx
import React from 'react'; import React from 'react';
import MessageBlock from './MessageBlock';
interface AnnouncerProps { interface AnnouncerProps {
politeMessage: string; statusMessage: string;
politeMessageId: string; responseMessage: string;
assertiveMessage: string;
assertiveMessageId: string;
} }
const Announcer: React.FC<AnnouncerProps> = ({ politeMessage, assertiveMessage }) => { const Announcer: React.FC<AnnouncerProps> = ({ statusMessage, responseMessage }) => {
return ( return (
<div> <div className="sr-only">
<MessageBlock aria-live="assertive" aria-atomic="true" message={assertiveMessage} /> <div aria-live="assertive" aria-atomic="true">
<MessageBlock aria-live="polite" aria-atomic="false" message={politeMessage} /> {statusMessage}
</div>
<div aria-live="polite" aria-atomic="true">
{responseMessage}
</div>
</div> </div>
); );
}; };

View file

@ -1,3 +1,4 @@
// client/src/a11y/LiveAnnouncer.tsx
import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react'; import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import { findLastSeparatorIndex } from 'librechat-data-provider'; import { findLastSeparatorIndex } from 'librechat-data-provider';
import type { AnnounceOptions } from '~/Providers/AnnouncerContext'; import type { AnnounceOptions } from '~/Providers/AnnouncerContext';
@ -15,30 +16,35 @@ interface AnnouncementItem {
isAssertive: boolean; isAssertive: boolean;
} }
const CHUNK_SIZE = 50; /** Chunk size for processing text */
const MIN_ANNOUNCEMENT_DELAY = 400; const CHUNK_SIZE = 200;
/** Minimum delay between announcements */
const MIN_ANNOUNCEMENT_DELAY = 1000;
/** Delay before clearing the live region */
const CLEAR_DELAY = 5000;
/** Regex to remove *, `, and _ from message text */ /** Regex to remove *, `, and _ from message text */
const replacementRegex = /[*`_]/g; const replacementRegex = /[*`_]/g;
const LiveAnnouncer: React.FC<LiveAnnouncerProps> = ({ children }) => { const LiveAnnouncer: React.FC<LiveAnnouncerProps> = ({ children }) => {
const [politeMessageId, setPoliteMessageId] = useState(''); const [statusMessage, setStatusMessage] = useState('');
const [assertiveMessageId, setAssertiveMessageId] = useState(''); const [responseMessage, setResponseMessage] = useState('');
const [announcePoliteMessage, setAnnouncePoliteMessage] = useState('');
const [announceAssertiveMessage, setAnnounceAssertiveMessage] = useState('');
const counterRef = useRef(0); const counterRef = useRef(0);
const isAnnouncingRef = useRef(false); const isAnnouncingRef = useRef(false);
const politeProcessedTextRef = useRef(''); const politeProcessedTextRef = useRef('');
const queueRef = useRef<AnnouncementItem[]>([]); const queueRef = useRef<AnnouncementItem[]>([]);
const timeoutRef = useRef<NodeJS.Timeout | null>(null); const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const lastAnnouncementTimeRef = useRef(0);
const localize = useLocalize(); const localize = useLocalize();
/** Generates a unique ID for announcement messages */
const generateUniqueId = (prefix: string) => { const generateUniqueId = (prefix: string) => {
counterRef.current += 1; counterRef.current += 1;
return `${prefix}-${counterRef.current}`; return `${prefix}-${counterRef.current}`;
}; };
/** Processes the text in chunks and returns a chunk of text */
const processChunks = (text: string, processedTextRef: React.MutableRefObject<string>) => { const processChunks = (text: string, processedTextRef: React.MutableRefObject<string>) => {
const remainingText = text.slice(processedTextRef.current.length); const remainingText = text.slice(processedTextRef.current.length);
@ -73,59 +79,76 @@ const LiveAnnouncer: React.FC<LiveAnnouncerProps> = ({ children }) => {
[localize], [localize],
); );
const announceNextInQueue = useCallback(() => { const announceMessage = useCallback(
if (queueRef.current.length > 0 && !isAnnouncingRef.current) { (message: string, isAssertive: boolean) => {
isAnnouncingRef.current = true; const setMessage = isAssertive ? setStatusMessage : setResponseMessage;
const nextAnnouncement = queueRef.current.shift(); setMessage(message);
if (nextAnnouncement) {
const { message: _msg, id, isAssertive } = nextAnnouncement;
const setMessage = isAssertive ? setAnnounceAssertiveMessage : setAnnouncePoliteMessage;
const setMessageId = isAssertive ? setAssertiveMessageId : setPoliteMessageId;
setMessage(''); if (timeoutRef.current) {
setMessageId(''); clearTimeout(timeoutRef.current);
/* 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]); lastAnnouncementTimeRef.current = Date.now();
isAnnouncingRef.current = true;
timeoutRef.current = setTimeout(
() => {
isAnnouncingRef.current = false;
setMessage(''); // Clear the message after a delay
if (queueRef.current.length > 0) {
const nextAnnouncement = queueRef.current.shift();
if (nextAnnouncement) {
const { message: _msg, isAssertive } = nextAnnouncement;
const nextMessage = (events[_msg] ?? _msg).replace(replacementRegex, '');
announceMessage(nextMessage, isAssertive);
}
}
},
isAssertive ? MIN_ANNOUNCEMENT_DELAY : CLEAR_DELAY,
);
},
[events],
);
const addToQueue = useCallback( const addToQueue = useCallback(
(item: AnnouncementItem) => { (item: AnnouncementItem) => {
queueRef.current.push(item); if (item.isAssertive) {
announceNextInQueue(); /* For assertive messages, clear the queue and announce immediately */
queueRef.current = [];
const { message: _msg, isAssertive } = item;
const message = (events[_msg] ?? _msg).replace(replacementRegex, '');
announceMessage(message, isAssertive);
} else {
queueRef.current.push(item);
if (!isAnnouncingRef.current) {
const nextAnnouncement = queueRef.current.shift();
if (nextAnnouncement) {
const { message: _msg, isAssertive } = nextAnnouncement;
const message = (events[_msg] ?? _msg).replace(replacementRegex, '');
announceMessage(message, isAssertive);
}
}
}
}, },
[announceNextInQueue], [events, announceMessage],
); );
/** Announces a polite message */
const announcePolite = useCallback( const announcePolite = useCallback(
({ message, id, isStream = false, isComplete = false }: AnnounceOptions) => { ({ message, id, isStream = false, isComplete = false }: AnnounceOptions) => {
const announcementId = id ?? generateUniqueId('polite'); const announcementId = id ?? generateUniqueId('polite');
if (isStream) { if (isStream || isComplete) {
const chunk = processChunks(message, politeProcessedTextRef); const chunk = processChunks(message, politeProcessedTextRef);
if (chunk) { if (chunk) {
addToQueue({ message: chunk, id: announcementId, isAssertive: false }); addToQueue({ message: chunk, id: announcementId, isAssertive: false });
} }
} else if (isComplete) { if (isComplete) {
const remainingText = message.slice(politeProcessedTextRef.current.length); const remainingText = message.slice(politeProcessedTextRef.current.length);
if (remainingText.trim()) { if (remainingText.trim()) {
addToQueue({ message: remainingText.trim(), id: announcementId, isAssertive: false }); addToQueue({ message: remainingText.trim(), id: announcementId, isAssertive: false });
}
politeProcessedTextRef.current = '';
} }
politeProcessedTextRef.current = '';
} else { } else {
addToQueue({ message, id: announcementId, isAssertive: false }); addToQueue({ message, id: announcementId, isAssertive: false });
politeProcessedTextRef.current = ''; politeProcessedTextRef.current = '';
@ -134,6 +157,7 @@ const LiveAnnouncer: React.FC<LiveAnnouncerProps> = ({ children }) => {
[addToQueue], [addToQueue],
); );
/** Announces an assertive message */
const announceAssertive = useCallback( const announceAssertive = useCallback(
({ message, id }: AnnounceOptions) => { ({ message, id }: AnnounceOptions) => {
const announcementId = id ?? generateUniqueId('assertive'); const announcementId = id ?? generateUniqueId('assertive');
@ -160,12 +184,7 @@ const LiveAnnouncer: React.FC<LiveAnnouncerProps> = ({ children }) => {
return ( return (
<AnnouncerContext.Provider value={contextValue}> <AnnouncerContext.Provider value={contextValue}>
{children} {children}
<Announcer <Announcer statusMessage={statusMessage} responseMessage={responseMessage} />
assertiveMessage={announceAssertiveMessage}
assertiveMessageId={assertiveMessageId}
politeMessage={announcePoliteMessage}
politeMessageId={politeMessageId}
/>
</AnnouncerContext.Provider> </AnnouncerContext.Provider>
); );
}; };