feat(a11y): focus return, typing announcement, shortcuts help (P3-4)

- Focus returns to the trigger when closing 4 genuine dialogs (room-topic
  viewer, reaction viewer, header topic, Search) — 20 inline popouts/menus
  correctly left as-is (returning focus to a hover target would be wrong).
- Typing indicator announced via a visually-hidden role="status" region;
  the visual text is aria-hidden to avoid double announcement.
- New keyboard-shortcuts help dialog (press ?, ignored while typing),
  mounted in ClientNonUIFeatures.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 11:45:22 -04:00
parent 4380041014
commit 21dda93d1b
9 changed files with 297 additions and 7 deletions
-1
View File
@@ -583,7 +583,6 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: false,
clickOutsideDeactivates: true,
onDeactivate: () => setViewTopic(false),
escapeDeactivates: stopPropagation,
@@ -25,3 +25,16 @@ export const RoomViewTyping = style([
export const TypingText = style({
flexGrow: 1,
});
// Visually hidden but available to assistive technology.
export const SrOnly = style({
position: 'absolute',
width: 1,
height: 1,
padding: 0,
margin: -1,
overflow: 'hidden',
clip: 'rect(0, 0, 0, 0)',
whiteSpace: 'nowrap',
border: 0,
});
+19 -2
View File
@@ -37,6 +37,20 @@ export const RoomViewTyping = as<'div', RoomViewTypingProps>(
return null;
}
// A single, non-truncated string for assistive technology to announce.
let typingAnnouncement = '';
if (typingNames.length === 1) {
typingAnnouncement = `${typingNames[0]} is typing`;
} else if (typingNames.length === 2) {
typingAnnouncement = `${typingNames[0]} and ${typingNames[1]} are typing`;
} else if (typingNames.length === 3) {
typingAnnouncement = `${typingNames[0]}, ${typingNames[1]} and ${typingNames[2]} are typing`;
} else {
typingAnnouncement = `${typingNames[0]}, ${typingNames[1]}, ${typingNames[2]} and ${
typingNames.length - 3
} others are typing`;
}
const handleDropAll = () => {
// some homeserver does not timeout typing status
// we have given option so user can drop their typing status
@@ -50,7 +64,10 @@ export const RoomViewTyping = as<'div', RoomViewTypingProps>(
};
return (
<div style={{ position: 'relative' }} aria-live="polite" aria-atomic="false">
<div style={{ position: 'relative' }}>
<span className={css.SrOnly} role="status" aria-live="polite" aria-atomic="true">
{typingAnnouncement}
</span>
<Box
className={classNames(css.RoomViewTyping, className)}
alignItems="Center"
@@ -59,7 +76,7 @@ export const RoomViewTyping = as<'div', RoomViewTypingProps>(
ref={ref}
>
<TypingIndicator />
<Text className={css.TypingText} size="T300" truncate>
<Text className={css.TypingText} size="T300" truncate aria-hidden>
{typingNames.length === 1 && (
<>
<b>{typingNames[0]}</b>
@@ -106,7 +106,6 @@ export const Reactions = as<'div', ReactionsProps>(
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: false,
onDeactivate: () => setViewer(false),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,