a11y(frontend): enhance accessibility support - Sprint 3 Story 5

Improve accessibility to meet WCAG 2.1 Level AA standards.

Changes: Added eslint-plugin-jsx-a11y, keyboard navigation, ARIA labels, SkipLink component, main-content landmark.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Yaojia Wang
2025-11-05 20:10:41 +01:00
parent 99ba4c4b1a
commit 16174e271b
6 changed files with 78 additions and 13 deletions

View File

@@ -72,13 +72,23 @@ function EpicNode({ epic, onEpicClick, onStoryClick, onTaskClick }: EpicNodeProp
return (
<div className="border rounded-lg">
<div
role="button"
tabIndex={0}
aria-expanded={isExpanded}
className="flex items-center gap-2 p-3 hover:bg-accent cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setIsExpanded(!isExpanded);
}
}}
>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
aria-label={isExpanded ? 'Collapse epic' : 'Expand epic'}
onClick={(e) => {
e.stopPropagation();
setIsExpanded(!isExpanded);
@@ -91,19 +101,20 @@ function EpicNode({ epic, onEpicClick, onStoryClick, onTaskClick }: EpicNodeProp
)}
</Button>
<Folder className="h-5 w-5 text-blue-500" />
<Folder className="h-5 w-5 text-blue-500" aria-hidden="true" />
<div className="flex-1">
<div className="flex items-center gap-2">
<span
className="font-semibold hover:underline"
<button
className="font-semibold hover:underline text-left bg-transparent border-0 p-0 cursor-pointer"
onClick={(e) => {
e.stopPropagation();
onEpicClick?.(epic);
}}
aria-label={`View epic: ${epic.name}`}
>
{epic.name}
</span>
</button>
<StatusBadge status={epic.status} />
<PriorityBadge priority={epic.priority} />
</div>
@@ -115,7 +126,7 @@ function EpicNode({ epic, onEpicClick, onStoryClick, onTaskClick }: EpicNodeProp
</div>
{epic.estimatedHours && (
<div className="text-sm text-muted-foreground">
<div className="text-sm text-muted-foreground" aria-label={`Estimated: ${epic.estimatedHours} hours${epic.actualHours ? `, Actual: ${epic.actualHours} hours` : ''}`}>
{epic.estimatedHours}h
{epic.actualHours && ` / ${epic.actualHours}h`}
</div>
@@ -164,13 +175,23 @@ function StoryNode({ story, onStoryClick, onTaskClick }: StoryNodeProps) {
return (
<div className="border-l-2 border-muted pl-3">
<div
role="button"
tabIndex={0}
aria-expanded={isExpanded}
className="flex items-center gap-2 p-2 hover:bg-accent rounded cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setIsExpanded(!isExpanded);
}
}}
>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
aria-label={isExpanded ? 'Collapse story' : 'Expand story'}
onClick={(e) => {
e.stopPropagation();
setIsExpanded(!isExpanded);
@@ -183,19 +204,20 @@ function StoryNode({ story, onStoryClick, onTaskClick }: StoryNodeProps) {
)}
</Button>
<FileText className="h-4 w-4 text-green-500" />
<FileText className="h-4 w-4 text-green-500" aria-hidden="true" />
<div className="flex-1">
<div className="flex items-center gap-2">
<span
className="font-medium hover:underline"
<button
className="font-medium hover:underline text-left bg-transparent border-0 p-0 cursor-pointer"
onClick={(e) => {
e.stopPropagation();
onStoryClick?.(story);
}}
aria-label={`View story: ${story.title}`}
>
{story.title}
</span>
</button>
<StatusBadge status={story.status} size="sm" />
<PriorityBadge priority={story.priority} size="sm" />
</div>
@@ -207,7 +229,7 @@ function StoryNode({ story, onStoryClick, onTaskClick }: StoryNodeProps) {
</div>
{story.estimatedHours && (
<div className="text-xs text-muted-foreground">
<div className="text-xs text-muted-foreground" aria-label={`Estimated: ${story.estimatedHours} hours${story.actualHours ? `, Actual: ${story.actualHours} hours` : ''}`}>
{story.estimatedHours}h
{story.actualHours && ` / ${story.actualHours}h`}
</div>
@@ -242,14 +264,23 @@ interface TaskNodeProps {
function TaskNode({ task, onTaskClick }: TaskNodeProps) {
return (
<div
role="button"
tabIndex={0}
className="flex items-center gap-2 p-2 hover:bg-accent rounded cursor-pointer border-l-2 border-muted pl-3"
onClick={() => onTaskClick?.(task)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onTaskClick?.(task);
}
}}
aria-label={`View task: ${task.title}`}
>
<CheckSquare className="h-4 w-4 text-purple-500" />
<CheckSquare className="h-4 w-4 text-purple-500" aria-hidden="true" />
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium hover:underline">{task.title}</span>
<span className="text-sm font-medium">{task.title}</span>
<StatusBadge status={task.status} size="xs" />
<PriorityBadge priority={task.priority} size="xs" />
</div>
@@ -259,7 +290,7 @@ function TaskNode({ task, onTaskClick }: TaskNodeProps) {
</div>
{task.estimatedHours && (
<div className="text-xs text-muted-foreground">
<div className="text-xs text-muted-foreground" aria-label={`Estimated: ${task.estimatedHours} hours${task.actualHours ? `, Actual: ${task.actualHours} hours` : ''}`}>
{task.estimatedHours}h
{task.actualHours && ` / ${task.actualHours}h`}
</div>

View File

@@ -0,0 +1,10 @@
export function SkipLink() {
return (
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:px-4 focus:py-2 focus:bg-primary focus:text-primary-foreground focus:rounded-md focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
>
Skip to main content
</a>
);
}