From 724ad827bfa2b8d7467d95ca634ffcb69599cca3 Mon Sep 17 00:00:00 2001 From: Orinks <38449772+Orinks@users.noreply.github.com> Date: Tue, 6 Jan 2026 09:24:06 -0500 Subject: [PATCH] fix(a11y): Add context menu for keyboard-accessible task status changes (#710) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 * 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 * 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 --------- Co-authored-by: Claude Opus 4.5 Co-authored-by: Andy <119136210+AndyMik90@users.noreply.github.com> --- .../src/renderer/components/KanbanBoard.tsx | 19 +- .../renderer/components/SortableTaskCard.tsx | 10 +- .../src/renderer/components/TaskCard.tsx | 182 +++++++++++------- .../task-detail/TaskDetailModal.tsx | 2 +- .../components/task-detail/TaskHeader.tsx | 2 +- apps/frontend/src/shared/constants/task.ts | 12 +- .../src/shared/i18n/locales/en/tasks.json | 11 +- .../src/shared/i18n/locales/fr/tasks.json | 11 +- 8 files changed, 165 insertions(+), 84 deletions(-) diff --git a/apps/frontend/src/renderer/components/KanbanBoard.tsx b/apps/frontend/src/renderer/components/KanbanBoard.tsx index 05563d67..4eb3c134 100644 --- a/apps/frontend/src/renderer/components/KanbanBoard.tsx +++ b/apps/frontend/src/renderer/components/KanbanBoard.tsx @@ -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 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

- {TASK_STATUS_LABELS[status]} + {t(TASK_STATUS_LABELS[status])}

{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} diff --git a/apps/frontend/src/renderer/components/SortableTaskCard.tsx b/apps/frontend/src/renderer/components/SortableTaskCard.tsx index a23bac92..b830a181 100644 --- a/apps/frontend/src/renderer/components/SortableTaskCard.tsx +++ b/apps/frontend/src/renderer/components/SortableTaskCard.tsx @@ -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} > - +
); }, sortableTaskCardPropsAreEqual); diff --git a/apps/frontend/src/renderer/components/TaskCard.tsx b/apps/frontend/src/renderer/components/TaskCard.tsx index 52c14533..8d36affb 100644 --- a/apps/frontend/src/renderer/components/TaskCard.tsx +++ b/apps/frontend/src/renderer/components/TaskCard.tsx @@ -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 = { @@ -37,6 +47,7 @@ const CategoryIcon: Record = { 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) => ( + onStatusChange(status)} + > + {t(TASK_STATUS_LABELS[status])} + + )); + }, [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) {relativeTime}
- {/* Action buttons */} - {isStuck ? ( - - ) : isIncomplete ? ( - - ) : task.status === 'done' && !task.metadata?.archivedAt ? ( - - ) : (task.status === 'backlog' || task.status === 'in_progress') && ( - - )} +
+ {/* Action buttons */} + {isStuck ? ( + + ) : isIncomplete ? ( + + ) : task.status === 'done' && !task.metadata?.archivedAt ? ( + + ) : (task.status === 'backlog' || task.status === 'in_progress') && ( + + )} + + {/* Move to menu for keyboard accessibility */} + {statusMenuItems && ( + + + + + e.stopPropagation()}> + {t('actions.moveTo')} + + {statusMenuItems} + + + )} +
diff --git a/apps/frontend/src/renderer/components/task-detail/TaskDetailModal.tsx b/apps/frontend/src/renderer/components/task-detail/TaskDetailModal.tsx index 6b9d421a..f67f6282 100644 --- a/apps/frontend/src/renderer/components/task-detail/TaskDetailModal.tsx +++ b/apps/frontend/src/renderer/components/task-detail/TaskDetailModal.tsx @@ -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])} {task.status === 'human_review' && task.reviewReason && ( - {TASK_STATUS_LABELS[task.status]} + {t(TASK_STATUS_LABELS[task.status])} {task.status === 'human_review' && task.reviewReason && ( = { - 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 diff --git a/apps/frontend/src/shared/i18n/locales/en/tasks.json b/apps/frontend/src/shared/i18n/locales/en/tasks.json index 248129c8..c06f74e1 100644 --- a/apps/frontend/src/shared/i18n/locales/en/tasks.json +++ b/apps/frontend/src/shared/i18n/locales/en/tasks.json @@ -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", diff --git a/apps/frontend/src/shared/i18n/locales/fr/tasks.json b/apps/frontend/src/shared/i18n/locales/fr/tasks.json index a04145d0..ff347de4 100644 --- a/apps/frontend/src/shared/i18n/locales/fr/tasks.json +++ b/apps/frontend/src/shared/i18n/locales/fr/tasks.json @@ -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",