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:
@@ -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'
|
||||||
}`}
|
}`}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
10
components/ui/skip-link.tsx
Normal file
10
components/ui/skip-link.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
1
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user