fix: preserve platform shortcuts and IME input on the board (#1550)

Co-authored-by: bradgroux <brad@digitalmeld.io>
This commit is contained in:
Brad Groux 2026-09-07 18:43:12 -05:00 • committed by GitHub
parent 9733e817a2
commit 46e0709f28
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 65 additions and 2 deletions

View file

@ -113,6 +113,62 @@ describe('KeyboardProvider', () => {
);
});
it.each(['metaKey', 'ctrlKey', 'altKey', 'shiftKey'] as const)(
'leaves %s combinations to the platform',
(modifier) => {
const create = vi.fn();
const move = vi.fn();
function RegisterCreate() {
const { setOpenCreateDialog } = useKeyboard();
React.useEffect(() => setOpenCreateDialog(create), [setOpenCreateDialog]);
return null;
}
render(
<KeyboardProvider>
<RegisterCreate />
<TestConsumer tasks={[createMockTask({ id: 'task_one' })]} onMoveTask={move} />
</KeyboardProvider>
);
for (const key of ['c', 'j', 'k', '1', 'ArrowDown', 'ArrowUp', 'Enter']) {
const event = new KeyboardEvent('keydown', { key, [modifier]: true, cancelable: true });
fireEvent(window, event);
expect(event.defaultPrevented).toBe(false);
}
expect(create).not.toHaveBeenCalled();
expect(move).not.toHaveBeenCalled();
expect(screen.getByTestId('selected').textContent).toBe('none');
fireEvent.keyDown(window, { key: 'c' });
expect(create).toHaveBeenCalledOnce();
}
);
it('preserves the intentional chat chord and ignores IME composition', () => {
const chat = vi.fn();
const create = vi.fn();
function RegisterCommands() {
const { setOpenChatPanel, setOpenCreateDialog } = useKeyboard();
React.useEffect(() => {
setOpenChatPanel(chat);
setOpenCreateDialog(create);
}, [setOpenChatPanel, setOpenCreateDialog]);
return null;
}
render(
<KeyboardProvider>
<RegisterCommands />
</KeyboardProvider>
);
fireEvent.keyDown(window, { key: 'C', metaKey: true, shiftKey: true });
fireEvent.keyDown(window, { key: 'C', ctrlKey: true, shiftKey: true });
expect(chat).toHaveBeenCalledTimes(2);
fireEvent.keyDown(window, { key: 'C', ctrlKey: true, shiftKey: true, altKey: true });
fireEvent.keyDown(window, { key: 'C', metaKey: true, shiftKey: true, isComposing: true });
fireEvent.keyDown(window, { key: 'c', isComposing: true });
fireEvent.keyDown(window, { key: 'c', keyCode: 229 });
expect(chat).toHaveBeenCalledTimes(2);
expect(create).not.toHaveBeenCalled();
});
it('starts with no selected task and help closed', () => {
renderWithProvider();
expect(screen.getByTestId('selected').textContent).toBe('none');

View file

@ -375,6 +375,7 @@ export function KanbanBoard() {
// Register filtered tasks with keyboard context
useEffect(() => {
setTasks(filteredTasks);
return () => setTasks([]);
}, [filteredTasks, setTasks]);
// Handler for opening a task

View file

@ -116,6 +116,8 @@ export function KeyboardProvider({ children }: { children: ReactNode }) {
const target = e.target instanceof HTMLElement ? e.target : null;
if (
e.defaultPrevented ||
e.isComposing ||
e.keyCode === 229 ||
target?.isContentEditable ||
target?.closest(
'input, textarea, select, button, a[href], summary, [role="button"], [role="link"], [role="switch"], [role="checkbox"], [role="radio"], [role="combobox"], [role="listbox"], [role="option"], [role="slider"], [role="spinbutton"], [role="tab"], [role="menuitem"]'
@ -143,12 +145,16 @@ export function KeyboardProvider({ children }: { children: ReactNode }) {
const currentIndex = selectedTaskId ? taskList.findIndex((t) => t.id === selectedTaskId) : -1;
// Cmd+Shift+C (or Ctrl+Shift+C on Windows/Linux) - Toggle chat panel
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'C') {
if (e.metaKey !== e.ctrlKey && !e.altKey && e.shiftKey && e.key.toLowerCase() === 'c') {
e.preventDefault();
openChatPanel();
return;
}
// Unregistered modifiers belong to the platform or focused application.
// Shift is permitted only for '?' so shortcut help remains reachable.
if (e.metaKey || e.ctrlKey || e.altKey || (e.shiftKey && e.key !== '?')) return;
switch (e.key) {
case 'c':
e.preventDefault();
@ -215,7 +221,7 @@ export function KeyboardProvider({ children }: { children: ReactNode }) {
});
return;
}
if (selectedTaskId && onMoveTaskRef.current) {
if (selectedTaskId && currentIndex >= 0 && onMoveTaskRef.current) {
onMoveTaskRef.current(selectedTaskId, newStatus);
} else {
// Show toast when no task is selected