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

@@ -4,6 +4,7 @@ import { Header } from '@/components/layout/Header';
import { Sidebar } from '@/components/layout/Sidebar';
import { useUIStore } from '@/stores/ui-store';
import { AuthGuard } from '@/components/providers/AuthGuard';
import { SkipLink } from '@/components/ui/skip-link';
export default function DashboardLayout({
children,
@@ -14,11 +15,13 @@ export default function DashboardLayout({
return (
<AuthGuard>
<SkipLink />
<div className="min-h-screen">
<Header />
<div className="flex">
<Sidebar />
<main
id="main-content"
className={`flex-1 transition-all duration-200 ${
sidebarOpen ? 'ml-64' : 'ml-0'
}`}

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>
);
}

View File

@@ -1,10 +1,29 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
import jsxA11y from "eslint-plugin-jsx-a11y";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
{
rules: {
// Enable recommended jsx-a11y rules (plugin already included in nextVitals)
...jsxA11y.configs.recommended.rules,
// Enforce stricter accessibility rules
"jsx-a11y/anchor-is-valid": "error",
"jsx-a11y/alt-text": "error",
"jsx-a11y/aria-props": "error",
"jsx-a11y/aria-proptypes": "error",
"jsx-a11y/aria-unsupported-elements": "error",
"jsx-a11y/role-has-required-aria-props": "error",
"jsx-a11y/role-supports-aria-props": "error",
"jsx-a11y/label-has-associated-control": "error",
"jsx-a11y/click-events-have-key-events": "warn",
"jsx-a11y/no-static-element-interactions": "warn",
"jsx-a11y/interactive-supports-focus": "warn",
},
},
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:

1
package-lock.json generated
View File

@@ -45,6 +45,7 @@
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.0.1",
"eslint-plugin-jsx-a11y": "^6.10.2",
"prettier": "^3.6.2",
"prettier-plugin-tailwindcss": "^0.7.1",
"tailwindcss": "^4",

View File

@@ -59,6 +59,7 @@
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.0.1",
"eslint-plugin-jsx-a11y": "^6.10.2",
"prettier": "^3.6.2",
"prettier-plugin-tailwindcss": "^0.7.1",
"tailwindcss": "^4",