fix(a11y): correct call-control aria + picker/recorder/live-chip UX (DP7-DP12)

DP7: fix inverted Deafen/Undeafen aria-label on the sound button so it
  matches the action and tooltip.
DP8: add aria-pressed to Sound/Video/ScreenShare toggles and align the
  Microphone toggle to the same "pressed = feature on/active" semantic.
DP9: return focus to the trigger when the GifPicker closes and cap its
  fixed width to min(312px, calc(100vw - 16px)) so it can't overflow
  narrow viewports.
DP10: drop redundant mouse-only clear onClick nested inside the search
  filter Chip buttons (parent chip/menu already performs the clear);
  the cross icons are now purely decorative.
DP11: constrain the voice recorder widget and let the waveform shrink so
  it fits a narrow composer, and expose the live duration as a role="timer"
  snapshot instead of spamming a screen reader every 100ms.
DP12: announce the live-call participant count via a visually-hidden
  role="status" region.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 22:37:22 -04:00
co-authored by Claude Opus 4.8
parent db86432644
commit 6cf18c3bd9
6 changed files with 120 additions and 139 deletions
+89 -84
View File
@@ -45,93 +45,98 @@ export function LiveChip({ count, room, members }: LiveChipProps) {
};
return (
<PopOut
anchor={cords}
position="Top"
align="Start"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setCords(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<Menu
style={{
maxHeight: '75vh',
maxWidth: toRem(300),
display: 'flex',
<>
<span className={css.SrOnly} role="status" aria-live="polite" aria-atomic="true">
{`${count} in call`}
</span>
<PopOut
anchor={cords}
position="Top"
align="Start"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setCords(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<Box grow="Yes">
<Scroll size="0" hideTrack visibility="Hover">
<Box direction="Column" style={{ padding: config.space.S100 }}>
{members.map((callMember) => {
const userId = callMember.sender;
if (!userId) return null;
const name =
getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
const avatarMxc = getMemberAvatarMxc(room, userId);
const avatarUrl = avatarMxc
? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96) ?? undefined)
: undefined;
<Menu
style={{
maxHeight: '75vh',
maxWidth: toRem(300),
display: 'flex',
}}
>
<Box grow="Yes">
<Scroll size="0" hideTrack visibility="Hover">
<Box direction="Column" style={{ padding: config.space.S100 }}>
{members.map((callMember) => {
const userId = callMember.sender;
if (!userId) return null;
const name =
getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
const avatarMxc = getMemberAvatarMxc(room, userId);
const avatarUrl = avatarMxc
? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96) ?? undefined)
: undefined;
return (
<MenuItem
key={callMember.memberId}
size="400"
variant="Surface"
radii="300"
style={{ paddingLeft: config.space.S200 }}
onClick={(evt) =>
openUserProfile(
room.roomId,
undefined,
userId,
getMouseEventCords(evt.nativeEvent),
'Right',
)
}
before={
<Avatar size="200" radii="400">
<UserAvatar
userId={userId}
src={avatarUrl}
alt={name}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/>
</Avatar>
}
>
<Text size="T300" truncate>
{name}
</Text>
</MenuItem>
);
})}
</Box>
</Scroll>
</Box>
</Menu>
</FocusTrap>
}
>
<Chip
variant="Surface"
fill="Soft"
before={<Badge variant="Critical" fill="Solid" size="200" />}
after={<Icon size="50" src={cords ? Icons.ChevronBottom : Icons.ChevronTop} />}
radii="Pill"
onClick={handleOpenMenu}
return (
<MenuItem
key={callMember.memberId}
size="400"
variant="Surface"
radii="300"
style={{ paddingLeft: config.space.S200 }}
onClick={(evt) =>
openUserProfile(
room.roomId,
undefined,
userId,
getMouseEventCords(evt.nativeEvent),
'Right',
)
}
before={
<Avatar size="200" radii="400">
<UserAvatar
userId={userId}
src={avatarUrl}
alt={name}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/>
</Avatar>
}
>
<Text size="T300" truncate>
{name}
</Text>
</MenuItem>
);
})}
</Box>
</Scroll>
</Box>
</Menu>
</FocusTrap>
}
>
<Text className={css.LiveChipText} as="span" size="L400" truncate>
{count} Live
</Text>
</Chip>
</PopOut>
<Chip
variant="Surface"
fill="Soft"
before={<Badge variant="Critical" fill="Solid" size="200" />}
after={<Icon size="50" src={cords ? Icons.ChevronBottom : Icons.ChevronTop} />}
radii="Pill"
onClick={handleOpenMenu}
>
<Text className={css.LiveChipText} as="span" size="L400" truncate>
{count} Live
</Text>
</Chip>
</PopOut>
</>
);
}