Skip to content

Commit 967e8a1

Browse files
authored
🍎 refactor(a11y): Optimize Live Region Announcements for Apple VoiceOver (danny-avila#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
1 parent f86e9dd commit 967e8a1

File tree

2 files changed

+79
-58
lines changed

2 files changed

+79
-58
lines changed

client/src/a11y/Announcer.tsx

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,20 @@
1+
// client/src/a11y/Announcer.tsx
12
import React from 'react';
2-
import MessageBlock from './MessageBlock';
33

44
interface AnnouncerProps {
5-
politeMessage: string;
6-
politeMessageId: string;
7-
assertiveMessage: string;
8-
assertiveMessageId: string;
5+
statusMessage: string;
6+
responseMessage: string;
97
}
108

11-
const Announcer: React.FC<AnnouncerProps> = ({ politeMessage, assertiveMessage }) => {
9+
const Announcer: React.FC<AnnouncerProps> = ({ statusMessage, responseMessage }) => {
1210
return (
13-
<div>
14-
<MessageBlock aria-live="assertive" aria-atomic="true" message={assertiveMessage} />
15-
<MessageBlock aria-live="polite" aria-atomic="false" message={politeMessage} />
11+
<div className="sr-only">
12+
<div aria-live="assertive" aria-atomic="true">
13+
{statusMessage}
14+
</div>
15+
<div aria-live="polite" aria-atomic="true">
16+
{responseMessage}
17+
</div>
1618
</div>
1719
);
1820
};

client/src/a11y/LiveAnnouncer.tsx

Lines changed: 68 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
// client/src/a11y/LiveAnnouncer.tsx
12
import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react';
23
import { findLastSeparatorIndex } from 'librechat-data-provider';
34
import type { AnnounceOptions } from '~/Providers/AnnouncerContext';
@@ -15,30 +16,35 @@ interface AnnouncementItem {
1516
isAssertive: boolean;
1617
}
1718

18-
const CHUNK_SIZE = 50;
19-
const MIN_ANNOUNCEMENT_DELAY = 400;
19+
/** Chunk size for processing text */
20+
const CHUNK_SIZE = 200;
21+
/** Minimum delay between announcements */
22+
const MIN_ANNOUNCEMENT_DELAY = 1000;
23+
/** Delay before clearing the live region */
24+
const CLEAR_DELAY = 5000;
2025
/** Regex to remove *, `, and _ from message text */
2126
const replacementRegex = /[*`_]/g;
2227

2328
const LiveAnnouncer: React.FC<LiveAnnouncerProps> = ({ children }) => {
24-
const [politeMessageId, setPoliteMessageId] = useState('');
25-
const [assertiveMessageId, setAssertiveMessageId] = useState('');
26-
const [announcePoliteMessage, setAnnouncePoliteMessage] = useState('');
27-
const [announceAssertiveMessage, setAnnounceAssertiveMessage] = useState('');
29+
const [statusMessage, setStatusMessage] = useState('');
30+
const [responseMessage, setResponseMessage] = useState('');
2831

2932
const counterRef = useRef(0);
3033
const isAnnouncingRef = useRef(false);
3134
const politeProcessedTextRef = useRef('');
3235
const queueRef = useRef<AnnouncementItem[]>([]);
3336
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
37+
const lastAnnouncementTimeRef = useRef(0);
3438

3539
const localize = useLocalize();
3640

41+
/** Generates a unique ID for announcement messages */
3742
const generateUniqueId = (prefix: string) => {
3843
counterRef.current += 1;
3944
return `${prefix}-${counterRef.current}`;
4045
};
4146

47+
/** Processes the text in chunks and returns a chunk of text */
4248
const processChunks = (text: string, processedTextRef: React.MutableRefObject<string>) => {
4349
const remainingText = text.slice(processedTextRef.current.length);
4450

@@ -73,59 +79,76 @@ const LiveAnnouncer: React.FC<LiveAnnouncerProps> = ({ children }) => {
7379
[localize],
7480
);
7581

76-
const announceNextInQueue = useCallback(() => {
77-
if (queueRef.current.length > 0 && !isAnnouncingRef.current) {
78-
isAnnouncingRef.current = true;
79-
const nextAnnouncement = queueRef.current.shift();
80-
if (nextAnnouncement) {
81-
const { message: _msg, id, isAssertive } = nextAnnouncement;
82-
const setMessage = isAssertive ? setAnnounceAssertiveMessage : setAnnouncePoliteMessage;
83-
const setMessageId = isAssertive ? setAssertiveMessageId : setPoliteMessageId;
84-
85-
setMessage('');
86-
setMessageId('');
87-
88-
/* Force a re-render before setting the new message */
89-
setTimeout(() => {
90-
const message = (events[_msg] ?? _msg).replace(replacementRegex, '');
91-
setMessage(message);
92-
setMessageId(id);
93-
94-
if (timeoutRef.current) {
95-
clearTimeout(timeoutRef.current);
96-
}
82+
const announceMessage = useCallback(
83+
(message: string, isAssertive: boolean) => {
84+
const setMessage = isAssertive ? setStatusMessage : setResponseMessage;
85+
setMessage(message);
9786

98-
timeoutRef.current = setTimeout(() => {
99-
isAnnouncingRef.current = false;
100-
announceNextInQueue();
101-
}, MIN_ANNOUNCEMENT_DELAY);
102-
}, 0);
87+
if (timeoutRef.current) {
88+
clearTimeout(timeoutRef.current);
10389
}
104-
}
105-
}, [events]);
90+
91+
lastAnnouncementTimeRef.current = Date.now();
92+
isAnnouncingRef.current = true;
93+
94+
timeoutRef.current = setTimeout(
95+
() => {
96+
isAnnouncingRef.current = false;
97+
setMessage(''); // Clear the message after a delay
98+
if (queueRef.current.length > 0) {
99+
const nextAnnouncement = queueRef.current.shift();
100+
if (nextAnnouncement) {
101+
const { message: _msg, isAssertive } = nextAnnouncement;
102+
const nextMessage = (events[_msg] ?? _msg).replace(replacementRegex, '');
103+
announceMessage(nextMessage, isAssertive);
104+
}
105+
}
106+
},
107+
isAssertive ? MIN_ANNOUNCEMENT_DELAY : CLEAR_DELAY,
108+
);
109+
},
110+
[events],
111+
);
106112

107113
const addToQueue = useCallback(
108114
(item: AnnouncementItem) => {
109-
queueRef.current.push(item);
110-
announceNextInQueue();
115+
if (item.isAssertive) {
116+
/* For assertive messages, clear the queue and announce immediately */
117+
queueRef.current = [];
118+
const { message: _msg, isAssertive } = item;
119+
const message = (events[_msg] ?? _msg).replace(replacementRegex, '');
120+
announceMessage(message, isAssertive);
121+
} else {
122+
queueRef.current.push(item);
123+
if (!isAnnouncingRef.current) {
124+
const nextAnnouncement = queueRef.current.shift();
125+
if (nextAnnouncement) {
126+
const { message: _msg, isAssertive } = nextAnnouncement;
127+
const message = (events[_msg] ?? _msg).replace(replacementRegex, '');
128+
announceMessage(message, isAssertive);
129+
}
130+
}
131+
}
111132
},
112-
[announceNextInQueue],
133+
[events, announceMessage],
113134
);
114135

136+
/** Announces a polite message */
115137
const announcePolite = useCallback(
116138
({ message, id, isStream = false, isComplete = false }: AnnounceOptions) => {
117139
const announcementId = id ?? generateUniqueId('polite');
118-
if (isStream) {
140+
if (isStream || isComplete) {
119141
const chunk = processChunks(message, politeProcessedTextRef);
120142
if (chunk) {
121143
addToQueue({ message: chunk, id: announcementId, isAssertive: false });
122144
}
123-
} else if (isComplete) {
124-
const remainingText = message.slice(politeProcessedTextRef.current.length);
125-
if (remainingText.trim()) {
126-
addToQueue({ message: remainingText.trim(), id: announcementId, isAssertive: false });
145+
if (isComplete) {
146+
const remainingText = message.slice(politeProcessedTextRef.current.length);
147+
if (remainingText.trim()) {
148+
addToQueue({ message: remainingText.trim(), id: announcementId, isAssertive: false });
149+
}
150+
politeProcessedTextRef.current = '';
127151
}
128-
politeProcessedTextRef.current = '';
129152
} else {
130153
addToQueue({ message, id: announcementId, isAssertive: false });
131154
politeProcessedTextRef.current = '';
@@ -134,6 +157,7 @@ const LiveAnnouncer: React.FC<LiveAnnouncerProps> = ({ children }) => {
134157
[addToQueue],
135158
);
136159

160+
/** Announces an assertive message */
137161
const announceAssertive = useCallback(
138162
({ message, id }: AnnounceOptions) => {
139163
const announcementId = id ?? generateUniqueId('assertive');
@@ -160,12 +184,7 @@ const LiveAnnouncer: React.FC<LiveAnnouncerProps> = ({ children }) => {
160184
return (
161185
<AnnouncerContext.Provider value={contextValue}>
162186
{children}
163-
<Announcer
164-
assertiveMessage={announceAssertiveMessage}
165-
assertiveMessageId={assertiveMessageId}
166-
politeMessage={announcePoliteMessage}
167-
politeMessageId={politeMessageId}
168-
/>
187+
<Announcer statusMessage={statusMessage} responseMessage={responseMessage} />
169188
</AnnouncerContext.Provider>
170189
);
171190
};

0 commit comments

Comments
 (0)