@@ -20,22 +20,32 @@ import {
2020 lastInteractionType ,
2121} from './InteractionController.js' ;
2222
23- const HOVER_DELAY = 300 ;
24-
2523export class HoverController extends InteractionController {
2624 override type = InteractionTypes . hover ;
2725
2826 private elementIds : string [ ] = [ ] ;
2927
30- focusedin = false ;
28+ private targetFocused = false ;
3129
3230 private hoverTimeout ?: ReturnType < typeof setTimeout > ;
3331
34- pointerentered = false ;
32+ private hovering = false ;
33+
34+ private overlayFocused = false ;
3535
3636 handleKeyup ( event : KeyboardEvent ) : void {
37- if ( event . code === 'Tab' || event . code === 'Escape' ) {
37+ if ( event . code === 'Tab' ) {
3838 this . open = true ;
39+ } else if ( event . code === 'Escape' ) {
40+ if ( this . open ) {
41+ event . preventDefault ( ) ;
42+ event . stopPropagation ( ) ;
43+ this . open = false ;
44+ // Return focus to trigger element
45+ if ( this . target ) {
46+ this . target . focus ( ) ;
47+ }
48+ }
3949 }
4050 }
4151
@@ -52,23 +62,29 @@ export class HoverController extends InteractionController {
5262 }
5363
5464 this . open = true ;
55- this . focusedin = true ;
65+ this . targetFocused = true ;
5666 }
5767
5868 handleTargetFocusout ( ) : void {
59- this . focusedin = false ;
60- if ( this . pointerentered ) return ;
61- this . open = false ;
69+ this . targetFocused = false ;
70+ // Don't close immediately if pointer is over the content
71+ if ( this . hovering ) return ;
72+ // Use delay to allow focus to move into overlay content
73+ this . doFocusleave ( ) ;
6274 }
6375
64- handleTargetPointerenter ( ) : void {
76+ private clearCloseTimeout ( ) : void {
6577 if ( this . hoverTimeout ) {
6678 clearTimeout ( this . hoverTimeout ) ;
6779 this . hoverTimeout = undefined ;
6880 }
81+ }
82+
83+ handleTargetPointerenter ( ) : void {
84+ this . clearCloseTimeout ( ) ;
6985 if ( this . overlay ?. disabled ) return ;
7086 this . open = true ;
71- this . pointerentered = true ;
87+ this . hovering = true ;
7288 }
7389
7490 handleTargetPointerleave ( ) : void {
@@ -78,16 +94,28 @@ export class HoverController extends InteractionController {
7894 // set a timeout once the pointer enters and the overlay is shown
7995 // give the user time to enter the overlay
8096 handleHostPointerenter ( ) : void {
81- if ( this . hoverTimeout ) {
82- clearTimeout ( this . hoverTimeout ) ;
83- this . hoverTimeout = undefined ;
84- }
97+ this . clearCloseTimeout ( ) ;
8598 }
8699
87100 handleHostPointerleave ( ) : void {
88101 this . doPointerleave ( ) ;
89102 }
90103
104+ handleOverlayFocusin ( ) : void {
105+ this . overlayFocused = true ;
106+ // Clear any pending close timeout when focus enters overlay
107+ this . clearCloseTimeout ( ) ;
108+ }
109+
110+ handleOverlayFocusout ( ) : void {
111+ this . overlayFocused = false ;
112+ // Don't close immediately if pointer is over the content or trigger has focus
113+ if ( this . hovering ) return ;
114+ if ( this . targetFocused && this . target . matches ( ':focus-visible' ) ) return ;
115+ // Use delay before closing
116+ this . doFocusleave ( ) ;
117+ }
118+
91119 override prepareDescription ( ) : void {
92120 // require "content" to apply relationship
93121 if ( ! this . overlay . elements . length ) return ;
@@ -138,14 +166,31 @@ export class HoverController extends InteractionController {
138166 } ;
139167 }
140168
141- protected doPointerleave ( ) : void {
142- this . pointerentered = false ;
143- const triggerElement = this . target as HTMLElement ;
144- if ( this . focusedin && triggerElement . matches ( ':focus-visible' ) ) return ;
145-
169+ private scheduleClose ( ) : void {
146170 this . hoverTimeout = setTimeout ( ( ) => {
147171 this . open = false ;
148- } , HOVER_DELAY ) ;
172+ } , 300 ) ;
173+ }
174+
175+ private doPointerleave ( ) : void {
176+ this . hovering = false ;
177+ const triggerElement = this . target as HTMLElement ;
178+ if ( this . targetFocused && triggerElement . matches ( ':focus-visible' ) )
179+ return ;
180+ // Don't close if focus is within overlay content
181+ if ( this . overlayFocused ) return ;
182+
183+ this . scheduleClose ( ) ;
184+ }
185+
186+ private doFocusleave ( ) : void {
187+ // Clear any existing timeout
188+ this . clearCloseTimeout ( ) ;
189+
190+ // Use same delay as pointer interactions for consistency
191+ if ( ! this . targetFocused && ! this . overlayFocused && ! this . hovering ) {
192+ this . scheduleClose ( ) ;
193+ }
149194 }
150195
151196 override init ( ) : void {
@@ -198,5 +243,20 @@ export class HoverController extends InteractionController {
198243 ( ) => this . handleHostPointerleave ( ) ,
199244 { signal }
200245 ) ;
246+ this . overlay . addEventListener (
247+ 'focusin' ,
248+ ( ) => this . handleOverlayFocusin ( ) ,
249+ { signal }
250+ ) ;
251+ this . overlay . addEventListener (
252+ 'focusout' ,
253+ ( ) => this . handleOverlayFocusout ( ) ,
254+ { signal }
255+ ) ;
256+ this . overlay . addEventListener (
257+ 'keyup' ,
258+ ( event ) => this . handleKeyup ( event ) ,
259+ { signal }
260+ ) ;
201261 }
202262}
0 commit comments