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

Issue number: resolves #24646
This commit is contained in:
Liam DeBeasi
2024-04-25 09:57:43 -04:00
committed by GitHub
parent e38e2e4d35
commit 7c00351680
11 changed files with 130 additions and 8 deletions

View File

@ -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';
@ -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 = () => {
@ -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);
};
/**

View File

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

View File

@ -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';
@ -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.
@ -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);
@ -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}

View File

@ -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 () => {
@ -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);
});
});

View File

@ -21,6 +21,7 @@ export interface PopoverOptions<T extends ComponentRef = ComponentRef> {
event?: Event;
delegate?: FrameworkDelegate;
animated?: boolean;
focusTrap?: boolean;
mode?: Mode;
keyboardClose?: boolean;

View File

@ -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';
@ -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() {
@ -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;
@ -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}

View File

@ -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({
@ -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);
});
});