Skip to content

Commit 28908f3

Browse files
msujawsclaude
andcommitted
feat(board): add keyboard navigation for bug selection and movement
Add keyboard-based navigation to the Kanban board: - Arrow keys to navigate between bugs and columns - Shift key to grab a bug for movement - Left/Right while grabbed to change target column - Release Shift to drop and stage the move - Shift+Enter to apply all staged changes - Escape to clear selection Visual feedback: - Selected bugs show blue ring with enhanced shadow - Grabbed bugs show warning ring with pulse animation - Keyboard hints displayed in header Implementation follows TDD with comprehensive test coverage. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 694b062 commit 28908f3

File tree

8 files changed

+770
-16
lines changed

8 files changed

+770
-16
lines changed

src/App.test.tsx

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,42 @@ describe('App', () => {
7373

7474
expect(screen.getByText(/connected/i)).toBeInTheDocument()
7575
})
76+
77+
describe('keyboard hints', () => {
78+
it('should display keyboard hints in header', () => {
79+
render(<App />)
80+
81+
expect(screen.getByText(/arrows/i)).toBeInTheDocument()
82+
// There are multiple Shift elements (Shift and Shift+Enter)
83+
expect(screen.getAllByText(/shift/i).length).toBeGreaterThanOrEqual(1)
84+
})
85+
86+
it('should show kbd elements for keyboard shortcuts', () => {
87+
const { container } = render(<App />)
88+
89+
const kbdElements = container.querySelectorAll('kbd')
90+
expect(kbdElements.length).toBeGreaterThanOrEqual(3) // Arrows, Shift, Shift+Enter
91+
})
92+
93+
it('should explain arrow navigation', () => {
94+
render(<App />)
95+
96+
expect(screen.getByText(/select/i)).toBeInTheDocument()
97+
})
98+
99+
it('should explain grab/drop with Shift', () => {
100+
render(<App />)
101+
102+
expect(screen.getByText(/grab/i)).toBeInTheDocument()
103+
})
104+
105+
it('should explain Shift+Enter for apply', () => {
106+
render(<App />)
107+
108+
// Multiple elements contain "apply" (keyboard hint and button)
109+
expect(screen.getByText('to apply')).toBeInTheDocument()
110+
})
111+
})
76112
})
77113

78114
it('should render ToastContainer', () => {

src/App.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,15 @@ function App() {
205205
FAQ
206206
</button>
207207
</div>
208+
{/* Keyboard hints */}
209+
<div className="mt-1 flex items-center gap-2 text-xs text-text-tertiary">
210+
<kbd className="rounded bg-bg-tertiary px-1.5 py-0.5 font-mono">Arrows</kbd>
211+
<span>to select,</span>
212+
<kbd className="rounded bg-bg-tertiary px-1.5 py-0.5 font-mono">Shift</kbd>
213+
<span>to grab/drop,</span>
214+
<kbd className="rounded bg-bg-tertiary px-1.5 py-0.5 font-mono">Shift+Enter</kbd>
215+
<span>to apply</span>
216+
</div>
208217
</div>
209218
<ApiKeyStatus onOpenModal={handleOpenModal} />
210219
</div>
@@ -238,6 +247,7 @@ function App() {
238247
stagedChanges={changes}
239248
onBugMove={handleBugMove}
240249
isLoading={isLoadingBugs}
250+
onApplyChanges={handleApplyChanges}
241251
/>
242252
</main>
243253

src/components/Board/Board.test.tsx

Lines changed: 309 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, it, expect, vi, beforeEach } from 'vitest'
2-
import { render, screen } from '@testing-library/react'
2+
import { render, screen, fireEvent } from '@testing-library/react'
3+
import userEvent from '@testing-library/user-event'
34
import { Board } from './Board'
45
import type { BugzillaBug } from '@/lib/bugzilla/types'
56

@@ -275,4 +276,311 @@ describe('Board', () => {
275276
expect(main).toHaveAttribute('aria-busy', 'true')
276277
})
277278
})
279+
280+
describe('keyboard navigation', () => {
281+
// Create multiple bugs in same column for up/down navigation
282+
const multipleBacklogBugs: BugzillaBug[] = [
283+
{
284+
id: 10,
285+
summary: 'First backlog bug',
286+
status: 'NEW',
287+
assigned_to: 'dev1@example.com',
288+
priority: 'P1',
289+
severity: 'major',
290+
component: 'Core',
291+
whiteboard: '[kanban]',
292+
last_change_time: '2024-01-15T10:00:00Z',
293+
},
294+
{
295+
id: 11,
296+
summary: 'Second backlog bug',
297+
status: 'NEW',
298+
assigned_to: 'dev2@example.com',
299+
priority: 'P2',
300+
severity: 'normal',
301+
component: 'Core',
302+
whiteboard: '[kanban]',
303+
last_change_time: '2024-01-14T09:00:00Z',
304+
},
305+
{
306+
id: 12,
307+
summary: 'Third backlog bug',
308+
status: 'NEW',
309+
assigned_to: 'dev3@example.com',
310+
priority: 'P3',
311+
severity: 'minor',
312+
component: 'Core',
313+
whiteboard: '[kanban]',
314+
last_change_time: '2024-01-13T08:00:00Z',
315+
},
316+
{
317+
id: 20,
318+
summary: 'First todo bug',
319+
status: 'ASSIGNED',
320+
assigned_to: 'dev4@example.com',
321+
priority: 'P2',
322+
severity: 'normal',
323+
component: 'UI',
324+
whiteboard: '[kanban]',
325+
last_change_time: '2024-01-12T07:00:00Z',
326+
},
327+
]
328+
329+
describe('arrow key navigation', () => {
330+
it('should select first bug in first non-empty column on initial arrow key press', () => {
331+
render(<Board {...defaultProps} bugs={multipleBacklogBugs} />)
332+
333+
fireEvent.keyDown(document, { key: 'ArrowDown' })
334+
335+
// Should select first bug in backlog (first column with bugs)
336+
const firstCard = screen.getByText('First backlog bug').closest('[role="article"]')
337+
expect(firstCard?.className).toContain('ring-2')
338+
})
339+
340+
it('should move selection down within column on ArrowDown', () => {
341+
render(<Board {...defaultProps} bugs={multipleBacklogBugs} />)
342+
343+
// Initial selection
344+
fireEvent.keyDown(document, { key: 'ArrowDown' })
345+
// Move down
346+
fireEvent.keyDown(document, { key: 'ArrowDown' })
347+
348+
const secondCard = screen.getByText('Second backlog bug').closest('[role="article"]')
349+
expect(secondCard?.className).toContain('ring-2')
350+
})
351+
352+
it('should move selection up within column on ArrowUp', () => {
353+
render(<Board {...defaultProps} bugs={multipleBacklogBugs} />)
354+
355+
// Select first, then second
356+
fireEvent.keyDown(document, { key: 'ArrowDown' })
357+
fireEvent.keyDown(document, { key: 'ArrowDown' })
358+
// Move back up
359+
fireEvent.keyDown(document, { key: 'ArrowUp' })
360+
361+
const firstCard = screen.getByText('First backlog bug').closest('[role="article"]')
362+
expect(firstCard?.className).toContain('ring-2')
363+
})
364+
365+
it('should move selection to next column on ArrowRight', () => {
366+
render(<Board {...defaultProps} bugs={multipleBacklogBugs} />)
367+
368+
// Select first bug in backlog
369+
fireEvent.keyDown(document, { key: 'ArrowDown' })
370+
// Move to todo column
371+
fireEvent.keyDown(document, { key: 'ArrowRight' })
372+
373+
const todoCard = screen.getByText('First todo bug').closest('[role="article"]')
374+
expect(todoCard?.className).toContain('ring-2')
375+
})
376+
377+
it('should move selection to previous column on ArrowLeft', () => {
378+
render(<Board {...defaultProps} bugs={multipleBacklogBugs} />)
379+
380+
// Select first bug in backlog, move right to todo, then back left
381+
fireEvent.keyDown(document, { key: 'ArrowDown' })
382+
fireEvent.keyDown(document, { key: 'ArrowRight' })
383+
fireEvent.keyDown(document, { key: 'ArrowLeft' })
384+
385+
const backlogCard = screen.getByText('First backlog bug').closest('[role="article"]')
386+
expect(backlogCard?.className).toContain('ring-2')
387+
})
388+
389+
it('should not move selection past first column on ArrowLeft', () => {
390+
render(<Board {...defaultProps} bugs={multipleBacklogBugs} />)
391+
392+
// Select first bug in backlog
393+
fireEvent.keyDown(document, { key: 'ArrowDown' })
394+
// Try to move left (should stay in backlog)
395+
fireEvent.keyDown(document, { key: 'ArrowLeft' })
396+
397+
const backlogCard = screen.getByText('First backlog bug').closest('[role="article"]')
398+
expect(backlogCard?.className).toContain('ring-2')
399+
})
400+
401+
it('should not move selection past last column on ArrowRight', () => {
402+
const bugsInDone: BugzillaBug[] = [
403+
{
404+
id: 50,
405+
summary: 'Done bug',
406+
status: 'VERIFIED',
407+
assigned_to: 'dev@example.com',
408+
priority: 'P1',
409+
severity: 'normal',
410+
component: 'Core',
411+
whiteboard: '[kanban]',
412+
last_change_time: '2024-01-15T10:00:00Z',
413+
},
414+
]
415+
render(<Board {...defaultProps} bugs={bugsInDone} />)
416+
417+
// Select bug in done column
418+
fireEvent.keyDown(document, { key: 'ArrowDown' })
419+
// Try to move right (should stay in done)
420+
fireEvent.keyDown(document, { key: 'ArrowRight' })
421+
422+
const doneCard = screen.getByText('Done bug').closest('[role="article"]')
423+
expect(doneCard?.className).toContain('ring-2')
424+
})
425+
426+
it('should clamp index when moving to column with fewer bugs', () => {
427+
render(<Board {...defaultProps} bugs={multipleBacklogBugs} />)
428+
429+
// Select third bug in backlog (index 2)
430+
fireEvent.keyDown(document, { key: 'ArrowDown' })
431+
fireEvent.keyDown(document, { key: 'ArrowDown' })
432+
fireEvent.keyDown(document, { key: 'ArrowDown' })
433+
// Move to todo (which only has 1 bug, so should clamp to index 0)
434+
fireEvent.keyDown(document, { key: 'ArrowRight' })
435+
436+
const todoCard = screen.getByText('First todo bug').closest('[role="article"]')
437+
expect(todoCard?.className).toContain('ring-2')
438+
})
439+
})
440+
441+
describe('escape key', () => {
442+
it('should clear selection on Escape', () => {
443+
render(<Board {...defaultProps} bugs={multipleBacklogBugs} />)
444+
445+
// Select a bug
446+
fireEvent.keyDown(document, { key: 'ArrowDown' })
447+
// Clear selection
448+
fireEvent.keyDown(document, { key: 'Escape' })
449+
450+
// No cards should have selection ring
451+
const cards = screen.getAllByRole('article')
452+
for (const card of cards) {
453+
expect(card.className).not.toContain('ring-accent-primary')
454+
}
455+
})
456+
})
457+
458+
describe('grab and move with Shift', () => {
459+
it('should enter grab mode when Shift is pressed with selection', () => {
460+
render(<Board {...defaultProps} bugs={multipleBacklogBugs} />)
461+
462+
// Select a bug
463+
fireEvent.keyDown(document, { key: 'ArrowDown' })
464+
// Hold shift
465+
fireEvent.keyDown(document, { key: 'Shift' })
466+
467+
const selectedCard = screen.getByText('First backlog bug').closest('[role="article"]')
468+
expect(selectedCard?.className).toContain('ring-accent-warning')
469+
expect(selectedCard?.className).toContain('animate-pulse')
470+
})
471+
472+
it('should exit grab mode and stage move when Shift is released', () => {
473+
const onBugMove = vi.fn()
474+
render(<Board {...defaultProps} bugs={multipleBacklogBugs} onBugMove={onBugMove} />)
475+
476+
// Select first backlog bug
477+
fireEvent.keyDown(document, { key: 'ArrowDown' })
478+
// Hold shift
479+
fireEvent.keyDown(document, { key: 'Shift' })
480+
// Move to todo column
481+
fireEvent.keyDown(document, { key: 'ArrowRight' })
482+
// Release shift
483+
fireEvent.keyUp(document, { key: 'Shift' })
484+
485+
// Should call onBugMove to stage the change
486+
expect(onBugMove).toHaveBeenCalledWith(10, 'backlog', 'todo')
487+
})
488+
489+
it('should not stage move if column did not change', () => {
490+
const onBugMove = vi.fn()
491+
render(<Board {...defaultProps} bugs={multipleBacklogBugs} onBugMove={onBugMove} />)
492+
493+
// Select first backlog bug
494+
fireEvent.keyDown(document, { key: 'ArrowDown' })
495+
// Hold shift (no movement)
496+
fireEvent.keyDown(document, { key: 'Shift' })
497+
// Release shift without moving
498+
fireEvent.keyUp(document, { key: 'Shift' })
499+
500+
// Should not call onBugMove
501+
expect(onBugMove).not.toHaveBeenCalled()
502+
})
503+
504+
it('should move grabbed bug to new column on ArrowRight while grabbing', () => {
505+
render(<Board {...defaultProps} bugs={multipleBacklogBugs} />)
506+
507+
// Select first backlog bug
508+
fireEvent.keyDown(document, { key: 'ArrowDown' })
509+
// Hold shift
510+
fireEvent.keyDown(document, { key: 'Shift' })
511+
// Move to todo column
512+
fireEvent.keyDown(document, { key: 'ArrowRight' })
513+
514+
// The bug should now appear selected in the todo column visually
515+
// (grab mode shows warning ring)
516+
const firstCard = screen.getByText('First backlog bug').closest('[role="article"]')
517+
expect(firstCard?.className).toContain('ring-accent-warning')
518+
})
519+
520+
it('should not allow movement past first column while grabbing', () => {
521+
const onBugMove = vi.fn()
522+
render(<Board {...defaultProps} bugs={multipleBacklogBugs} onBugMove={onBugMove} />)
523+
524+
// Select first backlog bug
525+
fireEvent.keyDown(document, { key: 'ArrowDown' })
526+
// Hold shift
527+
fireEvent.keyDown(document, { key: 'Shift' })
528+
// Try to move left (already at first column)
529+
fireEvent.keyDown(document, { key: 'ArrowLeft' })
530+
// Release shift
531+
fireEvent.keyUp(document, { key: 'Shift' })
532+
533+
// Should not stage any move
534+
expect(onBugMove).not.toHaveBeenCalled()
535+
})
536+
})
537+
538+
describe('Shift+Enter to apply changes', () => {
539+
it('should call onApplyChanges when Shift+Enter is pressed', () => {
540+
const onApplyChanges = vi.fn()
541+
render(
542+
<Board {...defaultProps} bugs={multipleBacklogBugs} onApplyChanges={onApplyChanges} />,
543+
)
544+
545+
// Press Shift+Enter
546+
fireEvent.keyDown(document, { key: 'Enter', shiftKey: true })
547+
548+
expect(onApplyChanges).toHaveBeenCalled()
549+
})
550+
551+
it('should not call onApplyChanges on Enter without Shift', () => {
552+
const onApplyChanges = vi.fn()
553+
render(
554+
<Board {...defaultProps} bugs={multipleBacklogBugs} onApplyChanges={onApplyChanges} />,
555+
)
556+
557+
// Press Enter without shift
558+
fireEvent.keyDown(document, { key: 'Enter', shiftKey: false })
559+
560+
expect(onApplyChanges).not.toHaveBeenCalled()
561+
})
562+
})
563+
564+
describe('disabled states', () => {
565+
it('should not respond to keyboard when loading', () => {
566+
render(<Board {...defaultProps} bugs={multipleBacklogBugs} isLoading={true} />)
567+
568+
fireEvent.keyDown(document, { key: 'ArrowDown' })
569+
570+
// Should not select anything (no ring class on any card)
571+
// When loading, cards are not rendered, so this just confirms no errors
572+
expect(screen.queryByText('First backlog bug')).not.toBeInTheDocument()
573+
})
574+
575+
it('should not respond to keyboard when no bugs', () => {
576+
render(<Board {...defaultProps} bugs={[]} />)
577+
578+
// Should not throw error when pressing keys with no bugs
579+
fireEvent.keyDown(document, { key: 'ArrowDown' })
580+
fireEvent.keyDown(document, { key: 'Shift' })
581+
582+
expect(screen.getAllByText(/no bugs here/i).length).toBe(5)
583+
})
584+
})
585+
})
278586
})

0 commit comments

Comments
 (0)