Skip to content

Commit 7c00351

Browse files
authored
feat(modal, popover): add ability to temporarily disable focus trapping (#29379)
Issue number: resolves #24646
1 parent e38e2e4 commit 7c00351

File tree

11 files changed

+130
-8
lines changed

11 files changed

+130
-8
lines changed

core/api.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -831,6 +831,7 @@ ion-modal,prop,backdropDismiss,boolean,true,false,false
831831
ion-modal,prop,breakpoints,number[] | undefined,undefined,false,false
832832
ion-modal,prop,canDismiss,((data?: any, role?: string | undefined) => Promise<boolean>) | boolean,true,false,false
833833
ion-modal,prop,enterAnimation,((baseEl: any, opts?: any) => Animation) | undefined,undefined,false,false
834+
ion-modal,prop,focusTrap,boolean,true,false,false
834835
ion-modal,prop,handle,boolean | undefined,undefined,false,false
835836
ion-modal,prop,handleBehavior,"cycle" | "none" | undefined,'none',false,false
836837
ion-modal,prop,htmlAttributes,undefined | { [key: string]: any; },undefined,false,false
@@ -979,6 +980,7 @@ ion-popover,prop,componentProps,undefined | { [key: string]: any; },undefined,fa
979980
ion-popover,prop,dismissOnSelect,boolean,false,false,false
980981
ion-popover,prop,enterAnimation,((baseEl: any, opts?: any) => Animation) | undefined,undefined,false,false
981982
ion-popover,prop,event,any,undefined,false,false
983+
ion-popover,prop,focusTrap,boolean,true,false,false
982984
ion-popover,prop,htmlAttributes,undefined | { [key: string]: any; },undefined,false,false
983985
ion-popover,prop,isOpen,boolean,false,false,false
984986
ion-popover,prop,keepContentsMounted,boolean,false,false,false

core/src/components.d.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1723,6 +1723,10 @@ export namespace Components {
17231723
* Animation to use when the modal is presented.
17241724
*/
17251725
"enterAnimation"?: AnimationBuilder;
1726+
/**
1727+
* If `true`, focus will not be allowed to move outside of this overlay. If `false`, focus will be allowed to move outside of the overlay. In most scenarios this property should remain set to `true`. Setting this property to `false` can cause severe accessibility issues as users relying on assistive technologies may be able to move focus into a confusing state. We recommend only setting this to `false` when absolutely necessary. Developers may want to consider disabling focus trapping if this overlay presents a non-Ionic overlay from a 3rd party library. Developers would disable focus trapping on the Ionic overlay when presenting the 3rd party overlay and then re-enable focus trapping when dismissing the 3rd party overlay and moving focus back to the Ionic overlay.
1728+
*/
1729+
"focusTrap": boolean;
17261730
/**
17271731
* Returns the current breakpoint of a sheet style modal
17281732
*/
@@ -2139,6 +2143,10 @@ export namespace Components {
21392143
* The event to pass to the popover animation.
21402144
*/
21412145
"event": any;
2146+
/**
2147+
* If `true`, focus will not be allowed to move outside of this overlay. If `false`, focus will be allowed to move outside of the overlay. In most scenarios this property should remain set to `true`. Setting this property to `false` can cause severe accessibility issues as users relying on assistive technologies may be able to move focus into a confusing state. We recommend only setting this to `false` when absolutely necessary. Developers may want to consider disabling focus trapping if this overlay presents a non-Ionic overlay from a 3rd party library. Developers would disable focus trapping on the Ionic overlay when presenting the 3rd party overlay and then re-enable focus trapping when dismissing the 3rd party overlay and moving focus back to the Ionic overlay.
2148+
*/
2149+
"focusTrap": boolean;
21422150
"getParentPopover": () => Promise<HTMLIonPopoverElement | null>;
21432151
"hasController": boolean;
21442152
/**
@@ -6457,6 +6465,10 @@ declare namespace LocalJSX {
64576465
* Animation to use when the modal is presented.
64586466
*/
64596467
"enterAnimation"?: AnimationBuilder;
6468+
/**
6469+
* If `true`, focus will not be allowed to move outside of this overlay. If `false`, focus will be allowed to move outside of the overlay. In most scenarios this property should remain set to `true`. Setting this property to `false` can cause severe accessibility issues as users relying on assistive technologies may be able to move focus into a confusing state. We recommend only setting this to `false` when absolutely necessary. Developers may want to consider disabling focus trapping if this overlay presents a non-Ionic overlay from a 3rd party library. Developers would disable focus trapping on the Ionic overlay when presenting the 3rd party overlay and then re-enable focus trapping when dismissing the 3rd party overlay and moving focus back to the Ionic overlay.
6470+
*/
6471+
"focusTrap"?: boolean;
64606472
/**
64616473
* The horizontal line that displays at the top of a sheet modal. It is `true` by default when setting the `breakpoints` and `initialBreakpoint` properties.
64626474
*/
@@ -6803,6 +6815,10 @@ declare namespace LocalJSX {
68036815
* The event to pass to the popover animation.
68046816
*/
68056817
"event"?: any;
6818+
/**
6819+
* If `true`, focus will not be allowed to move outside of this overlay. If `false`, focus will be allowed to move outside of the overlay. In most scenarios this property should remain set to `true`. Setting this property to `false` can cause severe accessibility issues as users relying on assistive technologies may be able to move focus into a confusing state. We recommend only setting this to `false` when absolutely necessary. Developers may want to consider disabling focus trapping if this overlay presents a non-Ionic overlay from a 3rd party library. Developers would disable focus trapping on the Ionic overlay when presenting the 3rd party overlay and then re-enable focus trapping when dismissing the 3rd party overlay and moving focus back to the Ionic overlay.
6820+
*/
6821+
"focusTrap"?: boolean;
68066822
"hasController"?: boolean;
68076823
/**
68086824
* Additional attributes to pass to the popover.

core/src/components/modal/gestures/sheet.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { isIonContent, findClosestIonContent } from '@utils/content';
22
import { createGesture } from '@utils/gesture';
33
import { clamp, raf, getElementRoot } from '@utils/helpers';
4+
import { FOCUS_TRAP_DISABLE_CLASS } from '@utils/overlays';
45

56
import type { Animation } from '../../../interface';
67
import type { GestureDetail } from '../../../utils/gesture';
@@ -92,7 +93,7 @@ export const createSheetGesture = (
9293
* as inputs should not be focusable outside
9394
* the sheet.
9495
*/
95-
baseEl.classList.remove('ion-disable-focus-trap');
96+
baseEl.classList.remove(FOCUS_TRAP_DISABLE_CLASS);
9697
};
9798

9899
const disableBackdrop = () => {
@@ -106,7 +107,7 @@ export const createSheetGesture = (
106107
* Adding this class disables focus trapping
107108
* for the sheet temporarily.
108109
*/
109-
baseEl.classList.add('ion-disable-focus-trap');
110+
baseEl.classList.add(FOCUS_TRAP_DISABLE_CLASS);
110111
};
111112

112113
/**

core/src/components/modal/modal-interface.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export interface ModalOptions<T extends ComponentRef = ComponentRef> {
1010
delegate?: FrameworkDelegate;
1111
animated?: boolean;
1212
canDismiss?: boolean | ((data?: any, role?: string) => Promise<boolean>);
13+
focusTrap?: boolean;
1314

1415
mode?: Mode;
1516
keyboardClose?: boolean;

core/src/components/modal/modal.tsx

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
present,
1717
createTriggerController,
1818
setOverlayId,
19+
FOCUS_TRAP_DISABLE_CLASS,
1920
} from '@utils/overlays';
2021
import { getClassMap } from '@utils/theme';
2122
import { deepReady, waitForMount } from '@utils/transition';
@@ -257,6 +258,25 @@ export class Modal implements ComponentInterface, OverlayInterface {
257258
*/
258259
@Prop() keepContentsMounted = false;
259260

261+
/**
262+
* If `true`, focus will not be allowed to move outside of this overlay.
263+
* If `false`, focus will be allowed to move outside of the overlay.
264+
*
265+
* In most scenarios this property should remain set to `true`. Setting
266+
* this property to `false` can cause severe accessibility issues as users
267+
* relying on assistive technologies may be able to move focus into
268+
* a confusing state. We recommend only setting this to `false` when
269+
* absolutely necessary.
270+
*
271+
* Developers may want to consider disabling focus trapping if this
272+
* overlay presents a non-Ionic overlay from a 3rd party library.
273+
* Developers would disable focus trapping on the Ionic overlay
274+
* when presenting the 3rd party overlay and then re-enable
275+
* focus trapping when dismissing the 3rd party overlay and moving
276+
* focus back to the Ionic overlay.
277+
*/
278+
@Prop() focusTrap = true;
279+
260280
/**
261281
* Determines whether or not a modal can dismiss
262282
* when calling the `dismiss` method.
@@ -905,7 +925,8 @@ export class Modal implements ComponentInterface, OverlayInterface {
905925
};
906926

907927
render() {
908-
const { handle, isSheetModal, presentingElement, htmlAttributes, handleBehavior, inheritedAttributes } = this;
928+
const { handle, isSheetModal, presentingElement, htmlAttributes, handleBehavior, inheritedAttributes, focusTrap } =
929+
this;
909930

910931
const showHandle = handle !== false && isSheetModal;
911932
const mode = getIonMode(this);
@@ -926,6 +947,7 @@ export class Modal implements ComponentInterface, OverlayInterface {
926947
[`modal-card`]: isCardModal,
927948
[`modal-sheet`]: isSheetModal,
928949
'overlay-hidden': true,
950+
[FOCUS_TRAP_DISABLE_CLASS]: focusTrap === false,
929951
...getClassMap(this.cssClass),
930952
}}
931953
onIonBackdropTap={this.onBackdropTap}

core/src/components/modal/test/basic/modal.spec.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { h } from '@stencil/core';
22
import { newSpecPage } from '@stencil/core/testing';
33

44
import { Modal } from '../../modal';
5+
import { FOCUS_TRAP_DISABLE_CLASS } from '@utils/overlays';
56

67
describe('modal: htmlAttributes inheritance', () => {
78
it('should correctly inherit attributes on host', async () => {
@@ -15,3 +16,26 @@ describe('modal: htmlAttributes inheritance', () => {
1516
await expect(modal.getAttribute('data-testid')).toBe('basic-modal');
1617
});
1718
});
19+
20+
describe('modal: focus trap', () => {
21+
it('should set the focus trap class when disabled', async () => {
22+
const page = await newSpecPage({
23+
components: [Modal],
24+
template: () => <ion-modal focusTrap={false} overlayIndex={1}></ion-modal>,
25+
});
26+
27+
const modal = page.body.querySelector('ion-modal')!;
28+
29+
expect(modal.classList.contains(FOCUS_TRAP_DISABLE_CLASS)).toBe(true);
30+
});
31+
it('should not set the focus trap class by default', async () => {
32+
const page = await newSpecPage({
33+
components: [Modal],
34+
template: () => <ion-modal overlayIndex={1}></ion-modal>,
35+
});
36+
37+
const modal = page.body.querySelector('ion-modal')!;
38+
39+
expect(modal.classList.contains(FOCUS_TRAP_DISABLE_CLASS)).toBe(false);
40+
});
41+
});

core/src/components/popover/popover-interface.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export interface PopoverOptions<T extends ComponentRef = ComponentRef> {
2121
event?: Event;
2222
delegate?: FrameworkDelegate;
2323
animated?: boolean;
24+
focusTrap?: boolean;
2425

2526
mode?: Mode;
2627
keyboardClose?: boolean;

core/src/components/popover/popover.tsx

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,15 @@ import { CoreDelegate, attachComponent, detachComponent } from '@utils/framework
55
import { addEventListener, raf, hasLazyBuild } from '@utils/helpers';
66
import { createLockController } from '@utils/lock-controller';
77
import { printIonWarning } from '@utils/logging';
8-
import { BACKDROP, dismiss, eventMethod, prepareOverlay, present, setOverlayId } from '@utils/overlays';
8+
import {
9+
BACKDROP,
10+
dismiss,
11+
eventMethod,
12+
prepareOverlay,
13+
present,
14+
setOverlayId,
15+
FOCUS_TRAP_DISABLE_CLASS,
16+
} from '@utils/overlays';
917
import { isPlatform } from '@utils/platform';
1018
import { getClassMap } from '@utils/theme';
1119
import { deepReady, waitForMount } from '@utils/transition';
@@ -236,6 +244,25 @@ export class Popover implements ComponentInterface, PopoverInterface {
236244
*/
237245
@Prop() keyboardEvents = false;
238246

247+
/**
248+
* If `true`, focus will not be allowed to move outside of this overlay.
249+
* If `false`, focus will be allowed to move outside of the overlay.
250+
*
251+
* In most scenarios this property should remain set to `true`. Setting
252+
* this property to `false` can cause severe accessibility issues as users
253+
* relying on assistive technologies may be able to move focus into
254+
* a confusing state. We recommend only setting this to `false` when
255+
* absolutely necessary.
256+
*
257+
* Developers may want to consider disabling focus trapping if this
258+
* overlay presents a non-Ionic overlay from a 3rd party library.
259+
* Developers would disable focus trapping on the Ionic overlay
260+
* when presenting the 3rd party overlay and then re-enable
261+
* focus trapping when dismissing the 3rd party overlay and moving
262+
* focus back to the Ionic overlay.
263+
*/
264+
@Prop() focusTrap = true;
265+
239266
@Watch('trigger')
240267
@Watch('triggerAction')
241268
onTriggerChange() {
@@ -656,7 +683,7 @@ export class Popover implements ComponentInterface, PopoverInterface {
656683

657684
render() {
658685
const mode = getIonMode(this);
659-
const { onLifecycle, parentPopover, dismissOnSelect, side, arrow, htmlAttributes } = this;
686+
const { onLifecycle, parentPopover, dismissOnSelect, side, arrow, htmlAttributes, focusTrap } = this;
660687
const desktop = isPlatform('desktop');
661688
const enableArrow = arrow && !parentPopover;
662689

@@ -676,6 +703,7 @@ export class Popover implements ComponentInterface, PopoverInterface {
676703
'overlay-hidden': true,
677704
'popover-desktop': desktop,
678705
[`popover-side-${side}`]: true,
706+
[FOCUS_TRAP_DISABLE_CLASS]: focusTrap === false,
679707
'popover-nested': !!parentPopover,
680708
}}
681709
onIonPopoverDidPresent={onLifecycle}

core/src/components/popover/test/basic/popover.spec.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { newSpecPage } from '@stencil/core/testing';
33

44
import { Popover } from '../../popover';
55

6+
import { FOCUS_TRAP_DISABLE_CLASS } from '@utils/overlays';
7+
68
describe('popover: htmlAttributes inheritance', () => {
79
it('should correctly inherit attributes on host', async () => {
810
const page = await newSpecPage({
@@ -15,3 +17,26 @@ describe('popover: htmlAttributes inheritance', () => {
1517
await expect(popover.getAttribute('data-testid')).toBe('basic-popover');
1618
});
1719
});
20+
21+
describe('popover: focus trap', () => {
22+
it('should set the focus trap class when disabled', async () => {
23+
const page = await newSpecPage({
24+
components: [Popover],
25+
template: () => <ion-popover focusTrap={false} overlayIndex={1}></ion-popover>,
26+
});
27+
28+
const popover = page.body.querySelector('ion-popover')!;
29+
30+
expect(popover.classList.contains(FOCUS_TRAP_DISABLE_CLASS)).toBe(true);
31+
});
32+
it('should not set the focus trap class by default', async () => {
33+
const page = await newSpecPage({
34+
components: [Popover],
35+
template: () => <ion-popover overlayIndex={1}></ion-popover>,
36+
});
37+
38+
const popover = page.body.querySelector('ion-popover')!;
39+
40+
expect(popover.classList.contains(FOCUS_TRAP_DISABLE_CLASS)).toBe(false);
41+
});
42+
});

core/src/utils/overlays.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,7 @@ const trapKeyboardFocus = (ev: Event, doc: Document) => {
199199
* behind the sheet should be focusable until
200200
* the backdrop is enabled.
201201
*/
202-
if (lastOverlay.classList.contains('ion-disable-focus-trap')) {
202+
if (lastOverlay.classList.contains(FOCUS_TRAP_DISABLE_CLASS)) {
203203
return;
204204
}
205205

@@ -990,3 +990,5 @@ const revealOverlaysToScreenReaders = () => {
990990
}
991991
}
992992
};
993+
994+
export const FOCUS_TRAP_DISABLE_CLASS = 'ion-disable-focus-trap';

packages/vue/src/components/Overlays.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export const IonPickerLegacy = /*@__PURE__*/ defineOverlayContainer<JSX.IonPicke
2727

2828
export const IonToast = /*@__PURE__*/ defineOverlayContainer<JSX.IonToast>('ion-toast', defineIonToastCustomElement, ['animated', 'buttons', 'color', 'cssClass', 'duration', 'enterAnimation', 'header', 'htmlAttributes', 'icon', 'isOpen', 'keyboardClose', 'layout', 'leaveAnimation', 'message', 'mode', 'position', 'positionAnchor', 'swipeGesture', 'translucent', 'trigger']);
2929

30-
export const IonModal = /*@__PURE__*/ defineOverlayContainer<JSX.IonModal>('ion-modal', defineIonModalCustomElement, ['animated', 'backdropBreakpoint', 'backdropDismiss', 'breakpoints', 'canDismiss', 'enterAnimation', 'handle', 'handleBehavior', 'htmlAttributes', 'initialBreakpoint', 'isOpen', 'keepContentsMounted', 'keyboardClose', 'leaveAnimation', 'mode', 'presentingElement', 'showBackdrop', 'trigger'], true);
30+
export const IonModal = /*@__PURE__*/ defineOverlayContainer<JSX.IonModal>('ion-modal', defineIonModalCustomElement, ['animated', 'backdropBreakpoint', 'backdropDismiss', 'breakpoints', 'canDismiss', 'enterAnimation', 'focusTrap', 'handle', 'handleBehavior', 'htmlAttributes', 'initialBreakpoint', 'isOpen', 'keepContentsMounted', 'keyboardClose', 'leaveAnimation', 'mode', 'presentingElement', 'showBackdrop', 'trigger'], true);
3131

32-
export const IonPopover = /*@__PURE__*/ defineOverlayContainer<JSX.IonPopover>('ion-popover', defineIonPopoverCustomElement, ['alignment', 'animated', 'arrow', 'backdropDismiss', 'component', 'componentProps', 'dismissOnSelect', 'enterAnimation', 'event', 'htmlAttributes', 'isOpen', 'keepContentsMounted', 'keyboardClose', 'leaveAnimation', 'mode', 'reference', 'showBackdrop', 'side', 'size', 'translucent', 'trigger', 'triggerAction']);
32+
export const IonPopover = /*@__PURE__*/ defineOverlayContainer<JSX.IonPopover>('ion-popover', defineIonPopoverCustomElement, ['alignment', 'animated', 'arrow', 'backdropDismiss', 'component', 'componentProps', 'dismissOnSelect', 'enterAnimation', 'event', 'focusTrap', 'htmlAttributes', 'isOpen', 'keepContentsMounted', 'keyboardClose', 'leaveAnimation', 'mode', 'reference', 'showBackdrop', 'side', 'size', 'translucent', 'trigger', 'triggerAction']);
3333

0 commit comments

Comments
 (0)