mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-03-26 17:46:34 +01:00
🗣️ a11y: Distinguish Conversation Headings for Screen Readers (#12341)
* fix: distinguish message headings for screen readers Before, each message would have the heading of either the name of the user or the name of the agent (e.g. "Dan Lew" or "Claude Sonnet"). If you tried to navigate that with a screen reader, you'd just see a ton of headings switching back and forth between the two with no way to figure out where in the conversation each is. Now, we prefix each header with whether it's a "prompt" or "response", plus we number them so that you can distinguish how far in the conversation each part is. (This is a screen reader only change - there's no visual difference.) * fix: patch MessageParts heading, guard negative depth, add tests - Add sr-only heading prefix to MessageParts.tsx (Assistants endpoint path) - Extract shared getMessageNumber helper to avoid DRY violation between getMessageAriaLabel and getHeaderPrefixForScreenReader - Guard against depth < 0 producing "Prompt 0:" / "Response 0:" - Remove unused lodash import - Add unit tests covering all branches including depth edge cases --------- Co-authored-by: Dan Lew <daniel@mightyacorn.com>
This commit is contained in:
parent
729ba96100
commit
6976414464
5 changed files with 123 additions and 9 deletions
|
|
@ -4,6 +4,7 @@ import { useRecoilValue } from 'recoil';
|
|||
import type { TMessageContentParts } from 'librechat-data-provider';
|
||||
import type { TMessageProps, TMessageIcon } from '~/common';
|
||||
import { useMessageHelpers, useLocalize, useAttachments, useContentMetadata } from '~/hooks';
|
||||
import { cn, getHeaderPrefixForScreenReader, getMessageAriaLabel } from '~/utils';
|
||||
import MessageIcon from '~/components/Chat/Messages/MessageIcon';
|
||||
import ContentParts from './Content/ContentParts';
|
||||
import { fontSizeAtom } from '~/store/fontSize';
|
||||
|
|
@ -11,7 +12,6 @@ import SiblingSwitch from './SiblingSwitch';
|
|||
import MultiMessage from './MultiMessage';
|
||||
import HoverButtons from './HoverButtons';
|
||||
import SubRow from './SubRow';
|
||||
import { cn, getMessageAriaLabel } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
export default function Message(props: TMessageProps) {
|
||||
|
|
@ -125,6 +125,9 @@ export default function Message(props: TMessageProps) {
|
|||
>
|
||||
{!hasParallelContent && (
|
||||
<h2 className={cn('select-none font-semibold text-text-primary', fontSize)}>
|
||||
<span className="sr-only">
|
||||
{getHeaderPrefixForScreenReader(message, localize)}
|
||||
</span>
|
||||
{name}
|
||||
</h2>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import React, { useCallback, useMemo, memo } from 'react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { type TMessage } from 'librechat-data-provider';
|
||||
import type { TMessage } from 'librechat-data-provider';
|
||||
import type { TMessageProps, TMessageIcon } from '~/common';
|
||||
import { cn, getHeaderPrefixForScreenReader, getMessageAriaLabel } from '~/utils';
|
||||
import MessageContent from '~/components/Chat/Messages/Content/MessageContent';
|
||||
import { useLocalize, useMessageActions, useContentMetadata } from '~/hooks';
|
||||
import PlaceholderRow from '~/components/Chat/Messages/ui/PlaceholderRow';
|
||||
|
|
@ -10,7 +11,6 @@ import SiblingSwitch from '~/components/Chat/Messages/SiblingSwitch';
|
|||
import HoverButtons from '~/components/Chat/Messages/HoverButtons';
|
||||
import MessageIcon from '~/components/Chat/Messages/MessageIcon';
|
||||
import SubRow from '~/components/Chat/Messages/SubRow';
|
||||
import { cn, getMessageAriaLabel } from '~/utils';
|
||||
import { fontSizeAtom } from '~/store/fontSize';
|
||||
import { MessageContext } from '~/Providers';
|
||||
import store from '~/store';
|
||||
|
|
@ -148,7 +148,10 @@ const MessageRender = memo(function MessageRender({
|
|||
)}
|
||||
>
|
||||
{!hasParallelContent && (
|
||||
<h2 className={cn('select-none font-semibold', fontSize)}>{messageLabel}</h2>
|
||||
<h2 className={cn('select-none font-semibold', fontSize)}>
|
||||
<span className="sr-only">{getHeaderPrefixForScreenReader(msg, localize)}</span>
|
||||
{messageLabel}
|
||||
</h2>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
|
|
|
|||
|
|
@ -4,13 +4,13 @@ import { useRecoilValue } from 'recoil';
|
|||
import type { TMessage, TMessageContentParts } from 'librechat-data-provider';
|
||||
import type { TMessageProps, TMessageIcon } from '~/common';
|
||||
import { useAttachments, useLocalize, useMessageActions, useContentMetadata } from '~/hooks';
|
||||
import { cn, getHeaderPrefixForScreenReader, getMessageAriaLabel } from '~/utils';
|
||||
import ContentParts from '~/components/Chat/Messages/Content/ContentParts';
|
||||
import PlaceholderRow from '~/components/Chat/Messages/ui/PlaceholderRow';
|
||||
import SiblingSwitch from '~/components/Chat/Messages/SiblingSwitch';
|
||||
import HoverButtons from '~/components/Chat/Messages/HoverButtons';
|
||||
import MessageIcon from '~/components/Chat/Messages/MessageIcon';
|
||||
import SubRow from '~/components/Chat/Messages/SubRow';
|
||||
import { cn, getMessageAriaLabel } from '~/utils';
|
||||
import { fontSizeAtom } from '~/store/fontSize';
|
||||
import store from '~/store';
|
||||
|
||||
|
|
@ -140,7 +140,10 @@ const ContentRender = memo(function ContentRender({
|
|||
)}
|
||||
>
|
||||
{!hasParallelContent && (
|
||||
<h2 className={cn('select-none font-semibold', fontSize)}>{messageLabel}</h2>
|
||||
<h2 className={cn('select-none font-semibold', fontSize)}>
|
||||
<span className="sr-only">{getHeaderPrefixForScreenReader(msg, localize)}</span>
|
||||
{messageLabel}
|
||||
</h2>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
|
|
|
|||
82
client/src/utils/__tests__/messages.test.ts
Normal file
82
client/src/utils/__tests__/messages.test.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import type { TMessage } from 'librechat-data-provider';
|
||||
import type { LocalizeFunction } from '~/common';
|
||||
import { getMessageAriaLabel, getHeaderPrefixForScreenReader } from '../messages';
|
||||
|
||||
const translations: Record<string, string> = {
|
||||
com_endpoint_message: 'Message',
|
||||
com_endpoint_message_new: 'Message {{0}}',
|
||||
com_ui_prompt: 'Prompt',
|
||||
com_ui_response: 'Response',
|
||||
};
|
||||
|
||||
const localize: LocalizeFunction = ((key: string, args?: Record<string, string | number>) => {
|
||||
const template = translations[key] ?? key;
|
||||
if (args) {
|
||||
return Object.entries(args).reduce(
|
||||
(result, [k, v]) => result.replace(`{{${k}}}`, String(v)),
|
||||
template,
|
||||
);
|
||||
}
|
||||
return template;
|
||||
}) as LocalizeFunction;
|
||||
|
||||
const makeMessage = (overrides: Partial<TMessage> = {}): TMessage =>
|
||||
({
|
||||
messageId: 'msg-1',
|
||||
isCreatedByUser: false,
|
||||
...overrides,
|
||||
}) as TMessage;
|
||||
|
||||
describe('getMessageAriaLabel', () => {
|
||||
it('returns "Message N" when depth is present and valid', () => {
|
||||
const msg = makeMessage({ depth: 2 });
|
||||
expect(getMessageAriaLabel(msg, localize)).toBe('Message 3');
|
||||
});
|
||||
|
||||
it('returns "Message" when depth is undefined', () => {
|
||||
const msg = makeMessage({ depth: undefined });
|
||||
expect(getMessageAriaLabel(msg, localize)).toBe('Message');
|
||||
});
|
||||
|
||||
it('returns "Message" when depth is negative', () => {
|
||||
const msg = makeMessage({ depth: -1 });
|
||||
expect(getMessageAriaLabel(msg, localize)).toBe('Message');
|
||||
});
|
||||
|
||||
it('returns "Message 1" for depth 0 (root message)', () => {
|
||||
const msg = makeMessage({ depth: 0 });
|
||||
expect(getMessageAriaLabel(msg, localize)).toBe('Message 1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getHeaderPrefixForScreenReader', () => {
|
||||
it('returns "Prompt N: " for user messages with valid depth', () => {
|
||||
const msg = makeMessage({ isCreatedByUser: true, depth: 2 });
|
||||
expect(getHeaderPrefixForScreenReader(msg, localize)).toBe('Prompt 3: ');
|
||||
});
|
||||
|
||||
it('returns "Response N: " for AI messages with valid depth', () => {
|
||||
const msg = makeMessage({ isCreatedByUser: false, depth: 0 });
|
||||
expect(getHeaderPrefixForScreenReader(msg, localize)).toBe('Response 1: ');
|
||||
});
|
||||
|
||||
it('returns "Prompt: " for user messages without depth', () => {
|
||||
const msg = makeMessage({ isCreatedByUser: true, depth: undefined });
|
||||
expect(getHeaderPrefixForScreenReader(msg, localize)).toBe('Prompt: ');
|
||||
});
|
||||
|
||||
it('returns "Response: " for AI messages without depth', () => {
|
||||
const msg = makeMessage({ isCreatedByUser: false, depth: undefined });
|
||||
expect(getHeaderPrefixForScreenReader(msg, localize)).toBe('Response: ');
|
||||
});
|
||||
|
||||
it('omits number when depth is -1 (no "Prompt 0:" regression)', () => {
|
||||
const msg = makeMessage({ isCreatedByUser: true, depth: -1 });
|
||||
expect(getHeaderPrefixForScreenReader(msg, localize)).toBe('Prompt: ');
|
||||
});
|
||||
|
||||
it('omits number when depth is negative', () => {
|
||||
const msg = makeMessage({ isCreatedByUser: false, depth: -5 });
|
||||
expect(getHeaderPrefixForScreenReader(msg, localize)).toBe('Response: ');
|
||||
});
|
||||
});
|
||||
|
|
@ -14,7 +14,6 @@ import type {
|
|||
} from 'librechat-data-provider';
|
||||
import type { QueryClient } from '@tanstack/react-query';
|
||||
import type { LocalizeFunction } from '~/common';
|
||||
import _ from 'lodash';
|
||||
|
||||
export const TEXT_KEY_DIVIDER = '|||';
|
||||
|
||||
|
|
@ -185,12 +184,36 @@ export const clearMessagesCache = (
|
|||
}
|
||||
};
|
||||
|
||||
/** Returns a 1-based message number, or null if depth is absent or invalid. */
|
||||
const getMessageNumber = (message: TMessage): number | null => {
|
||||
if (message.depth == null || message.depth < 0) {
|
||||
return null;
|
||||
}
|
||||
return message.depth + 1;
|
||||
};
|
||||
|
||||
export const getMessageAriaLabel = (message: TMessage, localize: LocalizeFunction): string => {
|
||||
return !_.isNil(message.depth)
|
||||
? localize('com_endpoint_message_new', { 0: message.depth + 1 })
|
||||
const number = getMessageNumber(message);
|
||||
return number != null
|
||||
? localize('com_endpoint_message_new', { 0: number })
|
||||
: localize('com_endpoint_message');
|
||||
};
|
||||
|
||||
/**
|
||||
* Provides a screen-reader-only heading prefix distinguishing prompts from responses,
|
||||
* with an optional 1-based turn number derived from message depth.
|
||||
*/
|
||||
export const getHeaderPrefixForScreenReader = (
|
||||
message: TMessage,
|
||||
localize: LocalizeFunction,
|
||||
): string => {
|
||||
const number = getMessageNumber(message);
|
||||
const suffix = number != null ? ` ${number}` : '';
|
||||
return message.isCreatedByUser
|
||||
? `${localize('com_ui_prompt')}${suffix}: `
|
||||
: `${localize('com_ui_response')}${suffix}: `;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates initial content parts for dual message display with agent-based grouping.
|
||||
* Sets up primary and added agent content parts with agentId for column rendering.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue