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:
Orinks
2026-01-06 09:24:06 -05:00
committed by GitHub
parent 2f321fb2aa
commit 724ad827bf
8 changed files with 165 additions and 84 deletions
@@ -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
+6 -6
View File
@@ -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",