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
parent db86432644
commit 6cf18c3bd9
6 changed files with 120 additions and 139 deletions
+5 -4
View File
@@ -7,6 +7,7 @@ import { useSetting } from '../state/hooks/settings';
import { settingsAtom } from '../state/settings'; import { settingsAtom } from '../state/settings';
const PICKER_WIDTH = 312; const PICKER_WIDTH = 312;
const PICKER_WIDTH_CSS = `min(${PICKER_WIDTH}px, calc(100vw - 16px))`;
type GifPickerInnerProps = { type GifPickerInnerProps = {
onSelect: (url: string, width: number, height: number) => void; onSelect: (url: string, width: number, height: number) => void;
@@ -31,7 +32,7 @@ function GifPickerInner({ onSelect, requestClose, lotusTerminal }: GifPickerInne
); );
return ( return (
<Box direction="Column" style={{ width: `${PICKER_WIDTH}px` }}> <Box direction="Column" style={{ width: PICKER_WIDTH_CSS }}>
{lotusTerminal && ( {lotusTerminal && (
<div <div
style={{ style={{
@@ -88,7 +89,7 @@ export function GifPicker({ apiKey, onSelect, requestClose }: GifPickerProps) {
overflow: 'hidden', overflow: 'hidden',
boxShadow: boxShadow:
'0 4px 24px color-mix(in srgb, var(--lt-accent-orange) 10%, transparent), 0 0 0 1px color-mix(in srgb, var(--lt-accent-orange) 8%, transparent)', '0 4px 24px color-mix(in srgb, var(--lt-accent-orange) 10%, transparent), 0 0 0 1px color-mix(in srgb, var(--lt-accent-orange) 8%, transparent)',
width: `${PICKER_WIDTH}px`, width: PICKER_WIDTH_CSS,
} }
: { : {
background: color.Surface.Container, background: color.Surface.Container,
@@ -96,14 +97,14 @@ export function GifPicker({ apiKey, onSelect, requestClose }: GifPickerProps) {
borderRadius: config.radii.R400, borderRadius: config.radii.R400,
overflow: 'hidden', overflow: 'hidden',
boxShadow: color.Other.Shadow, boxShadow: color.Other.Shadow,
width: `${PICKER_WIDTH}px`, width: PICKER_WIDTH_CSS,
}; };
return ( return (
<FocusTrap <FocusTrap
focusTrapOptions={{ focusTrapOptions={{
initialFocus: false, initialFocus: false,
returnFocusOnDeactivate: false, returnFocusOnDeactivate: true,
onDeactivate: requestClose, onDeactivate: requestClose,
clickOutsideDeactivates: true, clickOutsideDeactivates: true,
allowOutsideClick: true, allowOutsideClick: true,
+5 -1
View File
@@ -197,6 +197,8 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
background: color.SurfaceVariant.Container, background: color.SurfaceVariant.Container,
borderRadius: config.radii.R300, borderRadius: config.radii.R300,
padding: `${toRem(4)} ${toRem(8)}`, padding: `${toRem(4)} ${toRem(8)}`,
maxWidth: '100%',
minWidth: 0,
}} }}
> >
<Box <Box
@@ -212,6 +214,8 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
/> />
<Text <Text
size="T200" size="T200"
role="timer"
aria-label={`Recording duration ${formatDuration(durationMs)}`}
style={{ style={{
minWidth: toRem(32), minWidth: toRem(32),
fontVariantNumeric: 'tabular-nums', fontVariantNumeric: 'tabular-nums',
@@ -230,7 +234,7 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
data-voice-waveform data-voice-waveform
alignItems="Center" alignItems="Center"
gap="100" gap="100"
style={{ height: toRem(20), overflow: 'hidden', flexShrink: 0 }} style={{ height: toRem(20), overflow: 'hidden', flexShrink: 1, minWidth: 0 }}
> >
{waveformBars.map((h, i) => ( {waveformBars.map((h, i) => (
<div <div
+5 -2
View File
@@ -35,7 +35,7 @@ function MicrophoneButton({ enabled, onToggle, disabled }: MicrophoneButtonProps
outlined outlined
disabled={disabled || loading} disabled={disabled || loading}
aria-label={enabled ? 'Turn off microphone' : 'Turn on microphone'} aria-label={enabled ? 'Turn off microphone' : 'Turn on microphone'}
aria-pressed={!enabled} aria-pressed={enabled}
> >
<Icon size="100" src={enabled ? Icons.Mic : Icons.MicMute} filled={!enabled} /> <Icon size="100" src={enabled ? Icons.Mic : Icons.MicMute} filled={!enabled} />
</IconButton> </IconButton>
@@ -67,7 +67,8 @@ function SoundButton({ enabled, onToggle, disabled }: SoundButtonProps) {
radii="300" radii="300"
size="300" size="300"
onClick={() => onToggle()} onClick={() => onToggle()}
aria-label={enabled ? 'Undeafen' : 'Deafen'} aria-label={enabled ? 'Deafen' : 'Undeafen'}
aria-pressed={enabled}
outlined outlined
disabled={disabled} disabled={disabled}
> >
@@ -109,6 +110,7 @@ function VideoButton({ enabled, onToggle, disabled }: VideoButtonProps) {
size="300" size="300"
onClick={toggleVideo} onClick={toggleVideo}
aria-label={enabled ? 'Stop Video' : 'Start Video'} aria-label={enabled ? 'Stop Video' : 'Start Video'}
aria-pressed={enabled}
outlined outlined
disabled={disabled || loading} disabled={disabled || loading}
> >
@@ -147,6 +149,7 @@ function ScreenShareButton({ enabled, onToggle, disabled }: ScreenShareButtonPro
size="300" size="300"
onClick={onToggle} onClick={onToggle}
aria-label={enabled ? 'Stop Screenshare' : 'Start Screenshare'} aria-label={enabled ? 'Stop Screenshare' : 'Start Screenshare'}
aria-pressed={enabled}
outlined outlined
disabled={disabled} disabled={disabled}
> >
+89 -84
View File
@@ -45,93 +45,98 @@ export function LiveChip({ count, room, members }: LiveChipProps) {
}; };
return ( return (
<PopOut <>
anchor={cords} <span className={css.SrOnly} role="status" aria-live="polite" aria-atomic="true">
position="Top" {`${count} in call`}
align="Start" </span>
content={ <PopOut
<FocusTrap anchor={cords}
focusTrapOptions={{ position="Top"
initialFocus: false, align="Start"
onDeactivate: () => setCords(undefined), content={
clickOutsideDeactivates: true, <FocusTrap
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', focusTrapOptions={{
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', initialFocus: false,
escapeDeactivates: stopPropagation, onDeactivate: () => setCords(undefined),
}} clickOutsideDeactivates: true,
> isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
<Menu isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
style={{ escapeDeactivates: stopPropagation,
maxHeight: '75vh',
maxWidth: toRem(300),
display: 'flex',
}} }}
> >
<Box grow="Yes"> <Menu
<Scroll size="0" hideTrack visibility="Hover"> style={{
<Box direction="Column" style={{ padding: config.space.S100 }}> maxHeight: '75vh',
{members.map((callMember) => { maxWidth: toRem(300),
const userId = callMember.sender; display: 'flex',
if (!userId) return null; }}
const name = >
getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId; <Box grow="Yes">
const avatarMxc = getMemberAvatarMxc(room, userId); <Scroll size="0" hideTrack visibility="Hover">
const avatarUrl = avatarMxc <Box direction="Column" style={{ padding: config.space.S100 }}>
? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96) ?? undefined) {members.map((callMember) => {
: undefined; 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 ( return (
<MenuItem <MenuItem
key={callMember.memberId} key={callMember.memberId}
size="400" size="400"
variant="Surface" variant="Surface"
radii="300" radii="300"
style={{ paddingLeft: config.space.S200 }} style={{ paddingLeft: config.space.S200 }}
onClick={(evt) => onClick={(evt) =>
openUserProfile( openUserProfile(
room.roomId, room.roomId,
undefined, undefined,
userId, userId,
getMouseEventCords(evt.nativeEvent), getMouseEventCords(evt.nativeEvent),
'Right', 'Right',
) )
} }
before={ before={
<Avatar size="200" radii="400"> <Avatar size="200" radii="400">
<UserAvatar <UserAvatar
userId={userId} userId={userId}
src={avatarUrl} src={avatarUrl}
alt={name} alt={name}
renderFallback={() => <Icon size="50" src={Icons.User} filled />} renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/> />
</Avatar> </Avatar>
} }
> >
<Text size="T300" truncate> <Text size="T300" truncate>
{name} {name}
</Text> </Text>
</MenuItem> </MenuItem>
); );
})} })}
</Box> </Box>
</Scroll> </Scroll>
</Box> </Box>
</Menu> </Menu>
</FocusTrap> </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}
> >
<Text className={css.LiveChipText} as="span" size="L400" truncate> <Chip
{count} Live variant="Surface"
</Text> fill="Soft"
</Chip> before={<Badge variant="Critical" fill="Solid" size="200" />}
</PopOut> 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>
</>
); );
} }
@@ -5,6 +5,18 @@ export const LiveChipText = style({
color: color.Critical.Main, color: color.Critical.Main,
}); });
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,
});
export const CallStatus = style([ export const CallStatus = style([
{ {
padding: `${toRem(6)} ${config.space.S200}`, padding: `${toRem(6)} ${config.space.S200}`,
@@ -643,18 +643,7 @@ function DateRangeButton({ fromTs, toTs, onChange }: DateRangeButtonProps) {
variant={hasRange ? 'Primary' : 'SurfaceVariant'} variant={hasRange ? 'Primary' : 'SurfaceVariant'}
radii="Pill" radii="Pill"
before={<Icon size="100" src={Icons.RecentClock} />} before={<Icon size="100" src={Icons.RecentClock} />}
after={ after={hasRange ? <Icon size="50" src={Icons.Cross} /> : undefined}
hasRange ? (
<Icon
size="50"
src={Icons.Cross}
onClick={(e) => {
e.stopPropagation();
onChange(undefined, undefined);
}}
/>
) : undefined
}
onClick={(e: React.MouseEvent<HTMLButtonElement>) => onClick={(e: React.MouseEvent<HTMLButtonElement>) =>
setMenuAnchor(e.currentTarget.getBoundingClientRect()) setMenuAnchor(e.currentTarget.getBoundingClientRect())
} }
@@ -795,18 +784,7 @@ export function SearchFilters({
radii="Pill" radii="Pill"
aria-pressed={!!containsUrl} aria-pressed={!!containsUrl}
before={<Icon size="100" src={Icons.Link} />} before={<Icon size="100" src={Icons.Link} />}
after={ after={containsUrl ? <Icon size="50" src={Icons.Cross} /> : undefined}
containsUrl ? (
<Icon
size="50"
src={Icons.Cross}
onClick={(e) => {
e.stopPropagation();
onContainsUrlChange(undefined);
}}
/>
) : undefined
}
onClick={() => onContainsUrlChange(containsUrl ? undefined : true)} onClick={() => onContainsUrlChange(containsUrl ? undefined : true)}
> >
<Text size="T200">Has link</Text> <Text size="T200">Has link</Text>
@@ -821,18 +799,7 @@ export function SearchFilters({
radii="Pill" radii="Pill"
aria-pressed={active} aria-pressed={active}
before={<Icon size="100" src={icon} />} before={<Icon size="100" src={icon} />}
after={ after={active ? <Icon size="50" src={Icons.Cross} /> : undefined}
active ? (
<Icon
size="50"
src={Icons.Cross}
onClick={(e) => {
e.stopPropagation();
onToggleMsgTypeFilter(msgType);
}}
/>
) : undefined
}
onClick={() => onToggleMsgTypeFilter(msgType)} onClick={() => onToggleMsgTypeFilter(msgType)}
> >
<Text size="T200">{label}</Text> <Text size="T200">{label}</Text>
@@ -845,18 +812,7 @@ export function SearchFilters({
radii="Pill" radii="Pill"
aria-pressed={pinnedOnly} aria-pressed={pinnedOnly}
before={<Icon size="100" src={Icons.Pin} />} before={<Icon size="100" src={Icons.Pin} />}
after={ after={pinnedOnly ? <Icon size="50" src={Icons.Cross} /> : undefined}
pinnedOnly ? (
<Icon
size="50"
src={Icons.Cross}
onClick={(e) => {
e.stopPropagation();
onTogglePinnedOnly();
}}
/>
) : undefined
}
onClick={onTogglePinnedOnly} onClick={onTogglePinnedOnly}
> >
<Text size="T200">Pinned</Text> <Text size="T200">Pinned</Text>