From f108d0a8d82181750561db980eda0a8b0b7d6e07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Tue, 4 May 2021 17:19:57 +0200 Subject: [PATCH] MOBILE-3745 tabs: Add keyboard a11y to tabs --- src/core/classes/aria-role-tab.ts | 137 ++++++++++++++++++ src/core/classes/tabs.ts | 28 ++++ .../tabs-outlet/core-tabs-outlet.html | 29 +++- .../components/tabs-outlet/tabs-outlet.ts | 3 +- src/core/components/tabs/core-tabs.html | 31 +++- src/core/components/tabs/tab.ts | 4 + src/core/components/tabs/tabs.scss | 4 +- src/core/components/tabs/tabs.ts | 1 + .../features/mainmenu/pages/menu/menu.html | 33 ++++- .../features/mainmenu/pages/menu/menu.scss | 1 - src/core/features/mainmenu/pages/menu/menu.ts | 51 ++++++- .../mainmenu/services/mainmenu-delegate.ts | 5 + .../features/mainmenu/services/mainmenu.ts | 2 +- 13 files changed, 300 insertions(+), 29 deletions(-) create mode 100644 src/core/classes/aria-role-tab.ts diff --git a/src/core/classes/aria-role-tab.ts b/src/core/classes/aria-role-tab.ts new file mode 100644 index 000000000..12c54b8c6 --- /dev/null +++ b/src/core/classes/aria-role-tab.ts @@ -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 { + + 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(`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; +}; diff --git a/src/core/classes/tabs.ts b/src/core/classes/tabs.ts index 6338fc65d..190e754f4 100644 --- a/src/core/classes/tabs.ts +++ b/src/core/classes/tabs.ts @@ -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 implements OnInit, Aft protected slidesSwiperLoaded = false; protected scrollElements: Record = {}; // Scroll elements for each loaded tab. + tabAction: CoreTabsRoleTab; + constructor( protected element: ElementRef, ) { this.backButtonFunction = this.backButtonClicked.bind(this); + this.tabAction = new CoreTabsRoleTab(this); } /** @@ -632,6 +636,30 @@ export class CoreTabsBaseComponent implements OnInit, Aft } +/** + * Helper class to manage rol tab. + */ +class CoreTabsRoleTab extends CoreAriaRoleTab> { + + /** + * @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. */ diff --git a/src/core/components/tabs-outlet/core-tabs-outlet.html b/src/core/components/tabs-outlet/core-tabs-outlet.html index e20d9a1ea..4662bb224 100644 --- a/src/core/components/tabs-outlet/core-tabs-outlet.html +++ b/src/core/components/tabs-outlet/core-tabs-outlet.html @@ -7,15 +7,28 @@ + [attr.aria-label]="description"> - - - - + + + + {{ tab.title | translate}} {{ tab.badge }} diff --git a/src/core/components/tabs-outlet/tabs-outlet.ts b/src/core/components/tabs-outlet/tabs-outlet.ts index cb256592b..7c82bdd88 100644 --- a/src/core/components/tabs-outlet/tabs-outlet.ts +++ b/src/core/components/tabs-outlet/tabs-outlet.ts @@ -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', diff --git a/src/core/components/tabs/core-tabs.html b/src/core/components/tabs/core-tabs.html index fbba11446..c15340c3d 100644 --- a/src/core/components/tabs/core-tabs.html +++ b/src/core/components/tabs/core-tabs.html @@ -6,15 +6,30 @@ + [attr.aria-label]="description"> - - - - {{ tab.badge }} + + + + {{ tab.title | translate}} + {{ tab.badge }} + diff --git a/src/core/components/tabs/tab.ts b/src/core/components/tabs/tab.ts index 24726538a..dcd9c1f71 100644 --- a/src/core/components/tabs/tab.ts +++ b/src/core/components/tabs/tab.ts @@ -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); } diff --git a/src/core/components/tabs/tabs.scss b/src/core/components/tabs/tabs.scss index 52e8a9074..93ce49ca6 100644 --- a/src/core/components/tabs/tabs.scss +++ b/src/core/components/tabs/tabs.scss @@ -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 { diff --git a/src/core/components/tabs/tabs.ts b/src/core/components/tabs/tabs.ts index a06e28af0..e5c4f778e 100644 --- a/src/core/components/tabs/tabs.ts +++ b/src/core/components/tabs/tabs.ts @@ -45,6 +45,7 @@ import { CoreTabComponent } from './tab'; export class CoreTabsComponent extends CoreTabsBaseComponent 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; diff --git a/src/core/features/mainmenu/pages/menu/menu.html b/src/core/features/mainmenu/pages/menu/menu.html index 060d29bb6..5b4b064f7 100644 --- a/src/core/features/mainmenu/pages/menu/menu.html +++ b/src/core/features/mainmenu/pages/menu/menu.html @@ -3,17 +3,38 @@ - + {{ tab.badge }} - - - {{ 'core.more' | translate }} + + + diff --git a/src/core/features/mainmenu/pages/menu/menu.scss b/src/core/features/mainmenu/pages/menu/menu.scss index adeec2ef0..151bea4ec 100644 --- a/src/core/features/mainmenu/pages/menu/menu.scss +++ b/src/core/features/mainmenu/pages/menu/menu.scss @@ -29,7 +29,6 @@ height: 100%; flex-direction: column; ion-tab-button { - display: contents; width: 100%; ion-badge { top: calc(50% - 20px); diff --git a/src/core/features/mainmenu/pages/menu/menu.ts b/src/core/features/mainmenu/pages/menu/menu.ts index 3d2005371..36a197501 100644 --- a/src/core/features/mainmenu/pages/menu/menu.ts +++ b/src/core/features/mainmenu/pages/menu/menu.ts @@ -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 { + + /** + * @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'; + } + +} diff --git a/src/core/features/mainmenu/services/mainmenu-delegate.ts b/src/core/features/mainmenu/services/mainmenu-delegate.ts index 1fb88092a..862bb5b55 100644 --- a/src/core/features/mainmenu/services/mainmenu-delegate.ts +++ b/src/core/features/mainmenu/services/mainmenu-delegate.ts @@ -89,6 +89,11 @@ export interface CoreMainMenuHandlerToDisplay extends CoreDelegateToDisplay, Cor * Hide tab. Used then resizing. */ hide?: boolean; + + /** + * Used to control tabs. + */ + id?: string; } /** diff --git a/src/core/features/mainmenu/services/mainmenu.ts b/src/core/features/mainmenu/services/mainmenu.ts index 76814de46..aa602d7d4 100644 --- a/src/core/features/mainmenu/services/mainmenu.ts +++ b/src/core/features/mainmenu/services/mainmenu.ts @@ -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)));