feat(US-601): keyboard shortcuts

- Add KeyboardProvider context for global shortcut handling
- c: create task, j/k: navigate, Enter: open, Esc: close
- 1-4: move task to status columns
- ?: toggle keyboard shortcuts help dialog
- Visual selection indicator on task cards
This commit is contained in:
Brad Groux 2026-01-26 04:49:19 -06:00
parent e34edf90b4
commit b46f62ccc0
8 changed files with 398 additions and 18 deletions

37
docs/sprint-6.md Normal file
View file

@ -0,0 +1,37 @@
# Sprint 6: Polish & Quality of Life
**Goal:** Keyboard shortcuts, filtering, and UI polish.
**Started:** 2026-01-26
**Status:** In Progress
---
## Stories
| ID | Title | Status | Notes |
|----|-------|--------|-------|
| US-601 | Keyboard shortcuts | ✅ Complete | c, j/k, Enter, Esc, 1-4, ? |
| US-602 | Filter and search | ⏳ Todo | Project/type dropdowns, text search |
| US-603 | Task templates | ⏳ Todo | New from Template option |
| US-604 | Bulk actions | ⏳ Todo | Multi-select, bulk status/archive/delete |
| US-605 | Activity log | ⏳ Todo | Toggleable sidebar |
---
## Progress Log
### 2026-01-26
**US-601: Keyboard shortcuts** ✅
- Created `useKeyboard` context provider
- Implemented shortcuts:
- `c` - Create new task
- `j/k` or `↓/↑` - Navigate tasks
- `Enter` - Open selected task
- `Escape` - Close panel/clear selection
- `1-4` - Move task to column (Todo/In Progress/Review/Done)
- `?` - Toggle keyboard shortcuts help dialog
- Added keyboard icon to header
- Created `KeyboardShortcutsDialog` help modal
- Task selection highlighting with ring indicator

View file

@ -1,16 +1,21 @@
import { KanbanBoard } from './components/board/KanbanBoard';
import { Header } from './components/layout/Header';
import { Toaster } from './components/ui/toaster';
import { KeyboardProvider } from './hooks/useKeyboard';
import { KeyboardShortcutsDialog } from './components/layout/KeyboardShortcutsDialog';
function App() {
return (
<div className="min-h-screen bg-background">
<Header />
<main className="container mx-auto px-4 py-6">
<KanbanBoard />
</main>
<Toaster />
</div>
<KeyboardProvider>
<div className="min-h-screen bg-background">
<Header />
<main className="container mx-auto px-4 py-6">
<KanbanBoard />
</main>
<Toaster />
<KeyboardShortcutsDialog />
</div>
</KeyboardProvider>
);
}

View file

@ -13,8 +13,9 @@ import {
useSensor,
useSensors,
} from '@dnd-kit/core';
import { useState } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { TaskCard } from '@/components/task/TaskCard';
import { useKeyboard } from '@/hooks/useKeyboard';
const COLUMNS: { id: TaskStatus; title: string }[] = [
{ id: 'todo', title: 'To Do' },
@ -65,6 +66,40 @@ export function KanbanBoard() {
const [activeTask, setActiveTask] = useState<Task | null>(null);
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
const {
selectedTaskId,
setTasks,
setOnOpenTask,
setOnMoveTask,
} = useKeyboard();
// Register tasks with keyboard context
useEffect(() => {
if (tasks) {
setTasks(tasks);
}
}, [tasks, setTasks]);
// Handler for opening a task
const handleTaskClick = useCallback((task: Task) => {
setSelectedTask(task);
setDetailOpen(true);
}, []);
// Handler for moving a task
const handleMoveTask = useCallback((taskId: string, status: TaskStatus) => {
updateTask.mutate({ id: taskId, input: { status } });
}, [updateTask]);
// Register callbacks with keyboard context
useEffect(() => {
setOnOpenTask(handleTaskClick);
}, [handleTaskClick, setOnOpenTask]);
useEffect(() => {
setOnMoveTask(handleMoveTask);
}, [handleMoveTask, setOnMoveTask]);
const sensors = useSensors(
useSensor(PointerSensor, {
@ -99,11 +134,6 @@ export function KanbanBoard() {
}
};
const handleTaskClick = (task: Task) => {
setSelectedTask(task);
setDetailOpen(true);
};
const handleDetailClose = (open: boolean) => {
setDetailOpen(open);
if (!open) {
@ -152,6 +182,7 @@ export function KanbanBoard() {
title={column.title}
tasks={tasksByStatus[column.id]}
onTaskClick={handleTaskClick}
selectedTaskId={selectedTaskId}
/>
))}
</div>

View file

@ -8,6 +8,7 @@ interface KanbanColumnProps {
title: string;
tasks: Task[];
onTaskClick?: (task: Task) => void;
selectedTaskId?: string | null;
}
const columnColors: Record<TaskStatus, string> = {
@ -17,7 +18,7 @@ const columnColors: Record<TaskStatus, string> = {
'done': 'border-t-green-500',
};
export function KanbanColumn({ id, title, tasks, onTaskClick }: KanbanColumnProps) {
export function KanbanColumn({ id, title, tasks, onTaskClick, selectedTaskId }: KanbanColumnProps) {
const { setNodeRef, isOver } = useDroppable({ id });
return (
@ -52,6 +53,7 @@ export function KanbanColumn({ id, title, tasks, onTaskClick }: KanbanColumnProp
key={task.id}
task={task}
onClick={() => onTaskClick?.(task)}
isSelected={task.id === selectedTaskId}
/>
))
)}

View file

@ -1,12 +1,19 @@
import { Plus, Settings } from 'lucide-react';
import { Plus, Settings, Keyboard } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { CreateTaskDialog } from '@/components/task/CreateTaskDialog';
import { SettingsDialog } from '@/components/settings/SettingsDialog';
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { useKeyboard } from '@/hooks/useKeyboard';
export function Header() {
const [createOpen, setCreateOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const { setOpenCreateDialog, openHelpDialog } = useKeyboard();
// Register the create dialog opener with keyboard context
useEffect(() => {
setOpenCreateDialog(() => setCreateOpen(true));
}, [setOpenCreateDialog]);
return (
<header className="border-b border-border bg-card">
@ -28,6 +35,14 @@ export function Header() {
<Plus className="h-4 w-4 mr-1" />
New Task
</Button>
<Button
variant="ghost"
size="icon"
onClick={openHelpDialog}
title="Keyboard shortcuts (?)"
>
<Keyboard className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"

View file

@ -0,0 +1,96 @@
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { useKeyboard } from '@/hooks/useKeyboard';
interface Shortcut {
keys: string[];
description: string;
}
const shortcuts: { category: string; items: Shortcut[] }[] = [
{
category: 'Navigation',
items: [
{ keys: ['j', '↓'], description: 'Select next task' },
{ keys: ['k', '↑'], description: 'Select previous task' },
{ keys: ['Enter'], description: 'Open selected task' },
{ keys: ['Esc'], description: 'Close panel / Clear selection' },
],
},
{
category: 'Actions',
items: [
{ keys: ['c'], description: 'Create new task' },
{ keys: ['1'], description: 'Move to To Do' },
{ keys: ['2'], description: 'Move to In Progress' },
{ keys: ['3'], description: 'Move to Review' },
{ keys: ['4'], description: 'Move to Done' },
],
},
{
category: 'General',
items: [
{ keys: ['?'], description: 'Toggle this help' },
],
},
];
function KeyBadge({ children }: { children: React.ReactNode }) {
return (
<kbd className="inline-flex items-center justify-center min-w-[24px] h-6 px-2 text-xs font-medium bg-muted border border-border rounded shadow-sm">
{children}
</kbd>
);
}
export function KeyboardShortcutsDialog() {
const { isHelpOpen, closeHelpDialog } = useKeyboard();
return (
<Dialog open={isHelpOpen} onOpenChange={(open) => !open && closeHelpDialog()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
⌨️ Keyboard Shortcuts
</DialogTitle>
</DialogHeader>
<div className="space-y-6 py-2">
{shortcuts.map((section) => (
<div key={section.category}>
<h3 className="text-sm font-semibold text-muted-foreground mb-3">
{section.category}
</h3>
<div className="space-y-2">
{section.items.map((shortcut, i) => (
<div
key={i}
className="flex items-center justify-between"
>
<span className="text-sm">{shortcut.description}</span>
<div className="flex items-center gap-1">
{shortcut.keys.map((key, j) => (
<span key={j} className="flex items-center gap-1">
{j > 0 && <span className="text-muted-foreground text-xs">or</span>}
<KeyBadge>{key}</KeyBadge>
</span>
))}
</div>
</div>
))}
</div>
</div>
))}
</div>
<div className="text-xs text-muted-foreground text-center pt-2 border-t">
Press <KeyBadge>?</KeyBadge> anytime to toggle this help
</div>
</DialogContent>
</Dialog>
);
}

View file

@ -13,6 +13,7 @@ interface TaskCardProps {
task: Task;
isDragging?: boolean;
onClick?: () => void;
isSelected?: boolean;
}
const typeIcons: Record<TaskType, React.ReactNode> = {
@ -35,7 +36,7 @@ const priorityColors: Record<TaskPriority, string> = {
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'
)}
>
<div className="flex items-start gap-2">

View file

@ -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<KeyboardContextValue | null>(null);
const STATUS_MAP: Record<string, TaskStatus> = {
'1': 'todo',
'2': 'in-progress',
'3': 'review',
'4': 'done',
};
export function KeyboardProvider({ children }: { children: ReactNode }) {
const [isHelpOpen, setIsHelpOpen] = useState(false);
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
const [tasks, setTasks] = useState<Task[]>([]);
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 (
<KeyboardContext.Provider value={value}>
{children}
</KeyboardContext.Provider>
);
}
export function useKeyboard() {
const context = useContext(KeyboardContext);
if (!context) {
throw new Error('useKeyboard must be used within KeyboardProvider');
}
return context;
}