@@ -32,6 +32,7 @@ import {
3232 getElementRoot ,
3333 removeEventListener ,
3434} from './helpers' ;
35+ import { isPlatform } from './platform' ;
3536
3637let lastOverlayIndex = 0 ;
3738let lastId = 0 ;
@@ -512,6 +513,8 @@ export const present = async <OverlayPresentOptions>(
512513 return ;
513514 }
514515
516+ console . log ( "presenting overlay..." ) ;
517+
515518 /**
516519 * Due to accessibility guidelines, toasts do not have
517520 * focus traps.
@@ -521,9 +524,11 @@ export const present = async <OverlayPresentOptions>(
521524 */
522525 if ( overlay . el . tagName !== 'ION-TOAST' ) {
523526 setRootAriaHidden ( true ) ;
524- document . body . classList . add ( BACKDROP_NO_SCROLL ) ;
525527 }
526528
529+ hideUnderlyingOverlaysFromScreenReaders ( overlay . el ) ;
530+ hideAnimatingOverlayFromScreenReaders ( overlay . el ) ;
531+
527532 overlay . presented = true ;
528533 overlay . willPresent . emit ( ) ;
529534 overlay . willPresentShorthand ?. emit ( ) ;
@@ -575,6 +580,7 @@ export const present = async <OverlayPresentOptions>(
575580 * screen readers.
576581 */
577582 overlay . el . removeAttribute ( 'aria-hidden' ) ;
583+ overlay . el . removeAttribute ( 'inert' ) ;
578584} ;
579585
580586/**
@@ -672,6 +678,13 @@ export const dismiss = async <OverlayDismissOptions>(
672678 overlay . presented = false ;
673679
674680 try {
681+ /**
682+ * There is no need to show the overlay to screen readers during
683+ * the dismiss animation. This is because the overlay will be removed
684+ * from the DOM after the animation is complete.
685+ */
686+ hideAnimatingOverlayFromScreenReaders ( overlay . el ) ;
687+
675688 // Overlay contents should not be clickable during dismiss
676689 overlay . el . style . setProperty ( 'pointer-events' , 'none' ) ;
677690 overlay . willDismiss . emit ( { data, role } ) ;
@@ -719,6 +732,8 @@ export const dismiss = async <OverlayDismissOptions>(
719732
720733 overlay . el . remove ( ) ;
721734
735+ revealOverlaysToScreenReaders ( ) ;
736+
722737 return true ;
723738} ;
724739
@@ -955,4 +970,101 @@ export const createTriggerController = () => {
955970 } ;
956971} ;
957972
958- export const FOCUS_TRAP_DISABLE_CLASS = 'ion-disable-focus-trap' ;
973+ /**
974+ * The overlay that is being animated also needs to hide from screen
975+ * readers during its animation. This ensures that assistive technologies
976+ * like TalkBack do not announce or interact with the content until the
977+ * animation is complete, avoiding confusion for users.
978+ *
979+ * When the overlay is presented on an Android device, TalkBack's focus rings
980+ * may appear in the wrong position due to the transition (specifically
981+ * `transform` styles). This occurs because the focus rings are initially
982+ * displayed at the starting position of the elements before the transition
983+ * begins. This workaround ensures the focus rings do not appear in the
984+ * incorrect location.
985+ *
986+ * If this solution is applied to iOS devices, then it leads to a bug where
987+ * the overlays cannot be accessed by screen readers. This is due to
988+ * VoiceOver not being able to update the accessibility tree when the
989+ * `aria-hidden` is removed.
990+ *
991+ * @param overlay - The overlay that is being animated.
992+ */
993+ const hideAnimatingOverlayFromScreenReaders = ( overlay : HTMLIonOverlayElement ) => {
994+ if ( doc === undefined ) return ;
995+
996+ if ( isPlatform ( 'android' ) ) {
997+ /**
998+ * Once the animation is complete, this attribute will be removed.
999+ * This is done at the end of the `present` method.
1000+ */
1001+ overlay . setAttribute ( 'aria-hidden' , 'true' ) ;
1002+ overlay . setAttribute ( 'inert' , '' ) ;
1003+ }
1004+ } ;
1005+
1006+ /**
1007+ * Ensure that underlying overlays have aria-hidden if necessary so that screen readers
1008+ * cannot move focus to these elements. Note that we cannot rely on focus/focusin/focusout
1009+ * events here because those events do not fire when the screen readers moves to a non-focusable
1010+ * element such as text.
1011+ * Without this logic screen readers would be able to move focus outside of the top focus-trapped overlay.
1012+ *
1013+ * @param newTopMostOverlay - The overlay that is being presented. Since the overlay has not been
1014+ * fully presented yet at the time this function is called it will not be included in the getPresentedOverlays result.
1015+ */
1016+ const hideUnderlyingOverlaysFromScreenReaders = ( newTopMostOverlay : HTMLIonOverlayElement ) => {
1017+ if ( doc === undefined ) return ;
1018+
1019+ const overlays = getPresentedOverlays ( doc ) ;
1020+
1021+ for ( let i = overlays . length - 1 ; i >= 0 ; i -- ) {
1022+ const presentedOverlay = overlays [ i ] ;
1023+ const nextPresentedOverlay = overlays [ i + 1 ] ?? newTopMostOverlay ;
1024+
1025+ /**
1026+ * If next overlay has aria-hidden then all remaining overlays will have it too.
1027+ * Or, if the next overlay is a Toast that does not have aria-hidden then current overlay
1028+ * should not have aria-hidden either so focus can remain in the current overlay.
1029+ */
1030+ if ( nextPresentedOverlay . hasAttribute ( 'aria-hidden' ) || nextPresentedOverlay . tagName !== 'ION-TOAST' ) {
1031+ presentedOverlay . setAttribute ( 'aria-hidden' , 'true' ) ;
1032+ presentedOverlay . setAttribute ( 'inert' , '' ) ;
1033+ }
1034+ }
1035+ } ;
1036+
1037+ /**
1038+ * When dismissing an overlay we need to reveal the new top-most overlay to screen readers.
1039+ * If the top-most overlay is a Toast we potentially need to reveal more overlays since
1040+ * focus is never automatically moved to the Toast.
1041+ */
1042+ const revealOverlaysToScreenReaders = ( ) => {
1043+ if ( doc === undefined ) return ;
1044+
1045+ const overlays = getPresentedOverlays ( doc ) ;
1046+
1047+ for ( let i = overlays . length - 1 ; i >= 0 ; i -- ) {
1048+ const currentOverlay = overlays [ i ] ;
1049+
1050+ /**
1051+ * If the current we are looking at is a Toast then we can remove aria-hidden.
1052+ * However, we potentially need to keep looking at the overlay stack because there
1053+ * could be more Toasts underneath. Additionally, we need to unhide the closest non-Toast
1054+ * overlay too so focus can move there since focus is never automatically moved to the Toast.
1055+ */
1056+ currentOverlay . removeAttribute ( 'aria-hidden' ) ;
1057+ currentOverlay . removeAttribute ( 'inert' ) ;
1058+
1059+ /**
1060+ * If we found a non-Toast element then we can just remove aria-hidden and stop searching entirely
1061+ * since this overlay should always receive focus. As a result, all underlying overlays should still
1062+ * be hidden from screen readers.
1063+ */
1064+ if ( currentOverlay . tagName !== 'ION-TOAST' ) {
1065+ break ;
1066+ }
1067+ }
1068+ } ;
1069+
1070+ export const FOCUS_TRAP_DISABLE_CLASS = 'ion-disable-focus-trap' ;
0 commit comments