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;
|
maxHeight?: string;
|
||||||
editor: Editor;
|
editor: Editor;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
/** Explicit accessible name for the textbox; falls back to the placeholder. */
|
||||||
|
ariaLabel?: string;
|
||||||
onKeyDown?: KeyboardEventHandler;
|
onKeyDown?: KeyboardEventHandler;
|
||||||
onKeyUp?: KeyboardEventHandler;
|
onKeyUp?: KeyboardEventHandler;
|
||||||
onChange?: EditorChangeHandler;
|
onChange?: EditorChangeHandler;
|
||||||
@@ -82,6 +84,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
|
|||||||
maxHeight = '50vh',
|
maxHeight = '50vh',
|
||||||
editor,
|
editor,
|
||||||
placeholder,
|
placeholder,
|
||||||
|
ariaLabel,
|
||||||
onKeyDown,
|
onKeyDown,
|
||||||
onKeyUp,
|
onKeyUp,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -139,7 +142,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
|
|||||||
data-editable-name={editableName}
|
data-editable-name={editableName}
|
||||||
className={css.EditorTextarea}
|
className={css.EditorTextarea}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
aria-label={placeholder ?? 'Message input'}
|
aria-label={ariaLabel ?? placeholder ?? 'Message input'}
|
||||||
aria-multiline="true"
|
aria-multiline="true"
|
||||||
renderPlaceholder={renderPlaceholder}
|
renderPlaceholder={renderPlaceholder}
|
||||||
renderElement={renderElement}
|
renderElement={renderElement}
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ import {
|
|||||||
getMemberDisplayName,
|
getMemberDisplayName,
|
||||||
} from '../../../utils/room';
|
} from '../../../utils/room';
|
||||||
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
|
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
|
||||||
|
import { messageAriaLabel } from '../../../utils/a11y';
|
||||||
import { MessageLayout, MessageSpacing } from '../../../state/settings';
|
import { MessageLayout, MessageSpacing } from '../../../state/settings';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
|
import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
|
||||||
@@ -972,6 +973,10 @@ export const Message = React.memo(
|
|||||||
[MsgAppearClass]: playAppear,
|
[MsgAppearClass]: playAppear,
|
||||||
[MentionHighlightPulse]: playMentionPulse,
|
[MentionHighlightPulse]: playMentionPulse,
|
||||||
})}
|
})}
|
||||||
|
role="article"
|
||||||
|
aria-label={
|
||||||
|
collapse ? messageAriaLabel(senderDisplayName, mEvent.getTs(), hour24Clock) : undefined
|
||||||
|
}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
space={messageSpacing}
|
space={messageSpacing}
|
||||||
collapse={collapse}
|
collapse={collapse}
|
||||||
|
|||||||
@@ -51,7 +51,13 @@ import { UseStateProvider } from '../../../components/UseStateProvider';
|
|||||||
import { EmojiBoard } from '../../../components/emoji-board';
|
import { EmojiBoard } from '../../../components/emoji-board';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
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 { mobileOrTablet } from '../../../utils/user-agent';
|
||||||
import { useComposingCheck } from '../../../hooks/useComposingCheck';
|
import { useComposingCheck } from '../../../hooks/useComposingCheck';
|
||||||
|
|
||||||
@@ -66,6 +72,12 @@ export const MessageEditor = as<'div', MessageEditorProps>(
|
|||||||
({ room, roomId, mEvent, imagePackRooms, onCancel, ...props }, ref) => {
|
({ room, roomId, mEvent, imagePackRooms, onCancel, ...props }, ref) => {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const editor = useEditor();
|
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 [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
|
||||||
const [globalToolbar] = useSetting(settingsAtom, 'editorToolbar');
|
const [globalToolbar] = useSetting(settingsAtom, 'editorToolbar');
|
||||||
const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
|
const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
|
||||||
@@ -259,6 +271,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
|
|||||||
<CustomEditor
|
<CustomEditor
|
||||||
editor={editor}
|
editor={editor}
|
||||||
placeholder="Edit message..."
|
placeholder="Edit message..."
|
||||||
|
ariaLabel={editSenderId ? `Editing message from ${editSenderName}` : 'Edit message'}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onKeyUp={handleKeyUp}
|
onKeyUp={handleKeyUp}
|
||||||
bottom={
|
bottom={
|
||||||
|
|||||||
@@ -229,13 +229,21 @@ export const scaleSystemEmoji = (text: string): (string | JSX.Element)[] =>
|
|||||||
findAndReplace(
|
findAndReplace(
|
||||||
text,
|
text,
|
||||||
EMOJI_REG_G,
|
EMOJI_REG_G,
|
||||||
(match, pushIndex) => (
|
(match, pushIndex) => {
|
||||||
<span key={`scaleSystemEmoji-${pushIndex}`} className={css.EmoticonBase}>
|
const shortcode = getShortcodeFor(getHexcodeForEmoji(match[0]));
|
||||||
<span className={css.Emoticon()} title={getShortcodeFor(getHexcodeForEmoji(match[0]))}>
|
return (
|
||||||
{match[0]}
|
<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>
|
||||||
</span>
|
);
|
||||||
),
|
},
|
||||||
(txt) => txt,
|
(txt) => txt,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -574,10 +582,20 @@ export const getReactCustomHtmlParser = (
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (htmlSrc && 'data-mx-emoticon' in props) {
|
if (htmlSrc && 'data-mx-emoticon' in props) {
|
||||||
|
const emoticonAlt =
|
||||||
|
(typeof props.alt === 'string' && props.alt) ||
|
||||||
|
(typeof props.title === 'string' && props.title) ||
|
||||||
|
'emoji';
|
||||||
return (
|
return (
|
||||||
<span className={css.EmoticonBase}>
|
<span className={css.EmoticonBase}>
|
||||||
<span className={css.Emoticon()}>
|
<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>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -611,7 +629,6 @@ export const getReactCustomHtmlParser = (
|
|||||||
<>
|
<>
|
||||||
{segments.map((segment, index) => {
|
{segments.map((segment, index) => {
|
||||||
if (segment.type === 'text') {
|
if (segment.type === 'text') {
|
||||||
// eslint-disable-next-line react/no-array-index-key
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={index}>{renderTextChunk(segment.value)}</React.Fragment>
|
<React.Fragment key={index}>{renderTextChunk(segment.value)}</React.Fragment>
|
||||||
);
|
);
|
||||||
@@ -619,7 +636,6 @@ export const getReactCustomHtmlParser = (
|
|||||||
const raw =
|
const raw =
|
||||||
segment.type === 'block' ? `$$${segment.value}$$` : `$${segment.value}$`;
|
segment.type === 'block' ? `$$${segment.value}$$` : `$${segment.value}$`;
|
||||||
return (
|
return (
|
||||||
// eslint-disable-next-line react/no-array-index-key
|
|
||||||
<React.Fragment key={index}>
|
<React.Fragment key={index}>
|
||||||
{renderMath(segment.value, segment.type === 'block', raw, raw)}
|
{renderMath(segment.value, segment.type === 'block', raw, raw)}
|
||||||
</React.Fragment>
|
</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