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 { CoreSettingsHelper } from '@features/settings/services/settings-helper';
import { CoreAriaRoleTab, CoreAriaRoleTabFindable } from './aria-role-tab';
/**
* 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 scrollElements: Record<string | number, HTMLElement> = {}; // Scroll elements for each loaded tab.
tabAction: CoreTabsRoleTab<T>;
constructor(
protected element: ElementRef,
) {
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.
*/

View File

@ -7,15 +7,28 @@
</ion-col>
<ion-col class="ion-no-padding" size="10">
<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">
<ion-slide [hidden]="!hideUntil" [attr.aria-selected]="selected == tab.id" class="tab-slide" role="tab"
[attr.aria-label]="tab.title | translate" [attr.aria-controls]="tab.id" [id]="tab.id! + '-tab'"
[tabindex]="selected == tab.id ? null : -1">
<ion-tab-button (ionTabButtonClick)="selectTab(tab.id, $event)" [tab]="tab.page" [layout]="layout"
class="{{tab.class}}" [attr.aria-label]="tab.title | translate">
<ion-icon *ngIf="tab.icon" aria-hidden="true"></ion-icon>
<ion-label aria-hidden="true">{{ tab.title | translate}}</ion-label>
<ion-slide
role="presentation"
[hidden]="!hideUntil"
[id]="tab.id! + '-tab'"
class="tab-slide"
[class.selected]="selected == tab.id">
<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-tab-button>
</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.
*
* @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({
selector: 'core-tabs-outlet',

View File

@ -6,15 +6,30 @@
</ion-col>
<ion-col class="ion-no-padding" size="10">
<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">
<ion-slide *ngIf="tab.enabled" [hidden]="!hideUntil" [attr.aria-selected]="selected == tab.id"
class="tab-slide {{tab.class}}" role="tab" [attr.aria-label]="tab.title | translate"
[attr.aria-controls]="tab.id" [id]="tab.id! + '-tab'" [tabindex]="selected == tab.id ? null : -1"
(click)="selectTab(tab.id, $event)" [attr.aria-label]="tab.title | translate">
<ion-icon *ngIf="tab.icon" [name]="tab.icon" aria-hidden="true"></ion-icon>
<ion-label aria-hidden="true">{{ tab.title | translate}}</ion-label>
<ion-badge *ngIf="tab.badge">{{ tab.badge }}</ion-badge>
<ion-slide
*ngIf="tab.enabled"
role="presentation"
[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-label>{{ tab.title | translate}}</ion-label>
<ion-badge *ngIf="tab.badge">{{ tab.badge }}</ion-badge>
</ion-tab-button>
</ion-slide>
</ng-container>
</ion-slides>

View File

@ -84,6 +84,7 @@ export class CoreTabComponent implements OnInit, OnDestroy, CoreTabBase {
this.element.setAttribute('role', 'tabpanel');
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?.setAttribute('aria-selected', 'true');
this.element.setAttribute('aria-hidden', 'false');
this.loaded = true;
this.ionSelect.emit(this);
@ -128,6 +130,8 @@ export class CoreTabComponent implements OnInit, OnDestroy, CoreTabBase {
unselectTab(): void {
this.tabElement?.setAttribute('aria-selected', 'false');
this.element.classList.remove('selected');
this.element.setAttribute('aria-hidden', 'true');
this.showHideNavBarButtons(false);
}

View File

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

View File

@ -45,6 +45,7 @@ import { CoreTabComponent } from './tab';
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() layout: 'icon-top' | 'icon-start' | 'icon-end' | 'icon-bottom' | 'icon-hide' | 'label-hide' = 'icon-hide';
@ViewChild('originalTabs') originalTabsRef?: ElementRef;

View File

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

View File

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

View File

@ -25,6 +25,8 @@ import { CoreMainMenu, CoreMainMenuProvider } from '../../services/mainmenu';
import { CoreMainMenuDelegate, CoreMainMenuHandlerToDisplay } from '../../services/mainmenu-delegate';
import { CoreDomUtils } from '@services/utils/dom';
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.
@ -40,20 +42,22 @@ export class CoreMainMenuPage implements OnInit, OnDestroy {
allHandlers?: CoreMainMenuHandlerToDisplay[];
loaded = false;
showTabs = false;
tabsPlacement = 'bottom';
tabsPlacement: 'bottom' | 'side' = 'bottom';
hidden = false;
morePageName = CoreMainMenuProvider.MORE_PAGE_NAME;
selectedTab?: string;
protected subscription?: Subscription;
protected keyboardObserver?: CoreEventObserver;
protected resizeFunction: () => void;
protected backButtonFunction: (event: BackButtonEvent) => void;
protected selectHistory: string[] = [];
protected selectedTab?: string;
protected firstSelectedTab?: string;
@ViewChild('mainTabs') mainTabs?: IonTabs;
tabAction: CoreMainMenuRoleTab;
constructor(
protected route: ActivatedRoute,
protected changeDetector: ChangeDetectorRef,
@ -61,6 +65,7 @@ export class CoreMainMenuPage implements OnInit, OnDestroy {
) {
this.resizeFunction = this.initHandlers.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];
// 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;
handler.hide = false;
handler.id = handler.id || 'core-mainmenu-' + CoreUtils.getUniqueId('CoreMainMenuPage');
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?: boolean;
/**
* Used to control tabs.
*/
id?: string;
}
/**

View File

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