fix(a11y): review-wave fixes (P3-4)
CI / Build & Quality Checks (push) Successful in 11m3s
CI / Trigger Desktop Build (push) Successful in 22s

- `?` shortcut now stopImmediatePropagation so RoomView's type-to-focus handler
  doesn't steal focus into the composer behind the dialog (and swallow Escape) —
  CONFIRMED review finding.
- Typing live region stays mounted (empty when idle) so the FIRST "X is typing"
  is reliably announced (a status region added with its text isn't always read).
- Removed a stray empty `{}` JSX expression in MediaGallery (leftover from an
  auto-fix).

Reviewer verified the rest: collapsed-message labels, focus-return
classification (4 dialogs fixed, popouts correctly left), and all aria fixes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 11:57:32 -04:00
parent b5e7bcc0b8
commit 4d7a05c0f1
3 changed files with 85 additions and 80 deletions
-1
View File
@@ -261,7 +261,6 @@ function Lightbox({
escapeDeactivates: false, escapeDeactivates: false,
}} }}
> >
{}
<div <div
role="dialog" role="dialog"
aria-modal aria-modal
+81 -79
View File
@@ -33,11 +33,10 @@ export const RoomViewTyping = as<'div', RoomViewTypingProps>(
[typingMembers, myUserId, room], [typingMembers, myUserId, room],
); );
if (typingNames.length === 0) {
return null;
}
// A single, non-truncated string for assistive technology to announce. // A single, non-truncated string for assistive technology to announce.
// Computed even when empty so the live region can stay mounted (below) —
// a `role="status"` region added to the DOM together with its first text
// is not reliably announced by some screen readers.
let typingAnnouncement = ''; let typingAnnouncement = '';
if (typingNames.length === 1) { if (typingNames.length === 1) {
typingAnnouncement = `${typingNames[0]} is typing`; typingAnnouncement = `${typingNames[0]} is typing`;
@@ -65,85 +64,88 @@ export const RoomViewTyping = as<'div', RoomViewTypingProps>(
return ( return (
<div style={{ position: 'relative' }}> <div style={{ position: 'relative' }}>
{/* Persistently mounted so the FIRST "X is typing" is announced. */}
<span className={css.SrOnly} role="status" aria-live="polite" aria-atomic="true"> <span className={css.SrOnly} role="status" aria-live="polite" aria-atomic="true">
{typingAnnouncement} {typingAnnouncement}
</span> </span>
<Box {typingNames.length > 0 && (
className={classNames(css.RoomViewTyping, className)} <Box
alignItems="Center" className={classNames(css.RoomViewTyping, className)}
gap="400" alignItems="Center"
{...props} gap="400"
ref={ref} {...props}
> ref={ref}
<TypingIndicator />
<Text className={css.TypingText} size="T300" truncate aria-hidden>
{typingNames.length === 1 && (
<>
<b>{typingNames[0]}</b>
<Text as="span" size="Inherit" priority="300">
{' is typing...'}
</Text>
</>
)}
{typingNames.length === 2 && (
<>
<b>{typingNames[0]}</b>
<Text as="span" size="Inherit" priority="300">
{' and '}
</Text>
<b>{typingNames[1]}</b>
<Text as="span" size="Inherit" priority="300">
{' are typing...'}
</Text>
</>
)}
{typingNames.length === 3 && (
<>
<b>{typingNames[0]}</b>
<Text as="span" size="Inherit" priority="300">
{', '}
</Text>
<b>{typingNames[1]}</b>
<Text as="span" size="Inherit" priority="300">
{' and '}
</Text>
<b>{typingNames[2]}</b>
<Text as="span" size="Inherit" priority="300">
{' are typing...'}
</Text>
</>
)}
{typingNames.length > 3 && (
<>
<b>{typingNames[0]}</b>
<Text as="span" size="Inherit" priority="300">
{', '}
</Text>
<b>{typingNames[1]}</b>
<Text as="span" size="Inherit" priority="300">
{', '}
</Text>
<b>{typingNames[2]}</b>
<Text as="span" size="Inherit" priority="300">
{' and '}
</Text>
<b>{typingNames.length - 3} others</b>
<Text as="span" size="Inherit" priority="300">
{' are typing...'}
</Text>
</>
)}
</Text>
<IconButton
title="Drop Typing Status"
aria-label="Drop typing status"
size="300"
radii="Pill"
onClick={handleDropAll}
> >
<Icon size="50" src={Icons.Cross} /> <TypingIndicator />
</IconButton> <Text className={css.TypingText} size="T300" truncate aria-hidden>
</Box> {typingNames.length === 1 && (
<>
<b>{typingNames[0]}</b>
<Text as="span" size="Inherit" priority="300">
{' is typing...'}
</Text>
</>
)}
{typingNames.length === 2 && (
<>
<b>{typingNames[0]}</b>
<Text as="span" size="Inherit" priority="300">
{' and '}
</Text>
<b>{typingNames[1]}</b>
<Text as="span" size="Inherit" priority="300">
{' are typing...'}
</Text>
</>
)}
{typingNames.length === 3 && (
<>
<b>{typingNames[0]}</b>
<Text as="span" size="Inherit" priority="300">
{', '}
</Text>
<b>{typingNames[1]}</b>
<Text as="span" size="Inherit" priority="300">
{' and '}
</Text>
<b>{typingNames[2]}</b>
<Text as="span" size="Inherit" priority="300">
{' are typing...'}
</Text>
</>
)}
{typingNames.length > 3 && (
<>
<b>{typingNames[0]}</b>
<Text as="span" size="Inherit" priority="300">
{', '}
</Text>
<b>{typingNames[1]}</b>
<Text as="span" size="Inherit" priority="300">
{', '}
</Text>
<b>{typingNames[2]}</b>
<Text as="span" size="Inherit" priority="300">
{' and '}
</Text>
<b>{typingNames.length - 3} others</b>
<Text as="span" size="Inherit" priority="300">
{' are typing...'}
</Text>
</>
)}
</Text>
<IconButton
title="Drop Typing Status"
aria-label="Drop typing status"
size="300"
radii="Pill"
onClick={handleDropAll}
>
<Icon size="50" src={Icons.Cross} />
</IconButton>
</Box>
)}
</div> </div>
); );
}, },
@@ -55,6 +55,10 @@ export function useKeyboardShortcutsTrigger() {
// `?` is produced by Shift + `/` on the common layouts. // `?` is produced by Shift + `/` on the common layouts.
if (evt.key === '?') { if (evt.key === '?') {
evt.preventDefault(); evt.preventDefault();
// Stop RoomView's window-level "type any char → focus composer"
// handler from also firing — otherwise focus lands in the composer
// behind the dialog and Escape gets swallowed by the contenteditable.
evt.stopImmediatePropagation();
setOpen(true); setOpen(true);
} }
}, },