1+ import type React from 'react' ;
12import { useRef , useState , useEffect , useCallback } from 'react' ;
2- import { useFocusState , FocusState } from './use-focus-hover' ;
33
4+ function closest (
5+ node : HTMLElement | null ,
6+ cond : ( ( node : HTMLElement ) => boolean ) | string
7+ ) : HTMLElement | null {
8+ if ( typeof cond === 'string' ) {
9+ return node ?. closest ( cond ) ?? null ;
10+ }
11+
12+ let parent : HTMLElement | null = node ;
13+ while ( parent ) {
14+ if ( cond ( parent ) ) {
15+ return parent ;
16+ }
17+ parent = parent . parentElement ;
18+ }
19+ return null ;
20+ }
21+
22+ function vgridItemSelector ( idx ?: number ) : string {
23+ return idx ? `[data-vlist-item-idx="${ idx } "]` : '[data-vlist-item-idx]' ;
24+ }
25+
26+ function getItemIndex ( node : HTMLElement ) : number {
27+ if ( ! node . dataset . vlistItemIdx ) {
28+ throw new Error ( 'Trying to get vgrid item index from an non-item element' ) ;
29+ }
30+ return Number ( node . dataset . vlistItemIdx ) ;
31+ }
32+
33+ /**
34+ * Hook that adds support for the grid keyboard navigation while handling the
35+ * focus using the roving tab index
36+ *
37+ * {@link https://www.w3.org/TR/wai-aria-1.1/#grid}
38+ * {@link https://www.w3.org/TR/wai-aria-1.1/#gridcell}
39+ * {@link https://www.w3.org/TR/wai-aria-practices-1.1/#kbd_roving_tabindex}
40+ */
441export function useVirtualGridArrowNavigation <
542 T extends HTMLElement = HTMLElement
643> ( {
@@ -10,25 +47,78 @@ export function useVirtualGridArrowNavigation<
1047 resetActiveItemOnBlur = true ,
1148 pageSize = 3 ,
1249 defaultCurrentTabbable = 0 ,
50+ onFocusMove,
1351} : {
1452 colCount : number ;
1553 rowCount : number ;
1654 itemsCount : number ;
1755 resetActiveItemOnBlur ?: boolean ;
1856 pageSize ?: number ;
1957 defaultCurrentTabbable ?: number ;
58+ onFocusMove ( idx : number ) : void ;
2059} ) : [ React . HTMLProps < T > , number ] {
2160 const rootNode = useRef < T | null > ( null ) ;
22- const [ focusProps , focusState ] = useFocusState ( ) ;
61+ const [ tabIndex , setTabIndex ] = useState < 0 | - 1 > ( 0 ) ;
2362 const [ currentTabbable , setCurrentTabbable ] = useState (
2463 defaultCurrentTabbable
2564 ) ;
2665
27- useEffect ( ( ) => {
28- if ( resetActiveItemOnBlur && focusState === FocusState . NoFocus ) {
29- setCurrentTabbable ( defaultCurrentTabbable ) ;
66+ const onFocus = useCallback (
67+ ( evt : React . FocusEvent ) => {
68+ // If we received focus on the grid container itself, this is a keyboard
69+ // navigation, disable focus on the container to trigger a focus effect
70+ // for the currentTabbable element
71+ if ( evt . target === evt . currentTarget ) {
72+ setTabIndex ( - 1 ) ;
73+ } else {
74+ const focusedItem = closest (
75+ evt . target as HTMLElement ,
76+ vgridItemSelector ( )
77+ ) ;
78+
79+ // If focus was received somewhere inside grid item, disable focus on
80+ // the container and mark item that got the interaction as the
81+ // `currentTabbable` item
82+ if ( focusedItem ) {
83+ setTabIndex ( - 1 ) ;
84+ setCurrentTabbable ( getItemIndex ( focusedItem ) ) ;
85+ }
86+ }
87+ } ,
88+ [ defaultCurrentTabbable ]
89+ ) ;
90+
91+ const onBlur = useCallback ( ( ) => {
92+ const isFocusInside =
93+ closest (
94+ document . activeElement as HTMLElement ,
95+ ( node ) => node === rootNode . current
96+ ) !== null ;
97+
98+ // If focus is outside of the grid container, make the whole container
99+ // focusable again and reset tabbable item if needed
100+ if ( ! isFocusInside ) {
101+ setTabIndex ( 0 ) ;
102+ if ( resetActiveItemOnBlur ) {
103+ setCurrentTabbable ( defaultCurrentTabbable ) ;
104+ }
30105 }
31- } , [ resetActiveItemOnBlur , focusState , defaultCurrentTabbable ] ) ;
106+ } , [ resetActiveItemOnBlur , defaultCurrentTabbable ] ) ;
107+
108+ const onMouseDown = useCallback ( ( evt : React . MouseEvent ) => {
109+ const gridItem = closest ( evt . target as HTMLElement , vgridItemSelector ( ) ) ;
110+ // If mousedown didn't originate in one of the grid items (we just clicked
111+ // some empty space in the grid container), prevent default behavior to stop
112+ // focus on the grid container from happening
113+ if ( ! gridItem ) {
114+ evt . preventDefault ( ) ;
115+ // Simulate active element blur that normally happens when clicking a
116+ // non-focusable element
117+ ( document . activeElement as HTMLElement ) ?. blur ( ) ;
118+ }
119+ } , [ ] ) ;
120+
121+ const focusProps = { tabIndex, onFocus, onBlur, onMouseDown } ;
32122
33123 const onKeyDown = useCallback (
34124 ( evt : React . KeyboardEvent < T > ) => {
@@ -116,53 +206,38 @@ export function useVirtualGridArrowNavigation<
116206 [ currentTabbable , itemsCount , rowCount , colCount , pageSize ]
117207 ) ;
118208
119- return [ { ref : rootNode , onKeyDown, ...focusProps } , currentTabbable ] ;
120- }
121-
122- export function useVirtualRovingTabIndex < T extends HTMLElement = HTMLElement > ( {
123- currentTabbable,
124- onFocusMove,
125- } : {
126- currentTabbable : number ;
127- onFocusMove ( idx : number ) : void ;
128- } ) : React . HTMLProps < T > {
129- const rootNode = useRef < T | null > ( null ) ;
130- // We will set tabIndex on the parent element so that it can catch focus even
131- // if the currentTabbable is not rendered
132- const [ tabIndex , setTabIndex ] = useState < 0 | - 1 > ( 0 ) ;
133- const [ focusProps , focusState ] = useFocusState ( ) ;
134-
135- // Focuses vlist item by id or falls back to the first focusable element in
136- // the container
137- const focusTabbable = useCallback ( ( ) => {
138- const selector =
139- currentTabbable >= 0
140- ? `[data-vlist-item-idx="${ currentTabbable } "]`
141- : '[tabindex=0]' ;
142- rootNode . current ?. querySelector < T > ( selector ) ?. focus ( ) ;
143- } , [ rootNode , currentTabbable ] ) ;
209+ const activeCurrentTabbable = tabIndex === 0 ? - 1 : currentTabbable ;
144210
145211 useEffect ( ( ) => {
146- if (
147- [
148- FocusState . Focus ,
149- FocusState . FocusVisible ,
150- FocusState . FocusWithin ,
151- FocusState . FocusWithinVisible ,
152- ] . includes ( focusState )
153- ) {
154- setTabIndex ( - 1 ) ;
155- onFocusMove ( currentTabbable ) ;
156- const frame = requestAnimationFrame ( ( ) => {
157- focusTabbable ( ) ;
158- } ) ;
159- return ( ) => {
160- cancelAnimationFrame ( frame ) ;
161- } ;
162- } else {
163- setTabIndex ( 0 ) ;
212+ // If we have an active current tabbable item (there is a focus somewhere in
213+ // the grid container) ...
214+ if ( activeCurrentTabbable >= 0 ) {
215+ const gridItem = closest (
216+ document . activeElement as HTMLElement ,
217+ vgridItemSelector ( )
218+ ) ;
219+ const shouldMoveFocus =
220+ ! gridItem || getItemIndex ( gridItem ) !== activeCurrentTabbable ;
221+
222+ // ... and this item is currently not focused ...
223+ if ( shouldMoveFocus ) {
224+ // ... communicate that there will be a focus change happening (this is
225+ // needed so that we can scroll invisible virtual item into view if
226+ // needed) ...
227+ onFocusMove ( activeCurrentTabbable ) ;
228+ // ... and trigger a focus on the element after a frame delay, so that
229+ // the item has time to scroll into view and render if needed
230+ const frameId = requestAnimationFrame ( ( ) => {
231+ rootNode . current
232+ ?. querySelector < HTMLElement > ( vgridItemSelector ( currentTabbable ) )
233+ ?. focus ( ) ;
234+ } ) ;
235+ return ( ) => {
236+ cancelAnimationFrame ( frameId ) ;
237+ } ;
238+ }
164239 }
165- } , [ focusState , onFocusMove , focusTabbable , currentTabbable ] ) ;
240+ } , [ activeCurrentTabbable ] ) ;
166241
167- return { ref : rootNode , tabIndex , ...focusProps } ;
242+ return [ { ref : rootNode , onKeyDown , ...focusProps } , activeCurrentTabbable ] ;
168243}
0 commit comments