= {
low: 'bg-slate-500/20 text-slate-400',
};
-export function TaskCard({ task, isDragging, onClick }: TaskCardProps) {
+export function TaskCard({ task, isDragging, onClick, isSelected }: TaskCardProps) {
const { attributes, listeners, setNodeRef, transform, isDragging: isCurrentlyDragging } = useDraggable({
id: task.id,
});
@@ -68,7 +69,8 @@ export function TaskCard({ task, isDragging, onClick }: TaskCardProps) {
'border-l-2',
typeColors[task.type],
isDragging && 'opacity-50 shadow-lg rotate-2 scale-105',
- isCurrentlyDragging && 'opacity-50'
+ isCurrentlyDragging && 'opacity-50',
+ isSelected && 'ring-2 ring-primary border-primary'
)}
>
diff --git a/web/src/hooks/useKeyboard.tsx b/web/src/hooks/useKeyboard.tsx
new file mode 100644
index 00000000..9263dd0b
--- /dev/null
+++ b/web/src/hooks/useKeyboard.tsx
@@ -0,0 +1,192 @@
+import { createContext, useContext, useCallback, useEffect, useState, type ReactNode } from 'react';
+import type { Task, TaskStatus } from '@veritas-kanban/shared';
+
+interface KeyboardContextValue {
+ // Dialog triggers
+ openCreateDialog: () => void;
+ setOpenCreateDialog: (fn: () => void) => void;
+ openHelpDialog: () => void;
+ closeHelpDialog: () => void;
+ isHelpOpen: boolean;
+
+ // Task selection
+ selectedTaskId: string | null;
+ setSelectedTaskId: (id: string | null) => void;
+
+ // Task list for navigation
+ tasks: Task[];
+ setTasks: (tasks: Task[]) => void;
+
+ // Callbacks
+ onOpenTask: ((task: Task) => void) | null;
+ setOnOpenTask: (fn: (task: Task) => void) => void;
+ onMoveTask: ((taskId: string, status: TaskStatus) => void) | null;
+ setOnMoveTask: (fn: (taskId: string, status: TaskStatus) => void) => void;
+}
+
+const KeyboardContext = createContext(null);
+
+const STATUS_MAP: Record = {
+ '1': 'todo',
+ '2': 'in-progress',
+ '3': 'review',
+ '4': 'done',
+};
+
+export function KeyboardProvider({ children }: { children: ReactNode }) {
+ const [isHelpOpen, setIsHelpOpen] = useState(false);
+ const [selectedTaskId, setSelectedTaskId] = useState(null);
+ const [tasks, setTasks] = useState([]);
+ const [openCreateDialogFn, setOpenCreateDialogFn] = useState<(() => void) | null>(null);
+ const [onOpenTaskFn, setOnOpenTaskFn] = useState<((task: Task) => void) | null>(null);
+ const [onMoveTaskFn, setOnMoveTaskFn] = useState<((taskId: string, status: TaskStatus) => void) | null>(null);
+
+ const openCreateDialog = useCallback(() => {
+ openCreateDialogFn?.();
+ }, [openCreateDialogFn]);
+
+ const openHelpDialog = useCallback(() => {
+ setIsHelpOpen(true);
+ }, []);
+
+ const closeHelpDialog = useCallback(() => {
+ setIsHelpOpen(false);
+ }, []);
+
+ // Get flat list of tasks sorted by column then position
+ const getTaskList = useCallback(() => {
+ const statusOrder: TaskStatus[] = ['todo', 'in-progress', 'review', 'done'];
+ return [...tasks].sort((a, b) => {
+ const aIndex = statusOrder.indexOf(a.status);
+ const bIndex = statusOrder.indexOf(b.status);
+ if (aIndex !== bIndex) return aIndex - bIndex;
+ return a.title.localeCompare(b.title);
+ });
+ }, [tasks]);
+
+ // Keyboard event handler
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ // Ignore if typing in input/textarea
+ const target = e.target as HTMLElement;
+ if (
+ target.tagName === 'INPUT' ||
+ target.tagName === 'TEXTAREA' ||
+ target.isContentEditable
+ ) {
+ return;
+ }
+
+ // Ignore if a dialog is open (except Escape)
+ const dialogOpen = document.querySelector('[role="dialog"]');
+ if (dialogOpen && e.key !== 'Escape') {
+ return;
+ }
+
+ const taskList = getTaskList();
+ const currentIndex = selectedTaskId
+ ? taskList.findIndex(t => t.id === selectedTaskId)
+ : -1;
+
+ switch (e.key) {
+ case 'c':
+ e.preventDefault();
+ openCreateDialog();
+ break;
+
+ case '?':
+ e.preventDefault();
+ setIsHelpOpen(prev => !prev);
+ break;
+
+ case 'Escape':
+ e.preventDefault();
+ if (isHelpOpen) {
+ setIsHelpOpen(false);
+ } else {
+ setSelectedTaskId(null);
+ }
+ break;
+
+ case 'j':
+ case 'ArrowDown':
+ e.preventDefault();
+ if (taskList.length > 0) {
+ const nextIndex = currentIndex < taskList.length - 1 ? currentIndex + 1 : 0;
+ setSelectedTaskId(taskList[nextIndex].id);
+ }
+ break;
+
+ case 'k':
+ case 'ArrowUp':
+ e.preventDefault();
+ if (taskList.length > 0) {
+ const prevIndex = currentIndex > 0 ? currentIndex - 1 : taskList.length - 1;
+ setSelectedTaskId(taskList[prevIndex].id);
+ }
+ break;
+
+ case 'Enter':
+ e.preventDefault();
+ if (selectedTaskId && onOpenTaskFn) {
+ const task = taskList.find(t => t.id === selectedTaskId);
+ if (task) {
+ onOpenTaskFn(task);
+ }
+ }
+ break;
+
+ case '1':
+ case '2':
+ case '3':
+ case '4':
+ if (selectedTaskId && onMoveTaskFn) {
+ e.preventDefault();
+ const newStatus = STATUS_MAP[e.key];
+ onMoveTaskFn(selectedTaskId, newStatus);
+ }
+ break;
+ }
+ };
+
+ window.addEventListener('keydown', handleKeyDown);
+ return () => window.removeEventListener('keydown', handleKeyDown);
+ }, [
+ getTaskList,
+ selectedTaskId,
+ isHelpOpen,
+ openCreateDialog,
+ onOpenTaskFn,
+ onMoveTaskFn,
+ ]);
+
+ const value: KeyboardContextValue = {
+ openCreateDialog,
+ setOpenCreateDialog: (fn) => setOpenCreateDialogFn(() => fn),
+ openHelpDialog,
+ closeHelpDialog,
+ isHelpOpen,
+ selectedTaskId,
+ setSelectedTaskId,
+ tasks,
+ setTasks,
+ onOpenTask: onOpenTaskFn,
+ setOnOpenTask: (fn) => setOnOpenTaskFn(() => fn),
+ onMoveTask: onMoveTaskFn,
+ setOnMoveTask: (fn) => setOnMoveTaskFn(() => fn),
+ };
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useKeyboard() {
+ const context = useContext(KeyboardContext);
+ if (!context) {
+ throw new Error('useKeyboard must be used within KeyboardProvider');
+ }
+ return context;
+}