mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-17 00:40:14 +01:00
🍎 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:
parent
f86e9dd04c
commit
967e8a1e92
2 changed files with 79 additions and 58 deletions
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue