mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-10-07 04:07:50 +00:00
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:
parent
e34edf90b4
commit
b46f62ccc0
8 changed files with 398 additions and 18 deletions
37
docs/sprint-6.md
Normal file
37
docs/sprint-6.md
Normal 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
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
96
web/src/components/layout/KeyboardShortcutsDialog.tsx
Normal file
96
web/src/components/layout/KeyboardShortcutsDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
192
web/src/hooks/useKeyboard.tsx
Normal file
192
web/src/hooks/useKeyboard.tsx
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue