|
1 | 1 | import { doc } from '@utils/browser';
|
2 | 2 | import type { BackButtonEvent } from '@utils/hardware-back-button';
|
3 |
| -import { shoudUseCloseWatcher } from '@utils/hardware-back-button'; |
| 3 | +import { shouldUseCloseWatcher } from '@utils/hardware-back-button'; |
4 | 4 |
|
5 | 5 | import { config } from '../global/config';
|
6 | 6 | import { getIonMode } from '../global/ionic-global';
|
@@ -428,7 +428,7 @@ const connectListeners = (doc: Document) => {
|
428 | 428 | * this behavior will be handled via the ionBackButton
|
429 | 429 | * event.
|
430 | 430 | */
|
431 |
| - if (!shoudUseCloseWatcher()) { |
| 431 | + if (!shouldUseCloseWatcher()) { |
432 | 432 | doc.addEventListener('keydown', (ev) => {
|
433 | 433 | if (ev.key === 'Escape') {
|
434 | 434 | const lastOverlay = getPresentedOverlay(doc);
|
@@ -541,16 +541,7 @@ export const present = async <OverlayPresentOptions>(
|
541 | 541 | }
|
542 | 542 |
|
543 | 543 | setRootAriaHidden(true);
|
544 |
| - |
545 |
| - /** |
546 |
| - * Hide all other overlays from screen readers so only this one |
547 |
| - * can be read. Note that presenting an overlay always makes |
548 |
| - * it the topmost one. |
549 |
| - */ |
550 |
| - if (doc !== undefined) { |
551 |
| - const presentedOverlays = getPresentedOverlays(doc); |
552 |
| - presentedOverlays.forEach((o) => o.setAttribute('aria-hidden', 'true')); |
553 |
| - } |
| 544 | + hideOverlaysFromScreenReaders(overlay.el); |
554 | 545 |
|
555 | 546 | overlay.presented = true;
|
556 | 547 | overlay.willPresent.emit();
|
@@ -723,13 +714,7 @@ export const dismiss = async <OverlayDismissOptions>(
|
723 | 714 |
|
724 | 715 | overlay.el.remove();
|
725 | 716 |
|
726 |
| - /** |
727 |
| - * If there are other overlays presented, unhide the new |
728 |
| - * topmost one from screen readers. |
729 |
| - */ |
730 |
| - if (doc !== undefined) { |
731 |
| - getPresentedOverlay(doc)?.removeAttribute('aria-hidden'); |
732 |
| - } |
| 717 | + revealOverlaysToScreenReaders(); |
733 | 718 |
|
734 | 719 | return true;
|
735 | 720 | };
|
@@ -966,3 +951,65 @@ export const createTriggerController = () => {
|
966 | 951 | removeClickListener,
|
967 | 952 | };
|
968 | 953 | };
|
| 954 | + |
| 955 | +/** |
| 956 | + * Ensure that underlying overlays have aria-hidden if necessary so that screen readers |
| 957 | + * cannot move focus to these elements. Note that we cannot rely on focus/focusin/focusout |
| 958 | + * events here because those events do not fire when the screen readers moves to a non-focusable |
| 959 | + * element such as text. |
| 960 | + * Without this logic screen readers would be able to move focus outside of the top focus-trapped overlay. |
| 961 | + * |
| 962 | + * @param newTopMostOverlay - The overlay that is being presented. Since the overlay has not been |
| 963 | + * fully presented yet at the time this function is called it will not be included in the getPresentedOverlays result. |
| 964 | + */ |
| 965 | +const hideOverlaysFromScreenReaders = (newTopMostOverlay: HTMLIonOverlayElement) => { |
| 966 | + if (doc === undefined) return; |
| 967 | + |
| 968 | + const overlays = getPresentedOverlays(doc); |
| 969 | + |
| 970 | + for (let i = overlays.length - 1; i >= 0; i--) { |
| 971 | + const presentedOverlay = overlays[i]; |
| 972 | + const nextPresentedOverlay = overlays[i + 1] ?? newTopMostOverlay; |
| 973 | + |
| 974 | + /** |
| 975 | + * If next overlay has aria-hidden then all remaining overlays will have it too. |
| 976 | + * Or, if the next overlay is a Toast that does not have aria-hidden then current overlay |
| 977 | + * should not have aria-hidden either so focus can remain in the current overlay. |
| 978 | + */ |
| 979 | + if (nextPresentedOverlay.hasAttribute('aria-hidden') || nextPresentedOverlay.tagName !== 'ION-TOAST') { |
| 980 | + presentedOverlay.setAttribute('aria-hidden', 'true'); |
| 981 | + } |
| 982 | + } |
| 983 | +}; |
| 984 | + |
| 985 | +/** |
| 986 | + * When dismissing an overlay we need to reveal the new top-most overlay to screen readers. |
| 987 | + * If the top-most overlay is a Toast we potentially need to reveal more overlays since |
| 988 | + * focus is never automatically moved to the Toast. |
| 989 | + */ |
| 990 | +const revealOverlaysToScreenReaders = () => { |
| 991 | + if (doc === undefined) return; |
| 992 | + |
| 993 | + const overlays = getPresentedOverlays(doc); |
| 994 | + |
| 995 | + for (let i = overlays.length - 1; i >= 0; i--) { |
| 996 | + const currentOverlay = overlays[i]; |
| 997 | + |
| 998 | + /** |
| 999 | + * If the current we are looking at is a Toast then we can remove aria-hidden. |
| 1000 | + * However, we potentially need to keep looking at the overlay stack because there |
| 1001 | + * could be more Toasts underneath. Additionally, we need to unhide the closest non-Toast |
| 1002 | + * overlay too so focus can move there since focus is never automatically moved to the Toast. |
| 1003 | + */ |
| 1004 | + currentOverlay.removeAttribute('aria-hidden'); |
| 1005 | + |
| 1006 | + /** |
| 1007 | + * If we found a non-Toast element then we can just remove aria-hidden and stop searching entirely |
| 1008 | + * since this overlay should always receive focus. As a result, all underlying overlays should still |
| 1009 | + * be hidden from screen readers. |
| 1010 | + */ |
| 1011 | + if (currentOverlay.tagName !== 'ION-TOAST') { |
| 1012 | + break; |
| 1013 | + } |
| 1014 | + } |
| 1015 | +}; |
0 commit comments