diff --git a/packages/core/src/components.d.ts b/packages/core/src/components.d.ts index 11ab6a1c51..9a5d6362f5 100644 --- a/packages/core/src/components.d.ts +++ b/packages/core/src/components.d.ts @@ -1946,6 +1946,10 @@ export namespace Components { * Closes the presented modal with the modal controller */ "dismiss": (data?: any, role?: string) => Promise; + /** + * 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; /** * If `true`, a backdrop will be displayed behind the modal. */ @@ -7147,6 +7151,10 @@ declare namespace LocalJSX { "dataTestId"?: string; "delegate"?: BalProps.FrameworkDelegate; "demo"?: boolean; + /** + * 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; /** * If `true`, a backdrop will be displayed behind the modal. */ diff --git a/packages/core/src/components/bal-modal/bal-modal.tsx b/packages/core/src/components/bal-modal/bal-modal.tsx index 4a61e4bc0f..3e7f8c59c1 100644 --- a/packages/core/src/components/bal-modal/bal-modal.tsx +++ b/packages/core/src/components/bal-modal/bal-modal.tsx @@ -1,5 +1,5 @@ import { Component, Host, h, State, Method, Listen, Prop, Event, EventEmitter, Element, writeTask } from '@stencil/core' -import { dismiss, eventMethod, prepareOverlay } from '../../utils/overlays/overlays' +import { dismiss, eventMethod, FOCUS_TRAP_DISABLE_CLASS, prepareOverlay } from '../../utils/overlays/overlays' import { attachComponent, detachComponent } from '../../utils/framework-delegate' import { OverlayEventDetail, OverlayInterface } from './bal-modal.type' import { deepReady, wait } from '../../utils/helpers' @@ -75,6 +75,25 @@ export class Modal implements OverlayInterface { */ @Prop() backdropDismiss = true + /** + * 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 + /** * @internal */ @@ -306,6 +325,7 @@ export class Modal implements OverlayInterface { 'bal-modal': true, 'bal-modal--is-closable': this.isClosable, 'bal-modal--is-active': this.presented, + [FOCUS_TRAP_DISABLE_CLASS]: this.focusTrap === false, ...getClassMap(this.cssClass), }} style={{ diff --git a/packages/core/src/utils/overlays/focus-trap.ts b/packages/core/src/utils/overlays/focus-trap.ts new file mode 100644 index 0000000000..cb03961579 --- /dev/null +++ b/packages/core/src/utils/overlays/focus-trap.ts @@ -0,0 +1,103 @@ +/** + * This query string selects elements that + * are eligible to receive focus. We select + * interactive elements that meet the following + * criteria: + * 1. Element does not have a negative tabindex + * 2. Element does not have `hidden` + * 3. Element does not have `disabled` for non-Ionic components. + * 4. Element does not have `disabled` or `disabled="true"` for Ionic components. + * Note: We need this distinction because `disabled="false"` is + * valid usage for the disabled property on bal-button. + */ +export const focusableQueryString = + '[tabindex]:not([tabindex^="-"]):not([hidden]):not([disabled]), input:not([type=hidden]):not([tabindex^="-"]):not([hidden]):not([disabled]), textarea:not([tabindex^="-"]):not([hidden]):not([disabled]), button:not([tabindex^="-"]):not([hidden]):not([disabled]), select:not([tabindex^="-"]):not([hidden]):not([disabled]), .bal-focusable:not([tabindex^="-"]):not([hidden]):not([disabled]), .bal-focusable[disabled="false"]:not([tabindex^="-"]):not([hidden])' + +/** + * Focuses the first descendant in a context + * that can receive focus. If none exists, + * a fallback element will be focused. + * This fallback is typically an ancestor + * container such as a menu or overlay so focus does not + * leave the container we are trying to trap focus in. + * + * If no fallback is specified then we focus the container itself. + */ +export const focusFirstDescendant = (ref: R, fallbackElement?: T) => { + const firstInput = ref.querySelector(focusableQueryString) + debugger + focusElementInContext(firstInput, fallbackElement ?? ref) +} + +/** + * Focuses the last descendant in a context + * that can receive focus. If none exists, + * a fallback element will be focused. + * This fallback is typically an ancestor + * container such as a menu or overlay so focus does not + * leave the container we are trying to trap focus in. + * + * If no fallback is specified then we focus the container itself. + */ +export const focusLastDescendant = (ref: R, fallbackElement?: T) => { + const inputs = Array.from(ref.querySelectorAll(focusableQueryString)) + const lastInput = inputs.length > 0 ? inputs[inputs.length - 1] : null + + focusElementInContext(lastInput, fallbackElement ?? ref) +} + +/** + * Focuses a particular element in a context. If the element + * doesn't have anything focusable associated with it then + * a fallback element will be focused. + * + * This fallback is typically an ancestor + * container such as a menu or overlay so focus does not + * leave the container we are trying to trap focus in. + * This should be used instead of the focus() method + * on most elements because the focusable element + * may not be the host element. + * + * For example, if an bal-button should be focused + * then we should actually focus the native