From 7c00351680a955130fa10d25d4439d3cc18db805 Mon Sep 17 00:00:00 2001 From: Liam DeBeasi Date: Thu, 25 Apr 2024 09:57:43 -0400 Subject: [PATCH] feat(modal, popover): add ability to temporarily disable focus trapping (#29379) Issue number: resolves #24646 --- core/api.txt | 2 ++ core/src/components.d.ts | 16 ++++++++++ core/src/components/modal/gestures/sheet.ts | 5 +-- core/src/components/modal/modal-interface.ts | 1 + core/src/components/modal/modal.tsx | 24 +++++++++++++- .../modal/test/basic/modal.spec.tsx | 24 ++++++++++++++ .../components/popover/popover-interface.ts | 1 + core/src/components/popover/popover.tsx | 32 +++++++++++++++++-- .../popover/test/basic/popover.spec.tsx | 25 +++++++++++++++ core/src/utils/overlays.ts | 4 ++- packages/vue/src/components/Overlays.ts | 4 +-- 11 files changed, 130 insertions(+), 8 deletions(-) diff --git a/core/api.txt b/core/api.txt index 77fb356712c..7b9ac052327 100644 --- a/core/api.txt +++ b/core/api.txt @@ -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,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 diff --git a/core/src/components.d.ts b/core/src/components.d.ts index 73af9bcf998..0d85c29c79f 100644 --- a/core/src/components.d.ts +++ b/core/src/components.d.ts @@ -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; "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. diff --git a/core/src/components/modal/gestures/sheet.ts b/core/src/components/modal/gestures/sheet.ts index f61a3baaa8a..a90eb5d99ea 100644 --- a/core/src/components/modal/gestures/sheet.ts +++ b/core/src/components/modal/gestures/sheet.ts @@ -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); }; /** diff --git a/core/src/components/modal/modal-interface.ts b/core/src/components/modal/modal-interface.ts index 159e2810670..733fcc9a55d 100644 --- a/core/src/components/modal/modal-interface.ts +++ b/core/src/components/modal/modal-interface.ts @@ -10,6 +10,7 @@ export interface ModalOptions { delegate?: FrameworkDelegate; animated?: boolean; canDismiss?: boolean | ((data?: any, role?: string) => Promise); + focusTrap?: boolean; mode?: Mode; keyboardClose?: boolean; diff --git a/core/src/components/modal/modal.tsx b/core/src/components/modal/modal.tsx index 2d84e42c964..7a3fd4b047e 100644 --- a/core/src/components/modal/modal.tsx +++ b/core/src/components/modal/modal.tsx @@ -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} diff --git a/core/src/components/modal/test/basic/modal.spec.tsx b/core/src/components/modal/test/basic/modal.spec.tsx index 01bd0ab22f8..67996e2ad18 100644 --- a/core/src/components/modal/test/basic/modal.spec.tsx +++ b/core/src/components/modal/test/basic/modal.spec.tsx @@ -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: () => , + }); + + 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: () => , + }); + + const modal = page.body.querySelector('ion-modal')!; + + expect(modal.classList.contains(FOCUS_TRAP_DISABLE_CLASS)).toBe(false); + }); +}); diff --git a/core/src/components/popover/popover-interface.ts b/core/src/components/popover/popover-interface.ts index b0bec79aa41..9fbfb6145f9 100644 --- a/core/src/components/popover/popover-interface.ts +++ b/core/src/components/popover/popover-interface.ts @@ -21,6 +21,7 @@ export interface PopoverOptions { event?: Event; delegate?: FrameworkDelegate; animated?: boolean; + focusTrap?: boolean; mode?: Mode; keyboardClose?: boolean; diff --git a/core/src/components/popover/popover.tsx b/core/src/components/popover/popover.tsx index d765bc3edf3..4506e3b7caf 100644 --- a/core/src/components/popover/popover.tsx +++ b/core/src/components/popover/popover.tsx @@ -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} diff --git a/core/src/components/popover/test/basic/popover.spec.tsx b/core/src/components/popover/test/basic/popover.spec.tsx index 84ecc7150c3..abe5db8fc65 100644 --- a/core/src/components/popover/test/basic/popover.spec.tsx +++ b/core/src/components/popover/test/basic/popover.spec.tsx @@ -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: () => , + }); + + 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: () => , + }); + + const popover = page.body.querySelector('ion-popover')!; + + expect(popover.classList.contains(FOCUS_TRAP_DISABLE_CLASS)).toBe(false); + }); +}); diff --git a/core/src/utils/overlays.ts b/core/src/utils/overlays.ts index cf1761ce285..e69bd49dcda 100644 --- a/core/src/utils/overlays.ts +++ b/core/src/utils/overlays.ts @@ -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'; diff --git a/packages/vue/src/components/Overlays.ts b/packages/vue/src/components/Overlays.ts index 98b07274e98..2e5b0d31737 100644 --- a/packages/vue/src/components/Overlays.ts +++ b/packages/vue/src/components/Overlays.ts @@ -27,7 +27,7 @@ export const IonPickerLegacy = /*@__PURE__*/ defineOverlayContainer('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('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('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('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('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']);