@@ -370,6 +370,96 @@ const connectListeners = (doc: Document) => {
370370 true
371371 ) ;
372372
373+ // Listen for keydown events to intercept Tab navigation.
374+ // This is needed for Safari and Firefox which may skip focusable
375+ // elements or allow focus to escape the overlay.
376+ // It also ensures proper focus delegation for shadow DOM elements
377+ // like ion-textarea.
378+ doc . addEventListener (
379+ 'keydown' ,
380+ ( ev : KeyboardEvent ) => {
381+ if ( ev . key !== 'Tab' && ev . key !== 'Alt+Tab' ) return ;
382+
383+ const lastOverlay = getPresentedOverlay (
384+ doc ,
385+ 'ion-alert,ion-action-sheet,ion-loading,ion-modal,ion-picker-legacy,ion-popover'
386+ ) ;
387+
388+ if ( ! lastOverlay || lastOverlay . classList . contains ( FOCUS_TRAP_DISABLE_CLASS ) ) return ;
389+
390+ const activeElement = doc . activeElement as HTMLElement | null ;
391+
392+ if ( activeElement === lastOverlay ) {
393+ ev . preventDefault ( ) ;
394+ focusFirstDescendant ( lastOverlay ) ;
395+ return ;
396+ }
397+
398+ // Check if activeElement is inside the overlay (including shadow DOM)
399+ const isInsideOverlay = activeElement
400+ ? lastOverlay . contains ( activeElement ) ||
401+ ( activeElement . getRootNode ( ) instanceof ShadowRoot &&
402+ lastOverlay . contains ( ( activeElement . getRootNode ( ) as ShadowRoot ) . host as HTMLElement ) ) ||
403+ ( lastOverlay . shadowRoot ?. contains ( activeElement ) ?? false )
404+ : false ;
405+
406+ if ( ! isInsideOverlay ) return ;
407+
408+ // Get all focusable elements from both light and shadow DOM
409+ const allFocusable = [
410+ ...lastOverlay . querySelectorAll < HTMLElement > ( focusableQueryString ) ,
411+ ...( lastOverlay . shadowRoot ?. querySelectorAll < HTMLElement > ( focusableQueryString ) || [ ] ) ,
412+ ] ;
413+
414+ if ( allFocusable . length === 0 ) {
415+ ev . preventDefault ( ) ;
416+ return ;
417+ }
418+
419+ // Find current element's index (accounting for shadow DOM)
420+ const currentIndex = activeElement
421+ ? allFocusable . findIndex ( ( el ) => {
422+ if ( el === activeElement ) return true ;
423+ if ( el . shadowRoot ?. contains ( activeElement ) ) return true ;
424+ const rootNode = activeElement . getRootNode ( ) ;
425+ return rootNode instanceof ShadowRoot && rootNode . host === el ;
426+ } )
427+ : - 1 ;
428+
429+ ev . preventDefault ( ) ;
430+
431+ // Helper to focus an element, handling shadow DOM properly
432+ const focusElement = ( element : HTMLElement ) => {
433+ const shadowRoot = element . shadowRoot ;
434+ if ( shadowRoot ) {
435+ const innerFocusable = shadowRoot . querySelector < HTMLElement > ( focusableQueryString ) ;
436+ if ( innerFocusable && typeof ( element as any ) . setFocus !== 'function' ) {
437+ focusVisibleElement ( innerFocusable ) ;
438+ return ;
439+ }
440+ }
441+ focusVisibleElement ( element ) ;
442+ } ;
443+
444+ if ( ev . shiftKey ) {
445+ // Shift+Tab: previous element, wrap to last if at first
446+ if ( currentIndex <= 0 ) {
447+ focusLastDescendant ( lastOverlay ) ;
448+ } else {
449+ focusElement ( allFocusable [ currentIndex - 1 ] ) ;
450+ }
451+ } else {
452+ // Tab: next element, wrap to first if at last
453+ if ( currentIndex < 0 || currentIndex >= allFocusable . length - 1 ) {
454+ focusFirstDescendant ( lastOverlay ) ;
455+ } else {
456+ focusElement ( allFocusable [ currentIndex + 1 ] ) ;
457+ }
458+ }
459+ } ,
460+ true
461+ ) ;
462+
373463 // handle back-button click
374464 doc . addEventListener ( 'ionBackButton' , ( ev ) => {
375465 const lastOverlay = getPresentedOverlay ( doc ) ;
0 commit comments