fix(a11y): Add context menu for keyboard-accessible task status changes (#710)
* fix(a11y): Add context menu for keyboard-accessible task status changes Adds a kebab menu (⋮) to task cards with "Move to" options for changing task status without drag-and-drop. This enables screen reader users to move tasks between Kanban columns using standard keyboard navigation. - Add DropdownMenu with status options (excluding current status) - Wire up persistTaskStatus through KanbanBoard → SortableTaskCard → TaskCard - Add i18n translations for menu labels (en/fr) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(i18n): Internationalize task status column labels Replace hardcoded English strings in TASK_STATUS_LABELS with translation keys. Update all components that display status labels to use t() for proper internationalization. - Add columns.* translation keys to en/tasks.json and fr/tasks.json - Update TASK_STATUS_LABELS to store translation keys instead of strings - Update TaskCard, KanbanBoard, TaskHeader, TaskDetailModal to use t() 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * perf(TaskCard): Memoize dropdown menu items for status changes Wrap the TASK_STATUS_COLUMNS filter/map in useMemo to avoid recreating the menu items on every render. Only recomputes when task.status, onStatusChange handler, or translations change. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(types): Allow async functions for onStatusChange prop Change onStatusChange signature from returning void to unknown to accept async functions like persistTaskStatus. Updated in TaskCard, SortableTaskCard, and KanbanBoard interfaces. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: Andy <119136210+AndyMik90@users.noreply.github.com>
This commit is contained in:
@@ -42,6 +42,7 @@ interface DroppableColumnProps {
|
||||
status: TaskStatus;
|
||||
tasks: Task[];
|
||||
onTaskClick: (task: Task) => void;
|
||||
onStatusChange: (taskId: string, newStatus: TaskStatus) => unknown;
|
||||
isOver: boolean;
|
||||
onAddClick?: () => void;
|
||||
onArchiveAll?: () => void;
|
||||
@@ -85,6 +86,7 @@ function droppableColumnPropsAreEqual(
|
||||
if (prevProps.status !== nextProps.status) return false;
|
||||
if (prevProps.isOver !== nextProps.isOver) return false;
|
||||
if (prevProps.onTaskClick !== nextProps.onTaskClick) return false;
|
||||
if (prevProps.onStatusChange !== nextProps.onStatusChange) return false;
|
||||
if (prevProps.onAddClick !== nextProps.onAddClick) return false;
|
||||
if (prevProps.onArchiveAll !== nextProps.onArchiveAll) return false;
|
||||
if (prevProps.archivedCount !== nextProps.archivedCount) return false;
|
||||
@@ -143,7 +145,7 @@ const getEmptyStateContent = (status: TaskStatus, t: (key: string) => string): {
|
||||
}
|
||||
};
|
||||
|
||||
const DroppableColumn = memo(function DroppableColumn({ status, tasks, onTaskClick, isOver, onAddClick, onArchiveAll, archivedCount, showArchived, onToggleArchived }: DroppableColumnProps) {
|
||||
const DroppableColumn = memo(function DroppableColumn({ status, tasks, onTaskClick, onStatusChange, isOver, onAddClick, onArchiveAll, archivedCount, showArchived, onToggleArchived }: DroppableColumnProps) {
|
||||
const { t } = useTranslation(['tasks', 'common']);
|
||||
const { setNodeRef } = useDroppable({
|
||||
id: status
|
||||
@@ -161,6 +163,15 @@ const DroppableColumn = memo(function DroppableColumn({ status, tasks, onTaskCli
|
||||
return handlers;
|
||||
}, [tasks, onTaskClick]);
|
||||
|
||||
// Create stable onStatusChange handlers for each task
|
||||
const onStatusChangeHandlers = useMemo(() => {
|
||||
const handlers = new Map<string, (newStatus: TaskStatus) => unknown>();
|
||||
tasks.forEach((task) => {
|
||||
handlers.set(task.id, (newStatus: TaskStatus) => onStatusChange(task.id, newStatus));
|
||||
});
|
||||
return handlers;
|
||||
}, [tasks, onStatusChange]);
|
||||
|
||||
// Memoize task card elements to prevent recreation on every render
|
||||
const taskCards = useMemo(() => {
|
||||
if (tasks.length === 0) return null;
|
||||
@@ -169,9 +180,10 @@ const DroppableColumn = memo(function DroppableColumn({ status, tasks, onTaskCli
|
||||
key={task.id}
|
||||
task={task}
|
||||
onClick={onClickHandlers.get(task.id)!}
|
||||
onStatusChange={onStatusChangeHandlers.get(task.id)}
|
||||
/>
|
||||
));
|
||||
}, [tasks, onClickHandlers]);
|
||||
}, [tasks, onClickHandlers, onStatusChangeHandlers]);
|
||||
|
||||
const getColumnBorderColor = (): string => {
|
||||
switch (status) {
|
||||
@@ -206,7 +218,7 @@ const DroppableColumn = memo(function DroppableColumn({ status, tasks, onTaskCli
|
||||
<div className="flex items-center justify-between p-4 border-b border-white/5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<h2 className="font-semibold text-sm text-foreground">
|
||||
{TASK_STATUS_LABELS[status]}
|
||||
{t(TASK_STATUS_LABELS[status])}
|
||||
</h2>
|
||||
<span className="column-count-badge">
|
||||
{tasks.length}
|
||||
@@ -482,6 +494,7 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR
|
||||
status={status}
|
||||
tasks={tasksByStatus[status]}
|
||||
onTaskClick={onTaskClick}
|
||||
onStatusChange={persistTaskStatus}
|
||||
isOver={overColumnId === status}
|
||||
onAddClick={status === 'backlog' ? onNewTaskClick : undefined}
|
||||
onArchiveAll={status === 'done' ? handleArchiveAll : undefined}
|
||||
|
||||
@@ -3,11 +3,12 @@ import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { TaskCard } from './TaskCard';
|
||||
import { cn } from '../lib/utils';
|
||||
import type { Task } from '../../shared/types';
|
||||
import type { Task, TaskStatus } from '../../shared/types';
|
||||
|
||||
interface SortableTaskCardProps {
|
||||
task: Task;
|
||||
onClick: () => void;
|
||||
onStatusChange?: (newStatus: TaskStatus) => unknown;
|
||||
}
|
||||
|
||||
// Custom comparator - only re-render when task or onClick actually changed
|
||||
@@ -19,11 +20,12 @@ function sortableTaskCardPropsAreEqual(
|
||||
// for the task object and onClick handler
|
||||
return (
|
||||
prevProps.task === nextProps.task &&
|
||||
prevProps.onClick === nextProps.onClick
|
||||
prevProps.onClick === nextProps.onClick &&
|
||||
prevProps.onStatusChange === nextProps.onStatusChange
|
||||
);
|
||||
}
|
||||
|
||||
export const SortableTaskCard = memo(function SortableTaskCard({ task, onClick }: SortableTaskCardProps) {
|
||||
export const SortableTaskCard = memo(function SortableTaskCard({ task, onClick, onStatusChange }: SortableTaskCardProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
@@ -58,7 +60,7 @@ export const SortableTaskCard = memo(function SortableTaskCard({ task, onClick }
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<TaskCard task={task} onClick={handleClick} />
|
||||
<TaskCard task={task} onClick={handleClick} onStatusChange={onStatusChange} />
|
||||
</div>
|
||||
);
|
||||
}, sortableTaskCardPropsAreEqual);
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import { useState, useEffect, useRef, useCallback, memo, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Play, Square, Clock, Zap, Target, Shield, Gauge, Palette, FileCode, Bug, Wrench, Loader2, AlertTriangle, RotateCcw, Archive } from 'lucide-react';
|
||||
import { Play, Square, Clock, Zap, Target, Shield, Gauge, Palette, FileCode, Bug, Wrench, Loader2, AlertTriangle, RotateCcw, Archive, MoreVertical } from 'lucide-react';
|
||||
import { Card, CardContent } from './ui/card';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Button } from './ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from './ui/dropdown-menu';
|
||||
import { cn, formatRelativeTime, sanitizeMarkdownForDisplay } from '../lib/utils';
|
||||
import { PhaseProgressIndicator } from './PhaseProgressIndicator';
|
||||
import {
|
||||
@@ -16,10 +24,12 @@ import {
|
||||
TASK_PRIORITY_COLORS,
|
||||
TASK_PRIORITY_LABELS,
|
||||
EXECUTION_PHASE_LABELS,
|
||||
EXECUTION_PHASE_BADGE_COLORS
|
||||
EXECUTION_PHASE_BADGE_COLORS,
|
||||
TASK_STATUS_COLUMNS,
|
||||
TASK_STATUS_LABELS
|
||||
} from '../../shared/constants';
|
||||
import { startTask, stopTask, checkTaskRunning, recoverStuckTask, isIncompleteHumanReview, archiveTasks } from '../stores/task-store';
|
||||
import type { Task, TaskCategory, ReviewReason } from '../../shared/types';
|
||||
import type { Task, TaskCategory, ReviewReason, TaskStatus } from '../../shared/types';
|
||||
|
||||
// Category icon mapping
|
||||
const CategoryIcon: Record<TaskCategory, typeof Zap> = {
|
||||
@@ -37,6 +47,7 @@ const CategoryIcon: Record<TaskCategory, typeof Zap> = {
|
||||
interface TaskCardProps {
|
||||
task: Task;
|
||||
onClick: () => void;
|
||||
onStatusChange?: (newStatus: TaskStatus) => unknown;
|
||||
}
|
||||
|
||||
// Custom comparator for React.memo - only re-render when relevant task data changes
|
||||
@@ -45,7 +56,7 @@ function taskCardPropsAreEqual(prevProps: TaskCardProps, nextProps: TaskCardProp
|
||||
const nextTask = nextProps.task;
|
||||
|
||||
// Fast path: same reference
|
||||
if (prevTask === nextTask && prevProps.onClick === nextProps.onClick) {
|
||||
if (prevTask === nextTask && prevProps.onClick === nextProps.onClick && prevProps.onStatusChange === nextProps.onStatusChange) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -83,7 +94,7 @@ function taskCardPropsAreEqual(prevProps: TaskCardProps, nextProps: TaskCardProp
|
||||
return isEqual;
|
||||
}
|
||||
|
||||
export const TaskCard = memo(function TaskCard({ task, onClick }: TaskCardProps) {
|
||||
export const TaskCard = memo(function TaskCard({ task, onClick, onStatusChange }: TaskCardProps) {
|
||||
const { t } = useTranslation('tasks');
|
||||
const [isStuck, setIsStuck] = useState(false);
|
||||
const [isRecovering, setIsRecovering] = useState(false);
|
||||
@@ -112,6 +123,19 @@ export const TaskCard = memo(function TaskCard({ task, onClick }: TaskCardProps)
|
||||
[task.updatedAt]
|
||||
);
|
||||
|
||||
// Memoize status menu items to avoid recreating on every render
|
||||
const statusMenuItems = useMemo(() => {
|
||||
if (!onStatusChange) return null;
|
||||
return TASK_STATUS_COLUMNS.filter(status => status !== task.status).map((status) => (
|
||||
<DropdownMenuItem
|
||||
key={status}
|
||||
onClick={() => onStatusChange(status)}
|
||||
>
|
||||
{t(TASK_STATUS_LABELS[status])}
|
||||
</DropdownMenuItem>
|
||||
));
|
||||
}, [task.status, onStatusChange, t]);
|
||||
|
||||
// Memoized stuck check function to avoid recreating on every render
|
||||
const performStuckCheck = useCallback(() => {
|
||||
// Use requestIdleCallback for non-blocking check when available
|
||||
@@ -421,68 +445,92 @@ export const TaskCard = memo(function TaskCard({ task, onClick }: TaskCardProps)
|
||||
<span>{relativeTime}</span>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
{isStuck ? (
|
||||
<Button
|
||||
variant="warning"
|
||||
size="sm"
|
||||
className="h-7 px-2.5"
|
||||
onClick={handleRecover}
|
||||
disabled={isRecovering}
|
||||
>
|
||||
{isRecovering ? (
|
||||
<>
|
||||
<Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
|
||||
{t('labels.recovering')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RotateCcw className="mr-1.5 h-3 w-3" />
|
||||
{t('actions.recover')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
) : isIncomplete ? (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="h-7 px-2.5"
|
||||
onClick={handleStartStop}
|
||||
>
|
||||
<Play className="mr-1.5 h-3 w-3" />
|
||||
{t('actions.resume')}
|
||||
</Button>
|
||||
) : task.status === 'done' && !task.metadata?.archivedAt ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2.5 hover:bg-muted-foreground/10"
|
||||
onClick={handleArchive}
|
||||
title={t('tooltips.archiveTask')}
|
||||
>
|
||||
<Archive className="mr-1.5 h-3 w-3" />
|
||||
{t('actions.archive')}
|
||||
</Button>
|
||||
) : (task.status === 'backlog' || task.status === 'in_progress') && (
|
||||
<Button
|
||||
variant={isRunning ? 'destructive' : 'default'}
|
||||
size="sm"
|
||||
className="h-7 px-2.5"
|
||||
onClick={handleStartStop}
|
||||
>
|
||||
{isRunning ? (
|
||||
<>
|
||||
<Square className="mr-1.5 h-3 w-3" />
|
||||
{t('actions.stop')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="mr-1.5 h-3 w-3" />
|
||||
{t('actions.start')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* Action buttons */}
|
||||
{isStuck ? (
|
||||
<Button
|
||||
variant="warning"
|
||||
size="sm"
|
||||
className="h-7 px-2.5"
|
||||
onClick={handleRecover}
|
||||
disabled={isRecovering}
|
||||
>
|
||||
{isRecovering ? (
|
||||
<>
|
||||
<Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
|
||||
{t('labels.recovering')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RotateCcw className="mr-1.5 h-3 w-3" />
|
||||
{t('actions.recover')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
) : isIncomplete ? (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="h-7 px-2.5"
|
||||
onClick={handleStartStop}
|
||||
>
|
||||
<Play className="mr-1.5 h-3 w-3" />
|
||||
{t('actions.resume')}
|
||||
</Button>
|
||||
) : task.status === 'done' && !task.metadata?.archivedAt ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2.5 hover:bg-muted-foreground/10"
|
||||
onClick={handleArchive}
|
||||
title={t('tooltips.archiveTask')}
|
||||
>
|
||||
<Archive className="mr-1.5 h-3 w-3" />
|
||||
{t('actions.archive')}
|
||||
</Button>
|
||||
) : (task.status === 'backlog' || task.status === 'in_progress') && (
|
||||
<Button
|
||||
variant={isRunning ? 'destructive' : 'default'}
|
||||
size="sm"
|
||||
className="h-7 px-2.5"
|
||||
onClick={handleStartStop}
|
||||
>
|
||||
{isRunning ? (
|
||||
<>
|
||||
<Square className="mr-1.5 h-3 w-3" />
|
||||
{t('actions.stop')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="mr-1.5 h-3 w-3" />
|
||||
{t('actions.start')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Move to menu for keyboard accessibility */}
|
||||
{statusMenuItems && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={t('actions.taskActions')}
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" onClick={(e) => e.stopPropagation()}>
|
||||
<DropdownMenuLabel>{t('actions.moveTo')}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{statusMenuItems}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -289,7 +289,7 @@ function TaskDetailModalContent({ open, task, onOpenChange, onSwitchToTerminals,
|
||||
variant={task.status === 'done' ? 'success' : task.status === 'human_review' ? 'purple' : task.status === 'in_progress' ? 'info' : 'secondary'}
|
||||
className={cn('text-xs', (task.status === 'in_progress' && !state.isStuck) && 'status-running')}
|
||||
>
|
||||
{TASK_STATUS_LABELS[task.status]}
|
||||
{t(TASK_STATUS_LABELS[task.status])}
|
||||
</Badge>
|
||||
{task.status === 'human_review' && task.reviewReason && (
|
||||
<Badge
|
||||
|
||||
@@ -68,7 +68,7 @@ export function TaskHeader({
|
||||
variant={task.status === 'done' ? 'success' : task.status === 'human_review' ? 'purple' : task.status === 'in_progress' ? 'info' : 'secondary'}
|
||||
className={cn('text-xs', (task.status === 'in_progress' && !isStuck) && 'status-running')}
|
||||
>
|
||||
{TASK_STATUS_LABELS[task.status]}
|
||||
{t(TASK_STATUS_LABELS[task.status])}
|
||||
</Badge>
|
||||
{task.status === 'human_review' && task.reviewReason && (
|
||||
<Badge
|
||||
|
||||
@@ -16,13 +16,13 @@ export const TASK_STATUS_COLUMNS = [
|
||||
'done'
|
||||
] as const;
|
||||
|
||||
// Human-readable status labels
|
||||
// Status label translation keys (use with t() from react-i18next)
|
||||
export const TASK_STATUS_LABELS: Record<string, string> = {
|
||||
backlog: 'Planning',
|
||||
in_progress: 'In Progress',
|
||||
ai_review: 'AI Review',
|
||||
human_review: 'Human Review',
|
||||
done: 'Done'
|
||||
backlog: 'columns.backlog',
|
||||
in_progress: 'columns.in_progress',
|
||||
ai_review: 'columns.ai_review',
|
||||
human_review: 'columns.human_review',
|
||||
done: 'columns.done'
|
||||
};
|
||||
|
||||
// Status colors for UI
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
"resume": "Resume",
|
||||
"archive": "Archive",
|
||||
"delete": "Delete",
|
||||
"view": "View Details"
|
||||
"view": "View Details",
|
||||
"moveTo": "Move to",
|
||||
"taskActions": "Task actions"
|
||||
},
|
||||
"labels": {
|
||||
"running": "Running",
|
||||
@@ -46,6 +48,13 @@
|
||||
"title": "No tasks yet",
|
||||
"description": "Create your first task to get started"
|
||||
},
|
||||
"columns": {
|
||||
"backlog": "Planning",
|
||||
"in_progress": "In Progress",
|
||||
"ai_review": "AI Review",
|
||||
"human_review": "Human Review",
|
||||
"done": "Done"
|
||||
},
|
||||
"kanban": {
|
||||
"emptyBacklog": "No tasks planned",
|
||||
"emptyBacklogHint": "Add a task to get started",
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
"resume": "Reprendre",
|
||||
"archive": "Archiver",
|
||||
"delete": "Supprimer",
|
||||
"view": "Voir les détails"
|
||||
"view": "Voir les détails",
|
||||
"moveTo": "Déplacer vers",
|
||||
"taskActions": "Actions de la tâche"
|
||||
},
|
||||
"labels": {
|
||||
"running": "En cours",
|
||||
@@ -46,6 +48,13 @@
|
||||
"title": "Aucune tâche",
|
||||
"description": "Créez votre première tâche pour commencer"
|
||||
},
|
||||
"columns": {
|
||||
"backlog": "Planification",
|
||||
"in_progress": "En cours",
|
||||
"ai_review": "Révision IA",
|
||||
"human_review": "Révision humaine",
|
||||
"done": "Terminé"
|
||||
},
|
||||
"kanban": {
|
||||
"emptyBacklog": "Aucune tâche planifiée",
|
||||
"emptyBacklogHint": "Ajoutez une tâche pour commencer",
|
||||
|
||||
Reference in New Issue
Block a user