Skip to content

feat(modal, popover): add ability to temporarily disable focus trapping #29379

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Apr 25, 2024
2 changes: 2 additions & 0 deletions core/api.txt
Original file line number Diff line number Diff line change
Expand Up @@ -831,6 +831,7 @@ ion-modal,prop,backdropDismiss,boolean,true,false,false
ion-modal,prop,breakpoints,number[] | undefined,undefined,false,false
ion-modal,prop,canDismiss,((data?: any, role?: string | undefined) => Promise<boolean>) | boolean,true,false,false
ion-modal,prop,enterAnimation,((baseEl: any, opts?: any) => Animation) | undefined,undefined,false,false
ion-modal,prop,focusTrap,boolean,true,false,false
ion-modal,prop,handle,boolean | undefined,undefined,false,false
ion-modal,prop,handleBehavior,"cycle" | "none" | undefined,'none',false,false
ion-modal,prop,htmlAttributes,undefined | { [key: string]: any; },undefined,false,false
Expand Down Expand Up @@ -979,6 +980,7 @@ ion-popover,prop,componentProps,undefined | { [key: string]: any; },undefined,fa
ion-popover,prop,dismissOnSelect,boolean,false,false,false
ion-popover,prop,enterAnimation,((baseEl: any, opts?: any) => Animation) | undefined,undefined,false,false
ion-popover,prop,event,any,undefined,false,false
ion-popover,prop,focusTrap,boolean,true,false,false
ion-popover,prop,htmlAttributes,undefined | { [key: string]: any; },undefined,false,false
ion-popover,prop,isOpen,boolean,false,false,false
ion-popover,prop,keepContentsMounted,boolean,false,false,false
Expand Down
16 changes: 16 additions & 0 deletions core/src/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1723,6 +1723,10 @@ export namespace Components {
* Animation to use when the modal is presented.
*/
"enterAnimation"?: AnimationBuilder;
/**
* 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.
*/
"focusTrap": boolean;
/**
* Returns the current breakpoint of a sheet style modal
*/
Expand Down Expand Up @@ -2139,6 +2143,10 @@ export namespace Components {
* The event to pass to the popover animation.
*/
"event": any;
/**
* 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.
*/
"focusTrap": boolean;
"getParentPopover": () => Promise<HTMLIonPopoverElement | null>;
"hasController": boolean;
/**
Expand Down Expand Up @@ -6457,6 +6465,10 @@ declare namespace LocalJSX {
* Animation to use when the modal is presented.
*/
"enterAnimation"?: AnimationBuilder;
/**
* 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.
*/
"focusTrap"?: boolean;
/**
* The horizontal line that displays at the top of a sheet modal. It is `true` by default when setting the `breakpoints` and `initialBreakpoint` properties.
*/
Expand Down Expand Up @@ -6803,6 +6815,10 @@ declare namespace LocalJSX {
* The event to pass to the popover animation.
*/
"event"?: any;
/**
* 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.
*/
"focusTrap"?: boolean;
"hasController"?: boolean;
/**
* Additional attributes to pass to the popover.
Expand Down
5 changes: 3 additions & 2 deletions core/src/components/modal/gestures/sheet.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { isIonContent, findClosestIonContent } from '@utils/content';
import { createGesture } from '@utils/gesture';
import { clamp, raf, getElementRoot } from '@utils/helpers';
import { FOCUS_TRAP_DISABLE_CLASS } from '@utils/overlays';

import type { Animation } from '../../../interface';
import type { GestureDetail } from '../../../utils/gesture';
Expand Down Expand Up @@ -92,7 +93,7 @@ export const createSheetGesture = (
* as inputs should not be focusable outside
* the sheet.
*/
baseEl.classList.remove('ion-disable-focus-trap');
baseEl.classList.remove(FOCUS_TRAP_DISABLE_CLASS);
};

const disableBackdrop = () => {
Expand All @@ -106,7 +107,7 @@ export const createSheetGesture = (
* Adding this class disables focus trapping
* for the sheet temporarily.
*/
baseEl.classList.add('ion-disable-focus-trap');
baseEl.classList.add(FOCUS_TRAP_DISABLE_CLASS);
};

/**
Expand Down
1 change: 1 addition & 0 deletions core/src/components/modal/modal-interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export interface ModalOptions<T extends ComponentRef = ComponentRef> {
delegate?: FrameworkDelegate;
animated?: boolean;
canDismiss?: boolean | ((data?: any, role?: string) => Promise<boolean>);
focusTrap?: boolean;

mode?: Mode;
keyboardClose?: boolean;
Expand Down
24 changes: 23 additions & 1 deletion core/src/components/modal/modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
present,
createTriggerController,
setOverlayId,
FOCUS_TRAP_DISABLE_CLASS,
} from '@utils/overlays';
import { getClassMap } from '@utils/theme';
import { deepReady, waitForMount } from '@utils/transition';
Expand Down Expand Up @@ -257,6 +258,25 @@ export class Modal implements ComponentInterface, OverlayInterface {
*/
@Prop() keepContentsMounted = false;

/**
* 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.
*/
@Prop() focusTrap = true;

/**
* Determines whether or not a modal can dismiss
* when calling the `dismiss` method.
Expand Down Expand Up @@ -905,7 +925,8 @@ export class Modal implements ComponentInterface, OverlayInterface {
};

render() {
const { handle, isSheetModal, presentingElement, htmlAttributes, handleBehavior, inheritedAttributes } = this;
const { handle, isSheetModal, presentingElement, htmlAttributes, handleBehavior, inheritedAttributes, focusTrap } =
this;

const showHandle = handle !== false && isSheetModal;
const mode = getIonMode(this);
Expand All @@ -926,6 +947,7 @@ export class Modal implements ComponentInterface, OverlayInterface {
[`modal-card`]: isCardModal,
[`modal-sheet`]: isSheetModal,
'overlay-hidden': true,
[FOCUS_TRAP_DISABLE_CLASS]: focusTrap === false,
...getClassMap(this.cssClass),
}}
onIonBackdropTap={this.onBackdropTap}
Expand Down
24 changes: 24 additions & 0 deletions core/src/components/modal/test/basic/modal.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { h } from '@stencil/core';
import { newSpecPage } from '@stencil/core/testing';

import { Modal } from '../../modal';
import { FOCUS_TRAP_DISABLE_CLASS } from '@utils/overlays';

describe('modal: htmlAttributes inheritance', () => {
it('should correctly inherit attributes on host', async () => {
Expand All @@ -15,3 +16,26 @@ describe('modal: htmlAttributes inheritance', () => {
await expect(modal.getAttribute('data-testid')).toBe('basic-modal');
});
});

describe('modal: focus trap', () => {
it('should set the focus trap class when disabled', async () => {
const page = await newSpecPage({
components: [Modal],
template: () => <ion-modal focusTrap={false} overlayIndex={1}></ion-modal>,
});

const modal = page.body.querySelector('ion-modal')!;

expect(modal.classList.contains(FOCUS_TRAP_DISABLE_CLASS)).toBe(true);
});
it('should not set the focus trap class by default', async () => {
const page = await newSpecPage({
components: [Modal],
template: () => <ion-modal overlayIndex={1}></ion-modal>,
});

const modal = page.body.querySelector('ion-modal')!;

expect(modal.classList.contains(FOCUS_TRAP_DISABLE_CLASS)).toBe(false);
});
});
1 change: 1 addition & 0 deletions core/src/components/popover/popover-interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export interface PopoverOptions<T extends ComponentRef = ComponentRef> {
event?: Event;
delegate?: FrameworkDelegate;
animated?: boolean;
focusTrap?: boolean;

mode?: Mode;
keyboardClose?: boolean;
Expand Down
32 changes: 30 additions & 2 deletions core/src/components/popover/popover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,15 @@ import { CoreDelegate, attachComponent, detachComponent } from '@utils/framework
import { addEventListener, raf, hasLazyBuild } from '@utils/helpers';
import { createLockController } from '@utils/lock-controller';
import { printIonWarning } from '@utils/logging';
import { BACKDROP, dismiss, eventMethod, prepareOverlay, present, setOverlayId } from '@utils/overlays';
import {
BACKDROP,
dismiss,
eventMethod,
prepareOverlay,
present,
setOverlayId,
FOCUS_TRAP_DISABLE_CLASS,
} from '@utils/overlays';
import { isPlatform } from '@utils/platform';
import { getClassMap } from '@utils/theme';
import { deepReady, waitForMount } from '@utils/transition';
Expand Down Expand Up @@ -236,6 +244,25 @@ export class Popover implements ComponentInterface, PopoverInterface {
*/
@Prop() keyboardEvents = false;

/**
* 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.
*/
@Prop() focusTrap = true;

@Watch('trigger')
@Watch('triggerAction')
onTriggerChange() {
Expand Down Expand Up @@ -656,7 +683,7 @@ export class Popover implements ComponentInterface, PopoverInterface {

render() {
const mode = getIonMode(this);
const { onLifecycle, parentPopover, dismissOnSelect, side, arrow, htmlAttributes } = this;
const { onLifecycle, parentPopover, dismissOnSelect, side, arrow, htmlAttributes, focusTrap } = this;
const desktop = isPlatform('desktop');
const enableArrow = arrow && !parentPopover;

Expand All @@ -676,6 +703,7 @@ export class Popover implements ComponentInterface, PopoverInterface {
'overlay-hidden': true,
'popover-desktop': desktop,
[`popover-side-${side}`]: true,
[FOCUS_TRAP_DISABLE_CLASS]: focusTrap === false,
'popover-nested': !!parentPopover,
}}
onIonPopoverDidPresent={onLifecycle}
Expand Down
25 changes: 25 additions & 0 deletions core/src/components/popover/test/basic/popover.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { newSpecPage } from '@stencil/core/testing';

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

import { FOCUS_TRAP_DISABLE_CLASS } from '@utils/overlays';

describe('popover: htmlAttributes inheritance', () => {
it('should correctly inherit attributes on host', async () => {
const page = await newSpecPage({
Expand All @@ -15,3 +17,26 @@ describe('popover: htmlAttributes inheritance', () => {
await expect(popover.getAttribute('data-testid')).toBe('basic-popover');
});
});

describe('popover: focus trap', () => {
it('should set the focus trap class when disabled', async () => {
const page = await newSpecPage({
components: [Popover],
template: () => <ion-popover focusTrap={false} overlayIndex={1}></ion-popover>,
});

const popover = page.body.querySelector('ion-popover')!;

expect(popover.classList.contains(FOCUS_TRAP_DISABLE_CLASS)).toBe(true);
});
it('should not set the focus trap class by default', async () => {
const page = await newSpecPage({
components: [Popover],
template: () => <ion-popover overlayIndex={1}></ion-popover>,
});

const popover = page.body.querySelector('ion-popover')!;

expect(popover.classList.contains(FOCUS_TRAP_DISABLE_CLASS)).toBe(false);
});
});
4 changes: 3 additions & 1 deletion core/src/utils/overlays.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ const trapKeyboardFocus = (ev: Event, doc: Document) => {
* behind the sheet should be focusable until
* the backdrop is enabled.
*/
if (lastOverlay.classList.contains('ion-disable-focus-trap')) {
if (lastOverlay.classList.contains(FOCUS_TRAP_DISABLE_CLASS)) {
return;
}

Expand Down Expand Up @@ -990,3 +990,5 @@ const revealOverlaysToScreenReaders = () => {
}
}
};

export const FOCUS_TRAP_DISABLE_CLASS = 'ion-disable-focus-trap';
4 changes: 2 additions & 2 deletions packages/vue/src/components/Overlays.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export const IonPickerLegacy = /*@__PURE__*/ defineOverlayContainer<JSX.IonPicke

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']);

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);
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);

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']);
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']);