MOBILE-3745 tabs: Add keyboard a11y to tabs

main
Pau Ferrer Ocaña 2021-05-04 17:19:57 +02:00
parent 43ed1d9917
commit f108d0a8d8
13 changed files with 300 additions and 29 deletions

View File

@ -0,0 +1,137 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
export class CoreAriaRoleTab<T = unknown> {
componentInstance: T;
constructor(componentInstance: T) {
this.componentInstance = componentInstance;
}
/**
* A11y key functionallity that prevents keyDown events.
*
* @param e Event.
*/
keyDown(e: KeyboardEvent): void {
if (e.key == ' ' ||
e.key == 'Enter' ||
e.key == 'Home' ||
e.key == 'End' ||
(this.isHorizontal() && (e.key == 'ArrowRight' || e.key == 'ArrowLeft')) ||
(!this.isHorizontal() && (e.key == 'ArrowUp' ||e.key == 'ArrowDown'))
) {
e.preventDefault();
e.stopPropagation();
}
}
/**
* A11y key functionallity.
*
* Enter or Space: When a tab has focus, activates the tab, causing its associated panel to be displayed.
* Right Arrow: When a tab has focus: Moves focus to the next tab. If focus is on the last tab, moves focus to the first tab.
* Left Arrow: When a tab has focus: Moves focus to the previous tab. If focus is on the first tab, moves focus to the last tab.
* Home: When a tab has focus, moves focus to the first tab.
* End: When a tab has focus, moves focus to the last tab.
* https://www.w3.org/TR/wai-aria-practices-1.1/examples/tabs/tabs-2/tabs.html
*
* @param tabFindIndex Tab finable index.
* @param e Event.
* @return Promise resolved when done.
*/
keyUp(tabFindIndex: string, e: KeyboardEvent): void {
if (e.key == ' ' || e.key == 'Enter') {
this.selectTab(tabFindIndex, e);
return;
}
e.preventDefault();
e.stopPropagation();
const tabs = this.getSelectableTabs();
let index = tabs.findIndex((tab) => tabFindIndex == tab.findIndex);
const previousKey = this.isHorizontal() ? 'ArrowLeft' : 'ArrowUp';
const nextKey = this.isHorizontal() ? 'ArrowRight' : 'ArrowDown';
switch (e.key) {
case nextKey:
index++;
if (index >= tabs.length) {
index = 0;
}
break;
case 'Home':
index = 0;
break;
case previousKey:
index--;
if (index < 0) {
index = tabs.length - 1;
}
break;
case 'End':
index = tabs.length - 1;
break;
default:
return;
}
const tabId = tabs[index].id;
// @todo Pages should match aria-controls id.
const tabElement = document.querySelector<HTMLIonTabButtonElement>(`ion-tab-button[aria-controls=${tabId}]`);
tabElement?.focus();
}
/**
* Selects the tab.
*
* @param tabId Tab identifier.
* @param e Event.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
selectTab(tabId: string, e: Event): void {
//
}
/**
* Return all the selectable tabs.
*
* @returns all the selectable tabs.
*/
getSelectableTabs(): CoreAriaRoleTabFindable[] {
return [];
}
/**
* Returns if tabs are displayed horizontal or not.
*
* @returns Where the tabs are displayed horizontal.
*/
isHorizontal(): boolean {
return true;
}
}
export type CoreAriaRoleTabFindable = {
id: string;
findIndex: string;
};

View File

@ -31,6 +31,7 @@ import { Subscription } from 'rxjs';
import { Platform, Translate } from '@singletons'; import { Platform, Translate } from '@singletons';
import { CoreSettingsHelper } from '@features/settings/services/settings-helper'; import { CoreSettingsHelper } from '@features/settings/services/settings-helper';
import { CoreAriaRoleTab, CoreAriaRoleTabFindable } from './aria-role-tab';
/** /**
* Class to abstract some common code for tabs. * Class to abstract some common code for tabs.
@ -89,10 +90,13 @@ export class CoreTabsBaseComponent<T extends CoreTabBase> implements OnInit, Aft
protected slidesSwiperLoaded = false; protected slidesSwiperLoaded = false;
protected scrollElements: Record<string | number, HTMLElement> = {}; // Scroll elements for each loaded tab. protected scrollElements: Record<string | number, HTMLElement> = {}; // Scroll elements for each loaded tab.
tabAction: CoreTabsRoleTab<T>;
constructor( constructor(
protected element: ElementRef, protected element: ElementRef,
) { ) {
this.backButtonFunction = this.backButtonClicked.bind(this); this.backButtonFunction = this.backButtonClicked.bind(this);
this.tabAction = new CoreTabsRoleTab(this);
} }
/** /**
@ -632,6 +636,30 @@ export class CoreTabsBaseComponent<T extends CoreTabBase> implements OnInit, Aft
} }
/**
* Helper class to manage rol tab.
*/
class CoreTabsRoleTab<T extends CoreTabBase> extends CoreAriaRoleTab<CoreTabsBaseComponent<T>> {
/**
* @inheritdoc
*/
selectTab(tabId: string, e: Event): void {
this.componentInstance.selectTab(tabId, e);
}
/**
* @inheritdoc
*/
getSelectableTabs(): CoreAriaRoleTabFindable[] {
return this.componentInstance.tabs.filter((tab) => tab.enabled).map((tab) => ({
id: tab.id!,
findIndex: tab.id!,
}));
}
}
/** /**
* Data for each tab. * Data for each tab.
*/ */

View File

@ -7,15 +7,28 @@
</ion-col> </ion-col>
<ion-col class="ion-no-padding" size="10"> <ion-col class="ion-no-padding" size="10">
<ion-slides (ionSlideDidChange)="slideChanged()" [options]="slidesOpts" [dir]="direction" role="tablist" <ion-slides (ionSlideDidChange)="slideChanged()" [options]="slidesOpts" [dir]="direction" role="tablist"
[attr.aria-label]="description" aria-hidden="false"> [attr.aria-label]="description">
<ng-container *ngFor="let tab of tabs"> <ng-container *ngFor="let tab of tabs">
<ion-slide [hidden]="!hideUntil" [attr.aria-selected]="selected == tab.id" class="tab-slide" role="tab" <ion-slide
[attr.aria-label]="tab.title | translate" [attr.aria-controls]="tab.id" [id]="tab.id! + '-tab'" role="presentation"
[tabindex]="selected == tab.id ? null : -1"> [hidden]="!hideUntil"
<ion-tab-button (ionTabButtonClick)="selectTab(tab.id, $event)" [tab]="tab.page" [layout]="layout" [id]="tab.id! + '-tab'"
class="{{tab.class}}" [attr.aria-label]="tab.title | translate"> class="tab-slide"
<ion-icon *ngIf="tab.icon" aria-hidden="true"></ion-icon> [class.selected]="selected == tab.id">
<ion-label aria-hidden="true">{{ tab.title | translate}}</ion-label> <ion-tab-button
(ionTabButtonClick)="selectTab(tab.id, $event)"
(keydown)="tabAction.keyDown($event)"
(keyup)="tabAction.keyUp(tab.id, $event)"
[tab]="tab.page"
[layout]="layout"
class="{{tab.class}}"
role="tab"
[attr.aria-controls]="tab.id"
[attr.aria-selected]="selected == tab.id"
[tabindex]="selected == tab.id ? 0 : -1"
>
<ion-icon *ngIf="tab.icon" [name]="tab.icon" aria-hidden="true"></ion-icon>
<ion-label>{{ tab.title | translate}}</ion-label>
<ion-badge *ngIf="tab.badge">{{ tab.badge }}</ion-badge> <ion-badge *ngIf="tab.badge">{{ tab.badge }}</ion-badge>
</ion-tab-button> </ion-tab-button>
</ion-slide> </ion-slide>

View File

@ -45,7 +45,8 @@ import { CoreTabBase, CoreTabsBaseComponent } from '@classes/tabs';
* Tab contents will only be shown if that tab is selected. * Tab contents will only be shown if that tab is selected.
* *
* @todo: Test RTL and tab history. * @todo: Test RTL and tab history.
* @todo: This should behave like the split-view in relation to routing (maybe we could reuse some code from CoreItemsListManager). * @todo: This should behave like the split-view in relation to routing (maybe we could reuse some code from
* CorePageItemsListManager).
*/ */
@Component({ @Component({
selector: 'core-tabs-outlet', selector: 'core-tabs-outlet',

View File

@ -6,15 +6,30 @@
</ion-col> </ion-col>
<ion-col class="ion-no-padding" size="10"> <ion-col class="ion-no-padding" size="10">
<ion-slides (ionSlideDidChange)="slideChanged()" [options]="slidesOpts" [dir]="direction" role="tablist" <ion-slides (ionSlideDidChange)="slideChanged()" [options]="slidesOpts" [dir]="direction" role="tablist"
[attr.aria-label]="description" aria-hidden="false"> [attr.aria-label]="description">
<ng-container *ngFor="let tab of tabs"> <ng-container *ngFor="let tab of tabs">
<ion-slide *ngIf="tab.enabled" [hidden]="!hideUntil" [attr.aria-selected]="selected == tab.id" <ion-slide
class="tab-slide {{tab.class}}" role="tab" [attr.aria-label]="tab.title | translate" *ngIf="tab.enabled"
[attr.aria-controls]="tab.id" [id]="tab.id! + '-tab'" [tabindex]="selected == tab.id ? null : -1" role="presentation"
(click)="selectTab(tab.id, $event)" [attr.aria-label]="tab.title | translate"> [hidden]="!hideUntil"
class="tab-slide"
[id]="tab.id! + '-tab'"
[class.selected]="selected == tab.id">
<ion-tab-button
(click)="selectTab(tab.id, $event)"
(keydown)="tabAction.keyDown($event)"
(keyup)="tabAction.keyUp(tab.id, $event)"
class="{{tab.class}}"
[layout]="layout"
role="tab"
[attr.aria-controls]="tab.id"
[attr.aria-selected]="selected == tab.id"
[tabindex]="selected == tab.id ? 0 : -1"
>
<ion-icon *ngIf="tab.icon" [name]="tab.icon" aria-hidden="true"></ion-icon> <ion-icon *ngIf="tab.icon" [name]="tab.icon" aria-hidden="true"></ion-icon>
<ion-label aria-hidden="true">{{ tab.title | translate}}</ion-label> <ion-label>{{ tab.title | translate}}</ion-label>
<ion-badge *ngIf="tab.badge">{{ tab.badge }}</ion-badge> <ion-badge *ngIf="tab.badge">{{ tab.badge }}</ion-badge>
</ion-tab-button>
</ion-slide> </ion-slide>
</ng-container> </ng-container>
</ion-slides> </ion-slides>

View File

@ -84,6 +84,7 @@ export class CoreTabComponent implements OnInit, OnDestroy, CoreTabBase {
this.element.setAttribute('role', 'tabpanel'); this.element.setAttribute('role', 'tabpanel');
this.element.setAttribute('tabindex', '0'); this.element.setAttribute('tabindex', '0');
this.element.setAttribute('aria-hidden', 'true');
} }
/** /**
@ -113,6 +114,7 @@ export class CoreTabComponent implements OnInit, OnDestroy, CoreTabBase {
this.tabElement = this.tabElement || document.getElementById(this.id + '-tab'); this.tabElement = this.tabElement || document.getElementById(this.id + '-tab');
this.tabElement?.setAttribute('aria-selected', 'true'); this.tabElement?.setAttribute('aria-selected', 'true');
this.element.setAttribute('aria-hidden', 'false');
this.loaded = true; this.loaded = true;
this.ionSelect.emit(this); this.ionSelect.emit(this);
@ -128,6 +130,8 @@ export class CoreTabComponent implements OnInit, OnDestroy, CoreTabBase {
unselectTab(): void { unselectTab(): void {
this.tabElement?.setAttribute('aria-selected', 'false'); this.tabElement?.setAttribute('aria-selected', 'false');
this.element.classList.remove('selected'); this.element.classList.remove('selected');
this.element.setAttribute('aria-hidden', 'true');
this.showHideNavBarButtons(false); this.showHideNavBarButtons(false);
} }

View File

@ -30,6 +30,7 @@
overflow: hidden; overflow: hidden;
ion-tab-button { ion-tab-button {
max-width: 100%;
ion-label { ion-label {
font-size: 16px; font-size: 16px;
font-weight: 400; font-weight: 400;
@ -44,7 +45,8 @@
} }
} }
&[aria-selected=true] { &[aria-selected=true],
&.selected {
color: var(--color-active); color: var(--color-active);
border-bottom-color: var(--border-color-active); border-bottom-color: var(--border-color-active);
ion-tab-button { ion-tab-button {

View File

@ -45,6 +45,7 @@ import { CoreTabComponent } from './tab';
export class CoreTabsComponent extends CoreTabsBaseComponent<CoreTabComponent> implements AfterViewInit { export class CoreTabsComponent extends CoreTabsBaseComponent<CoreTabComponent> implements AfterViewInit {
@Input() parentScrollable = false; // Determine if the scroll should be in the parent content or the tab itself. @Input() parentScrollable = false; // Determine if the scroll should be in the parent content or the tab itself.
@Input() layout: 'icon-top' | 'icon-start' | 'icon-end' | 'icon-bottom' | 'icon-hide' | 'label-hide' = 'icon-hide';
@ViewChild('originalTabs') originalTabsRef?: ElementRef; @ViewChild('originalTabs') originalTabsRef?: ElementRef;

View File

@ -3,17 +3,38 @@
<ion-tab-bar slot="bottom" [hidden]="hidden"> <ion-tab-bar slot="bottom" [hidden]="hidden">
<ion-spinner *ngIf="!loaded"></ion-spinner> <ion-spinner *ngIf="!loaded"></ion-spinner>
<ion-tab-button (ionTabButtonClick)="tabClicked($event, tab.page)" [hidden]="!loaded && tab.hide" *ngFor="let tab of tabs" <ion-tab-button
[tab]="tab.page" [disabled]="tab.hide" layout="label-hide" class="{{tab.class}}" *ngFor="let tab of tabs"
[attr.aria-label]="tab.title | translate"> (click)="tabClicked($event, tab.page)"
(keydown)="tabAction.keyDown($event)"
(keyup)="tabAction.keyUp(tab.page, $event)"
[hidden]="!loaded && tab.hide"
[tab]="tab.page"
[disabled]="tab.hide"
layout="label-hide"
class="{{tab.class}}"
[tabindex]="selectedTab == tab.page ? 0 : -1"
[attr.aria-controls]="tab.id"
[attr.aria-label]="tab.title | translate"
>
<ion-icon [name]="tab.icon" aria-hidden="true"></ion-icon> <ion-icon [name]="tab.icon" aria-hidden="true"></ion-icon>
<ion-label aria-hidden="true">{{ tab.title | translate }}</ion-label> <ion-label aria-hidden="true">{{ tab.title | translate }}</ion-label>
<ion-badge *ngIf="tab.badge">{{ tab.badge }}</ion-badge> <ion-badge *ngIf="tab.badge">{{ tab.badge }}</ion-badge>
</ion-tab-button> </ion-tab-button>
<ion-tab-button (ionTabButtonClick)="tabClicked($event, morePageName)" [hidden]="!loaded" [tab]="morePageName" layout="label-hide"> <ion-tab-button
<ion-icon name="fas-bars" [attr.aria-labelledby]="'mainmenu-tab-more'"></ion-icon> (click)="tabClicked($event, morePageName)"
<ion-label id="mainmenu-tab-more">{{ 'core.more' | translate }}</ion-label> (keydown)="tabAction.keyDown($event)"
(keyup)="tabAction.keyUp(morePageName, $event)"
[hidden]="!loaded"
[tab]="morePageName"
layout="label-hide"
[tabindex]="selectedTab == morePageName ? 0 : -1"
[attr.aria-controls]="morePageName"
[attr.aria-label]="'core.more' | translate"
>
<ion-icon name="fas-bars" aria-hidden="true"></ion-icon>
<ion-label aria-hidden="true">{{ 'core.more' | translate }}</ion-label>
</ion-tab-button> </ion-tab-button>
</ion-tab-bar> </ion-tab-bar>
</ion-tabs> </ion-tabs>

View File

@ -29,7 +29,6 @@
height: 100%; height: 100%;
flex-direction: column; flex-direction: column;
ion-tab-button { ion-tab-button {
display: contents;
width: 100%; width: 100%;
ion-badge { ion-badge {
top: calc(50% - 20px); top: calc(50% - 20px);

View File

@ -25,6 +25,8 @@ import { CoreMainMenu, CoreMainMenuProvider } from '../../services/mainmenu';
import { CoreMainMenuDelegate, CoreMainMenuHandlerToDisplay } from '../../services/mainmenu-delegate'; import { CoreMainMenuDelegate, CoreMainMenuHandlerToDisplay } from '../../services/mainmenu-delegate';
import { CoreDomUtils } from '@services/utils/dom'; import { CoreDomUtils } from '@services/utils/dom';
import { Translate } from '@singletons'; import { Translate } from '@singletons';
import { CoreUtils } from '@services/utils/utils';
import { CoreAriaRoleTab, CoreAriaRoleTabFindable } from '@classes/aria-role-tab';
/** /**
* Page that displays the main menu of the app. * Page that displays the main menu of the app.
@ -40,20 +42,22 @@ export class CoreMainMenuPage implements OnInit, OnDestroy {
allHandlers?: CoreMainMenuHandlerToDisplay[]; allHandlers?: CoreMainMenuHandlerToDisplay[];
loaded = false; loaded = false;
showTabs = false; showTabs = false;
tabsPlacement = 'bottom'; tabsPlacement: 'bottom' | 'side' = 'bottom';
hidden = false; hidden = false;
morePageName = CoreMainMenuProvider.MORE_PAGE_NAME; morePageName = CoreMainMenuProvider.MORE_PAGE_NAME;
selectedTab?: string;
protected subscription?: Subscription; protected subscription?: Subscription;
protected keyboardObserver?: CoreEventObserver; protected keyboardObserver?: CoreEventObserver;
protected resizeFunction: () => void; protected resizeFunction: () => void;
protected backButtonFunction: (event: BackButtonEvent) => void; protected backButtonFunction: (event: BackButtonEvent) => void;
protected selectHistory: string[] = []; protected selectHistory: string[] = [];
protected selectedTab?: string;
protected firstSelectedTab?: string; protected firstSelectedTab?: string;
@ViewChild('mainTabs') mainTabs?: IonTabs; @ViewChild('mainTabs') mainTabs?: IonTabs;
tabAction: CoreMainMenuRoleTab;
constructor( constructor(
protected route: ActivatedRoute, protected route: ActivatedRoute,
protected changeDetector: ChangeDetectorRef, protected changeDetector: ChangeDetectorRef,
@ -61,6 +65,7 @@ export class CoreMainMenuPage implements OnInit, OnDestroy {
) { ) {
this.resizeFunction = this.initHandlers.bind(this); this.resizeFunction = this.initHandlers.bind(this);
this.backButtonFunction = this.backButtonClicked.bind(this); this.backButtonFunction = this.backButtonClicked.bind(this);
this.tabAction = new CoreMainMenuRoleTab(this);
} }
/** /**
@ -111,10 +116,11 @@ export class CoreMainMenuPage implements OnInit, OnDestroy {
const handler = handlers[i]; const handler = handlers[i];
// Check if the handler is already in the tabs list. If so, use it. // Check if the handler is already in the tabs list. If so, use it.
const tab = this.tabs.find((tab) => tab.title == handler.title && tab.icon == handler.icon); const tab = this.tabs.find((tab) => tab.page == handler.page);
tab ? tab.hide = false : null; tab ? tab.hide = false : null;
handler.hide = false; handler.hide = false;
handler.id = handler.id || 'core-mainmenu-' + CoreUtils.getUniqueId('CoreMainMenuPage');
newTabs.push(tab || handler); newTabs.push(tab || handler);
} }
@ -246,3 +252,42 @@ export class CoreMainMenuPage implements OnInit, OnDestroy {
} }
} }
/**
* Helper class to manage rol tab.
*/
class CoreMainMenuRoleTab extends CoreAriaRoleTab<CoreMainMenuPage> {
/**
* @inheritdoc
*/
selectTab(tabId: string, e: Event): void {
this.componentInstance.tabClicked(e, tabId);
}
/**
* @inheritdoc
*/
getSelectableTabs(): CoreAriaRoleTabFindable[] {
const allTabs: CoreAriaRoleTabFindable[] =
this.componentInstance.tabs.filter((tab) => !tab.hide).map((tab) => ({
id: tab.id || tab.page,
findIndex: tab.page,
}));
allTabs.push({
id: this.componentInstance.morePageName,
findIndex: this.componentInstance.morePageName,
});
return allTabs;
}
/**
* @inheritdoc
*/
isHorizontal(): boolean {
return this.componentInstance.tabsPlacement == 'bottom';
}
}

View File

@ -89,6 +89,11 @@ export interface CoreMainMenuHandlerToDisplay extends CoreDelegateToDisplay, Cor
* Hide tab. Used then resizing. * Hide tab. Used then resizing.
*/ */
hide?: boolean; hide?: boolean;
/**
* Used to control tabs.
*/
id?: string;
} }
/** /**

View File

@ -179,7 +179,7 @@ export class CoreMainMenuProvider {
* *
* @return Tabs placement including side value. * @return Tabs placement including side value.
*/ */
getTabPlacement(): string { getTabPlacement(): 'bottom' | 'side' {
const tablet = !!(window.innerWidth && window.innerWidth >= 576 && (window.innerHeight >= 576 || const tablet = !!(window.innerWidth && window.innerWidth >= 576 && (window.innerHeight >= 576 ||
((CoreApp.isKeyboardVisible() || CoreApp.isKeyboardOpening()) && window.innerHeight >= 200))); ((CoreApp.isKeyboardVisible() || CoreApp.isKeyboardOpening()) && window.innerHeight >= 200)));