@@ -44,15 +44,20 @@ jest.mock('../tasks-utils', () => {
4444} ) ;
4545
4646jest . mock ( '@/components/ui/multi-select' , ( ) => ( {
47- MultiSelectFilter : jest . fn ( ( { title, completionStats } ) => (
48- < div data-testid = { `multi-select-${ title . toLowerCase ( ) } ` } >
47+ MultiSelectFilter : jest . fn ( ( { id, title, completionStats } ) => (
48+ < button
49+ id = { id }
50+ data-testid = { `multi-select-${ title . toLowerCase ( ) } ` }
51+ aria-expanded = "false"
52+ onClick = { ( e ) => e . currentTarget . setAttribute ( 'aria-expanded' , 'true' ) }
53+ >
4954 Mocked MultiSelect: { title }
5055 { completionStats && (
5156 < span data-testid = { `stats-${ title . toLowerCase ( ) } ` } >
5257 { JSON . stringify ( completionStats ) }
5358 </ span >
5459 ) }
55- </ div >
60+ </ button >
5661 ) ) ,
5762} ) ) ;
5863
@@ -863,10 +868,6 @@ describe('Tasks Component', () => {
863868 const MultiSelectFilter =
864869 require ( '@/components/ui/multi-select' ) . MultiSelectFilter ;
865870
866- MultiSelectFilter . mockImplementation ( ( { title } : { title : string } ) => {
867- return < div data-testid = { `ms-${ title } ` } > Mocked MultiSelect: { title } </ div > ;
868- } ) ;
869-
870871 render ( < Tasks { ...mockProps } /> ) ;
871872
872873 await waitFor ( async ( ) => {
@@ -1737,4 +1738,178 @@ describe('Tasks Component', () => {
17371738 expect ( task1Row ) . toBeInTheDocument ( ) ;
17381739 } ) ;
17391740 } ) ;
1741+
1742+ describe ( 'Keyboard Navigation' , ( ) => {
1743+ describe ( 'Arrow Key Navigation' , ( ) => {
1744+ test ( 'ArrowDown key moves selection to next task' , async ( ) => {
1745+ render ( < Tasks { ...mockProps } /> ) ;
1746+ await screen . findByText ( 'Task 1' ) ;
1747+
1748+ fireEvent . keyDown ( window , { key : 'ArrowDown' } ) ;
1749+ fireEvent . keyDown ( window , { key : 'Enter' } ) ;
1750+
1751+ const dialog = await screen . findByRole ( 'dialog' ) ;
1752+ expect ( within ( dialog ) . getByText ( 'Deleted Task 1' ) ) . toBeInTheDocument ( ) ;
1753+ } ) ;
1754+
1755+ test ( 'ArrowUp moves selection back to previous task' , async ( ) => {
1756+ render ( < Tasks { ...mockProps } /> ) ;
1757+ await screen . findByText ( 'Task 1' ) ;
1758+
1759+ fireEvent . keyDown ( window , { key : 'ArrowDown' } ) ;
1760+ fireEvent . keyDown ( window , { key : 'ArrowDown' } ) ;
1761+ fireEvent . keyDown ( window , { key : 'ArrowUp' } ) ;
1762+ fireEvent . keyDown ( window , { key : 'Enter' } ) ;
1763+
1764+ const dialog = await screen . findByRole ( 'dialog' ) ;
1765+ expect ( within ( dialog ) . getByText ( 'Deleted Task 1' ) ) . toBeInTheDocument ( ) ;
1766+ } ) ;
1767+
1768+ test ( 'ArrowDown stops at last task on page' , async ( ) => {
1769+ render ( < Tasks { ...mockProps } /> ) ;
1770+ await screen . findByText ( 'Task 1' ) ;
1771+
1772+ for ( let i = 0 ; i < 20 ; i ++ ) {
1773+ fireEvent . keyDown ( window , { key : 'ArrowDown' } ) ;
1774+ }
1775+
1776+ fireEvent . keyDown ( window , { key : 'Enter' } ) ;
1777+
1778+ const dialog = await screen . findByRole ( 'dialog' ) ;
1779+ expect ( within ( dialog ) . getByText ( 'Task 9' ) ) . toBeInTheDocument ( ) ;
1780+ } ) ;
1781+
1782+ test ( 'ArrowUp stops at index zero task' , async ( ) => {
1783+ render ( < Tasks { ...mockProps } /> ) ;
1784+ await screen . findByText ( 'Task 1' ) ;
1785+
1786+ for ( let i = 0 ; i < 5 ; i ++ ) {
1787+ fireEvent . keyDown ( window , { key : 'ArrowUp' } ) ;
1788+ }
1789+
1790+ fireEvent . keyDown ( window , { key : 'Enter' } ) ;
1791+
1792+ const dialog = await screen . findByRole ( 'dialog' ) ;
1793+ expect ( within ( dialog ) . getByText ( 'tag1' ) ) . toBeInTheDocument ( ) ;
1794+ expect ( within ( dialog ) . getByText ( 'Overdue' ) ) . toBeInTheDocument ( ) ;
1795+ } ) ;
1796+ } ) ;
1797+
1798+ describe ( 'Hotkey Shortcuts' , ( ) => {
1799+ test ( 'pressing "a" opens the Add Task dialog' , async ( ) => {
1800+ render ( < Tasks { ...mockProps } /> ) ;
1801+ await screen . findByText ( 'Task 1' ) ;
1802+
1803+ fireEvent . keyDown ( window , { key : 'a' } ) ;
1804+
1805+ expect (
1806+ await screen . findByText (
1807+ / f i l l i n t h e d e t a i l s b e l o w t o a d d a n e w t a s k / i
1808+ )
1809+ ) . toBeInTheDocument ( ) ;
1810+ } ) ;
1811+
1812+ test . each ( [
1813+ [ 'c' , 'complete' ] ,
1814+ [ 'd' , 'delete' ] ,
1815+ ] ) (
1816+ 'pressing %s attempts to open task dialog and trigger %s action' ,
1817+ async ( key , _action ) => {
1818+ jest . useFakeTimers ( ) ;
1819+
1820+ render ( < Tasks { ...mockProps } /> ) ;
1821+ await screen . findByText ( 'Task 1' ) ;
1822+
1823+ fireEvent . keyDown ( window , { key } ) ;
1824+
1825+ expect ( await screen . findByText ( 'Tags:' ) ) . toBeInTheDocument ( ) ;
1826+
1827+ act ( ( ) => {
1828+ jest . advanceTimersByTime ( 200 ) ;
1829+ } ) ;
1830+
1831+ expect ( await screen . findByText ( 'Are you' ) ) . toBeInTheDocument ( ) ;
1832+
1833+ jest . useRealTimers ( ) ;
1834+ }
1835+ ) ;
1836+
1837+ test ( 'pressing "Enter" key opens the selected task dialog' , async ( ) => {
1838+ render ( < Tasks { ...mockProps } /> ) ;
1839+ await screen . findByText ( 'Task 1' ) ;
1840+
1841+ fireEvent . keyDown ( window , { key : 'Enter' } ) ;
1842+
1843+ expect ( await screen . findByText ( 'Description:' ) ) . toBeInTheDocument ( ) ;
1844+ } ) ;
1845+
1846+ test ( 'pressing "f" focuses the search input' , async ( ) => {
1847+ render ( < Tasks { ...mockProps } /> ) ;
1848+ await screen . findByText ( 'Task 1' ) ;
1849+
1850+ fireEvent . keyDown ( window , { key : 'f' } ) ;
1851+
1852+ const searchInput = screen . getByPlaceholderText ( 'Search tasks...' ) ;
1853+ expect ( document . activeElement ) . toBe ( searchInput ) ;
1854+ } ) ;
1855+
1856+ test ( 'pressing "r" triggers sync' , async ( ) => {
1857+ render ( < Tasks { ...mockProps } /> ) ;
1858+ await screen . findByText ( 'Task 1' ) ;
1859+
1860+ fireEvent . keyDown ( window , { key : 'r' } ) ;
1861+
1862+ expect ( mockProps . setIsLoading ) . toHaveBeenCalledWith ( true ) ;
1863+ } ) ;
1864+
1865+ test . each ( [
1866+ [ 'p' , 'projects' ] ,
1867+ [ 's' , 'status' ] ,
1868+ [ 't' , 'tags' ] ,
1869+ ] ) ( 'pressing "%s" opens the %s filter' , async ( key , filterName ) => {
1870+ render ( < Tasks { ...mockProps } /> ) ;
1871+ await screen . findByText ( 'Task 1' ) ;
1872+
1873+ const filterButton = screen . getByTestId ( `multi-select-${ filterName } ` ) ;
1874+ expect ( filterButton ) . toHaveAttribute ( 'aria-expanded' , 'false' ) ;
1875+
1876+ fireEvent . keyDown ( window , { key } ) ;
1877+
1878+ expect ( filterButton ) . toHaveAttribute ( 'aria-expanded' , 'true' ) ;
1879+ } ) ;
1880+
1881+ test ( 'hotkeys are disabled when input is focused' , async ( ) => {
1882+ render ( < Tasks { ...mockProps } /> ) ;
1883+ await screen . findByText ( 'Task 1' ) ;
1884+
1885+ const searchInput = screen . getByPlaceholderText ( 'Search tasks...' ) ;
1886+ searchInput . focus ( ) ;
1887+
1888+ fireEvent . keyDown ( searchInput , { key : 'r' } ) ;
1889+
1890+ expect ( mockProps . setIsLoading ) . not . toHaveBeenCalledWith ( true ) ;
1891+ } ) ;
1892+ } ) ;
1893+
1894+ describe ( 'Complete Hotkey When Dialog Open' , ( ) => {
1895+ test . each ( [
1896+ [ 'c' , 'complete' ] ,
1897+ [ 'd' , 'delete' ] ,
1898+ ] ) (
1899+ 'pressing "%s" when dialog is already open triggers %s confirmation' ,
1900+ async ( key , _action ) => {
1901+ render ( < Tasks { ...mockProps } /> ) ;
1902+ await screen . findByText ( 'Task 1' ) ;
1903+
1904+ fireEvent . click ( screen . getByText ( 'Task 1' ) ) ;
1905+
1906+ expect ( await screen . findByRole ( 'dialog' ) ) . toBeInTheDocument ( ) ;
1907+
1908+ fireEvent . keyDown ( window , { key } ) ;
1909+
1910+ expect ( await screen . findByText ( 'Are you' ) ) . toBeInTheDocument ( ) ;
1911+ }
1912+ ) ;
1913+ } ) ;
1914+ } ) ;
17401915} ) ;
0 commit comments