By default, a screen reader like VoiceOver for IOS and TalkBack for Android will read the closest element when a user transitions from one page to another. This is really a fallback behavior of the screen reader when the focus is removed and it is unsure of where the focus should go.
This behavior can be changed so that the screen reader will read a chosen element (such as the first element on the page) using some code and markup.
Implementing
The following Angular service can be pasted into your project. You will need to call the init method on the startup of your application.
import { Injectable } from '@angular/core';
import { NavigationEnd, Router, RouterEvent } from '@angular/router';
import { Capacitor } from '@capacitor/core';
import { ScreenReader } from '@capacitor/screen-reader';
@Injectable({ providedIn: 'root'})
export class RouterFocusService {
constructor(private router: Router) {}
public init() {
this.router.events.subscribe((event: RouterEvent) => this.focusFirst(event));
}
private async focusFirst(event: RouterEvent) {
if (!(event instanceof NavigationEnd) || !ScreenReader.isEnabled()) {
return;
}
// This prevents reading of previously focused element
await ScreenReader.speak({ value: ' ' });
// We look for an element on an ion-content that we want to focus
const all = document.getElementsByClassName('page-focus');
// We repeatedly look as the previous page will eventually disappear and the new one will animate in
let repeat = true;
let e: Element;
while (repeat) {
let count = 0;
for (let i = 0, max = all.length; i < max; i++) {
if (this.getVisible(all[i] as HTMLElement)) {
count++;
e = all[i];
}
}
repeat = (count > 1);
if (repeat) {
await this.delay(100);
}
}
// We need to set tabindex to -1 and focus the element for the screen reader to read what we want
(e as HTMLElement).setAttribute('tabindex', '-1');
if (Capacitor.isNativePlatform()) {
// This will prevent the visual change for keyboard
(e as HTMLElement).setAttribute('outline', 'none');
}
(e as HTMLElement).focus();
}
private delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
private getVisible(el: HTMLElement): boolean {
// look for parent elements with class ion-page and see if ion-page-hidden class is present
let e = el;
while (e) {
if (e.classList.contains('ion-page')) {
return !e.classList.contains('ion-page-hidden');
}
e = e.parentElement;
}
}
}
A sample project using this technique can be found here.
Where To Focus
The code above will look for any page that has a element on it with the class page-focus. For example:
<h1 class="page-focus">My Title</h1>
When the user visits a page it will focus on this element and the screen reader will announce it. Without this change it would read the closest element to where you pressed last.
So, after implementing this focusing feature you will need to edit each page in your app and mark a suitable element with the class page-focus.
Other Quirks
You will notice this line in the code:
await ScreenReader.speak({ value: ' ' });
This is a workaround for a webkit bug where VoiceOver will announce elements from the prior page when navigating to a new page.
You will notice this code:
if (Capacitor.isNativePlatform()) {
(e as HTMLElement).setAttribute('outline', 'none');
}
This code is prevents an additional outline around elements associated with keyboard focus (on iOS and Android only). If you expect that on mobile device a user may use a bluetooth keyboard then you can remove this line.
Finally, you'll notice that before focusing we repeated check to see if there is only one visible ion-page , this is done so that animations complete and we focus on the element in the page we transition to.
This solution is brittle, in the sense that it relies on HTML elements of particular types (ion-page), with certain classes (ion-page-hidden, page-focus) and timing (waiting for state to settle). This is why it is written as a article with code you can copy rather than a library that can be installed. The implementation may be dependent on your project.
Comments
0 comments
Please sign in to leave a comment.