diff --git a/web/src/__tests__/keyboard-shortcut-help.test.tsx b/web/src/__tests__/keyboard-shortcut-help.test.tsx new file mode 100644 index 00000000..3e154a93 --- /dev/null +++ b/web/src/__tests__/keyboard-shortcut-help.test.tsx @@ -0,0 +1,80 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, screen, within } from '@testing-library/react'; +import { DEFAULT_FEATURE_SETTINGS } from '@veritas-kanban/shared'; +import { KeyboardProvider } from '@/hooks/useKeyboard'; +import { KeyboardShortcutsDialog } from '@/components/layout/KeyboardShortcutsDialog'; +import { renderWithProviders } from './test-utils'; + +const config = vi.hoisted(() => ({ + settings: { board: { columns: [] as Array<{ id: string; title: string }> } }, +})); +vi.mock('@/hooks/useFeatureSettings', () => ({ useFeatureSettings: () => config })); + +function Surface() { + return ( + + + + ); +} +function keyFor(label: string) { + const description = screen.getByText(`Move to ${label}`); + const row = description.parentElement; + if (!row) throw new Error('Shortcut description has no row'); + return within(row).getByText(/^\d$/).textContent; +} +describe('configured shortcut help', () => { + beforeEach(() => { + config.settings.board.columns = [...DEFAULT_FEATURE_SETTINGS.board.columns]; + }); + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + }); + + it('matches default destinations and updates rename, order, additions and removals while open', async () => { + const view = renderWithProviders(); + fireEvent.keyDown(window, { key: '?' }); + await screen.findByRole('dialog'); + for (const [i, column] of DEFAULT_FEATURE_SETTINGS.board.columns.entries()) { + expect(keyFor(column.title)).toBe(String(i + 1)); + } + expect(screen.queryByText('Move to Planning')).toBeNull(); + config.settings.board.columns = [ + { id: 'done', title: 'Complete' }, + { id: 'ready', title: 'Ready' }, + { id: 'todo', title: 'To Do' }, + ]; + view.rerender(); + expect(keyFor('Complete')).toBe('1'); + expect(keyFor('Ready')).toBe('2'); + expect(keyFor('To Do')).toBe('3'); + expect(screen.queryByText('Move to In Progress')).toBeNull(); + expect(screen.queryByText('Move to Done')).toBeNull(); + }); + + it('lists only nine numeric destinations and explains the limit', async () => { + config.settings.board.columns = Array.from({ length: 11 }, (_, i) => ({ + id: `stage-${i}`, + title: `Stage ${i + 1}`, + })); + renderWithProviders(); + fireEvent.keyDown(window, { key: '?' }); + await screen.findByRole('dialog'); + expect(keyFor('Stage 9')).toBe('9'); + expect(screen.queryByText('Move to Stage 10')).toBeNull(); + expect(screen.getByText(/Number shortcuts cover the first nine columns/)).toBeDefined(); + }); + + it.each([ + ['MacIntel', '⌘⇧C'], + ['Win32', 'Ctrl+Shift+C'], + ['Linux x86_64', 'Ctrl+Shift+C'], + ])('labels chat modifiers for %s', async (platform, label) => { + vi.spyOn(navigator, 'platform', 'get').mockReturnValue(platform); + renderWithProviders(); + fireEvent.keyDown(window, { key: '?' }); + await screen.findByRole('dialog'); + expect(screen.getByText(label)).toBeDefined(); + }); +}); diff --git a/web/src/components/layout/KeyboardShortcutsDialog.tsx b/web/src/components/layout/KeyboardShortcutsDialog.tsx index 68dcacf8..d6d9f145 100644 --- a/web/src/components/layout/KeyboardShortcutsDialog.tsx +++ b/web/src/components/layout/KeyboardShortcutsDialog.tsx @@ -8,34 +8,6 @@ interface Shortcut { 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: ['⌘⇧C'], description: 'Open agent chat' }, - { keys: ['1'], description: 'Move to To Do' }, - { keys: ['2'], description: 'Move to Planning' }, - { keys: ['3'], description: 'Move to In Progress' }, - { keys: ['4'], description: 'Move to Blocked' }, - { keys: ['5'], description: 'Move to Done' }, - ], - }, - { - category: 'General', - items: [{ keys: ['?'], description: 'Toggle this help' }], - }, -]; - function KeyBadge({ children }: { children: React.ReactNode }) { return ( @@ -45,7 +17,34 @@ function KeyBadge({ children }: { children: React.ReactNode }) { } export function KeyboardShortcutsDialog() { - const { isHelpOpen, closeHelpDialog } = useKeyboard(); + const { isHelpOpen, closeHelpDialog, columns } = useKeyboard(); + const isMac = /Mac|iPhone|iPad/.test(navigator.platform); + 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: [isMac ? '⌘⇧C' : 'Ctrl+Shift+C'], description: 'Open agent chat' }, + ...columns.slice(0, 9).map((column, index) => ({ + keys: [String(index + 1)], + description: `Move to ${column.title}`, + })), + ], + }, + { + category: 'General', + items: [{ keys: ['?'], description: 'Toggle this help' }], + }, + ]; return ( ))} + {columns.length > 9 && ( + + Number shortcuts cover the first nine columns. Use the card status control for other + columns. + + )}
diff --git a/web/src/hooks/useKeyboard.tsx b/web/src/hooks/useKeyboard.tsx index c29f4619..b8ac9adf 100644 --- a/web/src/hooks/useKeyboard.tsx +++ b/web/src/hooks/useKeyboard.tsx @@ -34,6 +34,8 @@ interface KeyboardContextValue { selectedTaskId: string | null; setSelectedTaskId: (id: string | null) => void; + columns: ReturnType; + // Task list for navigation tasks: Task[]; setTasks: (tasks: Task[]) => void; @@ -253,6 +255,7 @@ export function KeyboardProvider({ children }: { children: ReactNode }) { isHelpOpen, selectedTaskId, setSelectedTaskId, + columns, tasks, setTasks, setOnOpenTask, @@ -268,6 +271,7 @@ export function KeyboardProvider({ children }: { children: ReactNode }) { isHelpOpen, selectedTaskId, setSelectedTaskId, + columns, tasks, setTasks, setOnOpenTask,