Skip to content

Commit d82cb88

Browse files
committed
fix(Tasks): implement hotkeysEnabled state to control keyboard shortcuts
- Add `enabled` parameter to useHotkeys hook with default true value - Pass hotkeysEnabled state to all useHotkeys calls in Tasks component - Add hotkeysEnabled check in manual keyboard handler (ArrowUp/Down/Enter) - Add data-testid to tasks table container for testing - Add tests for useHotkeys enabled parameter - Add tests for hotkeys enable/disable on mouse hover - Add active context pattern with `onPointerDown` for tablet/touch support - Add global pointerdown listener to disable hotkeys on outside click - Add tests for active context scenarios
1 parent 39e595c commit d82cb88

4 files changed

Lines changed: 259 additions & 75 deletions

File tree

frontend/src/components/HomeComponents/Tasks/Tasks.tsx

Lines changed: 128 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,9 @@ export const Tasks = (
115115
new Set()
116116
);
117117
const tableRef = useRef<HTMLDivElement>(null);
118-
const [hotkeysEnabled, setHotkeysEnabled] = useState(false);
118+
const [isMouseOver, setIsMouseOver] = useState(false);
119+
const [activeContext, setActiveContext] = useState<string | null>(null);
120+
const hotkeysEnabled = activeContext === 'TASKS' || isMouseOver;
119121
const [selectedIndex, setSelectedIndex] = useState(0);
120122
const {
121123
state: editState,
@@ -150,8 +152,27 @@ export const Tasks = (
150152
const paginate = (pageNumber: number) => setCurrentPage(pageNumber);
151153
const totalPages = Math.ceil(tempTasks.length / tasksPerPage) || 1;
152154

155+
useEffect(() => {
156+
const handleGlobalPointerDown = (e: PointerEvent) => {
157+
if (tableRef.current && !tableRef.current.contains(e.target as Node)) {
158+
setActiveContext(null);
159+
}
160+
};
161+
162+
document.addEventListener('pointerdown', handleGlobalPointerDown, true);
163+
return () => {
164+
document.removeEventListener(
165+
'pointerdown',
166+
handleGlobalPointerDown,
167+
true
168+
);
169+
};
170+
}, []);
171+
153172
useEffect(() => {
154173
const handler = (e: KeyboardEvent) => {
174+
if (!hotkeysEnabled) return;
175+
155176
const target = e.target as HTMLElement;
156177
if (
157178
target instanceof HTMLInputElement ||
@@ -991,83 +1012,115 @@ export const Tasks = (
9911012
}
9921013
};
9931014

994-
useHotkeys(['f'], () => {
995-
if (!showReports) {
996-
document.getElementById('search')?.focus();
997-
}
998-
});
999-
useHotkeys(['a'], () => {
1000-
if (!showReports) {
1001-
document.getElementById('add-new-task')?.click();
1002-
}
1003-
});
1004-
useHotkeys(['r'], () => {
1005-
if (!showReports) {
1006-
document.getElementById('sync-task')?.click();
1007-
}
1008-
});
1009-
useHotkeys(['p'], () => {
1010-
if (!showReports) {
1011-
document.getElementById('projects')?.click();
1012-
}
1013-
});
1014-
useHotkeys(['s'], () => {
1015-
if (!showReports) {
1016-
document.getElementById('status')?.click();
1017-
}
1018-
});
1019-
useHotkeys(['t'], () => {
1020-
if (!showReports) {
1021-
document.getElementById('tags')?.click();
1022-
}
1023-
});
1024-
useHotkeys(['c'], () => {
1025-
if (!showReports && !_isDialogOpen) {
1026-
const task = currentTasks[selectedIndex];
1027-
if (!task) return;
1028-
const openBtn = document.getElementById(`task-row-${task.id}`);
1029-
openBtn?.click();
1030-
setTimeout(() => {
1031-
const confirmBtn = document.getElementById(
1032-
`mark-task-complete-${task.id}`
1033-
);
1034-
confirmBtn?.click();
1035-
}, 200);
1036-
} else {
1037-
if (_isDialogOpen) {
1015+
useHotkeys(
1016+
['f'],
1017+
() => {
1018+
if (!showReports) {
1019+
document.getElementById('search')?.focus();
1020+
}
1021+
},
1022+
hotkeysEnabled
1023+
);
1024+
useHotkeys(
1025+
['a'],
1026+
() => {
1027+
if (!showReports) {
1028+
document.getElementById('add-new-task')?.click();
1029+
}
1030+
},
1031+
hotkeysEnabled
1032+
);
1033+
useHotkeys(
1034+
['r'],
1035+
() => {
1036+
if (!showReports) {
1037+
document.getElementById('sync-task')?.click();
1038+
}
1039+
},
1040+
hotkeysEnabled
1041+
);
1042+
useHotkeys(
1043+
['p'],
1044+
() => {
1045+
if (!showReports) {
1046+
document.getElementById('projects')?.click();
1047+
}
1048+
},
1049+
hotkeysEnabled
1050+
);
1051+
useHotkeys(
1052+
['s'],
1053+
() => {
1054+
if (!showReports) {
1055+
document.getElementById('status')?.click();
1056+
}
1057+
},
1058+
hotkeysEnabled
1059+
);
1060+
useHotkeys(
1061+
['t'],
1062+
() => {
1063+
if (!showReports) {
1064+
document.getElementById('tags')?.click();
1065+
}
1066+
},
1067+
hotkeysEnabled
1068+
);
1069+
useHotkeys(
1070+
['c'],
1071+
() => {
1072+
if (!showReports && !_isDialogOpen) {
10381073
const task = currentTasks[selectedIndex];
10391074
if (!task) return;
1040-
const confirmBtn = document.getElementById(
1041-
`mark-task-complete-${task.id}`
1042-
);
1043-
confirmBtn?.click();
1075+
const openBtn = document.getElementById(`task-row-${task.id}`);
1076+
openBtn?.click();
1077+
setTimeout(() => {
1078+
const confirmBtn = document.getElementById(
1079+
`mark-task-complete-${task.id}`
1080+
);
1081+
confirmBtn?.click();
1082+
}, 200);
1083+
} else {
1084+
if (_isDialogOpen) {
1085+
const task = currentTasks[selectedIndex];
1086+
if (!task) return;
1087+
const confirmBtn = document.getElementById(
1088+
`mark-task-complete-${task.id}`
1089+
);
1090+
confirmBtn?.click();
1091+
}
10441092
}
1045-
}
1046-
});
1093+
},
1094+
hotkeysEnabled
1095+
);
10471096

1048-
useHotkeys(['d'], () => {
1049-
if (!showReports && !_isDialogOpen) {
1050-
const task = currentTasks[selectedIndex];
1051-
if (!task) return;
1052-
const openBtn = document.getElementById(`task-row-${task.id}`);
1053-
openBtn?.click();
1054-
setTimeout(() => {
1055-
const confirmBtn = document.getElementById(
1056-
`mark-task-as-deleted-${task.id}`
1057-
);
1058-
confirmBtn?.click();
1059-
}, 200);
1060-
} else {
1061-
if (_isDialogOpen) {
1097+
useHotkeys(
1098+
['d'],
1099+
() => {
1100+
if (!showReports && !_isDialogOpen) {
10621101
const task = currentTasks[selectedIndex];
10631102
if (!task) return;
1064-
const confirmBtn = document.getElementById(
1065-
`mark-task-as-deleted-${task.id}`
1066-
);
1067-
confirmBtn?.click();
1103+
const openBtn = document.getElementById(`task-row-${task.id}`);
1104+
openBtn?.click();
1105+
setTimeout(() => {
1106+
const confirmBtn = document.getElementById(
1107+
`mark-task-as-deleted-${task.id}`
1108+
);
1109+
confirmBtn?.click();
1110+
}, 200);
1111+
} else {
1112+
if (_isDialogOpen) {
1113+
const task = currentTasks[selectedIndex];
1114+
if (!task) return;
1115+
const confirmBtn = document.getElementById(
1116+
`mark-task-as-deleted-${task.id}`
1117+
);
1118+
confirmBtn?.click();
1119+
}
10681120
}
1069-
}
1070-
});
1121+
},
1122+
hotkeysEnabled
1123+
);
10711124

10721125
return (
10731126
<section
@@ -1128,8 +1181,10 @@ export const Tasks = (
11281181
) : (
11291182
<div
11301183
ref={tableRef}
1131-
onMouseEnter={() => setHotkeysEnabled(true)}
1132-
onMouseLeave={() => setHotkeysEnabled(false)}
1184+
data-testid="tasks-table-container"
1185+
onPointerDown={() => setActiveContext('TASKS')}
1186+
onMouseEnter={() => setIsMouseOver(true)}
1187+
onMouseLeave={() => setIsMouseOver(false)}
11331188
>
11341189
{tasks.length != 0 ? (
11351190
<>

frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1737,4 +1737,88 @@ describe('Tasks Component', () => {
17371737
expect(task1Row).toBeInTheDocument();
17381738
});
17391739
});
1740+
1741+
describe('Hotkeys Enable/Disable on Hover and Active Context', () => {
1742+
test('hotkeys are disabled by default (mouse not over task table)', async () => {
1743+
render(<Tasks {...mockProps} />);
1744+
await screen.findByText('Task 1');
1745+
1746+
fireEvent.keyDown(window, { key: 'f' });
1747+
1748+
const searchInput = screen.getByPlaceholderText('Search tasks...');
1749+
expect(document.activeElement).not.toBe(searchInput);
1750+
});
1751+
1752+
test('hotkeys are enabled when mouse enters task table', async () => {
1753+
render(<Tasks {...mockProps} />);
1754+
await screen.findByText('Task 1');
1755+
1756+
const taskContainer = screen.getByTestId('tasks-table-container');
1757+
fireEvent.mouseEnter(taskContainer);
1758+
1759+
fireEvent.keyDown(window, { key: 'f' });
1760+
1761+
const searchInput = screen.getByPlaceholderText('Search tasks...');
1762+
expect(document.activeElement).toBe(searchInput);
1763+
});
1764+
1765+
test('hotkeys are disabled when mouse leaves task table', async () => {
1766+
render(<Tasks {...mockProps} />);
1767+
await screen.findByText('Task 1');
1768+
1769+
const taskContainer = screen.getByTestId('tasks-table-container');
1770+
1771+
fireEvent.mouseEnter(taskContainer);
1772+
fireEvent.mouseLeave(taskContainer);
1773+
1774+
fireEvent.keyDown(window, { key: 'f' });
1775+
1776+
const searchInput = screen.getByPlaceholderText('Search tasks...');
1777+
expect(document.activeElement).not.toBe(searchInput);
1778+
});
1779+
1780+
test('hotkeys are enabled when user clicks/taps on task table', async () => {
1781+
render(<Tasks {...mockProps} />);
1782+
await screen.findByText('Task 1');
1783+
1784+
const taskContainer = screen.getByTestId('tasks-table-container');
1785+
1786+
fireEvent.pointerDown(taskContainer);
1787+
fireEvent.keyDown(window, { key: 'f' });
1788+
1789+
const searchInput = screen.getByPlaceholderText('Search tasks...');
1790+
1791+
expect(document.activeElement).toBe(searchInput);
1792+
});
1793+
1794+
test('hotkeys remain enabled after mouse leaves if user clicked on task table', async () => {
1795+
render(<Tasks {...mockProps} />);
1796+
await screen.findByText('Task 1');
1797+
1798+
const taskContainer = screen.getByTestId('tasks-table-container');
1799+
1800+
fireEvent.pointerDown(taskContainer);
1801+
fireEvent.mouseLeave(taskContainer);
1802+
fireEvent.keyDown(window, { key: 'f' });
1803+
1804+
const searchInput = screen.getByPlaceholderText('Search tasks...');
1805+
1806+
expect(document.activeElement).toBe(searchInput);
1807+
});
1808+
1809+
test('hotkeys are disabled when user clicks outside task table', async () => {
1810+
render(<Tasks {...mockProps} />);
1811+
await screen.findByText('Task 1');
1812+
1813+
const taskContainer = screen.getByTestId('tasks-table-container');
1814+
1815+
fireEvent.pointerDown(taskContainer);
1816+
fireEvent.pointerDown(document.body);
1817+
fireEvent.keyDown(window, { key: 'f' });
1818+
1819+
const searchInput = screen.getByPlaceholderText('Search tasks...');
1820+
1821+
expect(document.activeElement).not.toBe(searchInput);
1822+
});
1823+
});
17401824
});

frontend/src/components/utils/__tests__/use-hotkeys.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,4 +216,43 @@ describe('useHotkeys', () => {
216216

217217
removeEventListenerSpy.mockRestore();
218218
});
219+
220+
it('should call callback when enabled is true', () => {
221+
renderHook(() => useHotkeys(['s'], callback, true));
222+
223+
const event = new KeyboardEvent('keydown', {
224+
key: 's',
225+
bubbles: true,
226+
});
227+
228+
window.dispatchEvent(event);
229+
230+
expect(callback).toHaveBeenCalledTimes(1);
231+
});
232+
233+
it('should not call callback when enabled is false', () => {
234+
renderHook(() => useHotkeys(['s'], callback, false));
235+
236+
const event = new KeyboardEvent('keydown', {
237+
key: 's',
238+
bubbles: true,
239+
});
240+
241+
window.dispatchEvent(event);
242+
243+
expect(callback).not.toHaveBeenCalled();
244+
});
245+
246+
it('should default enabled to true when not provided', () => {
247+
renderHook(() => useHotkeys(['s'], callback));
248+
249+
const event = new KeyboardEvent('keydown', {
250+
key: 's',
251+
bubbles: true,
252+
});
253+
254+
window.dispatchEvent(event);
255+
256+
expect(callback).toHaveBeenCalledTimes(1);
257+
});
219258
});

frontend/src/components/utils/use-hotkeys.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
import { useEffect } from 'react';
22

3-
export function useHotkeys(keys: string[], callback: () => void) {
3+
export function useHotkeys(
4+
keys: string[],
5+
callback: () => void,
6+
enabled: boolean = true
7+
) {
48
useEffect(() => {
9+
if (!enabled) return;
10+
511
const handler = (e: KeyboardEvent) => {
612
const target = e.target as HTMLElement;
713
if (
@@ -29,5 +35,5 @@ export function useHotkeys(keys: string[], callback: () => void) {
2935

3036
window.addEventListener('keydown', handler);
3137
return () => window.removeEventListener('keydown', handler);
32-
}, [keys, callback]);
38+
}, [keys, callback, enabled]);
3339
}

0 commit comments

Comments
 (0)