From a678520de474fa1943f1560d2bf14ab27ac8a3c0 Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Mon, 13 Dec 2021 17:05:16 +0100 Subject: [PATCH] Implemented tab activation, keyboard support for a11y --- .../Core/Ui/Page/Menu/Container.ts | 9 ++ ts/WoltLabSuite/Core/Ui/Page/Menu/Main.ts | 4 + ts/WoltLabSuite/Core/Ui/Page/Menu/Provider.ts | 2 + ts/WoltLabSuite/Core/Ui/Page/Menu/User.ts | 95 +++++++++++++++++++ .../Core/Ui/Page/Menu/Container.js | 7 ++ .../js/WoltLabSuite/Core/Ui/Page/Menu/Main.js | 3 + .../js/WoltLabSuite/Core/Ui/Page/Menu/User.js | 79 +++++++++++++++ 7 files changed, 199 insertions(+) diff --git a/ts/WoltLabSuite/Core/Ui/Page/Menu/Container.ts b/ts/WoltLabSuite/Core/Ui/Page/Menu/Container.ts index c539480190..064fcb7378 100644 --- a/ts/WoltLabSuite/Core/Ui/Page/Menu/Container.ts +++ b/ts/WoltLabSuite/Core/Ui/Page/Menu/Container.ts @@ -34,6 +34,8 @@ export class PageMenuContainer { scrollDisable(); this.container.hidden = false; + this.provider.refresh(); + this.getFocusTrap().activate(); } @@ -55,6 +57,10 @@ export class PageMenuContainer { } } + getContent(): HTMLElement { + return this.content; + } + private buildElements(): void { if (this.container.classList.contains("pageMenuContainer")) { return; @@ -69,6 +75,9 @@ export class PageMenuContainer { }); this.content.classList.add("pageMenuContent"); + this.content.addEventListener("click", (event) => { + event.stopPropagation(); + }); this.container.append(this.content); diff --git a/ts/WoltLabSuite/Core/Ui/Page/Menu/Main.ts b/ts/WoltLabSuite/Core/Ui/Page/Menu/Main.ts index 0b5532dc65..9e6c7761f3 100644 --- a/ts/WoltLabSuite/Core/Ui/Page/Menu/Main.ts +++ b/ts/WoltLabSuite/Core/Ui/Page/Menu/Main.ts @@ -113,6 +113,10 @@ export class PageMenuMain implements PageMenuProvider { return this.mainMenu; } + refresh(): void { + /* Does nothing */ + } + private buildMainMenu(): HTMLElement[] { const boxMenu = this.mainMenu.querySelector(".boxMenu") as HTMLElement; diff --git a/ts/WoltLabSuite/Core/Ui/Page/Menu/Provider.ts b/ts/WoltLabSuite/Core/Ui/Page/Menu/Provider.ts index c7e2d031b1..bed52a2feb 100644 --- a/ts/WoltLabSuite/Core/Ui/Page/Menu/Provider.ts +++ b/ts/WoltLabSuite/Core/Ui/Page/Menu/Provider.ts @@ -6,4 +6,6 @@ export interface PageMenuProvider { getContent(): DocumentFragment; getMenuButton(): HTMLElement; + + refresh(): void; } diff --git a/ts/WoltLabSuite/Core/Ui/Page/Menu/User.ts b/ts/WoltLabSuite/Core/Ui/Page/Menu/User.ts index a1deaf16a4..cd9b91e6b9 100644 --- a/ts/WoltLabSuite/Core/Ui/Page/Menu/User.ts +++ b/ts/WoltLabSuite/Core/Ui/Page/Menu/User.ts @@ -27,6 +27,8 @@ type TabMenu = [TabList, TabPanelContainer]; export class PageMenuUser implements PageMenuProvider { private readonly callbackOpen: CallbackOpen; private readonly container: PageMenuContainer; + private readonly tabPanels = new Map(); + private readonly tabs: HTMLAnchorElement[] = []; private readonly userMenu: HTMLElement; constructor() { @@ -69,6 +71,87 @@ export class PageMenuUser implements PageMenuProvider { return this.userMenu; } + refresh(): void { + this.openNotifications(); + } + + private openNotifications(): void { + const notifications = this.tabs.find((element) => element.dataset.origin === "userNotifications"); + if (!notifications) { + throw new Error("Unable to find the notifications tab."); + } + + notifications.click(); + } + + private openTab(tab: HTMLAnchorElement): void { + if (tab.getAttribute("aria-selected") === "true") { + return; + } + + const activeTab = this.tabs.find((element) => element.getAttribute("aria-selected") === "true"); + if (activeTab) { + activeTab.setAttribute("aria-selected", "false"); + activeTab.tabIndex = -1; + + const activePanel = this.tabPanels.get(activeTab)!; + activePanel.hidden = true; + } + + tab.setAttribute("aria-selected", "true"); + tab.tabIndex = 0; + + const tabPanel = this.tabPanels.get(tab)!; + tabPanel.hidden = false; + + if (document.activeElement !== tab) { + tab.focus(); + } + } + + private keydown(event: KeyboardEvent): void { + const tab = event.currentTarget as HTMLAnchorElement; + + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + + this.openTab(tab); + + return; + } + + const navigationKeyEvents = ["ArrowLeft", "ArrowRight", "End", "Home"]; + if (!navigationKeyEvents.includes(event.key)) { + return; + } + + event.preventDefault(); + + const currentIndex = this.tabs.indexOf(tab); + const lastIndex = this.tabs.length - 1; + + let index: number; + if (event.key === "ArrowLeft") { + if (currentIndex === 0) { + index = lastIndex; + } else { + index = currentIndex - 1; + } + } else if (event.key === "ArrowRight") { + if (currentIndex === lastIndex) { + index = 0; + } else { + index = currentIndex + 1; + } + } else if (event.key === "End") { + index = lastIndex; + } else { + index = 0; + } + + this.tabs[index].focus(); + } + private buildTabMenu(): TabMenu { const tabList = document.createElement("div"); tabList.classList.add("pageMenuUserTabList"); @@ -84,6 +167,9 @@ export class PageMenuUser implements PageMenuProvider { tabList.append(tab); tabPanelContainer.append(tabPanel); + + this.tabs.push(tab); + this.tabPanels.set(tab, tabPanel); }); // TODO: Inject legacy user panel items. @@ -97,6 +183,7 @@ export class PageMenuUser implements PageMenuProvider { const tab = document.createElement("a"); tab.classList.add("pageMenuUserTab"); + tab.dataset.origin = provider.getPanelButton().id; tab.id = tabId; tab.setAttribute("aria-controls", panelId); tab.setAttribute("aria-selected", "false"); @@ -107,6 +194,13 @@ export class PageMenuUser implements PageMenuProvider { tab.setAttribute("aria-label", button.dataset.title || button.title); tab.innerHTML = button.querySelector(".icon")!.outerHTML; + tab.addEventListener("click", (event) => { + event.preventDefault(); + + this.openTab(tab); + }); + tab.addEventListener("keydown", (event) => this.keydown(event)); + const panel = document.createElement("div"); panel.classList.add("pageMenuUserTabPanel"); panel.id = panelId; @@ -114,6 +208,7 @@ export class PageMenuUser implements PageMenuProvider { panel.setAttribute("aria-labelledby", tabId); panel.setAttribute("role", "tabpanel"); panel.tabIndex = 0; + panel.textContent = "panel for #" + provider.getPanelButton().id; return [tab, panel]; } diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Page/Menu/Container.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Page/Menu/Container.js index a90e0c038d..616c73fb6c 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Page/Menu/Container.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Page/Menu/Container.js @@ -26,6 +26,7 @@ define(["require", "exports", "tslib", "focus-trap", "../../Screen", "../../Clos (0, Screen_1.pageOverlayOpen)(); (0, Screen_1.scrollDisable)(); this.container.hidden = false; + this.provider.refresh(); this.getFocusTrap().activate(); } close() { @@ -43,6 +44,9 @@ define(["require", "exports", "tslib", "focus-trap", "../../Screen", "../../Clos this.close(); } } + getContent() { + return this.content; + } buildElements() { if (this.container.classList.contains("pageMenuContainer")) { return; @@ -55,6 +59,9 @@ define(["require", "exports", "tslib", "focus-trap", "../../Screen", "../../Clos } }); this.content.classList.add("pageMenuContent"); + this.content.addEventListener("click", (event) => { + event.stopPropagation(); + }); this.container.append(this.content); document.body.append(this.container); } diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Page/Menu/Main.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Page/Menu/Main.js index b1fc6bf777..28f01c7452 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Page/Menu/Main.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Page/Menu/Main.js @@ -81,6 +81,9 @@ define(["require", "exports", "tslib", "./Container", "../../../Language", "../. getMenuButton() { return this.mainMenu; } + refresh() { + /* Does nothing */ + } buildMainMenu() { const boxMenu = this.mainMenu.querySelector(".boxMenu"); const nav = this.buildMenu(boxMenu); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Page/Menu/User.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Page/Menu/User.js index 6692db57e0..21aefa9ae0 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Page/Menu/User.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Page/Menu/User.js @@ -15,6 +15,8 @@ define(["require", "exports", "tslib", "./Container", "../../../Language", "../. Util_1 = (0, tslib_1.__importDefault)(Util_1); class PageMenuUser { constructor() { + this.tabPanels = new Map(); + this.tabs = []; this.userMenu = document.querySelector(".userPanel"); this.container = new Container_1.default(this); this.callbackOpen = (event) => { @@ -44,6 +46,74 @@ define(["require", "exports", "tslib", "./Container", "../../../Language", "../. getMenuButton() { return this.userMenu; } + refresh() { + this.openNotifications(); + } + openNotifications() { + const notifications = this.tabs.find((element) => element.dataset.origin === "userNotifications"); + if (!notifications) { + throw new Error("Unable to find the notifications tab."); + } + notifications.click(); + } + openTab(tab) { + if (tab.getAttribute("aria-selected") === "true") { + return; + } + const activeTab = this.tabs.find((element) => element.getAttribute("aria-selected") === "true"); + if (activeTab) { + activeTab.setAttribute("aria-selected", "false"); + activeTab.tabIndex = -1; + const activePanel = this.tabPanels.get(activeTab); + activePanel.hidden = true; + } + tab.setAttribute("aria-selected", "true"); + tab.tabIndex = 0; + const tabPanel = this.tabPanels.get(tab); + tabPanel.hidden = false; + if (document.activeElement !== tab) { + tab.focus(); + } + } + keydown(event) { + const tab = event.currentTarget; + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + this.openTab(tab); + return; + } + const navigationKeyEvents = ["ArrowLeft", "ArrowRight", "End", "Home"]; + if (!navigationKeyEvents.includes(event.key)) { + return; + } + event.preventDefault(); + const currentIndex = this.tabs.indexOf(tab); + const lastIndex = this.tabs.length - 1; + let index; + if (event.key === "ArrowLeft") { + if (currentIndex === 0) { + index = lastIndex; + } + else { + index = currentIndex - 1; + } + } + else if (event.key === "ArrowRight") { + if (currentIndex === lastIndex) { + index = 0; + } + else { + index = currentIndex + 1; + } + } + else if (event.key === "End") { + index = lastIndex; + } + else { + index = 0; + } + this.tabs[index].focus(); + } buildTabMenu() { const tabList = document.createElement("div"); tabList.classList.add("pageMenuUserTabList"); @@ -55,6 +125,8 @@ define(["require", "exports", "tslib", "./Container", "../../../Language", "../. const [tab, tabPanel] = this.buildTab(provider); tabList.append(tab); tabPanelContainer.append(tabPanel); + this.tabs.push(tab); + this.tabPanels.set(tab, tabPanel); }); // TODO: Inject legacy user panel items. return [tabList, tabPanelContainer]; @@ -64,6 +136,7 @@ define(["require", "exports", "tslib", "./Container", "../../../Language", "../. const panelId = Util_1.default.getUniqueId(); const tab = document.createElement("a"); tab.classList.add("pageMenuUserTab"); + tab.dataset.origin = provider.getPanelButton().id; tab.id = tabId; tab.setAttribute("aria-controls", panelId); tab.setAttribute("aria-selected", "false"); @@ -72,6 +145,11 @@ define(["require", "exports", "tslib", "./Container", "../../../Language", "../. const button = provider.getPanelButton().querySelector("a"); tab.setAttribute("aria-label", button.dataset.title || button.title); tab.innerHTML = button.querySelector(".icon").outerHTML; + tab.addEventListener("click", (event) => { + event.preventDefault(); + this.openTab(tab); + }); + tab.addEventListener("keydown", (event) => this.keydown(event)); const panel = document.createElement("div"); panel.classList.add("pageMenuUserTabPanel"); panel.id = panelId; @@ -79,6 +157,7 @@ define(["require", "exports", "tslib", "./Container", "../../../Language", "../. panel.setAttribute("aria-labelledby", tabId); panel.setAttribute("role", "tabpanel"); panel.tabIndex = 0; + panel.textContent = "panel for #" + provider.getPanelButton().id; return [tab, panel]; } } -- 2.20.1