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:
@@ -7,6 +7,7 @@ import { useSetting } from '../state/hooks/settings';
|
||||
import { settingsAtom } from '../state/settings';
|
||||
|
||||
const PICKER_WIDTH = 312;
|
||||
const PICKER_WIDTH_CSS = `min(${PICKER_WIDTH}px, calc(100vw - 16px))`;
|
||||
|
||||
type GifPickerInnerProps = {
|
||||
onSelect: (url: string, width: number, height: number) => void;
|
||||
@@ -31,7 +32,7 @@ function GifPickerInner({ onSelect, requestClose, lotusTerminal }: GifPickerInne
|
||||
);
|
||||
|
||||
return (
|
||||
<Box direction="Column" style={{ width: `${PICKER_WIDTH}px` }}>
|
||||
<Box direction="Column" style={{ width: PICKER_WIDTH_CSS }}>
|
||||
{lotusTerminal && (
|
||||
<div
|
||||
style={{
|
||||
@@ -88,7 +89,7 @@ export function GifPicker({ apiKey, onSelect, requestClose }: GifPickerProps) {
|
||||
overflow: 'hidden',
|
||||
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)',
|
||||
width: `${PICKER_WIDTH}px`,
|
||||
width: PICKER_WIDTH_CSS,
|
||||
}
|
||||
: {
|
||||
background: color.Surface.Container,
|
||||
@@ -96,14 +97,14 @@ export function GifPicker({ apiKey, onSelect, requestClose }: GifPickerProps) {
|
||||
borderRadius: config.radii.R400,
|
||||
overflow: 'hidden',
|
||||
boxShadow: color.Other.Shadow,
|
||||
width: `${PICKER_WIDTH}px`,
|
||||
width: PICKER_WIDTH_CSS,
|
||||
};
|
||||
|
||||
return (
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
returnFocusOnDeactivate: false,
|
||||
returnFocusOnDeactivate: true,
|
||||
onDeactivate: requestClose,
|
||||
clickOutsideDeactivates: true,
|
||||
allowOutsideClick: true,
|
||||
|
||||
@@ -197,6 +197,8 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
|
||||
background: color.SurfaceVariant.Container,
|
||||
borderRadius: config.radii.R300,
|
||||
padding: `${toRem(4)} ${toRem(8)}`,
|
||||
maxWidth: '100%',
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
@@ -212,6 +214,8 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
|
||||
/>
|
||||
<Text
|
||||
size="T200"
|
||||
role="timer"
|
||||
aria-label={`Recording duration ${formatDuration(durationMs)}`}
|
||||
style={{
|
||||
minWidth: toRem(32),
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
@@ -230,7 +234,7 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
|
||||
data-voice-waveform
|
||||
alignItems="Center"
|
||||
gap="100"
|
||||
style={{ height: toRem(20), overflow: 'hidden', flexShrink: 0 }}
|
||||
style={{ height: toRem(20), overflow: 'hidden', flexShrink: 1, minWidth: 0 }}
|
||||
>
|
||||
{waveformBars.map((h, i) => (
|
||||
<div
|
||||
|
||||
@@ -35,7 +35,7 @@ function MicrophoneButton({ enabled, onToggle, disabled }: MicrophoneButtonProps
|
||||
outlined
|
||||
disabled={disabled || loading}
|
||||
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} />
|
||||
</IconButton>
|
||||
@@ -67,7 +67,8 @@ function SoundButton({ enabled, onToggle, disabled }: SoundButtonProps) {
|
||||
radii="300"
|
||||
size="300"
|
||||
onClick={() => onToggle()}
|
||||
aria-label={enabled ? 'Undeafen' : 'Deafen'}
|
||||
aria-label={enabled ? 'Deafen' : 'Undeafen'}
|
||||
aria-pressed={enabled}
|
||||
outlined
|
||||
disabled={disabled}
|
||||
>
|
||||
@@ -109,6 +110,7 @@ function VideoButton({ enabled, onToggle, disabled }: VideoButtonProps) {
|
||||
size="300"
|
||||
onClick={toggleVideo}
|
||||
aria-label={enabled ? 'Stop Video' : 'Start Video'}
|
||||
aria-pressed={enabled}
|
||||
outlined
|
||||
disabled={disabled || loading}
|
||||
>
|
||||
@@ -147,6 +149,7 @@ function ScreenShareButton({ enabled, onToggle, disabled }: ScreenShareButtonPro
|
||||
size="300"
|
||||
onClick={onToggle}
|
||||
aria-label={enabled ? 'Stop Screenshare' : 'Start Screenshare'}
|
||||
aria-pressed={enabled}
|
||||
outlined
|
||||
disabled={disabled}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,18 @@ export const LiveChipText = style({
|
||||
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([
|
||||
{
|
||||
padding: `${toRem(6)} ${config.space.S200}`,
|
||||
|
||||
@@ -643,18 +643,7 @@ function DateRangeButton({ fromTs, toTs, onChange }: DateRangeButtonProps) {
|
||||
variant={hasRange ? 'Primary' : 'SurfaceVariant'}
|
||||
radii="Pill"
|
||||
before={<Icon size="100" src={Icons.RecentClock} />}
|
||||
after={
|
||||
hasRange ? (
|
||||
<Icon
|
||||
size="50"
|
||||
src={Icons.Cross}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onChange(undefined, undefined);
|
||||
}}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
after={hasRange ? <Icon size="50" src={Icons.Cross} /> : undefined}
|
||||
onClick={(e: React.MouseEvent<HTMLButtonElement>) =>
|
||||
setMenuAnchor(e.currentTarget.getBoundingClientRect())
|
||||
}
|
||||
@@ -795,18 +784,7 @@ export function SearchFilters({
|
||||
radii="Pill"
|
||||
aria-pressed={!!containsUrl}
|
||||
before={<Icon size="100" src={Icons.Link} />}
|
||||
after={
|
||||
containsUrl ? (
|
||||
<Icon
|
||||
size="50"
|
||||
src={Icons.Cross}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onContainsUrlChange(undefined);
|
||||
}}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
after={containsUrl ? <Icon size="50" src={Icons.Cross} /> : undefined}
|
||||
onClick={() => onContainsUrlChange(containsUrl ? undefined : true)}
|
||||
>
|
||||
<Text size="T200">Has link</Text>
|
||||
@@ -821,18 +799,7 @@ export function SearchFilters({
|
||||
radii="Pill"
|
||||
aria-pressed={active}
|
||||
before={<Icon size="100" src={icon} />}
|
||||
after={
|
||||
active ? (
|
||||
<Icon
|
||||
size="50"
|
||||
src={Icons.Cross}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleMsgTypeFilter(msgType);
|
||||
}}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
after={active ? <Icon size="50" src={Icons.Cross} /> : undefined}
|
||||
onClick={() => onToggleMsgTypeFilter(msgType)}
|
||||
>
|
||||
<Text size="T200">{label}</Text>
|
||||
@@ -845,18 +812,7 @@ export function SearchFilters({
|
||||
radii="Pill"
|
||||
aria-pressed={pinnedOnly}
|
||||
before={<Icon size="100" src={Icons.Pin} />}
|
||||
after={
|
||||
pinnedOnly ? (
|
||||
<Icon
|
||||
size="50"
|
||||
src={Icons.Cross}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onTogglePinnedOnly();
|
||||
}}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
after={pinnedOnly ? <Icon size="50" src={Icons.Cross} /> : undefined}
|
||||
onClick={onTogglePinnedOnly}
|
||||
>
|
||||
<Text size="T200">Pinned</Text>
|
||||
|
||||
Reference in New Issue
Block a user