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

feat: add experimental transition focus manager #29400

Merged
merged 28 commits into from May 1, 2024
Merged
Show file tree
Hide file tree
Changes from 26 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
17 changes: 17 additions & 0 deletions core/src/css/core.scss
Expand Up @@ -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 {
thetaPC marked this conversation as resolved.
Show resolved Hide resolved
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
Expand Down
10 changes: 10 additions & 0 deletions core/src/utils/config.ts
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
123 changes: 123 additions & 0 deletions 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<HTMLElement>(`[${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<HTMLElement>('main, [role="main"]');
if (content && isVisible(content)) {
moveFocus(content);
return;
}
break;
case 'heading':
const headingOne = referenceEl.querySelector<HTMLElement>('h1, [role="heading"][aria-level="1"]');
if (headingOne && isVisible(headingOne)) {
moveFocus(headingOne);
return;
}
break;
case 'banner':
const header = referenceEl.querySelector<HTMLElement>('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';
@@ -0,0 +1,63 @@
import { expect } from '@playwright/test';
import { configs, test } 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');
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');
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');
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');
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();
});
});
});
105 changes: 105 additions & 0 deletions core/src/utils/focus-controller/test/generic/index.html
@@ -0,0 +1,105 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8" />
<title>Focus Manager</title>
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet" />
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet" />
<script src="../../../../../scripts/testing/scripts.js"></script>
<script nomodule src="../../../../../dist/ionic/ionic.js"></script>
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
<script>
class PageRoot extends HTMLElement {
connectedCallback() {
this.innerHTML = `
<ion-header>
<ion-toolbar>
<h1>Root</h1>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<ion-nav-link router-direction="forward" component="page-one">
<button class="page-one">Go to Page One</button>
</ion-nav-link>
<ion-nav-link router-direction="forward" component="page-two">
<button class="page-two">Go to Page Two</button>
</ion-nav-link>
<ion-nav-link router-direction="forward" component="page-three">
<button class="page-three">Go to Page Three</button>
</ion-nav-link>
</ion-content>
`;
}
}
class PageOne extends HTMLElement {
connectedCallback() {
this.innerHTML = `
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button></ion-back-button>
</ion-buttons>
<h1>Page One</h1>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
Content
</ion-content>
`;
}
}
class PageTwo extends HTMLElement {
connectedCallback() {
this.innerHTML = `
<main class="ion-padding">
Content
</main>
`;
}
}
class PageThree extends HTMLElement {
connectedCallback() {
this.innerHTML = `
<header>
<ion-toolbar>
<ion-buttons slot="start">
<!-- Back button is hidden when not in an ion-header, so default-href makes it visible -->
<ion-back-button default-href="/"></ion-back-button>
</ion-buttons>
</ion-toolbar>
</header>
<ion-content class="ion-padding">
Content
</ion-content>
`;
}
}
customElements.define('page-root', PageRoot);
customElements.define('page-one', PageOne);
customElements.define('page-two', PageTwo);
customElements.define('page-three', PageThree);

window.Ionic = {
config: {
focusManagerPriority: ['heading', 'banner', 'content'],
},
};
</script>
</head>

<body>
<ion-app>
<ion-router>
<ion-route url="/" component="page-root"></ion-route>
<ion-route url="/page-one" component="page-one"></ion-route>
<ion-route url="/page-two" component="page-two"></ion-route>
<ion-route url="/page-three" component="page-three"></ion-route>
</ion-router>
<ion-nav></ion-nav>
</ion-app>
</body>
</html>