Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

♿ a11y(bal-modal, bal-popup): Overlay background accessible #1475

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/core/src/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1946,6 +1946,10 @@ export namespace Components {
* Closes the presented modal with the modal controller
*/
"dismiss": (data?: any, role?: string) => Promise<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.
*/
Expand Down Expand Up @@ -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.
*/
Expand Down
22 changes: 21 additions & 1 deletion packages/core/src/components/bal-modal/bal-modal.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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={{
Expand Down
103 changes: 103 additions & 0 deletions packages/core/src/utils/overlays/focus-trap.ts
Original file line number Diff line number Diff line change
@@ -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 = <R extends HTMLElement, T extends HTMLElement>(ref: R, fallbackElement?: T) => {
const firstInput = ref.querySelector<HTMLElement>(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 = <R extends HTMLElement, T extends HTMLElement>(ref: R, fallbackElement?: T) => {
const inputs = Array.from(ref.querySelectorAll<HTMLElement>(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 <button>
* element inside of bal-button's shadow root, not
* the host element itself.
*/
const focusElementInContext = <T extends HTMLElement>(
hostToFocus: HTMLElement | null | undefined,
fallbackElement: T,
) => {
let elementToFocus = hostToFocus

const shadowRoot = hostToFocus?.shadowRoot
if (shadowRoot) {
// If there are no inner focusable elements, just focus the host element.
elementToFocus = shadowRoot.querySelector<HTMLElement>(focusableQueryString) || hostToFocus
}

if (elementToFocus) {
focusVisibleElement(elementToFocus)
} else {
// Focus fallback element instead of letting focus escape
fallbackElement.focus()
}
}

export const focusVisibleElement = (el: HTMLElement) => {
el.focus()

/**
* When programmatically focusing an element,
* the focus-visible utility will not run because
* it is expecting a keyboard event to have triggered this;
* however, there are times when we need to manually control
* this behavior so we call the `setFocus` method on bal-app
* which will let us explicitly set the elements to focus.
*/
if (el.classList.contains('bal-focusable')) {
const app = el.closest('bal-app')
if (app) {
app.setFocus([el])
}
}
}
Loading
Loading