-
Notifications
You must be signed in to change notification settings - Fork 13.4k
feat: add experimental transition focus manager #29400
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
Merged
Merged
Changes from 25 commits
Commits
Show all changes
28 commits
Select commit
Hold shift + click to select a range
078436b
Add POC
liamdebeasi 7ad8f64
remove test code
liamdebeasi 1c02e02
lint
liamdebeasi 4445247
integrate with config
liamdebeasi 40c01eb
TEMP content fix
liamdebeasi a40ee3a
clean up
liamdebeasi 1c07d3a
test
liamdebeasi 847c82b
fix: wait for page to render
liamdebeasi 8fa6776
remove log
liamdebeasi fa730de
chore: sync
liamdebeasi a0442a9
chore: sync
liamdebeasi 1009126
update comment
liamdebeasi 5d85377
comments and lint
liamdebeasi 92210d1
more coments
liamdebeasi 4e0e7d3
rename
liamdebeasi 10082dc
comments
liamdebeasi b38499b
clean up
liamdebeasi 3f64b53
more clean up
liamdebeasi dc8190a
new line
liamdebeasi 02dc77b
refactor: use controller approach
liamdebeasi d3fbe05
Merge branch 'feature-8.1' into FW-2094
liamdebeasi 12a9392
clean up
liamdebeasi 7ec1040
revert template changes
liamdebeasi 97dd187
add tests
liamdebeasi 057a557
outline ring does not show when focusing
liamdebeasi 2c84f63
Update index.ts
liamdebeasi 49681e5
chore: lint
liamdebeasi 25b1743
fix types
liamdebeasi File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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'; |
63 changes: 63 additions & 0 deletions
63
core/src/utils/focus-controller/test/generic/focus-controller.e2e.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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
105
core/src/utils/focus-controller/test/generic/index.html
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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> |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.