mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-10-09 13:17:52 +00:00
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:
parent
59382fb749
commit
0aa897116e
7 changed files with 153 additions and 13 deletions
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue