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:
2026-07-02 11:45:21 -04:00
parent 8ab1ec254b
commit 8729ccfcf5
6 changed files with 90 additions and 11 deletions
+4 -1
View File
@@ -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={
+25 -9
View File
@@ -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>
+28
View File
@@ -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('<'));
});
+14
View File
@@ -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)}`;