Merge pull request #1563 from BradGroux/fix/configured-shortcut-help-1534

fix: derive shortcut help from board configuration and platform
This commit is contained in:
Brad Groux 2026-09-07 18:59:26 -05:00 • committed by GitHub
commit 103641bfd0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 118 additions and 29 deletions

View file

@ -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 (
<KeyboardProvider>
<KeyboardShortcutsDialog />
</KeyboardProvider>
);
}
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(<Surface />);
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(<Surface />);
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(<Surface />);
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(<Surface />);
fireEvent.keyDown(window, { key: '?' });
await screen.findByRole('dialog');
expect(screen.getByText(label)).toBeDefined();
});
});

View file

@ -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 (
<Kbd className="inline-flex min-w-[24px] items-center justify-center px-2 text-xs font-medium">
@ -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 (
<Modal
@ -87,6 +86,12 @@ export function KeyboardShortcutsDialog() {
</dl>
</section>
))}
{columns.length > 9 && (
<Text size="sm" c="dimmed">
Number shortcuts cover the first nine columns. Use the card status control for other
columns.
</Text>
)}
</Stack>
<OverlayFooter>
<div className="text-xs text-muted-foreground">

View file

@ -34,6 +34,8 @@ interface KeyboardContextValue {
selectedTaskId: string | null;
setSelectedTaskId: (id: string | null) => void;
columns: ReturnType<typeof normalizeBoardColumns>;
// 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,