🎙️ a11y: Screen Reader Support for Dynamic Content Updates (#3625)

* WIP: first pass, hooks

* wip: isStream arg

* feat: first pass, dynamic content updates, screen reader announcements

* chore: unrelated, styling redundancy
This commit is contained in:
Danny Avila 2024-08-13 03:04:27 -04:00 committed by GitHub
parent 05696233a9
commit 6655304753
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 353 additions and 54 deletions

View file

@ -0,0 +1,54 @@
import React, { useState, useEffect } from 'react';
import MessageBlock from './MessageBlock';
interface AnnouncerProps {
politeMessage: string;
politeMessageId: string;
assertiveMessage: string;
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]);
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} />
</div>
);
};
export default Announcer;

View file

@ -0,0 +1,109 @@
import React, { useState, useCallback, useRef, useEffect } from 'react';
import { findLastSeparatorIndex } from 'librechat-data-provider';
import type { AnnounceOptions } from '~/Providers/AnnouncerContext';
import AnnouncerContext from '~/Providers/AnnouncerContext';
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('');
const politeProcessedTextRef = useRef('');
const politeQueueRef = useRef<Array<{ message: string; id: string }>>([]);
const isAnnouncingRef = useRef(false);
const counterRef = useRef(0);
const generateUniqueId = (prefix: string) => {
counterRef.current += 1;
return `${prefix}-${counterRef.current}`;
};
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);
}
}
}, []);
const announcePolite = useCallback(
({ message, id, isStream = false, isComplete = false }: AnnounceOptions) => {
const announcementId = id ?? generateUniqueId('polite');
if (isStream) {
const chunk = processChunks(message, politeProcessedTextRef);
if (chunk) {
politeQueueRef.current.push({ message: chunk, id: announcementId });
announceNextInQueue();
}
} else if (isComplete) {
const remainingText = message.slice(politeProcessedTextRef.current.length);
if (remainingText.trim()) {
politeQueueRef.current.push({ message: remainingText.trim(), id: announcementId });
announceNextInQueue();
}
politeProcessedTextRef.current = '';
} else {
politeQueueRef.current.push({ message, id: announcementId });
announceNextInQueue();
politeProcessedTextRef.current = '';
}
},
[announceNextInQueue],
);
const announceAssertive = useCallback(({ message, id }: AnnounceOptions) => {
const announcementId = id ?? generateUniqueId('assertive');
setAnnounceAssertiveMessage(message);
setAssertiveMessageId(announcementId);
}, []);
const contextValue = {
announcePolite,
announceAssertive,
};
useEffect(() => {
return () => {
politeQueueRef.current = [];
isAnnouncingRef.current = false;
};
}, []);
return (
<AnnouncerContext.Provider value={contextValue}>
{children}
<Announcer
assertiveMessage={announceAssertiveMessage}
assertiveMessageId={assertiveMessageId}
politeMessage={announcePoliteMessage}
politeMessageId={politeMessageId}
/>
</AnnouncerContext.Provider>
);
};
export default LiveAnnouncer;

View file

@ -0,0 +1,37 @@
import React, { useEffect, useContext } from 'react';
import AnnouncerContext from '~/Providers/AnnouncerContext';
interface LiveMessageProps {
message: string;
'aria-live': 'polite' | 'assertive';
clearOnUnmount?: boolean | 'true' | 'false';
}
const LiveMessage: React.FC<LiveMessageProps> = ({
message,
'aria-live': ariaLive,
clearOnUnmount,
}) => {
const { announceAssertive, announcePolite } = useContext(AnnouncerContext);
useEffect(() => {
if (ariaLive === 'assertive') {
announceAssertive(message);
} else if (ariaLive === 'polite') {
announcePolite(message);
}
}, [message, ariaLive, announceAssertive, announcePolite]);
useEffect(() => {
return () => {
if (clearOnUnmount === true || clearOnUnmount === 'true') {
announceAssertive('');
announcePolite('');
}
};
}, [clearOnUnmount, announceAssertive, announcePolite]);
return null;
};
export default LiveMessage;

View file

@ -0,0 +1,12 @@
import React from 'react';
import AnnouncerContext from '~/Providers/AnnouncerContext';
interface LiveMessengerProps {
children: (context: React.ContextType<typeof AnnouncerContext>) => React.ReactNode;
}
const LiveMessenger: React.FC<LiveMessengerProps> = ({ children }) => (
<AnnouncerContext.Consumer>{(contextProps) => children(contextProps)}</AnnouncerContext.Consumer>
);
export default LiveMessenger;

View file

@ -0,0 +1,26 @@
import React from 'react';
const offScreenStyle: React.CSSProperties = {
border: 0,
clip: 'rect(0 0 0 0)',
height: '1px',
margin: '-1px',
overflow: 'hidden',
whiteSpace: 'nowrap',
padding: 0,
width: '1px',
position: 'absolute',
};
interface MessageBlockProps {
message: string;
'aria-live': 'polite' | 'assertive';
}
const MessageBlock: React.FC<MessageBlockProps> = ({ message, 'aria-live': ariaLive }) => (
<div style={offScreenStyle} role="log" aria-live={ariaLive}>
{message}
</div>
);
export default MessageBlock;

1
client/src/a11y/index.ts Normal file
View file

@ -0,0 +1 @@
export { default as LiveAnnouncer } from './LiveAnnouncer';