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

View File

@@ -72,13 +72,23 @@ function EpicNode({ epic, onEpicClick, onStoryClick, onTaskClick }: EpicNodeProp
return ( return (
<div className="border rounded-lg"> <div className="border rounded-lg">
<div <div
role="button"
tabIndex={0}
aria-expanded={isExpanded}
className="flex items-center gap-2 p-3 hover:bg-accent cursor-pointer" className="flex items-center gap-2 p-3 hover:bg-accent cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)} onClick={() => setIsExpanded(!isExpanded)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setIsExpanded(!isExpanded);
}
}}
> >
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="h-6 w-6 p-0" className="h-6 w-6 p-0"
aria-label={isExpanded ? 'Collapse epic' : 'Expand epic'}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setIsExpanded(!isExpanded); setIsExpanded(!isExpanded);
@@ -91,19 +101,20 @@ function EpicNode({ epic, onEpicClick, onStoryClick, onTaskClick }: EpicNodeProp
)} )}
</Button> </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-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span <button
className="font-semibold hover:underline" className="font-semibold hover:underline text-left bg-transparent border-0 p-0 cursor-pointer"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onEpicClick?.(epic); onEpicClick?.(epic);
}} }}
aria-label={`View epic: ${epic.name}`}
> >
{epic.name} {epic.name}
</span> </button>
<StatusBadge status={epic.status} /> <StatusBadge status={epic.status} />
<PriorityBadge priority={epic.priority} /> <PriorityBadge priority={epic.priority} />
</div> </div>
@@ -115,7 +126,7 @@ function EpicNode({ epic, onEpicClick, onStoryClick, onTaskClick }: EpicNodeProp
</div> </div>
{epic.estimatedHours && ( {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.estimatedHours}h
{epic.actualHours && ` / ${epic.actualHours}h`} {epic.actualHours && ` / ${epic.actualHours}h`}
</div> </div>
@@ -164,13 +175,23 @@ function StoryNode({ story, onStoryClick, onTaskClick }: StoryNodeProps) {
return ( return (
<div className="border-l-2 border-muted pl-3"> <div className="border-l-2 border-muted pl-3">
<div <div
role="button"
tabIndex={0}
aria-expanded={isExpanded}
className="flex items-center gap-2 p-2 hover:bg-accent rounded cursor-pointer" className="flex items-center gap-2 p-2 hover:bg-accent rounded cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)} onClick={() => setIsExpanded(!isExpanded)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setIsExpanded(!isExpanded);
}
}}
> >
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="h-6 w-6 p-0" className="h-6 w-6 p-0"
aria-label={isExpanded ? 'Collapse story' : 'Expand story'}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setIsExpanded(!isExpanded); setIsExpanded(!isExpanded);
@@ -183,19 +204,20 @@ function StoryNode({ story, onStoryClick, onTaskClick }: StoryNodeProps) {
)} )}
</Button> </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-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span <button
className="font-medium hover:underline" className="font-medium hover:underline text-left bg-transparent border-0 p-0 cursor-pointer"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onStoryClick?.(story); onStoryClick?.(story);
}} }}
aria-label={`View story: ${story.title}`}
> >
{story.title} {story.title}
</span> </button>
<StatusBadge status={story.status} size="sm" /> <StatusBadge status={story.status} size="sm" />
<PriorityBadge priority={story.priority} size="sm" /> <PriorityBadge priority={story.priority} size="sm" />
</div> </div>
@@ -207,7 +229,7 @@ function StoryNode({ story, onStoryClick, onTaskClick }: StoryNodeProps) {
</div> </div>
{story.estimatedHours && ( {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.estimatedHours}h
{story.actualHours && ` / ${story.actualHours}h`} {story.actualHours && ` / ${story.actualHours}h`}
</div> </div>
@@ -242,14 +264,23 @@ interface TaskNodeProps {
function TaskNode({ task, onTaskClick }: TaskNodeProps) { function TaskNode({ task, onTaskClick }: TaskNodeProps) {
return ( return (
<div <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" className="flex items-center gap-2 p-2 hover:bg-accent rounded cursor-pointer border-l-2 border-muted pl-3"
onClick={() => onTaskClick?.(task)} 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-1">
<div className="flex items-center gap-2"> <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" /> <StatusBadge status={task.status} size="xs" />
<PriorityBadge priority={task.priority} size="xs" /> <PriorityBadge priority={task.priority} size="xs" />
</div> </div>
@@ -259,7 +290,7 @@ function TaskNode({ task, onTaskClick }: TaskNodeProps) {
</div> </div>
{task.estimatedHours && ( {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.estimatedHours}h
{task.actualHours && ` / ${task.actualHours}h`} {task.actualHours && ` / ${task.actualHours}h`}
</div> </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 { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals"; import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript"; import nextTs from "eslint-config-next/typescript";
import jsxA11y from "eslint-plugin-jsx-a11y";
const eslintConfig = defineConfig([ const eslintConfig = defineConfig([
...nextVitals, ...nextVitals,
...nextTs, ...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. // Override default ignores of eslint-config-next.
globalIgnores([ globalIgnores([
// Default ignores of eslint-config-next: // Default ignores of eslint-config-next:

1
package-lock.json generated
View File

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

View File

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