1+ import { useEffect , useState } from 'react' ;
2+ import { useLocation } from 'react-router-dom' ;
3+
4+ export const useKeyboardNavigation = ( ) => {
5+ const [ currentIndex , setCurrentIndex ] = useState < number > ( - 1 ) ;
6+ const [ links , setLinks ] = useState < HTMLAnchorElement [ ] > ( [ ] ) ;
7+ const location = useLocation ( ) ;
8+
9+ // Reset navigation when route changes
10+ useEffect ( ( ) => {
11+ setCurrentIndex ( - 1 ) ;
12+ // Small delay to let the new page render
13+ setTimeout ( ( ) => {
14+ const newLinks = getAllVisibleLinks ( ) ;
15+ setLinks ( newLinks ) ;
16+ } , 100 ) ;
17+ } , [ location ] ) ;
18+
19+ const getAllVisibleLinks = ( ) => {
20+ // Get all <a> elements
21+ const allLinks = Array . from ( document . getElementsByTagName ( 'a' ) ) ; //Array.from([...Array.from(document.getElementsByTagName('a')), ...Array.from(document.getElementsByTagName('button'))]);
22+
23+ // Filter for actually clickable links
24+ return allLinks . filter ( link => {
25+ // Get computed style
26+ const style = window . getComputedStyle ( link ) ;
27+ const rect = link . getBoundingClientRect ( ) ;
28+
29+ // Check if the link is visible and clickable
30+ const isVisible = style . display !== 'none' &&
31+ style . visibility !== 'hidden' &&
32+ style . opacity !== '0' &&
33+ rect . width > 0 &&
34+ rect . height > 0 ;
35+
36+ // Check if it's an actual clickable link
37+ const isClickable = link . hasAttribute ( 'href' ) &&
38+ ! link . getAttribute ( 'href' ) ?. startsWith ( '#' ) &&
39+ ! link . getAttribute ( 'aria-hidden' ) ;
40+
41+ // Check if any parent is hidden
42+ let parent = link . parentElement ;
43+ while ( parent ) {
44+ const parentStyle = window . getComputedStyle ( parent ) ;
45+ if ( parentStyle . display === 'none' ||
46+ parentStyle . visibility === 'hidden' ||
47+ parentStyle . opacity === '0' ) {
48+ return false ;
49+ }
50+ parent = parent . parentElement ;
51+ }
52+
53+ // Check if this link is the actual target (not a parent of another link)
54+ const hasNestedLinks = link . getElementsByTagName ( 'a' ) . length > 0 ;
55+
56+ return isVisible && isClickable && ! hasNestedLinks ;
57+ } ) ;
58+ } ;
59+
60+ useEffect ( ( ) => {
61+ // Update links when DOM changes
62+ const updateLinks = ( ) => {
63+ const newLinks = getAllVisibleLinks ( ) ;
64+ if ( JSON . stringify ( newLinks . map ( l => l . href ) ) !== JSON . stringify ( links . map ( l => l . href ) ) ) {
65+ setLinks ( newLinks ) ;
66+ // Reset index if current index is invalid
67+ if ( currentIndex >= newLinks . length ) {
68+ setCurrentIndex ( - 1 ) ;
69+ }
70+ }
71+ } ;
72+
73+ // Initial links collection
74+ updateLinks ( ) ;
75+
76+ // Set up mutation observer to watch for DOM changes
77+ const observer = new MutationObserver ( ( ) => {
78+ requestAnimationFrame ( updateLinks ) ;
79+ } ) ;
80+
81+ observer . observe ( document . body , {
82+ childList : true ,
83+ subtree : true ,
84+ attributes : true ,
85+ attributeFilter : [ 'style' , 'class' , 'href' ]
86+ } ) ;
87+
88+ const handleKeyDown = ( e : KeyboardEvent ) => {
89+ // Don't handle if user is typing in an input or textarea
90+ if ( e . target instanceof HTMLInputElement || e . target instanceof HTMLTextAreaElement ) {
91+ return ;
92+ }
93+
94+ // Get fresh list of links
95+ const currentLinks = getAllVisibleLinks ( ) ;
96+
97+ switch ( e . key ) {
98+ case 'ArrowDown' :
99+ case 'ArrowRight' : {
100+ if ( currentLinks . length === 0 ) return ;
101+
102+ e . preventDefault ( ) ;
103+ setCurrentIndex ( prev => {
104+ const next = prev + 1 ;
105+ return next >= currentLinks . length ? 0 : next ;
106+ } ) ;
107+ setLinks ( currentLinks ) ;
108+ break ;
109+ }
110+ case 'ArrowUp' :
111+ case 'ArrowLeft' : {
112+ if ( currentLinks . length === 0 ) return ;
113+
114+ e . preventDefault ( ) ;
115+ setCurrentIndex ( prev => {
116+ const next = prev - 1 ;
117+ return next < 0 ? currentLinks . length - 1 : next ;
118+ } ) ;
119+ setLinks ( currentLinks ) ;
120+ break ;
121+ }
122+ case 'Enter' :
123+ if ( currentIndex >= 0 && currentIndex < currentLinks . length ) {
124+ currentLinks [ currentIndex ] . click ( ) ;
125+ }
126+ break ;
127+ }
128+ } ;
129+
130+ window . addEventListener ( 'keydown' , handleKeyDown ) ;
131+
132+ return ( ) => {
133+ window . removeEventListener ( 'keydown' , handleKeyDown ) ;
134+ observer . disconnect ( ) ;
135+ } ;
136+ } , [ currentIndex , links ] ) ;
137+
138+ // Effect to handle focus and styling
139+ useEffect ( ( ) => {
140+ // Remove previous highlights
141+ document . querySelectorAll ( 'a' ) . forEach ( link => {
142+ link . style . background = '' ;
143+ } ) ;
144+
145+ if ( currentIndex >= 0 && currentIndex < links . length ) {
146+ const currentLink = links [ currentIndex ] ;
147+ currentLink . style . background = 'rgba(0,255,0,0.7)' ;
148+ currentLink . scrollIntoView ( { behavior : 'instant' , block : 'nearest' } ) ;
149+ }
150+ } , [ currentIndex , links ] ) ;
151+
152+ return { currentIndex } ;
153+ } ;
0 commit comments