mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2025-08-15 01:03:03 +08:00
feat(modal, popover): add ability to temporarily disable focus trapping (#29379)
Issue number: resolves #24646
This commit is contained in:
@ -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
|
||||
@ -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
|
||||
|
16
core/src/components.d.ts
vendored
16
core/src/components.d.ts
vendored
@ -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
|
||||
*/
|
||||
@ -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;
|
||||
/**
|
||||
@ -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.
|
||||
*/
|
||||
@ -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.
|
||||
|
@ -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);
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -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;
|
||||
|
@ -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}
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
@ -21,6 +21,7 @@ export interface PopoverOptions<T extends ComponentRef = ComponentRef> {
|
||||
event?: Event;
|
||||
delegate?: FrameworkDelegate;
|
||||
animated?: boolean;
|
||||
focusTrap?: boolean;
|
||||
|
||||
mode?: Mode;
|
||||
keyboardClose?: boolean;
|
||||
|
@ -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}
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
@ -990,3 +990,5 @@ const revealOverlaysToScreenReaders = () => {
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const FOCUS_TRAP_DISABLE_CLASS = 'ion-disable-focus-trap';
|
||||
|
@ -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']);
|
||||
|
||||
|
Reference in New Issue
Block a user