feat(settings): Add WCAG 2.1 AA accessibility improvements (US-1156)

- Add aria-live regions to SaveIndicator for screen reader announcements
- Add Move Up/Down buttons to ManagedListManager for keyboard navigation
- Implement focus management: focus first tab on dialog open, focus content on tab switch
- Add comprehensive ARIA labels to all interactive elements (switches, buttons)
- Add aria-hidden to decorative icons
- Improve Switch accessibility with proper labels and aria-label attributes
- Add role=tabpanel and aria-controls for proper tab/tabpanel relationships
- Radix Dialog provides built-in focus trap functionality
This commit is contained in:
Brad Groux 2026-01-28 04:08:51 -06:00
parent 59382fb749
commit 0aa897116e
7 changed files with 153 additions and 13 deletions

View file

@ -533,3 +533,5 @@
{"type":"task.archived","taskId":"task_20260128_gUHeIV","project":"veritas-kanban","status":"done","id":"evt_pVbJN-iGAoy9","timestamp":"2026-01-28T09:28:59.929Z"}
{"type":"task.archived","taskId":"task_20260128_X6nifI","project":"veritas-kanban","status":"done","id":"evt_K4Iqlt-Dw14T","timestamp":"2026-01-28T09:28:59.931Z"}
{"type":"task.status_changed","taskId":"task_20260128_8HU2XD","project":"veritas-kanban","status":"in-progress","previousStatus":"todo","id":"evt_4rB1Mba649ZE","timestamp":"2026-01-28T10:03:19.726Z"}
{"type":"task.status_changed","taskId":"task_20260128_8HU2XD","project":"veritas-kanban","status":"done","previousStatus":"in-progress","id":"evt_v40bF7Xzb8sF","timestamp":"2026-01-28T10:05:18.411Z"}
{"type":"task.status_changed","taskId":"task_20260128_F-XLhL","project":"veritas-kanban","status":"in-progress","previousStatus":"todo","id":"evt_pO7WDomJvI6K","timestamp":"2026-01-28T10:05:29.028Z"}

View file

@ -1,4 +1,37 @@
[
{
"id": "activity_1769594729028_e3u00fq38",
"type": "status_changed",
"taskId": "task_20260128_F-XLhL",
"taskTitle": "US-1156: Settings accessibility (WCAG 2.1 AA)",
"details": {
"from": "todo",
"status": "in-progress"
},
"timestamp": "2026-01-28T10:05:29.028Z"
},
{
"id": "activity_1769594718437_r5xkcmuti",
"type": "comment_added",
"taskId": "task_20260128_8HU2XD",
"taskTitle": "US-1158: Replace alert() calls with toast notifications",
"details": {
"author": "Veritas",
"preview": "Replaced all 5 alert() calls with toast notificati..."
},
"timestamp": "2026-01-28T10:05:18.437Z"
},
{
"id": "activity_1769594718411_t76i5yv6c",
"type": "status_changed",
"taskId": "task_20260128_8HU2XD",
"taskTitle": "US-1158: Replace alert() calls with toast notifications",
"details": {
"from": "in-progress",
"status": "done"
},
"timestamp": "2026-01-28T10:05:18.411Z"
},
{
"id": "activity_1769594599726_5lp9h98h5",
"type": "status_changed",

View file

@ -12,7 +12,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from '../ui/alert-dialog';
import { Trash2, GripVertical } from 'lucide-react';
import { Trash2, GripVertical, ChevronUp, ChevronDown } from 'lucide-react';
import {
DndContext,
closestCenter,
@ -46,16 +46,24 @@ export interface ManagedListManagerProps<T extends ManagedListItem> {
interface SortableItemProps<T extends ManagedListItem> {
item: T;
index: number;
totalItems: number;
onUpdate: (id: string, patch: any) => Promise<any>;
onDelete: (id: string) => Promise<any>;
onMoveUp: (index: number) => void;
onMoveDown: (index: number) => void;
renderExtraFields?: (item: T, onChange: (patch: Partial<T>) => void) => React.ReactNode;
canDeleteCheck?: (id: string) => Promise<{ allowed: boolean; referenceCount: number; isDefault: boolean }>;
}
const SortableItem = memo(function SortableItem<T extends ManagedListItem>({
item,
index,
totalItems,
onUpdate,
onDelete,
onMoveUp,
onMoveDown,
renderExtraFields,
canDeleteCheck,
}: SortableItemProps<T>) {
@ -114,12 +122,38 @@ const SortableItem = memo(function SortableItem<T extends ManagedListItem>({
>
<button
className="cursor-grab active:cursor-grabbing text-gray-400 hover:text-gray-600 flex-shrink-0"
aria-label="Drag to reorder"
{...attributes}
{...listeners}
>
<GripVertical className="h-3.5 w-3.5" />
</button>
<div className="flex gap-0.5 flex-shrink-0">
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={() => onMoveUp(index)}
disabled={index === 0}
title="Move up"
aria-label="Move up"
>
<ChevronUp className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={() => onMoveDown(index)}
disabled={index === totalItems - 1}
title="Move down"
aria-label="Move down"
>
<ChevronDown className="h-3.5 w-3.5" />
</Button>
</div>
<div className="flex-1 min-w-0">
{isEditing ? (
<Input
@ -156,9 +190,10 @@ const SortableItem = memo(function SortableItem<T extends ManagedListItem>({
size="sm"
className="h-7 w-7 p-0 flex-shrink-0"
onClick={handleDeleteClick}
title="Delete"
title={`Delete ${item.label}`}
aria-label={`Delete ${item.label}`}
>
<Trash2 className="h-3.5 w-3.5" />
<Trash2 className="h-3.5 w-3.5" aria-hidden="true" />
</Button>
</div>
@ -261,6 +296,28 @@ export function ManagedListManager<T extends ManagedListItem>({
}
};
const handleMoveUp = (index: number) => {
if (index === 0) return;
const reordered = arrayMove(localItems, index, index - 1);
const orderedIds = reordered.map((item) => item.id);
setLocalItems(reordered);
onReorder(orderedIds).catch((error) => {
console.error('Failed to reorder items:', error);
setLocalItems(items);
});
};
const handleMoveDown = (index: number) => {
if (index === localItems.length - 1) return;
const reordered = arrayMove(localItems, index, index + 1);
const orderedIds = reordered.map((item) => item.id);
setLocalItems(reordered);
onReorder(orderedIds).catch((error) => {
console.error('Failed to reorder items:', error);
setLocalItems(items);
});
};
if (isLoading) {
return <div className="text-sm text-muted-foreground">Loading {title.toLowerCase()}...</div>;
}
@ -278,12 +335,16 @@ export function ManagedListManager<T extends ManagedListItem>({
items={localItems.map((item) => item.id)}
strategy={verticalListSortingStrategy}
>
{localItems.map((item) => (
{localItems.map((item, index) => (
<SortableItem
key={item.id}
item={item}
index={index}
totalItems={localItems.length}
onUpdate={onUpdate}
onDelete={onDelete}
onMoveUp={handleMoveUp}
onMoveDown={handleMoveDown}
renderExtraFields={renderExtraFields}
canDeleteCheck={canDeleteCheck}
/>

View file

@ -1,4 +1,4 @@
import { useState, useRef, useCallback, lazy, Suspense } from 'react';
import { useState, useRef, useCallback, lazy, Suspense, useEffect } from 'react';
import {
Dialog,
DialogContent,
@ -97,6 +97,23 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
const { debouncedUpdate } = useDebouncedFeatureUpdate();
const settingsFileInputRef = useRef<HTMLInputElement>(null);
const { toast } = useToast();
const contentAreaRef = useRef<HTMLDivElement>(null);
const firstTabButtonRef = useRef<HTMLButtonElement>(null);
// Focus first tab when dialog opens
useEffect(() => {
if (open && firstTabButtonRef.current) {
// Small delay to ensure dialog is fully rendered
setTimeout(() => firstTabButtonRef.current?.focus(), 100);
}
}, [open]);
// Focus content area when switching tabs
useEffect(() => {
if (contentAreaRef.current) {
contentAreaRef.current.focus();
}
}, [activeTab]);
const handleExportSettings = () => {
const blob = new Blob([JSON.stringify(currentSettings, null, 2)], { type: 'application/json' });
@ -229,13 +246,16 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
aria-orientation="vertical"
onKeyDown={handleKeyDown}
>
{TABS.map((tab) => {
{TABS.map((tab, index) => {
const Icon = tab.icon;
return (
<button
key={tab.id}
id={`tab-${tab.id}`}
ref={index === 0 ? firstTabButtonRef : undefined}
role="tab"
aria-selected={activeTab === tab.id}
aria-controls="settings-tab-content"
tabIndex={activeTab === tab.id ? 0 : -1}
onClick={() => setActiveTab(tab.id)}
className={cn(
@ -322,7 +342,14 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
<DialogTitle>Settings</DialogTitle>
</DialogHeader>
<ScrollArea className="flex-1 min-h-0">
<div className="max-w-lg px-6 py-4">
<div
id="settings-tab-content"
ref={contentAreaRef}
className="max-w-lg px-6 py-4"
role="tabpanel"
tabIndex={-1}
aria-labelledby={`tab-${activeTab}`}
>
{renderTab()}
</div>
</ScrollArea>

View file

@ -18,16 +18,26 @@ export function SaveIndicator({ isPending }: { isPending: boolean }) {
if (isPending) {
return (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground animate-pulse">
<Save className="h-3 w-3" />
<div
className="flex items-center gap-1.5 text-xs text-muted-foreground animate-pulse"
role="status"
aria-live="polite"
aria-atomic="true"
>
<Save className="h-3 w-3" aria-hidden="true" />
Saving...
</div>
);
}
if (showSaved) {
return (
<div className="flex items-center gap-1.5 text-xs text-green-500">
<Check className="h-3 w-3" />
<div
className="flex items-center gap-1.5 text-xs text-green-500"
role="status"
aria-live="polite"
aria-atomic="true"
>
<Check className="h-3 w-3" aria-hidden="true" />
Saved
</div>
);

View file

@ -1,5 +1,6 @@
import { memo } from 'react';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { SettingRow } from './SettingRow';
export const ToggleRow = memo(function ToggleRow({ label, description, checked, onCheckedChange }: {
@ -8,9 +9,11 @@ export const ToggleRow = memo(function ToggleRow({ label, description, checked,
checked: boolean;
onCheckedChange: (v: boolean) => void;
}) {
const id = `toggle-${label.toLowerCase().replace(/\s+/g, '-')}`;
return (
<SettingRow label={label} description={description}>
<Switch checked={checked} onCheckedChange={onCheckedChange} />
<Label htmlFor={id} className="sr-only">{label}</Label>
<Switch id={id} checked={checked} onCheckedChange={onCheckedChange} aria-label={label} />
</SettingRow>
);
});

View file

@ -112,7 +112,11 @@ function AgentToggleItem({ agent, onToggle }: { agent: AgentConfig; onToggle: ()
</code>
</div>
</div>
<Switch checked={agent.enabled} onCheckedChange={onToggle} />
<Switch
checked={agent.enabled}
onCheckedChange={onToggle}
aria-label={`Enable ${agent.name}`}
/>
</div>
);
}