mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2025-11-03 19:43:27 +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:
@ -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;
|
||||
|
||||
@ -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;
|
||||
@ -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>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
describe('IonModal', () => {
|
||||
beforeEach(() => {
|
||||
cy.visit('/overlay-components/modal');
|
||||
cy.visit('/overlay-components/modal-basic');
|
||||
});
|
||||
|
||||
it('display modal', () => {
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
@ -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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user