Files
ColaFlow-Web/components/projects/hierarchy-tree.tsx
Yaojia Wang 16174e271b 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>
2025-11-05 20:10:41 +01:00

366 lines
11 KiB
TypeScript

'use client';
import { useState } from 'react';
import { ChevronRight, ChevronDown, Folder, FileText, CheckSquare } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { useEpics } from '@/lib/hooks/use-epics';
import { useStories } from '@/lib/hooks/use-stories';
import { useTasks } from '@/lib/hooks/use-tasks';
import type { Epic, Story, Task, WorkItemStatus, WorkItemPriority } from '@/types/project';
interface HierarchyTreeProps {
projectId: string;
onEpicClick?: (epic: Epic) => void;
onStoryClick?: (story: Story) => void;
onTaskClick?: (task: Task) => void;
}
export function HierarchyTree({
projectId,
onEpicClick,
onStoryClick,
onTaskClick,
}: HierarchyTreeProps) {
const { data: epics = [], isLoading: epicsLoading } = useEpics(projectId);
if (epicsLoading) {
return <HierarchyTreeSkeleton />;
}
if (epics.length === 0) {
return (
<div className="flex flex-col items-center justify-center p-8 text-center">
<Folder className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">No Epics Found</h3>
<p className="text-sm text-muted-foreground">
Create your first epic to start organizing work
</p>
</div>
);
}
return (
<div className="space-y-2">
{epics.map((epic) => (
<EpicNode
key={epic.id}
epic={epic}
onEpicClick={onEpicClick}
onStoryClick={onStoryClick}
onTaskClick={onTaskClick}
/>
))}
</div>
);
}
interface EpicNodeProps {
epic: Epic;
onEpicClick?: (epic: Epic) => void;
onStoryClick?: (story: Story) => void;
onTaskClick?: (task: Task) => void;
}
function EpicNode({ epic, onEpicClick, onStoryClick, onTaskClick }: EpicNodeProps) {
const [isExpanded, setIsExpanded] = useState(false);
const { data: stories = [], isLoading: storiesLoading } = useStories(
isExpanded ? epic.id : undefined
);
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);
}}
>
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</Button>
<Folder className="h-5 w-5 text-blue-500" aria-hidden="true" />
<div className="flex-1">
<div className="flex items-center gap-2">
<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}
</button>
<StatusBadge status={epic.status} />
<PriorityBadge priority={epic.priority} />
</div>
{epic.description && (
<p className="text-sm text-muted-foreground mt-1 line-clamp-1">
{epic.description}
</p>
)}
</div>
{epic.estimatedHours && (
<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>
)}
</div>
{isExpanded && (
<div className="pl-8 pr-3 pb-3 space-y-2">
{storiesLoading ? (
<div className="space-y-2">
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
</div>
) : stories.length === 0 ? (
<div className="p-4 text-center text-sm text-muted-foreground border-l-2 border-muted">
No stories in this epic
</div>
) : (
stories.map((story) => (
<StoryNode
key={story.id}
story={story}
onStoryClick={onStoryClick}
onTaskClick={onTaskClick}
/>
))
)}
</div>
)}
</div>
);
}
interface StoryNodeProps {
story: Story;
onStoryClick?: (story: Story) => void;
onTaskClick?: (task: Task) => void;
}
function StoryNode({ story, onStoryClick, onTaskClick }: StoryNodeProps) {
const [isExpanded, setIsExpanded] = useState(false);
const { data: tasks = [], isLoading: tasksLoading } = useTasks(
isExpanded ? story.id : undefined
);
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);
}}
>
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</Button>
<FileText className="h-4 w-4 text-green-500" aria-hidden="true" />
<div className="flex-1">
<div className="flex items-center gap-2">
<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}
</button>
<StatusBadge status={story.status} size="sm" />
<PriorityBadge priority={story.priority} size="sm" />
</div>
{story.description && (
<p className="text-xs text-muted-foreground mt-1 line-clamp-1">
{story.description}
</p>
)}
</div>
{story.estimatedHours && (
<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>
)}
</div>
{isExpanded && (
<div className="ml-6 mt-2 space-y-1">
{tasksLoading ? (
<div className="space-y-1">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</div>
) : tasks.length === 0 ? (
<div className="p-3 text-center text-xs text-muted-foreground border-l-2 border-muted">
No tasks in this story
</div>
) : (
tasks.map((task) => <TaskNode key={task.id} task={task} onTaskClick={onTaskClick} />)
)}
</div>
)}
</div>
);
}
interface TaskNodeProps {
task: Task;
onTaskClick?: (task: Task) => void;
}
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" aria-hidden="true" />
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{task.title}</span>
<StatusBadge status={task.status} size="xs" />
<PriorityBadge priority={task.priority} size="xs" />
</div>
{task.description && (
<p className="text-xs text-muted-foreground mt-1 line-clamp-1">{task.description}</p>
)}
</div>
{task.estimatedHours && (
<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>
)}
</div>
);
}
interface StatusBadgeProps {
status: WorkItemStatus;
size?: 'default' | 'sm' | 'xs';
}
function StatusBadge({ status, size = 'default' }: StatusBadgeProps) {
const variants: Record<WorkItemStatus, 'default' | 'secondary' | 'outline' | 'destructive'> = {
Backlog: 'secondary',
Todo: 'outline',
InProgress: 'default',
Done: 'outline',
};
const sizeClasses = {
default: 'text-xs',
sm: 'text-xs px-1.5 py-0',
xs: 'text-[10px] px-1 py-0',
};
return (
<Badge variant={variants[status]} className={sizeClasses[size]}>
{status}
</Badge>
);
}
interface PriorityBadgeProps {
priority: WorkItemPriority;
size?: 'default' | 'sm' | 'xs';
}
function PriorityBadge({ priority, size = 'default' }: PriorityBadgeProps) {
const colors: Record<WorkItemPriority, string> = {
Low: 'bg-gray-100 text-gray-700',
Medium: 'bg-blue-100 text-blue-700',
High: 'bg-orange-100 text-orange-700',
Critical: 'bg-red-100 text-red-700',
};
const sizeClasses = {
default: 'text-xs',
sm: 'text-xs px-1.5 py-0',
xs: 'text-[10px] px-1 py-0',
};
return <Badge className={`${colors[priority]} ${sizeClasses[size]}`}>{priority}</Badge>;
}
function HierarchyTreeSkeleton() {
return (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<div key={i} className="border rounded-lg p-3">
<div className="flex items-center gap-2">
<Skeleton className="h-6 w-6" />
<Skeleton className="h-5 w-5" />
<Skeleton className="h-6 flex-1" />
<Skeleton className="h-5 w-16" />
</div>
</div>
))}
</div>
);
}