From 0aa897116ee4995790955fb1e8930f299535bf34 Mon Sep 17 00:00:00 2001 From: Brad Groux Date: Wed, 28 Jan 2026 04:08:51 -0600 Subject: [PATCH] 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 --- .../telemetry/events-2026-01-28.ndjson | 2 + server/.veritas-kanban/activity.json | 33 +++++++++ .../settings/ManagedListManager.tsx | 69 +++++++++++++++++-- .../components/settings/SettingsDialog.tsx | 33 ++++++++- .../settings/shared/SaveIndicator.tsx | 18 +++-- .../components/settings/shared/ToggleRow.tsx | 5 +- .../components/settings/tabs/AgentsTab.tsx | 6 +- 7 files changed, 153 insertions(+), 13 deletions(-) diff --git a/.veritas-kanban/telemetry/events-2026-01-28.ndjson b/.veritas-kanban/telemetry/events-2026-01-28.ndjson index 817c0af1..d1273a82 100644 --- a/.veritas-kanban/telemetry/events-2026-01-28.ndjson +++ b/.veritas-kanban/telemetry/events-2026-01-28.ndjson @@ -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"} diff --git a/server/.veritas-kanban/activity.json b/server/.veritas-kanban/activity.json index b786a3e5..c60fbf95 100644 --- a/server/.veritas-kanban/activity.json +++ b/server/.veritas-kanban/activity.json @@ -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", diff --git a/web/src/components/settings/ManagedListManager.tsx b/web/src/components/settings/ManagedListManager.tsx index 751177dc..dac77381 100644 --- a/web/src/components/settings/ManagedListManager.tsx +++ b/web/src/components/settings/ManagedListManager.tsx @@ -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 { interface SortableItemProps { item: T; + index: number; + totalItems: number; onUpdate: (id: string, patch: any) => Promise; onDelete: (id: string) => Promise; + onMoveUp: (index: number) => void; + onMoveDown: (index: number) => void; renderExtraFields?: (item: T, onChange: (patch: Partial) => void) => React.ReactNode; canDeleteCheck?: (id: string) => Promise<{ allowed: boolean; referenceCount: number; isDefault: boolean }>; } const SortableItem = memo(function SortableItem({ item, + index, + totalItems, onUpdate, onDelete, + onMoveUp, + onMoveDown, renderExtraFields, canDeleteCheck, }: SortableItemProps) { @@ -114,12 +122,38 @@ const SortableItem = memo(function SortableItem({ > +
+ + +
+
{isEditing ? ( ({ 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}`} > - +
@@ -261,6 +296,28 @@ export function ManagedListManager({ } }; + 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
Loading {title.toLowerCase()}...
; } @@ -278,12 +335,16 @@ export function ManagedListManager({ items={localItems.map((item) => item.id)} strategy={verticalListSortingStrategy} > - {localItems.map((item) => ( + {localItems.map((item, index) => ( diff --git a/web/src/components/settings/SettingsDialog.tsx b/web/src/components/settings/SettingsDialog.tsx index 3ccbee01..5c2d76e2 100644 --- a/web/src/components/settings/SettingsDialog.tsx +++ b/web/src/components/settings/SettingsDialog.tsx @@ -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(null); const { toast } = useToast(); + const contentAreaRef = useRef(null); + const firstTabButtonRef = useRef(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 (