Skip to content

Commit

Permalink
feat(modal, popover): add ability to temporarily disable focus trappi…
Browse files Browse the repository at this point in the history
…ng (#29379)

Issue number: resolves #24646
  • Loading branch information
liamdebeasi authored Apr 25, 2024
1 parent e38e2e4 commit 7c00351
Show file tree
Hide file tree
Showing 11 changed files with 130 additions and 8 deletions.
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']);

0 comments on commit 7c00351

Please sign in to comment.