diff --git a/core/src/components.d.ts b/core/src/components.d.ts index d1e2462378a..d1dca6a73a8 100644 --- a/core/src/components.d.ts +++ b/core/src/components.d.ts @@ -334,6 +334,7 @@ export namespace Components { "mode"?: "ios" | "md"; /** * Used to set focus on an element that uses `ion-focusable`. Do not use this if focusing the element as a result of a keyboard event as the focus utility should handle this for us. This method should be used when we want to programmatically focus an element as a result of another user action. (Ex: We focus the first element inside of a popover when the user presents it, but the popover is not always presented as a result of keyboard action.) + * @param elements - The elements to set focus on. */ "setFocus": (elements: HTMLElement[]) => Promise; /** diff --git a/core/src/components/app/app.tsx b/core/src/components/app/app.tsx index 756f010bdca..c4aabed0722 100644 --- a/core/src/components/app/app.tsx +++ b/core/src/components/app/app.tsx @@ -1,6 +1,6 @@ import type { ComponentInterface } from '@stencil/core'; import { Component, Element, Host, Method, h } from '@stencil/core'; -import { getOrInitFocusVisibleUtility } from '@utils/focus-visible'; +import { focusElements } from '@utils/focus-visible'; import { config } from '../../global/config'; import { getIonTheme } from '../../global/ionic-global'; @@ -24,11 +24,16 @@ export class App implements ComponentInterface { * a result of another user action. (Ex: We focus the first element * inside of a popover when the user presents it, but the popover is not always * presented as a result of keyboard action.) + * + * @param elements - The elements to set focus on. */ @Method() async setFocus(elements: HTMLElement[]) { - const focusVisible = getOrInitFocusVisibleUtility(); - focusVisible.setFocus(elements); + /** + * The focus-visible utility is used to set focus on an + * element that uses `ion-focusable`. + */ + focusElements(elements); } render() { diff --git a/core/src/utils/focus-visible.ts b/core/src/utils/focus-visible.ts index 259518789cb..3fba715defe 100644 --- a/core/src/utils/focus-visible.ts +++ b/core/src/utils/focus-visible.ts @@ -30,6 +30,22 @@ export const getOrInitFocusVisibleUtility = () => { return focusVisibleUtility; }; +/** + * Used to set focus on an element that uses `ion-focusable`. + * Do not use this if focusing the element as a result of a keyboard + * event as the focus utility should handle this for us. This method + * should be used when we want to programmatically focus an element as + * a result of another user action. (Ex: We focus the first element + * inside of a popover when the user presents it, but the popover is not always + * presented as a result of keyboard action.) + * + * @param elements - The elements to set focus on. + */ +export const focusElements = (elements: Element[]) => { + const focusVisible = getOrInitFocusVisibleUtility(); + focusVisible.setFocus(elements); +}; + export const startFocusVisible = (rootEl?: HTMLElement): FocusVisibleUtility => { let currentFocus: Element[] = []; let keyboardMode = true; diff --git a/core/src/utils/helpers.ts b/core/src/utils/helpers.ts index c57c95a70c5..292895c99d5 100644 --- a/core/src/utils/helpers.ts +++ b/core/src/utils/helpers.ts @@ -1,4 +1,5 @@ import type { EventEmitter } from '@stencil/core'; +import { focusElements } from '@utils/focus-visible'; import type { Side } from '../components/menu/menu-interface'; import { config } from '../global/config'; @@ -255,6 +256,17 @@ export const hasShadowDom = (el: HTMLElement) => { return !!el.shadowRoot && !!(el as any).attachShadow; }; +/** + * Focuses a given element while ensuring proper focus management + * within the Ionic framework. If the element is marked as `ion-focusable`, + * this function will delegate focus handling to `ion-app` or manually + * apply focus when a custom app root is used. + * + * This function helps maintain accessibility and expected focus behavior + * in both standard and custom root environments. + * + * @param el - The element to focus. + */ export const focusVisibleElement = (el: HTMLElement) => { el.focus(); @@ -267,10 +279,35 @@ export const focusVisibleElement = (el: HTMLElement) => { * which will let us explicitly set the elements to focus. */ if (el.classList.contains('ion-focusable')) { - const appRootSelector = config.get('appRootSelector', 'ion-app'); + const appRootSelector: string = config.get('appRootSelector', 'ion-app'); const app = el.closest(appRootSelector) as HTMLIonAppElement | null; if (app) { - app.setFocus([el]); + if (appRootSelector === 'ion-app') { + /** + * If the app root is the default, then it will be + * in charge of setting focus. This is because the + * focus-visible utility is attached to the app root + * and will handle setting focus on the correct element. + */ + app.setFocus([el]); + } else { + /** + * When using a custom app root selector, the focus-visible + * utility is not available to manage focus automatically. + * If we set focus immediately, the element may not be fully + * rendered or interactive, especially if it was just added + * to the DOM. Using requestAnimationFrame ensures that focus + * is applied on the next frame, allowing the DOM to settle + * before changing focus. + */ + requestAnimationFrame(() => { + /** + * The focus-visible utility is used to set focus on an + * element that uses `ion-focusable`. + */ + focusElements([el]); + }); + } } } };