Implemented tab activation, keyboard support for a11y
authorAlexander Ebert <ebert@woltlab.com>
Mon, 13 Dec 2021 16:05:16 +0000 (17:05 +0100)
committerAlexander Ebert <ebert@woltlab.com>
Mon, 13 Dec 2021 16:05:16 +0000 (17:05 +0100)
ts/WoltLabSuite/Core/Ui/Page/Menu/Container.ts
ts/WoltLabSuite/Core/Ui/Page/Menu/Main.ts
ts/WoltLabSuite/Core/Ui/Page/Menu/Provider.ts
ts/WoltLabSuite/Core/Ui/Page/Menu/User.ts
wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Page/Menu/Container.js
wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Page/Menu/Main.js
wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Page/Menu/User.js

index c539480190fd768165c21c1419c390badc79f273..064fcb7378c54c915e1585aaedd1c6d71a2aafff 100644 (file)
@@ -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);
 
index 0b5532dc659c4d26937e1466d4047a4e1b0a671b..9e6c7761f3fab7d53461cb560bd0709691feb171 100644 (file)
@@ -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;
 
index c7e2d031b1a9095c38f6f7b4a094e00dcf19935b..bed52a2feb7e4f2bf803af02641b1d593de85f50 100644 (file)
@@ -6,4 +6,6 @@ export interface PageMenuProvider {
   getContent(): DocumentFragment;
 
   getMenuButton(): HTMLElement;
+
+  refresh(): void;
 }
index a1deaf16a466dab98f7431126d0f6e32bcd894c4..cd9b91e6b981608982cb5e3755b3ebefec5b01e3 100644 (file)
@@ -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<HTMLAnchorElement, HTMLDivElement>();
+  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];
   }
index a90e0c038dff65290659012bac4c8c9a626315b2..616c73fb6c6b0d343f5cbdbff90924dec65ff4c9 100644 (file)
@@ -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);
         }
index b1fc6bf777f39c13727056ae5a265a3c830393c7..28f01c7452b40493b496b9e1ba4687137966c0b5 100644 (file)
@@ -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);
index 6692db57e086e21d5b51b8c78628121590c27837..21aefa9ae0830c6c1f6ad6c51a8327f7535db92e 100644 (file)
@@ -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];
         }
     }