diff --git a/core/src/css/core.scss b/core/src/css/core.scss index ae76ad3ad9..e0d8f7fa41 100644 --- a/core/src/css/core.scss +++ b/core/src/css/core.scss @@ -395,6 +395,23 @@ ion-input input::-webkit-date-and-time-value { } /** + * When moving focus on page transitions we call .focus() on an element which can + * add an undesired outline ring. This CSS removes the outline ring. + * We also remove the outline ring from elements that are actively being focused + * by the focus manager. We are intentionally selective about which elements this + * applies to so we do not accidentally override outlines set by the developer. + */ +[ion-last-focus], +header[tabindex="-1"]:focus, +[role="banner"][tabindex="-1"]:focus, +main[tabindex="-1"]:focus, +[role="main"][tabindex="-1"]:focus, +h1[tabindex="-1"]:focus, +[role="heading"][aria-level="1"][tabindex="-1"]:focus { + outline: none; +} + +/* * If a popover has a child ion-content (or class equivalent) then the .popover-viewport element * should not be scrollable to ensure the inner content does scroll. However, if the popover * does not have a child ion-content (or class equivalent) then the .popover-viewport element diff --git a/core/src/utils/config.ts b/core/src/utils/config.ts index ed2c4f42ae..e38d43beb4 100644 --- a/core/src/utils/config.ts +++ b/core/src/utils/config.ts @@ -204,6 +204,14 @@ export interface IonicConfig { */ platform?: PlatformConfig; + /** + * @experimental + * When defined, Ionic will move focus to the appropriate element after each + * page transition. This ensures that users relying on assistive technology + * are informed when a page transition happens. + */ + focusManagerPriority?: FocusManagerPriority[]; + /** * @experimental * If `true`, the [CloseWatcher API](https://github.com/WICG/close-watcher) will be used to handle @@ -231,6 +239,8 @@ export interface IonicConfig { _ce?: (eventName: string, opts: any) => any; } +type FocusManagerPriority = 'content' | 'heading' | 'banner'; + export const setupConfig = (config: IonicConfig) => { const win = window as any; const Ionic = win.Ionic; diff --git a/core/src/utils/focus-controller/index.ts b/core/src/utils/focus-controller/index.ts new file mode 100644 index 0000000000..762de335ee --- /dev/null +++ b/core/src/utils/focus-controller/index.ts @@ -0,0 +1,123 @@ +import { config } from '@global/config'; +import { printIonWarning } from '@utils/logging'; + +/** + * Moves focus to a specified element. Note that we do not remove the tabindex + * because that can result in an unintentional blur. Non-focusables can't be + * focused, so the body will get focused again. + */ +const moveFocus = (el: HTMLElement) => { + el.tabIndex = -1; + el.focus(); +}; + +/** + * Elements that are hidden using `display: none` should not be focused even if + * they are present in the DOM. + */ +const isVisible = (el: HTMLElement) => { + return el.offsetParent !== null; +}; + +/** + * The focus controller allows us to manage focus within a view so assistive + * technologies can inform users of changes to the navigation state. Traditional + * native apps have a way of informing assistive technology about a navigation + * state change. Mobile browsers have this too, but only when doing a full page + * load. In a single page app we do not do that, so we need to build this + * integration ourselves. + */ +export const createFocusController = (): FocusController => { + const saveViewFocus = (referenceEl?: HTMLElement) => { + const focusManagerEnabled = config.get('focusManagerPriority', false); + + /** + * When going back to a previously visited page focus should typically be moved + * back to the element that was last focused when the user was on this view. + */ + if (focusManagerEnabled) { + const activeEl = document.activeElement; + if (activeEl !== null && referenceEl?.contains(activeEl)) { + activeEl.setAttribute(LAST_FOCUS, 'true'); + } + } + }; + + const setViewFocus = (referenceEl: HTMLElement) => { + const focusManagerPriorities = config.get('focusManagerPriority', false); + /** + * If the focused element is a descendant of the referenceEl then it's possible + * that the app developer manually moved focus, so we do not want to override that. + * This can happen with inputs the are focused when a view transitions in. + */ + if (Array.isArray(focusManagerPriorities) && !referenceEl.contains(document.activeElement)) { + /** + * When going back to a previously visited view focus should always be moved back + * to the element that the user was last focused on when they were on this view. + */ + const lastFocus = referenceEl.querySelector(`[${LAST_FOCUS}]`); + if (lastFocus && isVisible(lastFocus)) { + moveFocus(lastFocus); + return; + } + + for (const priority of focusManagerPriorities) { + /** + * For each recognized case (excluding the default case) make sure to return + * so that the fallback focus behavior does not run. + * + * We intentionally query for specific roles/semantic elements so that the + * transition manager can work with both Ionic and non-Ionic UI components. + * + * If new selectors are added, be sure to remove the outline ring by adding + * new selectors to rule in core.scss. + */ + switch (priority) { + case 'content': + const content = referenceEl.querySelector('main, [role="main"]'); + if (content && isVisible(content)) { + moveFocus(content); + return; + } + break; + case 'heading': + const headingOne = referenceEl.querySelector('h1, [role="heading"][aria-level="1"]'); + if (headingOne && isVisible(headingOne)) { + moveFocus(headingOne); + return; + } + break; + case 'banner': + const header = referenceEl.querySelector('header, [role="banner"]'); + if (header && isVisible(header)) { + moveFocus(header); + return; + } + break; + default: + printIonWarning(`Unrecognized focus manager priority value ${priority}`); + break; + } + } + + /** + * If there is nothing to focus then focus the page so focus at least moves to + * the correct view. The browser will then determine where within the page to + * move focus to. + */ + moveFocus(referenceEl); + } + }; + + return { + saveViewFocus, + setViewFocus, + }; +}; + +export type FocusController = { + saveViewFocus: (referenceEl?: HTMLElement) => void; + setViewFocus: (referenceEl: HTMLElement) => void; +}; + +const LAST_FOCUS = 'ion-last-focus'; diff --git a/core/src/utils/focus-controller/test/generic/focus-controller.e2e.ts b/core/src/utils/focus-controller/test/generic/focus-controller.e2e.ts new file mode 100644 index 0000000000..dd10bd5990 --- /dev/null +++ b/core/src/utils/focus-controller/test/generic/focus-controller.e2e.ts @@ -0,0 +1,64 @@ +import { expect } from '@playwright/test'; +import { configs, test } from '@utils/test/playwright'; +import type { E2ELocator } from '@utils/test/playwright'; + +configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { + test.describe(title('focus controller: generic components'), () => { + test.beforeEach(async ({ page }) => { + await page.goto('/src/utils/focus-controller/test/generic', config); + }); + test('should focus heading', async ({ page }) => { + const goToPageOneButton = page.locator('page-root button.page-one'); + const nav = page.locator('ion-nav') as E2ELocator; + const ionNavDidChange = await (nav as any).spyOnEvent('ionNavDidChange'); + + // Focus heading on Page One + await goToPageOneButton.click(); + await ionNavDidChange.next(); + + const pageOneTitle = page.locator('page-one h1'); + await expect(pageOneTitle).toBeFocused(); + }); + + test('should focus banner', async ({ page }) => { + const goToPageThreeButton = page.locator('page-root button.page-three'); + const nav = page.locator('ion-nav') as E2ELocator; + const ionNavDidChange = await (nav as any).spyOnEvent('ionNavDidChange'); + + const pageThreeHeader = page.locator('page-three header'); + await goToPageThreeButton.click(); + await ionNavDidChange.next(); + + await expect(pageThreeHeader).toBeFocused(); + }); + + test('should focus content', async ({ page }) => { + const goToPageTwoButton = page.locator('page-root button.page-two'); + const nav = page.locator('ion-nav') as E2ELocator; + const ionNavDidChange = await (nav as any).spyOnEvent('ionNavDidChange'); + const pageTwoContent = page.locator('page-two main'); + + await goToPageTwoButton.click(); + await ionNavDidChange.next(); + + await expect(pageTwoContent).toBeFocused(); + }); + + test('should return focus when going back', async ({ page, browserName }) => { + test.skip(browserName === 'webkit', 'Desktop Safari does not consider buttons to be focusable'); + + const goToPageOneButton = page.locator('page-root button.page-one'); + const nav = page.locator('ion-nav') as E2ELocator; + const ionNavDidChange = await (nav as any).spyOnEvent('ionNavDidChange'); + const pageOneBackButton = page.locator('page-one ion-back-button'); + + await goToPageOneButton.click(); + await ionNavDidChange.next(); + + await pageOneBackButton.click(); + await ionNavDidChange.next(); + + await expect(goToPageOneButton).toBeFocused(); + }); + }); +}); diff --git a/core/src/utils/focus-controller/test/generic/index.html b/core/src/utils/focus-controller/test/generic/index.html new file mode 100644 index 0000000000..c39a834d3c --- /dev/null +++ b/core/src/utils/focus-controller/test/generic/index.html @@ -0,0 +1,105 @@ + + + + + Focus Manager + + + + + + + + + + + + + + + + + + + + + diff --git a/core/src/utils/focus-controller/test/ionic/focus-controller.e2e.ts b/core/src/utils/focus-controller/test/ionic/focus-controller.e2e.ts new file mode 100644 index 0000000000..8b576872b8 --- /dev/null +++ b/core/src/utils/focus-controller/test/ionic/focus-controller.e2e.ts @@ -0,0 +1,64 @@ +import { expect } from '@playwright/test'; +import { configs, test } from '@utils/test/playwright'; +import type { E2ELocator } from '@utils/test/playwright'; + +configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { + test.describe(title('focus controller: ionic components'), () => { + test.beforeEach(async ({ page }) => { + await page.goto('/src/utils/focus-controller/test/ionic', config); + }); + test('should focus heading', async ({ page }) => { + const goToPageOneButton = page.locator('page-root ion-button.page-one'); + const nav = page.locator('ion-nav') as E2ELocator; + const ionNavDidChange = await (nav as any).spyOnEvent('ionNavDidChange'); + + // Focus heading on Page One + await goToPageOneButton.click(); + await ionNavDidChange.next(); + + const pageOneTitle = page.locator('page-one ion-title'); + await expect(pageOneTitle).toBeFocused(); + }); + + test('should focus banner', async ({ page }) => { + const goToPageThreeButton = page.locator('page-root ion-button.page-three'); + const nav = page.locator('ion-nav') as E2ELocator; + const ionNavDidChange = await (nav as any).spyOnEvent('ionNavDidChange'); + + const pageThreeHeader = page.locator('page-three ion-header'); + await goToPageThreeButton.click(); + await ionNavDidChange.next(); + + await expect(pageThreeHeader).toBeFocused(); + }); + + test('should focus content', async ({ page }) => { + const goToPageTwoButton = page.locator('page-root ion-button.page-two'); + const nav = page.locator('ion-nav') as E2ELocator; + const ionNavDidChange = await (nav as any).spyOnEvent('ionNavDidChange'); + const pageTwoContent = page.locator('page-two ion-content'); + + await goToPageTwoButton.click(); + await ionNavDidChange.next(); + + await expect(pageTwoContent).toBeFocused(); + }); + + test('should return focus when going back', async ({ page, browserName }) => { + test.skip(browserName === 'webkit', 'Desktop Safari does not consider buttons to be focusable'); + + const goToPageOneButton = page.locator('page-root ion-button.page-one'); + const nav = page.locator('ion-nav') as E2ELocator; + const ionNavDidChange = await (nav as any).spyOnEvent('ionNavDidChange'); + const pageOneBackButton = page.locator('page-one ion-back-button'); + + await goToPageOneButton.click(); + await ionNavDidChange.next(); + + await pageOneBackButton.click(); + await ionNavDidChange.next(); + + await expect(goToPageOneButton).toBeFocused(); + }); + }); +}); diff --git a/core/src/utils/focus-controller/test/ionic/index.html b/core/src/utils/focus-controller/test/ionic/index.html new file mode 100644 index 0000000000..e0afb2a632 --- /dev/null +++ b/core/src/utils/focus-controller/test/ionic/index.html @@ -0,0 +1,104 @@ + + + + + Focus Manager + + + + + + + + + + + + + + + + + + + + + diff --git a/core/src/utils/transition/index.ts b/core/src/utils/transition/index.ts index 5600c5cf3f..69789bf862 100644 --- a/core/src/utils/transition/index.ts +++ b/core/src/utils/transition/index.ts @@ -1,3 +1,4 @@ +import { config } from '@global/config'; import { Build, writeTask } from '@stencil/core'; import { @@ -8,10 +9,12 @@ import { } from '../../components/nav/constants'; import type { NavOptions, NavDirection } from '../../components/nav/nav-interface'; import type { Animation, AnimationBuilder } from '../animation/animation-interface'; +import { createFocusController } from '../focus-controller'; import { raf } from '../helpers'; const iosTransitionAnimation = () => import('./ios.transition'); const mdTransitionAnimation = () => import('./md.transition'); +const focusController = createFocusController(); // TODO(FW-2832): types @@ -40,6 +43,8 @@ const beforeTransition = (opts: TransitionOptions) => { const enteringEl = opts.enteringEl; const leavingEl = opts.leavingEl; + focusController.saveViewFocus(leavingEl); + setZIndex(enteringEl, leavingEl, opts.direction); if (opts.showGoBack) { @@ -80,6 +85,8 @@ const afterTransition = (opts: TransitionOptions) => { leavingEl.classList.remove('ion-page-invisible'); leavingEl.style.removeProperty('pointer-events'); } + + focusController.setViewFocus(enteringEl); }; const getAnimationBuilder = async (opts: TransitionOptions): Promise => { @@ -125,8 +132,13 @@ const animation = async (animationBuilder: AnimationBuilder, opts: TransitionOpt const noAnimation = async (opts: TransitionOptions): Promise => { const enteringEl = opts.enteringEl; const leavingEl = opts.leavingEl; + const focusManagerEnabled = config.get('focusManagerPriority', false); - await waitForReady(opts, false); + /** + * If the focus manager is enabled then we need to wait for Ionic components to be + * rendered otherwise the component to focus may not be focused because it is hidden. + */ + await waitForReady(opts, focusManagerEnabled); fireWillEvents(enteringEl, leavingEl); fireDidEvents(enteringEl, leavingEl); diff --git a/core/tsconfig.json b/core/tsconfig.json index 2874f056cc..acdd409400 100644 --- a/core/tsconfig.json +++ b/core/tsconfig.json @@ -31,7 +31,8 @@ "baseUrl": ".", "paths": { "@utils/*": ["src/utils/*"], - "@utils/test": ["src/utils/test/utils"] + "@utils/test": ["src/utils/test/utils"], + "@global/*": ["src/global/*"] } }, "include": [