MOBILE-3745 tabs: Add keyboard a11y to tabs
parent
43ed1d9917
commit
f108d0a8d8
|
@ -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;
|
||||
};
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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-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 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-tab-button>
|
||||
</ion-slide>
|
||||
</ng-container>
|
||||
</ion-slides>
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -29,7 +29,6 @@
|
|||
height: 100%;
|
||||
flex-direction: column;
|
||||
ion-tab-button {
|
||||
display: contents;
|
||||
width: 100%;
|
||||
ion-badge {
|
||||
top: calc(50% - 20px);
|
||||
|
|
|
@ -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';
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -89,6 +89,11 @@ export interface CoreMainMenuHandlerToDisplay extends CoreDelegateToDisplay, Cor
|
|||
* Hide tab. Used then resizing.
|
||||
*/
|
||||
hide?: boolean;
|
||||
|
||||
/**
|
||||
* Used to control tabs.
|
||||
*/
|
||||
id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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)));
|
||||
|
||||
|
|
Loading…
Reference in New Issue