feat(a11y): message semantics for screen readers (P3-4)
- Each message is role="article"; collapsed messages (consecutive from one sender) now carry an aria-label with sender + time — previously a screen reader heard only the body with no attribution (the biggest a11y gap). Pure messageAriaLabel() reuses the existing time utils (+3 tests). - Editing a message announces "Editing message from <sender>" (ariaLabel threaded MessageEditor → CustomEditor; the main composer is unaffected). - System emoji get role="img" + aria-label from the shortcode; custom emoticons always have an accessible name. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -66,6 +66,8 @@ type CustomEditorProps = {
|
||||
maxHeight?: string;
|
||||
editor: Editor;
|
||||
placeholder?: string;
|
||||
/** Explicit accessible name for the textbox; falls back to the placeholder. */
|
||||
ariaLabel?: string;
|
||||
onKeyDown?: KeyboardEventHandler;
|
||||
onKeyUp?: KeyboardEventHandler;
|
||||
onChange?: EditorChangeHandler;
|
||||
@@ -82,6 +84,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
|
||||
maxHeight = '50vh',
|
||||
editor,
|
||||
placeholder,
|
||||
ariaLabel,
|
||||
onKeyDown,
|
||||
onKeyUp,
|
||||
onChange,
|
||||
@@ -139,7 +142,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
|
||||
data-editable-name={editableName}
|
||||
className={css.EditorTextarea}
|
||||
placeholder={placeholder}
|
||||
aria-label={placeholder ?? 'Message input'}
|
||||
aria-label={ariaLabel ?? placeholder ?? 'Message input'}
|
||||
aria-multiline="true"
|
||||
renderPlaceholder={renderPlaceholder}
|
||||
renderElement={renderElement}
|
||||
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
getMemberDisplayName,
|
||||
} from '../../../utils/room';
|
||||
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
|
||||
import { messageAriaLabel } from '../../../utils/a11y';
|
||||
import { MessageLayout, MessageSpacing } from '../../../state/settings';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
|
||||
@@ -972,6 +973,10 @@ export const Message = React.memo(
|
||||
[MsgAppearClass]: playAppear,
|
||||
[MentionHighlightPulse]: playMentionPulse,
|
||||
})}
|
||||
role="article"
|
||||
aria-label={
|
||||
collapse ? messageAriaLabel(senderDisplayName, mEvent.getTs(), hour24Clock) : undefined
|
||||
}
|
||||
tabIndex={0}
|
||||
space={messageSpacing}
|
||||
collapse={collapse}
|
||||
|
||||
@@ -51,7 +51,13 @@ import { UseStateProvider } from '../../../components/UseStateProvider';
|
||||
import { EmojiBoard } from '../../../components/emoji-board';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { getEditedEvent, getMentionContent, trimReplyFromFormattedBody } from '../../../utils/room';
|
||||
import {
|
||||
getEditedEvent,
|
||||
getMemberDisplayName,
|
||||
getMentionContent,
|
||||
trimReplyFromFormattedBody,
|
||||
} from '../../../utils/room';
|
||||
import { getMxIdLocalPart } from '../../../utils/matrix';
|
||||
import { mobileOrTablet } from '../../../utils/user-agent';
|
||||
import { useComposingCheck } from '../../../hooks/useComposingCheck';
|
||||
|
||||
@@ -66,6 +72,12 @@ export const MessageEditor = as<'div', MessageEditorProps>(
|
||||
({ room, roomId, mEvent, imagePackRooms, onCancel, ...props }, ref) => {
|
||||
const mx = useMatrixClient();
|
||||
const editor = useEditor();
|
||||
// Accessible name for the edit textbox so screen readers announce which
|
||||
// message is being edited (a11y, P3-4).
|
||||
const editSenderId = mEvent.getSender();
|
||||
const editSenderName = editSenderId
|
||||
? (getMemberDisplayName(room, editSenderId) ?? getMxIdLocalPart(editSenderId) ?? editSenderId)
|
||||
: '';
|
||||
const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
|
||||
const [globalToolbar] = useSetting(settingsAtom, 'editorToolbar');
|
||||
const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
|
||||
@@ -259,6 +271,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
|
||||
<CustomEditor
|
||||
editor={editor}
|
||||
placeholder="Edit message..."
|
||||
ariaLabel={editSenderId ? `Editing message from ${editSenderName}` : 'Edit message'}
|
||||
onKeyDown={handleKeyDown}
|
||||
onKeyUp={handleKeyUp}
|
||||
bottom={
|
||||
|
||||
@@ -229,13 +229,21 @@ export const scaleSystemEmoji = (text: string): (string | JSX.Element)[] =>
|
||||
findAndReplace(
|
||||
text,
|
||||
EMOJI_REG_G,
|
||||
(match, pushIndex) => (
|
||||
<span key={`scaleSystemEmoji-${pushIndex}`} className={css.EmoticonBase}>
|
||||
<span className={css.Emoticon()} title={getShortcodeFor(getHexcodeForEmoji(match[0]))}>
|
||||
{match[0]}
|
||||
(match, pushIndex) => {
|
||||
const shortcode = getShortcodeFor(getHexcodeForEmoji(match[0]));
|
||||
return (
|
||||
<span key={`scaleSystemEmoji-${pushIndex}`} className={css.EmoticonBase}>
|
||||
<span
|
||||
className={css.Emoticon()}
|
||||
title={shortcode}
|
||||
aria-label={shortcode || undefined}
|
||||
role={shortcode ? 'img' : undefined}
|
||||
>
|
||||
{match[0]}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
),
|
||||
);
|
||||
},
|
||||
(txt) => txt,
|
||||
);
|
||||
|
||||
@@ -574,10 +582,20 @@ export const getReactCustomHtmlParser = (
|
||||
);
|
||||
}
|
||||
if (htmlSrc && 'data-mx-emoticon' in props) {
|
||||
const emoticonAlt =
|
||||
(typeof props.alt === 'string' && props.alt) ||
|
||||
(typeof props.title === 'string' && props.title) ||
|
||||
'emoji';
|
||||
return (
|
||||
<span className={css.EmoticonBase}>
|
||||
<span className={css.Emoticon()}>
|
||||
<img {...props} className={css.EmoticonImg} src={htmlSrc} loading="lazy" />
|
||||
<img
|
||||
{...props}
|
||||
alt={emoticonAlt}
|
||||
className={css.EmoticonImg}
|
||||
src={htmlSrc}
|
||||
loading="lazy"
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
@@ -611,7 +629,6 @@ export const getReactCustomHtmlParser = (
|
||||
<>
|
||||
{segments.map((segment, index) => {
|
||||
if (segment.type === 'text') {
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
return (
|
||||
<React.Fragment key={index}>{renderTextChunk(segment.value)}</React.Fragment>
|
||||
);
|
||||
@@ -619,7 +636,6 @@ export const getReactCustomHtmlParser = (
|
||||
const raw =
|
||||
segment.type === 'block' ? `$$${segment.value}$$` : `$${segment.value}$`;
|
||||
return (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<React.Fragment key={index}>
|
||||
{renderMath(segment.value, segment.type === 'block', raw, raw)}
|
||||
</React.Fragment>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import dayjs from 'dayjs';
|
||||
import { messageAriaLabel } from './a11y';
|
||||
import { timeDayMonthYear, timeHourMinute } from './time';
|
||||
|
||||
test('messageAriaLabel composes sender, date and time (24h)', () => {
|
||||
const ts = dayjs('2026-07-01T14:30:00').valueOf();
|
||||
assert.equal(
|
||||
messageAriaLabel('Alice', ts, true),
|
||||
`Alice, ${timeDayMonthYear(ts)} ${timeHourMinute(ts, true)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('messageAriaLabel honours the 12-hour clock preference', () => {
|
||||
const ts = dayjs('2026-07-01T14:30:00').valueOf();
|
||||
assert.equal(
|
||||
messageAriaLabel('Bob', ts, false),
|
||||
`Bob, ${timeDayMonthYear(ts)} ${timeHourMinute(ts, false)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('messageAriaLabel keeps the sender name verbatim as plain text', () => {
|
||||
const ts = dayjs('2026-07-01T09:05:00').valueOf();
|
||||
const label = messageAriaLabel('@user:example.org', ts, true);
|
||||
assert.ok(label.startsWith('@user:example.org, '));
|
||||
assert.ok(!label.includes('<'));
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { timeDayMonthYear, timeHourMinute } from './time';
|
||||
|
||||
/**
|
||||
* Builds a plain-text accessible label for a message row, used when the
|
||||
* visible sender/timestamp header is collapsed and therefore hidden from
|
||||
* assistive technology.
|
||||
*
|
||||
* @param sender - Sender display name (already resolved to a human string).
|
||||
* @param ts - Message origin timestamp in milliseconds.
|
||||
* @param hour24Clock - Whether to format the time using a 24-hour clock.
|
||||
* @returns A label such as `Alice, 1 July 2026 14:30`.
|
||||
*/
|
||||
export const messageAriaLabel = (sender: string, ts: number, hour24Clock: boolean): string =>
|
||||
`${sender}, ${timeDayMonthYear(ts)} ${timeHourMinute(ts, hour24Clock)}`;
|
||||
Reference in New Issue
Block a user