mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-02-25 11:54:08 +01:00
🎙️ 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:
parent
05696233a9
commit
6655304753
14 changed files with 353 additions and 54 deletions
54
client/src/a11y/Announcer.tsx
Normal file
54
client/src/a11y/Announcer.tsx
Normal 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;
|
||||
109
client/src/a11y/LiveAnnouncer.tsx
Normal file
109
client/src/a11y/LiveAnnouncer.tsx
Normal 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;
|
||||
37
client/src/a11y/LiveMessage.tsx
Normal file
37
client/src/a11y/LiveMessage.tsx
Normal 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;
|
||||
12
client/src/a11y/LiveMessenger.tsx
Normal file
12
client/src/a11y/LiveMessenger.tsx
Normal 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;
|
||||
26
client/src/a11y/MessageBlock.tsx
Normal file
26
client/src/a11y/MessageBlock.tsx
Normal 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
1
client/src/a11y/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default as LiveAnnouncer } from './LiveAnnouncer';
|
||||
Loading…
Add table
Add a link
Reference in a new issue