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:
Shane
2025-09-24 12:29:58 -07:00
committed by GitHub
parent 5a06503d4a
commit a40d957ad9
35 changed files with 1094 additions and 25 deletions

View File

@ -0,0 +1,61 @@
import React, { useState } from 'react';
import { IonButton, IonContent, IonModal, IonPage } from '@ionic/react';
const ModalFocusTrap: React.FC = () => {
const [showNonTrapped, setShowNonTrapped] = useState(false);
const [showTrapped, setShowTrapped] = useState(false);
const [count, setCount] = useState(0);
return (
<IonPage>
<IonContent className="ion-padding">
<IonButton id="open-non-trapped-modal" onClick={() => setShowNonTrapped(true)}>
Open Non-Trapped Sheet Modal
</IonButton>
<IonButton id="open-trapped-modal" color="primary" onClick={() => setShowTrapped(true)}>
Open Focus-Trapped Sheet Modal
</IonButton>
<IonButton id="background-action" onClick={() => setCount((c) => c + 1)}>
Background Action
</IonButton>
<div>
Background action count: <span id="background-action-count">{count}</span>
</div>
<IonModal
isOpen={showNonTrapped}
onDidDismiss={() => setShowNonTrapped(false)}
breakpoints={[0, 0.25, 0.5, 0.75, 1]}
initialBreakpoint={0.25}
backdropDismiss={false}
focusTrap={false}
handleBehavior="cycle"
>
<IonContent className="ion-padding">
<p>Non-trapped modal content</p>
<IonButton onClick={() => setShowNonTrapped(false)}>Close</IonButton>
</IonContent>
</IonModal>
<IonModal
isOpen={showTrapped}
onDidDismiss={() => setShowTrapped(false)}
breakpoints={[0, 0.25, 0.5, 0.75, 1]}
initialBreakpoint={0.5}
backdropDismiss={false}
focusTrap={true}
handleBehavior="cycle"
>
<IonContent className="ion-padding">
<p>Focus-trapped modal content</p>
<IonButton onClick={() => setShowTrapped(false)}>Close</IonButton>
</IonContent>
</IonModal>
</IonContent>
</IonPage>
);
};
export default ModalFocusTrap;

View File

@ -0,0 +1,71 @@
import React, { useState } from 'react';
import {
IonButton,
IonButtons,
IonContent,
IonHeader,
IonModal,
IonPage,
IonTitle,
IonToolbar,
} from '@ionic/react';
const ModalTeleport: React.FC = () => {
const [isOpen, setIsOpen] = useState(false);
const [count, setCount] = useState(0);
return (
<IonPage>
<IonContent className="ion-padding">
<div id="example" style={{ minHeight: '40vh' }}></div>
<IonButton id="teleport-background-action" onClick={() => setCount((c) => c + 1)}>
Background Action
</IonButton>
<div>
Background action count: <span id="teleport-background-action-count">{count}</span>
</div>
<IonButton id="open-teleport-modal" onClick={() => setIsOpen(true)}>
Open Teleported Modal
</IonButton>
{isOpen && (
<IonModal
isOpen={true}
onDidDismiss={() => setIsOpen(false)}
onWillPresent={(event) => {
const container = document.getElementById('example');
if (container) {
container.appendChild(event.target as HTMLElement);
}
}}
breakpoints={[0.2, 0.5, 0.7]}
initialBreakpoint={0.5}
showBackdrop={false}
>
<IonHeader>
<IonToolbar>
<IonTitle>Modal</IonTitle>
<IonButtons slot="end">
<IonButton id="close-teleport-modal" onClick={() => setIsOpen(false)}>
Close
</IonButton>
</IonButtons>
</IonToolbar>
</IonHeader>
<IonContent className="ion-padding">
<p>
Lorem ipsum dolor sit amet consectetur adipisicing elit. Magni illum quidem recusandae ducimus quos
reprehenderit. Veniam, molestias quos, dolorum consequuntur nisi deserunt omnis id illo sit cum qui.
Eaque, dicta.
</p>
</IonContent>
</IonModal>
)}
</IonContent>
</IonPage>
);
};
export default ModalTeleport;

View File

@ -14,6 +14,8 @@ import ActionSheetComponent from './ActionSheetComponent';
import AlertComponent from './AlertComponent';
import LoadingComponent from './LoadingComponent';
import ModalComponent from './ModalComponent';
import ModalFocusTrap from './ModalFocusTrap';
import ModalTeleport from './ModalTeleport';
import PickerComponent from './PickerComponent';
import PopoverComponent from './PopoverComponent';
import ToastComponent from './ToastComponent';
@ -28,7 +30,9 @@ const OverlayHooks: React.FC<OverlayHooksProps> = () => {
<Route path="/overlay-components/actionsheet" component={ActionSheetComponent} />
<Route path="/overlay-components/alert" component={AlertComponent} />
<Route path="/overlay-components/loading" component={LoadingComponent} />
<Route path="/overlay-components/modal" component={ModalComponent} />
<Route path="/overlay-components/modal-basic" component={ModalComponent} />
<Route path="/overlay-components/modal-focus-trap" component={ModalFocusTrap} />
<Route path="/overlay-components/modal-teleport" component={ModalTeleport} />
<Route path="/overlay-components/picker" component={PickerComponent} />
<Route path="/overlay-components/popover" component={PopoverComponent} />
<Route path="/overlay-components/toast" component={ToastComponent} />
@ -46,10 +50,18 @@ const OverlayHooks: React.FC<OverlayHooksProps> = () => {
<IonIcon icon={addCircleOutline} />
<IonLabel>Loading</IonLabel>
</IonTabButton>
<IonTabButton tab="modal" href="/overlay-components/modal">
<IonTabButton tab="modal" href="/overlay-components/modal-basic">
<IonIcon icon={star} />
<IonLabel>Modal</IonLabel>
</IonTabButton>
<IonTabButton tab="modalFocus" href="/overlay-components/modal-focus-trap">
<IonIcon icon={star} />
<IonLabel>Modal Focus</IonLabel>
</IonTabButton>
<IonTabButton tab="modalTeleport" href="/overlay-components/modal-teleport">
<IonIcon icon={star} />
<IonLabel>Modal Teleport</IonLabel>
</IonTabButton>
<IonTabButton tab="picker" href="/overlay-components/picker">
<IonIcon icon={logoIonic} />
<IonLabel>Picker</IonLabel>

View File

@ -1,6 +1,6 @@
describe('IonModal', () => {
beforeEach(() => {
cy.visit('/overlay-components/modal');
cy.visit('/overlay-components/modal-basic');
});
it('display modal', () => {

View File

@ -0,0 +1,36 @@
describe('IonModal: focusTrap regression', () => {
beforeEach(() => {
cy.visit('/overlay-components/modal-focus-trap');
});
it('should allow interacting with background when focusTrap=false', () => {
cy.get('#open-non-trapped-modal').click();
cy.get('ion-modal').should('be.visible');
cy.get('#background-action').click();
cy.get('#background-action-count').should('have.text', '1');
});
it('should prevent interacting with background when focusTrap=true', () => {
cy.get('#open-trapped-modal').click();
cy.get('ion-modal').should('be.visible');
// Ensure backdrop is active and capturing pointer events
cy.get('ion-backdrop').should('exist');
cy.get('ion-backdrop').should('have.css', 'pointer-events', 'auto');
// Baseline: counter is 0
cy.get('#background-action-count').should('have.text', '0');
// Click the center of the background button via body coordinates (topmost element will receive it)
cy.get('#background-action').then(($btn) => {
const rect = $btn[0].getBoundingClientRect();
const x = rect.left + rect.width / 2;
const y = rect.top + rect.height / 2;
cy.get('body').click(x, y);
});
// Counter should remain unchanged
cy.get('#background-action-count').should('have.text', '0');
});
});

View File

@ -0,0 +1,27 @@
describe('IonModal: inline teleport with showBackdrop=false', () => {
beforeEach(() => {
cy.visit('/overlay-components/modal-teleport');
});
it('should render and remain interactive when appended into a page container', () => {
cy.get('#open-teleport-modal').click();
cy.get('ion-modal').should('be.visible');
// Verify modal content is interactable: close button should dismiss the modal
cy.get('#close-teleport-modal').click();
cy.get('ion-modal').should('not.exist');
});
it('should allow background interaction when showBackdrop=false', () => {
cy.get('#open-teleport-modal').click();
cy.get('ion-modal').should('be.visible');
// Ensure the background button is clickable while modal is open
cy.get('#teleport-background-action').click();
cy.get('#teleport-background-action-count').should('have.text', '1');
// Cleanup
cy.get('#close-teleport-modal').click();
cy.get('ion-modal').should('not.exist');
});
});

View File

@ -1,7 +1,7 @@
describe('keepContentsMounted', () => {
describe('modal', () => {
it('should not mount component if false', () => {
cy.visit('/overlay-components/modal');
cy.visit('/overlay-components/modal-basic');
cy.get('ion-modal ion-content').should('not.exist');
});