From 5b686efc1025cd4088c89ef29154311a3d7504ba Mon Sep 17 00:00:00 2001 From: Liam DeBeasi Date: Wed, 1 May 2024 10:09:05 -0400 Subject: [PATCH] feat: add experimental transition focus manager (#29400) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue number: resolves #23650 --------- ## What is the current behavior? In traditional native applications, navigation will inform screen readers that the view has changed. This allows screen readers to focus the correct view. In a single page app on the web, this same concept does not exist. As a result, transitioning from Page A to Page B results in screen reader focus remaining on Page A. This means that users who rely on screen readers are not informed of view changes. Currently, developers are responsible for implementing this on their own. ## What is the new behavior? - Introduces a new focus manager priority global config. When defined, the app developer can specify which area of the view focus should be moved to when the transition ends. The developer does this by specifying areas in order of priority which allows for fallbacks in the event that a particular UI component (such as a header) does not exist on a view. There is some risk here by managing focus for the application. As a result, this feature is considered experimental and disabled by default. The team should collect feedback based on usage and enable it by default when they feel this feature is stable enough. ## Does this introduce a breaking change? - [ ] Yes - [x] No ## Other information ⚠️ Due to the `tsconfig.json` change, reviewers should restart the Stencil dev server when checking out these changes locally. Reviewers: Please test both of the test template files on physical iOS and Android device with VoiceOver and TalkBack enabled, respectively. Docs Link: https://github.com/ionic-team/ionic-docs/pull/3627 --- core/src/css/core.scss | 17 +++ core/src/utils/config.ts | 10 ++ core/src/utils/focus-controller/index.ts | 123 ++++++++++++++++++ .../test/generic/focus-controller.e2e.ts | 64 +++++++++ .../focus-controller/test/generic/index.html | 105 +++++++++++++++ .../test/ionic/focus-controller.e2e.ts | 64 +++++++++ .../focus-controller/test/ionic/index.html | 104 +++++++++++++++ core/src/utils/transition/index.ts | 14 +- core/tsconfig.json | 3 +- 9 files changed, 502 insertions(+), 2 deletions(-) create mode 100644 core/src/utils/focus-controller/index.ts create mode 100644 core/src/utils/focus-controller/test/generic/focus-controller.e2e.ts create mode 100644 core/src/utils/focus-controller/test/generic/index.html create mode 100644 core/src/utils/focus-controller/test/ionic/focus-controller.e2e.ts create mode 100644 core/src/utils/focus-controller/test/ionic/index.html 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": [