Skip to content

Commit

Permalink
♿ a11y(bal-modal, bal-popup): Overlay background accessible (#1475)
Browse files Browse the repository at this point in the history
* Create PR for #1423

* feat(a11y): keep focus in modal when tab

* feat(a11y): run format

* fix(a11y): add changeset

* fix(a11y): remove debugger

* fix(ally): removed todos

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Marinkov, Magdalena <[email protected]>
Co-authored-by: Marco Zirkenbach <[email protected]>
  • Loading branch information
3 people authored Jan 31, 2025
1 parent 5f3e38c commit 4290704
Show file tree
Hide file tree
Showing 5 changed files with 353 additions and 2 deletions.
5 changes: 5 additions & 0 deletions .changeset/short-lizards-bake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@baloise/ds-core': patch
---

**core**: modal: keep focus within modal when navigating with keyboard
8 changes: 8 additions & 0 deletions packages/core/src/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1894,6 +1894,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 @@ -7049,6 +7053,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
24 changes: 22 additions & 2 deletions 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, Prop, Event, EventEmitter, Element, writeTask, Listen } from '@stencil/core'
import { dismiss, eventMethod, prepareOverlay } from '../../utils/overlays/overlays'
import { Component, Host, h, State, Method, Listen, Prop, Event, EventEmitter, Element, writeTask } from '@stencil/core'
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
102 changes: 102 additions & 0 deletions packages/core/src/utils/overlays/focus-trap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/**
* 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)
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

0 comments on commit 4290704

Please sign in to comment.