mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2025-11-01 09:38:28 +08:00
fix(modal): allow sheet modals to skip focus trap (#30689)
Issue number: resolves #30684 --------- <!-- Please do not submit updates to dependencies unless it fixes an issue. --> <!-- Please try to limit your pull request to one type (bugfix, feature, etc). Submit multiple pull requests if needed. --> ## What is the current behavior? <!-- Please describe the current behavior that you are modifying. --> Recently, we [fixed some issues with aria-hidden in modals](https://github.com/ionic-team/ionic-framework/pull/30563), unfortunately at this time we neglected modals that opt out of focus trapping. As a result, a lot of modals that disable focus trapping still have it happening and it doesn't get cleaned up properly on dismiss. ## What is the new behavior? <!-- Please describe the behavior or changes that are being added by this PR. --> We're now properly checking for and skipping focus traps on modals that do not want them. ## Does this introduce a breaking change? - [ ] Yes - [X] No <!-- If this introduces a breaking change: 1. Describe the impact and migration path for existing applications below. 2. Update the BREAKING.md file with the breaking change. 3. Add "BREAKING CHANGE: [...]" to the commit description when merging. See https://github.com/ionic-team/ionic-framework/blob/main/docs/CONTRIBUTING.md#footer for more information. --> ## Other information I created regression tests for Angular in this to prevent this from happening again. I initially tried to do this with core, but the issue doesn't seem to reproduce with core. <!-- Any other information that is important to this PR such as screenshots of how the component looks before and after the change. --> Current dev build: ``` 8.7.5-dev.11758652700.103435a3 ```
This commit is contained in:
@ -95,6 +95,12 @@ export const createSheetGesture = (
|
||||
const contentAnimation = animation.childAnimations.find((ani) => ani.id === 'contentAnimation');
|
||||
|
||||
const enableBackdrop = () => {
|
||||
// Respect explicit opt-out of focus trapping/backdrop interactions
|
||||
// If focusTrap is false or showBackdrop is false, do not enable the backdrop or re-enable focus trap
|
||||
const el = baseEl as HTMLIonModalElement & { focusTrap?: boolean; showBackdrop?: boolean };
|
||||
if (el.focusTrap === false || el.showBackdrop === false) {
|
||||
return;
|
||||
}
|
||||
baseEl.style.setProperty('pointer-events', 'auto');
|
||||
backdropEl.style.setProperty('pointer-events', 'auto');
|
||||
|
||||
@ -235,7 +241,10 @@ export const createSheetGesture = (
|
||||
* ion-backdrop and .modal-wrapper always have pointer-events: auto
|
||||
* applied, so the modal content can still be interacted with.
|
||||
*/
|
||||
const shouldEnableBackdrop = currentBreakpoint > backdropBreakpoint;
|
||||
const shouldEnableBackdrop =
|
||||
currentBreakpoint > backdropBreakpoint &&
|
||||
(baseEl as HTMLIonModalElement & { focusTrap?: boolean }).focusTrap !== false &&
|
||||
(baseEl as HTMLIonModalElement & { showBackdrop?: boolean }).showBackdrop !== false;
|
||||
if (shouldEnableBackdrop) {
|
||||
enableBackdrop();
|
||||
} else {
|
||||
@ -582,7 +591,10 @@ export const createSheetGesture = (
|
||||
* Backdrop should become enabled
|
||||
* after the backdropBreakpoint value
|
||||
*/
|
||||
const shouldEnableBackdrop = currentBreakpoint > backdropBreakpoint;
|
||||
const shouldEnableBackdrop =
|
||||
currentBreakpoint > backdropBreakpoint &&
|
||||
(baseEl as HTMLIonModalElement & { focusTrap?: boolean }).focusTrap !== false &&
|
||||
(baseEl as HTMLIonModalElement & { showBackdrop?: boolean }).showBackdrop !== false;
|
||||
if (shouldEnableBackdrop) {
|
||||
enableBackdrop();
|
||||
} else {
|
||||
|
||||
@ -494,10 +494,8 @@ export const setRootAriaHidden = (hidden = false) => {
|
||||
|
||||
if (hidden) {
|
||||
viewContainer.setAttribute('aria-hidden', 'true');
|
||||
viewContainer.setAttribute('inert', '');
|
||||
} else {
|
||||
viewContainer.removeAttribute('aria-hidden');
|
||||
viewContainer.removeAttribute('inert');
|
||||
}
|
||||
};
|
||||
|
||||
@ -529,15 +527,37 @@ export const present = async <OverlayPresentOptions>(
|
||||
* focus traps.
|
||||
*
|
||||
* All other overlays should have focus traps to prevent
|
||||
* the keyboard focus from leaving the overlay.
|
||||
* the keyboard focus from leaving the overlay unless
|
||||
* developers explicitly opt out (for example, sheet
|
||||
* modals that should permit background interaction).
|
||||
*
|
||||
* Note: Some apps move inline overlays to a specific container
|
||||
* during the willPresent lifecycle (e.g., React portals via
|
||||
* onWillPresent). Defer applying aria-hidden/inert to the app
|
||||
* root until after willPresent so we can detect where the
|
||||
* overlay is finally inserted. If the overlay is inside the
|
||||
* view container subtree, skip adding aria-hidden/inert there
|
||||
* to avoid disabling the overlay.
|
||||
*/
|
||||
if (overlay.el.tagName !== 'ION-TOAST') {
|
||||
setRootAriaHidden(true);
|
||||
document.body.classList.add(BACKDROP_NO_SCROLL);
|
||||
}
|
||||
const overlayEl = overlay.el as HTMLIonOverlayElement & { focusTrap?: boolean; showBackdrop?: boolean };
|
||||
const shouldTrapFocus = overlayEl.tagName !== 'ION-TOAST' && overlayEl.focusTrap !== false;
|
||||
// Only lock out root content when backdrop is active. Developers relying on showBackdrop=false
|
||||
// expect background interaction to remain enabled.
|
||||
const shouldLockRoot = shouldTrapFocus && overlayEl.showBackdrop !== false;
|
||||
|
||||
overlay.presented = true;
|
||||
overlay.willPresent.emit();
|
||||
|
||||
if (shouldLockRoot) {
|
||||
const root = getAppRoot(document);
|
||||
const viewContainer = root.querySelector('ion-router-outlet, #ion-view-container-root');
|
||||
const overlayInsideViewContainer = viewContainer ? viewContainer.contains(overlayEl) : false;
|
||||
|
||||
if (!overlayInsideViewContainer) {
|
||||
setRootAriaHidden(true);
|
||||
}
|
||||
document.body.classList.add(BACKDROP_NO_SCROLL);
|
||||
}
|
||||
overlay.willPresentShorthand?.emit();
|
||||
|
||||
const mode = getIonMode(overlay);
|
||||
@ -653,22 +673,28 @@ export const dismiss = async <OverlayDismissOptions>(
|
||||
* For accessibility, toasts lack focus traps and don't receive
|
||||
* `aria-hidden` on the root element when presented.
|
||||
*
|
||||
* All other overlays use focus traps to keep keyboard focus
|
||||
* within the overlay, setting `aria-hidden` on the root element
|
||||
* to enhance accessibility.
|
||||
*
|
||||
* Therefore, we must remove `aria-hidden` from the root element
|
||||
* when the last non-toast overlay is dismissed.
|
||||
* Overlays that opt into focus trapping set `aria-hidden`
|
||||
* on the root element to keep keyboard focus and pointer
|
||||
* events inside the overlay. We must remove `aria-hidden`
|
||||
* from the root element when the last focus-trapping overlay
|
||||
* is dismissed.
|
||||
*/
|
||||
const overlaysNotToast = presentedOverlays.filter((o) => o.tagName !== 'ION-TOAST');
|
||||
|
||||
const lastOverlayNotToast = overlaysNotToast.length === 1 && overlaysNotToast[0].id === overlay.el.id;
|
||||
const overlaysLockingRoot = presentedOverlays.filter((o) => {
|
||||
const el = o as HTMLIonOverlayElement & { focusTrap?: boolean; showBackdrop?: boolean };
|
||||
return el.tagName !== 'ION-TOAST' && el.focusTrap !== false && el.showBackdrop !== false;
|
||||
});
|
||||
const overlayEl = overlay.el as HTMLIonOverlayElement & { focusTrap?: boolean; showBackdrop?: boolean };
|
||||
const locksRoot =
|
||||
overlayEl.tagName !== 'ION-TOAST' && overlayEl.focusTrap !== false && overlayEl.showBackdrop !== false;
|
||||
|
||||
/**
|
||||
* If this is the last visible overlay that is not a toast
|
||||
* If this is the last visible overlay that is trapping focus
|
||||
* then we want to re-add the root to the accessibility tree.
|
||||
*/
|
||||
if (lastOverlayNotToast) {
|
||||
const lastOverlayTrappingFocus =
|
||||
locksRoot && overlaysLockingRoot.length === 1 && overlaysLockingRoot[0].id === overlayEl.id;
|
||||
|
||||
if (lastOverlayTrappingFocus) {
|
||||
setRootAriaHidden(false);
|
||||
document.body.classList.remove(BACKDROP_NO_SCROLL);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user