@@ -45,14 +45,19 @@ jest.mock('../tasks-utils', () => {
4545
4646jest . mock ( '@/components/ui/multi-select' , ( ) => ( {
4747 MultiSelectFilter : jest . fn ( ( { title, completionStats } ) => (
48- < div data-testid = { `multi-select-${ title . toLowerCase ( ) } ` } >
48+ < button
49+ id = { title . toLowerCase ( ) }
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
@@ -865,10 +870,6 @@ describe('Tasks Component', () => {
865870 const MultiSelectFilter =
866871 require ( '@/components/ui/multi-select' ) . MultiSelectFilter ;
867872
868- MultiSelectFilter . mockImplementation ( ( { title } : { title : string } ) => {
869- return < div data-testid = { `ms-${ title } ` } > Mocked MultiSelect: { title } </ div > ;
870- } ) ;
871-
872873 render ( < Tasks { ...mockProps } /> ) ;
873874
874875 await waitFor ( async ( ) => {
@@ -1739,4 +1740,197 @@ describe('Tasks Component', () => {
17391740 expect ( task1Row ) . toBeInTheDocument ( ) ;
17401741 } ) ;
17411742 } ) ;
1743+
1744+ describe ( 'Keyboard Navigation' , ( ) => {
1745+ describe ( 'Arrow Key Navigation' , ( ) => {
1746+ test ( 'ArrowDown key moves selection to next task' , async ( ) => {
1747+ render ( < Tasks { ...mockProps } /> ) ;
1748+ await screen . findByText ( 'Task 1' ) ;
1749+ const taskRows = screen . getAllByTestId ( / t a s k - r o w - / ) ;
1750+
1751+ expect ( taskRows [ 0 ] ) . toHaveAttribute ( 'data-selected' , 'true' ) ;
1752+ expect ( taskRows [ 1 ] ) . toHaveAttribute ( 'data-selected' , 'false' ) ;
1753+
1754+ fireEvent . keyDown ( window , { key : 'ArrowDown' } ) ;
1755+
1756+ expect ( taskRows [ 0 ] ) . toHaveAttribute ( 'data-selected' , 'false' ) ;
1757+ expect ( taskRows [ 1 ] ) . toHaveAttribute ( 'data-selected' , 'true' ) ;
1758+ } ) ;
1759+
1760+ test ( 'ArrowUp moves selection back to previous task' , async ( ) => {
1761+ render ( < Tasks { ...mockProps } /> ) ;
1762+ await screen . findByText ( 'Task 1' ) ;
1763+ const taskRows = screen . getAllByTestId ( / t a s k - r o w - / ) ;
1764+
1765+ fireEvent . keyDown ( window , { key : 'ArrowDown' } ) ;
1766+ fireEvent . keyDown ( window , { key : 'ArrowDown' } ) ;
1767+
1768+ expect ( taskRows [ 1 ] ) . toHaveAttribute ( 'data-selected' , 'false' ) ;
1769+ expect ( taskRows [ 2 ] ) . toHaveAttribute ( 'data-selected' , 'true' ) ;
1770+
1771+ fireEvent . keyDown ( window , { key : 'ArrowUp' } ) ;
1772+
1773+ expect ( taskRows [ 1 ] ) . toHaveAttribute ( 'data-selected' , 'true' ) ;
1774+ expect ( taskRows [ 2 ] ) . toHaveAttribute ( 'data-selected' , 'false' ) ;
1775+ } ) ;
1776+
1777+ test ( 'ArrowDown stops at last task on page' , async ( ) => {
1778+ render ( < Tasks { ...mockProps } /> ) ;
1779+ await screen . findByText ( 'Task 1' ) ;
1780+
1781+ const taskRows = screen . getAllByTestId ( / t a s k - r o w - / ) ;
1782+
1783+ for ( let i = 0 ; i < taskRows . length + 2 ; i ++ ) {
1784+ fireEvent . keyDown ( window , { key : 'ArrowDown' } ) ;
1785+ }
1786+
1787+ expect ( taskRows [ taskRows . length - 1 ] ) . toHaveAttribute (
1788+ 'data-selected' ,
1789+ 'true'
1790+ ) ;
1791+ } ) ;
1792+
1793+ test ( 'ArrowUp stops at first task' , async ( ) => {
1794+ render ( < Tasks { ...mockProps } /> ) ;
1795+ await screen . findByText ( 'Task 1' ) ;
1796+ const taskRows = screen . getAllByTestId ( / t a s k - r o w - / ) ;
1797+ const middleIndex = Math . floor ( taskRows . length / 2 ) ;
1798+
1799+ for ( let i = 0 ; i < middleIndex ; i ++ ) {
1800+ fireEvent . keyDown ( window , { key : 'ArrowDown' } ) ;
1801+ }
1802+ for ( let i = 0 ; i < middleIndex + 5 ; i ++ ) {
1803+ fireEvent . keyDown ( window , { key : 'ArrowUp' } ) ;
1804+ }
1805+
1806+ expect ( taskRows [ 0 ] ) . toHaveAttribute ( 'data-selected' , 'true' ) ;
1807+ } ) ;
1808+ } ) ;
1809+
1810+ describe ( 'Hotkey Shortcuts' , ( ) => {
1811+ test ( 'pressing "a" opens the Add Task dialog' , async ( ) => {
1812+ render ( < Tasks { ...mockProps } /> ) ;
1813+ await screen . findByText ( 'Task 1' ) ;
1814+
1815+ fireEvent . keyDown ( window , { key : 'a' } ) ;
1816+
1817+ const dialog = await screen . findByRole ( 'dialog' ) ;
1818+ expect ( within ( dialog ) . getByText ( / a d d a n e w t a s k / i) ) . toBeInTheDocument ( ) ;
1819+ } ) ;
1820+
1821+ test . each ( [
1822+ [ 'c' , 'complete' , 'markTaskAsCompleted' ] ,
1823+ [ 'd' , 'delete' , 'markTaskAsDeleted' ] ,
1824+ ] ) (
1825+ 'pressing %s attempts to open task dialog and trigger %s action' ,
1826+ async ( key , _action , fn ) => {
1827+ render ( < Tasks { ...mockProps } /> ) ;
1828+ await screen . findByText ( 'Task 1' ) ;
1829+
1830+ fireEvent . keyDown ( window , { key } ) ;
1831+
1832+ const yesButton = await screen . findByRole ( 'button' , {
1833+ name : / ^ y e s $ / i,
1834+ } ) ;
1835+ fireEvent . click ( yesButton ) ;
1836+
1837+ expect ( jest . requireMock ( '../tasks-utils' ) [ fn ] ) . toHaveBeenCalled ( ) ;
1838+ }
1839+ ) ;
1840+
1841+ test ( 'pressing "Enter" key opens the selected task dialog' , async ( ) => {
1842+ render ( < Tasks { ...mockProps } /> ) ;
1843+ await screen . findByText ( 'Task 1' ) ;
1844+
1845+ const taskRows = screen . getAllByTestId ( / t a s k - r o w - / ) ;
1846+ const selectedRow = taskRows . find (
1847+ ( row ) => row . getAttribute ( 'data-selected' ) === 'true'
1848+ ) ;
1849+ const selectedTaskId = selectedRow
1850+ ?. getAttribute ( 'data-testid' )
1851+ ?. replace ( 'task-row-' , '' ) ;
1852+
1853+ fireEvent . keyDown ( window , { key : 'Enter' } ) ;
1854+
1855+ const dialog = await screen . findByRole ( 'dialog' ) ;
1856+ const idCell = within ( dialog ) . getByText ( 'ID:' ) . closest ( 'tr' ) ;
1857+ expect ( within ( idCell ! ) . getByText ( selectedTaskId ! ) ) . toBeInTheDocument ( ) ;
1858+ } ) ;
1859+
1860+ test ( 'pressing "f" focuses the search input' , async ( ) => {
1861+ render ( < Tasks { ...mockProps } /> ) ;
1862+ await screen . findByText ( 'Task 1' ) ;
1863+
1864+ fireEvent . keyDown ( window , { key : 'f' } ) ;
1865+
1866+ const searchInput = screen . getByPlaceholderText ( 'Search tasks...' ) ;
1867+ expect ( document . activeElement ) . toBe ( searchInput ) ;
1868+ } ) ;
1869+
1870+ test ( 'pressing "r" triggers sync' , async ( ) => {
1871+ render ( < Tasks { ...mockProps } /> ) ;
1872+ await screen . findByText ( 'Task 1' ) ;
1873+
1874+ fireEvent . keyDown ( window , { key : 'r' } ) ;
1875+
1876+ expect ( mockProps . setIsLoading ) . toHaveBeenCalledWith ( true ) ;
1877+ expect (
1878+ jest . requireMock ( '../hooks' ) . fetchTaskwarriorTasks
1879+ ) . toHaveBeenCalled ( ) ;
1880+ } ) ;
1881+
1882+ test . each ( [
1883+ [ 'p' , 'projects' ] ,
1884+ [ 's' , 'status' ] ,
1885+ [ 't' , 'tags' ] ,
1886+ ] ) ( 'pressing "%s" opens the %s filter' , async ( key , filterName ) => {
1887+ render ( < Tasks { ...mockProps } /> ) ;
1888+ await screen . findByText ( 'Task 1' ) ;
1889+
1890+ const filterButton = screen . getByTestId ( `multi-select-${ filterName } ` ) ;
1891+ expect ( filterButton ) . toHaveAttribute ( 'aria-expanded' , 'false' ) ;
1892+
1893+ fireEvent . keyDown ( window , { key } ) ;
1894+
1895+ expect ( filterButton ) . toHaveAttribute ( 'aria-expanded' , 'true' ) ;
1896+ } ) ;
1897+
1898+ test ( 'hotkeys are disabled when input is focused' , async ( ) => {
1899+ render ( < Tasks { ...mockProps } /> ) ;
1900+ await screen . findByText ( 'Task 1' ) ;
1901+
1902+ const searchInput = screen . getByPlaceholderText ( 'Search tasks...' ) ;
1903+ searchInput . focus ( ) ;
1904+
1905+ fireEvent . keyDown ( searchInput , { key : 'r' } ) ;
1906+
1907+ expect ( mockProps . setIsLoading ) . not . toHaveBeenCalledWith ( true ) ;
1908+ } ) ;
1909+ } ) ;
1910+
1911+ describe ( 'Complete/Delete Hotkeys When Dialog Open' , ( ) => {
1912+ test . each ( [
1913+ [ 'c' , 'complete' , 'markTaskAsCompleted' ] ,
1914+ [ 'd' , 'delete' , 'markTaskAsDeleted' ] ,
1915+ ] ) (
1916+ 'pressing "%s" with dialog open triggers %s action on confirmation' ,
1917+ async ( key , _action , fn ) => {
1918+ render ( < Tasks { ...mockProps } /> ) ;
1919+ await screen . findByText ( 'Task 1' ) ;
1920+
1921+ fireEvent . click ( screen . getByText ( 'Task 1' ) ) ;
1922+ await screen . findByRole ( 'dialog' ) ;
1923+
1924+ fireEvent . keyDown ( window , { key } ) ;
1925+
1926+ const yesButton = await screen . findByRole ( 'button' , {
1927+ name : / ^ y e s $ / i,
1928+ } ) ;
1929+ fireEvent . click ( yesButton ) ;
1930+
1931+ expect ( jest . requireMock ( '../tasks-utils' ) [ fn ] ) . toHaveBeenCalled ( ) ;
1932+ }
1933+ ) ;
1934+ } ) ;
1935+ } ) ;
17421936} ) ;
0 commit comments