Skip to content

Commit 3c25ee6

Browse files
authored
Merge pull request #243 from ways2read/a11y/screen-reader-and-keyboard-improvements
a11y: screen reader and keyboard improvements
2 parents 670900b + b92b0dd commit 3c25ee6

16 files changed

Lines changed: 327 additions & 23 deletions

app/src/components/AudioPlayer/AudioPlayer.tsx

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useQuery } from '@tanstack/react-query';
22
import { Pause, Play, Repeat, Volume2, VolumeX, X } from 'lucide-react';
3-
import { useEffect, useMemo, useRef, useState } from 'react';
3+
import { useEffect, useId, useMemo, useRef, useState } from 'react';
44
import WaveSurfer from 'wavesurfer.js';
55
import { Button } from '@/components/ui/button';
66
import { Slider } from '@/components/ui/slider';
@@ -12,6 +12,7 @@ import { usePlatform } from '@/platform/PlatformContext';
1212

1313
export function AudioPlayer() {
1414
const platform = usePlatform();
15+
const volumeLabelId = useId();
1516
const {
1617
audioUrl,
1718
audioId,
@@ -831,6 +832,13 @@ export function AudioPlayer() {
831832
disabled={isLoading || duration === 0}
832833
className="shrink-0"
833834
title={duration === 0 && !isLoading ? 'Audio not loaded' : ''}
835+
aria-label={
836+
duration === 0 && !isLoading
837+
? 'Audio not loaded'
838+
: isPlaying
839+
? 'Pause'
840+
: 'Play'
841+
}
834842
>
835843
{isPlaying ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5" />}
836844
</Button>
@@ -845,6 +853,8 @@ export function AudioPlayer() {
845853
max={100}
846854
step={0.1}
847855
className="w-full"
856+
aria-label="Playback position"
857+
aria-valuetext={`${formatAudioDuration(currentTime)} of ${formatAudioDuration(duration)}`}
848858
/>
849859
)}
850860
{isLoading && (
@@ -872,26 +882,33 @@ export function AudioPlayer() {
872882
onClick={toggleLoop}
873883
className={isLooping ? 'text-primary' : ''}
874884
title="Toggle loop"
885+
aria-label={isLooping ? 'Stop looping' : 'Loop'}
875886
>
876887
<Repeat className="h-4 w-4" />
877888
</Button>
878889

879890
{/* Volume Control */}
880-
<div className="flex items-center gap-2 shrink-0 w-[120px]">
891+
<div className="flex items-center gap-2 shrink-0 w-[120px]" role="group" aria-label="Volume">
881892
<Button
882893
variant="ghost"
883894
size="icon"
884895
onClick={() => setVolume(volume > 0 ? 0 : 1)}
885896
className="h-8 w-8"
897+
aria-label={volume > 0 ? 'Mute' : 'Unmute'}
886898
>
887899
{volume > 0 ? <Volume2 className="h-4 w-4" /> : <VolumeX className="h-4 w-4" />}
888900
</Button>
901+
<span id={volumeLabelId} className="sr-only">
902+
Volume level, {Math.round(volume * 100)}%
903+
</span>
889904
<Slider
890905
value={[volume * 100]}
891906
onValueChange={handleVolumeChange}
892907
max={100}
893908
step={1}
894909
className="flex-1"
910+
aria-labelledby={volumeLabelId}
911+
aria-valuetext={`${Math.round(volume * 100)}%`}
895912
/>
896913
</div>
897914

@@ -902,6 +919,7 @@ export function AudioPlayer() {
902919
onClick={handleClose}
903920
className="shrink-0"
904921
title="Close player"
922+
aria-label="Close player"
905923
>
906924
<X className="h-5 w-5" />
907925
</Button>

app/src/components/Generation/FloatingGenerateBox.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,13 @@ export function FloatingGenerateBox({
300300
disabled={isPending || !selectedProfileId}
301301
className="h-10 w-10 rounded-full bg-accent hover:bg-accent/90 hover:scale-105 text-accent-foreground shadow-lg hover:shadow-accent/50 transition-all duration-200"
302302
size="icon"
303+
aria-label={
304+
isPending
305+
? 'Generating...'
306+
: !selectedProfileId
307+
? 'Select a voice profile first'
308+
: 'Generate speech'
309+
}
303310
>
304311
{isPending ? (
305312
<Loader2 className="h-4 w-4 animate-spin" />
@@ -336,6 +343,11 @@ export function FloatingGenerateBox({
336343
? 'bg-accent text-accent-foreground border border-accent hover:bg-accent/90'
337344
: 'bg-card border border-border hover:bg-background/50',
338345
)}
346+
aria-label={
347+
isInstructMode
348+
? 'Fine tune instructions, on'
349+
: 'Fine tune instructions'
350+
}
339351
>
340352
<SlidersHorizontal className="h-4 w-4" />
341353
</Button>

app/src/components/History/HistoryTable.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,10 +253,17 @@ export function HistoryTable() {
253253
return (
254254
<div
255255
key={gen.id}
256+
role="button"
257+
tabIndex={0}
256258
className={cn(
257259
'flex items-stretch gap-4 h-26 border rounded-md p-3 bg-card hover:bg-muted/70 transition-colors text-left w-full',
258260
isCurrentlyPlaying && 'bg-muted/70',
259261
)}
262+
aria-label={
263+
isCurrentlyPlaying
264+
? `Sample from ${gen.profile_name}, ${formatDuration(gen.duration)}, ${formatDate(gen.created_at)}. Playing. Press Enter to restart.`
265+
: `Sample from ${gen.profile_name}, ${formatDuration(gen.duration)}, ${formatDate(gen.created_at)}. Press Enter to play.`
266+
}
260267
onMouseDown={(e) => {
261268
// Don't trigger play if clicking on textarea or if text is selected
262269
const target = e.target as HTMLElement;
@@ -265,6 +272,14 @@ export function HistoryTable() {
265272
}
266273
handlePlay(gen.id, gen.text, gen.profile_id);
267274
}}
275+
onKeyDown={(e) => {
276+
const target = e.target as HTMLElement;
277+
if (target.closest('textarea') || target.closest('button')) return;
278+
if (e.key === 'Enter' || e.key === ' ') {
279+
e.preventDefault();
280+
handlePlay(gen.id, gen.text, gen.profile_id);
281+
}
282+
}}
268283
>
269284
{/* Waveform icon */}
270285
<div className="flex items-center shrink-0">
@@ -293,6 +308,7 @@ export function HistoryTable() {
293308
value={gen.text}
294309
className="flex-1 resize-none text-sm text-muted-foreground select-text"
295310
readOnly
311+
aria-label={`Transcript for sample from ${gen.profile_name}, ${formatDuration(gen.duration)}`}
296312
/>
297313
</div>
298314

app/src/components/ServerSettings/ConnectionForm.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,11 @@ export function ConnectionForm() {
5757
}
5858

5959
return (
60-
<Card>
60+
<Card
61+
role="region"
62+
aria-label="Server Connection"
63+
tabIndex={0}
64+
>
6165
<CardHeader>
6266
<CardTitle>Server Connection</CardTitle>
6367
</CardHeader>

app/src/components/ServerSettings/ModelManagement.tsx

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -773,6 +773,106 @@ export function ModelManagement() {
773773
</AlertDialogFooter>
774774
</AlertDialogContent>
775775
</AlertDialog>
776+
</Card>
777+
);
778+
}
779+
780+
interface ModelItemProps {
781+
model: {
782+
model_name: string;
783+
display_name: string;
784+
downloaded: boolean;
785+
downloading?: boolean; // From server - true if download in progress
786+
size_mb?: number;
787+
loaded: boolean;
788+
};
789+
onDownload: () => void;
790+
onDelete: () => void;
791+
isDownloading: boolean; // Local state - true if user just clicked download
792+
formatSize: (sizeMb?: number) => string;
793+
}
794+
795+
function ModelItem({ model, onDownload, onDelete, isDownloading, formatSize }: ModelItemProps) {
796+
// Use server's downloading state OR local state (for immediate feedback before server updates)
797+
const showDownloading = model.downloading || isDownloading;
798+
799+
const statusText = model.loaded
800+
? 'Loaded'
801+
: showDownloading
802+
? 'Downloading'
803+
: model.downloaded
804+
? 'Downloaded'
805+
: 'Not downloaded';
806+
const sizeText =
807+
model.downloaded && model.size_mb && !showDownloading ? `, ${formatSize(model.size_mb)}` : '';
808+
const rowLabel = `${model.display_name}, ${statusText}${sizeText}. Use Tab to reach Download or Delete.`;
809+
810+
return (
811+
<div
812+
className="flex items-center justify-between p-3 border rounded-lg"
813+
role="group"
814+
tabIndex={0}
815+
aria-label={rowLabel}
816+
>
817+
<div className="flex-1">
818+
<div className="flex items-center gap-2">
819+
<span className="font-medium text-sm">{model.display_name}</span>
820+
{model.loaded && (
821+
<Badge variant="default" className="text-xs">
822+
Loaded
823+
</Badge>
824+
)}
825+
{/* Only show Downloaded if actually downloaded AND not downloading */}
826+
{model.downloaded && !model.loaded && !showDownloading && (
827+
<Badge variant="secondary" className="text-xs">
828+
Downloaded
829+
</Badge>
830+
)}
831+
</div>
832+
{model.downloaded && model.size_mb && !showDownloading && (
833+
<div className="text-xs text-muted-foreground mt-1">
834+
Size: {formatSize(model.size_mb)}
835+
</div>
836+
)}
837+
</div>
838+
<div className="flex items-center gap-2">
839+
{model.downloaded && !showDownloading ? (
840+
<div className="flex items-center gap-2">
841+
<div className="flex items-center gap-1 text-sm text-muted-foreground">
842+
<span>Ready</span>
843+
</div>
844+
<Button
845+
size="sm"
846+
onClick={onDelete}
847+
variant="outline"
848+
disabled={model.loaded}
849+
title={model.loaded ? 'Unload model before deleting' : 'Delete model'}
850+
aria-label={
851+
model.loaded
852+
? 'Unload model before deleting'
853+
: `Delete ${model.display_name}`
854+
}
855+
>
856+
<Trash2 className="h-4 w-4" />
857+
</Button>
858+
</div>
859+
) : showDownloading ? (
860+
<Button size="sm" variant="outline" disabled aria-label={`${model.display_name} downloading`}>
861+
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
862+
Downloading...
863+
</Button>
864+
) : (
865+
<Button
866+
size="sm"
867+
onClick={onDownload}
868+
variant="outline"
869+
aria-label={`Download ${model.display_name}`}
870+
>
871+
<Download className="h-4 w-4 mr-2" />
872+
Download
873+
</Button>
874+
)}
875+
</div>
776876
</div>
777877
);
778878
}

app/src/components/ServerSettings/ServerStatus.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,11 @@ export function ServerStatus() {
1010
const serverUrl = useServerStore((state) => state.serverUrl);
1111

1212
return (
13-
<Card>
13+
<Card
14+
role="region"
15+
aria-label="Server Status"
16+
tabIndex={0}
17+
>
1418
<CardHeader>
1519
<CardTitle>Server Status</CardTitle>
1620
</CardHeader>

app/src/components/ServerSettings/UpdateStatus.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,11 @@ export function UpdateStatus() {
2020
}, [platform]);
2121

2222
return (
23-
<Card>
23+
<Card
24+
role="region"
25+
aria-label="App Updates"
26+
tabIndex={0}
27+
>
2428
<CardHeader>
2529
<CardTitle>App Updates</CardTitle>
2630
</CardHeader>

app/src/components/StoriesTab/StoryList.tsx

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -194,17 +194,29 @@ export function StoryList() {
194194
storyList.map((story) => (
195195
<div
196196
key={story.id}
197+
role="button"
198+
tabIndex={0}
197199
className={cn(
198-
'h-24 p-4 border rounded-2xl transition-colors group flex items-center',
200+
'h-24 p-4 border rounded-2xl transition-colors group flex items-center cursor-pointer',
199201
selectedStoryId === story.id && 'bg-muted border-primary',
200202
)}
203+
aria-label={
204+
selectedStoryId === story.id
205+
? `Story ${story.name}, ${story.item_count} ${story.item_count === 1 ? 'item' : 'items'}, ${formatDate(story.updated_at)}. Selected. Press Enter to select.`
206+
: `Story ${story.name}, ${story.item_count} ${story.item_count === 1 ? 'item' : 'items'}, ${formatDate(story.updated_at)}. Press Enter to select.`
207+
}
208+
aria-pressed={selectedStoryId === story.id}
209+
onClick={() => setSelectedStoryId(story.id)}
210+
onKeyDown={(e) => {
211+
if (e.target !== e.currentTarget) return;
212+
if (e.key === 'Enter' || e.key === ' ') {
213+
e.preventDefault();
214+
setSelectedStoryId(story.id);
215+
}
216+
}}
201217
>
202218
<div className="flex items-start justify-between gap-2 w-full min-w-0">
203-
<button
204-
type="button"
205-
className="flex-1 min-w-0 text-left cursor-pointer overflow-hidden"
206-
onClick={() => setSelectedStoryId(story.id)}
207-
>
219+
<div className="flex-1 min-w-0 text-left overflow-hidden">
208220
<h3 className="font-medium truncate">{story.name}</h3>
209221
{story.description && (
210222
<p className="text-sm text-muted-foreground mt-1 truncate">
@@ -218,14 +230,15 @@ export function StoryList() {
218230
<span></span>
219231
<span>{formatDate(story.updated_at)}</span>
220232
</div>
221-
</button>
233+
</div>
222234
<DropdownMenu>
223235
<DropdownMenuTrigger asChild>
224236
<Button
225237
variant="ghost"
226238
size="icon"
227239
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity"
228240
onClick={(e) => e.stopPropagation()}
241+
aria-label={`Actions for ${story.name}`}
229242
>
230243
<MoreHorizontal className="h-4 w-4" />
231244
</Button>

0 commit comments

Comments
 (0)