Skip to content

Commit 112718b

Browse files
committed
feat: add a11y for audio player and the rest of the buttons
1 parent f31554f commit 112718b

46 files changed

Lines changed: 738 additions & 115 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

src/components/Attachment/Audio.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,12 +46,22 @@ const AudioAttachmentUI = ({ audioPlayer }: AudioAttachmentUIProps) => {
4646
isPlaying={!!isPlaying}
4747
secondsElapsed={secondsElapsed}
4848
/>
49-
<ProgressBar progress={progress ?? 0} seek={audioPlayer.seek} />
49+
<ProgressBar
50+
durationSeconds={durationSeconds}
51+
progress={progress ?? 0}
52+
secondsElapsed={secondsElapsed}
53+
seek={audioPlayer.seek}
54+
/>
5055
</>
5156
) : (
5257
<>
5358
<FileSizeIndicator fileSize={audioPlayer.fileSize} />
54-
<ProgressBar progress={progress ?? 0} seek={audioPlayer.seek} />
59+
<ProgressBar
60+
durationSeconds={durationSeconds}
61+
progress={progress ?? 0}
62+
secondsElapsed={secondsElapsed}
63+
seek={audioPlayer.seek}
64+
/>
5565
</>
5666
)}
5767
</div>

src/components/Attachment/Geolocation.tsx

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -100,19 +100,24 @@ export type GeolocationAttachmentMapPlaceholderProps = {
100100

101101
const DefaultGeolocationAttachmentMapPlaceholder = ({
102102
location,
103-
}: GeolocationAttachmentMapPlaceholderProps) => (
104-
<div
105-
className='str-chat__message-attachment-geolocation__placeholder'
106-
data-testid='geolocation-attachment-map-placeholder'
107-
>
108-
<IconLocation />
109-
<a
110-
className='str-chat__message-attachment-geolocation__placeholder-link'
111-
href={`https://maps.google.com?q=${[location.latitude, location.longitude].join()}`}
112-
rel='noreferrer'
113-
target='_blank'
103+
}: GeolocationAttachmentMapPlaceholderProps) => {
104+
const { t } = useTranslationContext();
105+
106+
return (
107+
<div
108+
className='str-chat__message-attachment-geolocation__placeholder'
109+
data-testid='geolocation-attachment-map-placeholder'
114110
>
115-
<ExternalLinkIcon />
116-
</a>
117-
</div>
118-
);
111+
<IconLocation />
112+
<a
113+
aria-label={t('Open location in a map')}
114+
className='str-chat__message-attachment-geolocation__placeholder-link'
115+
href={`https://maps.google.com?q=${[location.latitude, location.longitude].join()}`}
116+
rel='noreferrer'
117+
target='_blank'
118+
>
119+
<ExternalLinkIcon />
120+
</a>
121+
</div>
122+
);
123+
};

src/components/Attachment/LinkPreview/CardAudio.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,10 @@ const SourceLink = ({
3939
);
4040

4141
const audioPlayerStateSelector = (state: AudioPlayerState) => ({
42+
durationSeconds: state.durationSeconds,
4243
isPlaying: state.isPlaying,
4344
progress: state.progressPercent,
45+
secondsElapsed: state.secondsElapsed,
4446
});
4547

4648
const AudioWidget = ({ mimeType, src }: { src: string; mimeType?: string }) => {
@@ -63,7 +65,7 @@ const AudioWidget = ({ mimeType, src }: { src: string; mimeType?: string }) => {
6365
src,
6466
});
6567

66-
const { isPlaying, progress } =
68+
const { durationSeconds, isPlaying, progress, secondsElapsed } =
6769
useStateStore(audioPlayer?.state, audioPlayerStateSelector) ?? {};
6870

6971
if (!audioPlayer) return;
@@ -73,7 +75,12 @@ const AudioWidget = ({ mimeType, src }: { src: string; mimeType?: string }) => {
7375
<div className='str-chat__message-attachment-audio-widget--play-controls'>
7476
<PlayButton isPlaying={!!isPlaying} onClick={audioPlayer.togglePlay} />
7577
</div>
76-
<ProgressBar progress={progress ?? 0} seek={audioPlayer.seek} />
78+
<ProgressBar
79+
durationSeconds={durationSeconds}
80+
progress={progress ?? 0}
81+
secondsElapsed={secondsElapsed}
82+
seek={audioPlayer.seek}
83+
/>
7784
</div>
7885
);
7986
};

src/components/Attachment/VoiceRecording.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ type VoiceRecordingPlayerUIProps = {
3737
// todo: finish creating a BaseAudioPlayer derived from VoiceRecordingPlayerUI and AudioAttachmentUI
3838
const VoiceRecordingPlayerUI = ({ audioPlayer }: VoiceRecordingPlayerUIProps) => {
3939
const { FileSizeIndicator = DefaultFileSizeIndicator } = useComponentContext();
40+
const { t } = useTranslationContext();
4041
const {
4142
canPlayRecord,
4243
durationSeconds,
@@ -69,14 +70,19 @@ const VoiceRecordingPlayerUI = ({ audioPlayer }: VoiceRecordingPlayerUIProps) =>
6970
)}
7071
</div>
7172
<WaveProgressBar
73+
durationSeconds={durationSeconds}
7274
progress={progress}
75+
secondsElapsed={secondsElapsed}
7376
seek={audioPlayer.seek}
7477
waveformData={audioPlayer.waveformData || []}
7578
/>
7679
</div>
7780
</div>
7881
<div className='str-chat__message-attachment__voice-recording-widget__right-section'>
7982
<PlaybackRateButton
83+
aria-label={t('Playback speed {{ rate }}x', {
84+
rate: playbackRate?.toString() ?? '1',
85+
})}
8086
disabled={!canPlayRecord}
8187
onClick={audioPlayer.increasePlaybackRate}
8288
>

src/components/Attachment/__tests__/WaveProgressBar.test.tsx

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,15 +127,21 @@ describe('WaveProgressBar', () => {
127127
expect(screen.queryByTestId(PROGRESS_INDICATOR_TEST_ID)).not.toHaveStyle('left: 0px');
128128
});
129129

130-
it('adds keyboard focus semantics and supports keyboard seek', () => {
130+
it('adds slider semantics and supports keyboard seek', () => {
131131
const seek = vi.fn();
132132
render(<WaveProgressBar progress={20} seek={seek} waveformData={originalSample} />);
133133

134134
const root = screen.getByTestId(BAR_ROOT_TEST_ID);
135+
expect(root).toHaveAttribute('role', 'slider');
136+
expect(root).toHaveAttribute('aria-label', 'aria/Seek audio position');
135137
expect(root).toHaveAttribute('tabindex', '0');
136138
expect(root).toHaveAttribute('aria-valuemin', '0');
137139
expect(root).toHaveAttribute('aria-valuemax', '100');
138140
expect(root).toHaveAttribute('aria-valuenow', '20');
141+
expect(root).toHaveAttribute(
142+
'aria-valuetext',
143+
'aria/Audio position {{ progress }} percent',
144+
);
139145

140146
fireEvent.keyDown(root, { key: 'End' });
141147

@@ -144,4 +150,17 @@ describe('WaveProgressBar', () => {
144150
currentTarget: root,
145151
});
146152
});
153+
154+
it('seeks backward with PageDown key using larger step', () => {
155+
const seek = vi.fn();
156+
render(<WaveProgressBar progress={20} seek={seek} waveformData={originalSample} />);
157+
158+
const root = screen.getByTestId(BAR_ROOT_TEST_ID);
159+
fireEvent.keyDown(root, { key: 'PageDown' });
160+
161+
expect(seek).toHaveBeenCalledWith({
162+
clientX: 12,
163+
currentTarget: root,
164+
});
165+
});
147166
});

src/components/AudioPlayback/__tests__/ProgressBar.test.tsx

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,20 @@ import { fromPartial } from '@total-typescript/shoehorn';
44
import { ProgressBar } from '../components/ProgressBar';
55

66
describe('ProgressBar', () => {
7-
it('adds keyboard focus semantics', () => {
7+
it('adds slider semantics', () => {
88
render(<ProgressBar progress={40} seek={vi.fn()} />);
99

1010
const root = screen.getByTestId('audio-progress');
11+
expect(root).toHaveAttribute('role', 'slider');
12+
expect(root).toHaveAttribute('aria-label', 'aria/Seek audio position');
1113
expect(root).toHaveAttribute('tabindex', '0');
1214
expect(root).toHaveAttribute('aria-valuemin', '0');
1315
expect(root).toHaveAttribute('aria-valuemax', '100');
1416
expect(root).toHaveAttribute('aria-valuenow', '40');
17+
expect(root).toHaveAttribute(
18+
'aria-valuetext',
19+
'aria/Audio position {{ progress }} percent',
20+
);
1521
});
1622

1723
it('seeks forward with ArrowRight key', () => {
@@ -33,4 +39,41 @@ describe('ProgressBar', () => {
3339
currentTarget: root,
3440
});
3541
});
42+
43+
it('seeks forward with PageUp key using larger step', () => {
44+
const seek = vi.fn();
45+
render(<ProgressBar progress={40} seek={seek} />);
46+
47+
const root = screen.getByTestId('audio-progress');
48+
vi.spyOn(root, 'getBoundingClientRect').mockReturnValue(
49+
fromPartial<DOMRect>({
50+
width: 200,
51+
x: 10,
52+
}),
53+
);
54+
55+
fireEvent.keyDown(root, { key: 'PageUp' });
56+
57+
expect(seek).toHaveBeenCalledWith({
58+
clientX: 110,
59+
currentTarget: root,
60+
});
61+
});
62+
63+
it('adds time-based aria-valuetext when duration data is available', () => {
64+
render(
65+
<ProgressBar
66+
durationSeconds={200}
67+
progress={40}
68+
secondsElapsed={80}
69+
seek={vi.fn()}
70+
/>,
71+
);
72+
73+
const root = screen.getByTestId('audio-progress');
74+
expect(root).toHaveAttribute(
75+
'aria-valuetext',
76+
'aria/Audio position {{ elapsed }} of {{ duration }}',
77+
);
78+
});
3679
});

src/components/AudioPlayback/components/DurationDisplay.tsx

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import React from 'react';
22
import clsx from 'clsx';
3+
import { formatTime } from './formatTime';
34

45
type DurationDisplayProps = {
56
/** Whether audio is currently playing */
@@ -14,22 +15,6 @@ type DurationDisplayProps = {
1415
showRemaining?: boolean;
1516
};
1617

17-
function formatTime(totalSeconds?: number, rounding: 'ceil' | 'floor' = 'ceil') {
18-
if (totalSeconds == null || Number.isNaN(totalSeconds) || totalSeconds < 0) {
19-
return null;
20-
}
21-
const roundedSeconds =
22-
rounding === 'floor' ? Math.floor(totalSeconds) : Math.ceil(totalSeconds);
23-
const hours = Math.floor(roundedSeconds / 3600);
24-
const minutes = Math.floor((roundedSeconds % 3600) / 60);
25-
const seconds = roundedSeconds % 60;
26-
const minSec = `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(
27-
2,
28-
'0',
29-
)}`;
30-
return hours ? `${String(hours).padStart(2, '0')}:${minSec}` : minSec;
31-
}
32-
3318
export function DurationDisplay({
3419
className,
3520
duration,

src/components/AudioPlayback/components/ProgressBar.tsx

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,31 @@
11
import clsx from 'clsx';
22
import React from 'react';
33
import { handleProgressBarKeyboardSeek } from './keyboardSeek';
4+
import { getAudioTrackSliderAriaValueText } from './progressBarA11y';
45
import { useInteractiveProgressBar } from './useInteractiveProgressBar';
56
import type { SeekFn as AudioPlayerSeekFn } from '../AudioPlayer';
7+
import { useTranslationContext } from '../../../context';
68

79
type SeekParams = Parameters<AudioPlayerSeekFn>[0];
810

911
export type ProgressBarProps = {
1012
/** Progress expressed in fractional number value btw 0 and 100. */
1113
progress: number;
14+
/** Total track duration in seconds. */
15+
durationSeconds?: number;
16+
/** Current elapsed position in seconds. */
17+
secondsElapsed?: number;
1218
seek: (params: SeekParams) => void;
1319
} & Pick<React.ComponentProps<'div'>, 'className'>;
1420

15-
export const ProgressBar = ({ className, progress, seek }: ProgressBarProps) => {
21+
export const ProgressBar = ({
22+
className,
23+
durationSeconds,
24+
progress,
25+
secondsElapsed,
26+
seek,
27+
}: ProgressBarProps) => {
28+
const { t } = useTranslationContext('ProgressBar');
1629
const {
1730
handleDrag,
1831
handleDragStart,
@@ -21,33 +34,47 @@ export const ProgressBar = ({ className, progress, seek }: ProgressBarProps) =>
2134
setProgressIndicator,
2235
setRoot,
2336
} = useInteractiveProgressBar({ progress, seek });
37+
const normalizedProgress = Math.max(0, Math.min(100, progress));
38+
const ariaValueText = getAudioTrackSliderAriaValueText({
39+
durationSeconds,
40+
progress: normalizedProgress,
41+
secondsElapsed,
42+
t,
43+
});
2444

2545
return (
2646
<div
47+
aria-label={t('aria/Seek audio position')}
48+
aria-orientation='horizontal'
2749
aria-valuemax={100}
2850
aria-valuemin={0}
29-
aria-valuenow={Math.round(progress)}
51+
aria-valuenow={Math.round(normalizedProgress)}
52+
aria-valuetext={ariaValueText}
3053
className={clsx(
3154
'str-chat__message-attachment-audio-widget--progress-track',
3255
className,
3356
)}
34-
data-progress={progress}
57+
data-progress={normalizedProgress}
3558
data-testid='audio-progress'
3659
onClick={seek}
37-
onKeyDown={(event) => handleProgressBarKeyboardSeek({ event, progress, seek })}
60+
onKeyDown={(event) =>
61+
handleProgressBarKeyboardSeek({ event, progress: normalizedProgress, seek })
62+
}
3863
onPointerDown={handleDragStart}
3964
onPointerMove={handleDrag}
4065
onPointerUp={handleDragStop}
4166
ref={setRoot}
42-
role='progressbar'
67+
role='slider'
4368
style={
4469
{
45-
'--str-chat__message-attachment-audio-widget-progress': progress + '%',
70+
'--str-chat__message-attachment-audio-widget-progress':
71+
normalizedProgress + '%',
4672
} as React.CSSProperties
4773
}
4874
tabIndex={0}
4975
>
5076
<div
77+
aria-hidden='true'
5178
className='str-chat__message-attachment-audio-widget--progress-indicator'
5279
ref={setProgressIndicator}
5380
style={{ insetInlineStart: `${indicatorLeft}px` }}

0 commit comments

Comments
 (0)