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