Add the basic keyboard support for menus
authorAlexander Ebert <ebert@woltlab.com>
Fri, 29 Sep 2023 15:29:58 +0000 (17:29 +0200)
committerTim Düsterhus <duesterhus@woltlab.com>
Thu, 5 Oct 2023 15:22:07 +0000 (17:22 +0200)
ts/WoltLabSuite/Core/Element/woltlab-core-menu.ts
ts/global.d.ts
wcfsetup/install/files/js/WoltLabSuite/Core/Element/woltlab-core-menu.js

index 09a8a015aad7195183d3b80f0fa7ff5a451f7a59..3a717f3eebe7f43494109e1258fae67bff23c7b2 100644 (file)
@@ -1,8 +1,23 @@
+import WoltlabCoreMenuItemElement from "./woltlab-core-menu-item";
+
 export class WoltlabCoreMenuElement extends HTMLElement {
+  #index = -1;
+
+  constructor() {
+    super();
+
+    this.addEventListener("keydown", (event) => {
+      this.#keydown(event);
+    });
+  }
+
   connectedCallback() {
     this.setAttribute("role", "menu");
 
     this.label = this.getAttribute("label")!;
+
+    this.#index = 0;
+    this.#focusCurrentItem();
   }
 
   get label(): string {
@@ -13,6 +28,82 @@ export class WoltlabCoreMenuElement extends HTMLElement {
     this.setAttribute("label", label);
     this.setAttribute("aria-label", label);
   }
+
+  #keydown(event: KeyboardEvent): void {
+    const { code, key } = event;
+
+    // Ignore any keystrokes that are most likely keyboard shortcuts.
+    if (event.altKey !== false || event.ctrlKey !== false || event.metaKey !== false) {
+      return;
+    }
+
+    if (code === "ArrowDown") {
+      this.#index++;
+      this.#focusCurrentItem();
+
+      event.preventDefault();
+      return;
+    }
+
+    if (code === "ArrowUp") {
+      this.#index--;
+      this.#focusCurrentItem();
+
+      event.preventDefault();
+      return;
+    }
+
+    if (code === "End") {
+      this.#index = this.#getItems().length - 1;
+      this.#focusCurrentItem();
+
+      event.preventDefault();
+      return;
+    }
+
+    if (code === "Home") {
+      this.#index = 0;
+      this.#focusCurrentItem();
+
+      event.preventDefault();
+      return;
+    }
+
+    if (key.length !== 1) {
+      return;
+    }
+
+    const value = event.key.toLowerCase();
+    const newIndex = this.#getItems().findIndex((item) => {
+      return item.textContent!.trim().toLowerCase().startsWith(value);
+    });
+
+    if (newIndex !== -1) {
+      this.#index = newIndex;
+      this.#focusCurrentItem();
+
+      event.preventDefault();
+    }
+  }
+
+  #focusCurrentItem(): void {
+    const items = this.#getItems();
+    if (items.length === 0) {
+      throw new Error("There are no focusable items");
+    }
+
+    if (this.#index < 0) {
+      this.#index = items.length - 1;
+    } else if (this.#index >= items.length) {
+      this.#index = 0;
+    }
+
+    items[this.#index].focus();
+  }
+
+  #getItems(): WoltlabCoreMenuItemElement[] {
+    return Array.from(this.querySelectorAll("woltlab-core-menu-item:not([disabled])"));
+  }
 }
 
 export default WoltlabCoreMenuElement;
index 29daeeab06e7332b96a75399fa117dc53f41773f..b7b22355402c2d91605854a03604dd42dd88c4d1 100644 (file)
@@ -10,6 +10,10 @@ import { Reaction } from "WoltLabSuite/Core/Ui/Reaction/Data";
 import type WoltlabCoreDialogElement from "WoltLabSuite/Core/Element/woltlab-core-dialog";
 import type WoltlabCoreDialogControlElement from "WoltLabSuite/Core/Element/woltlab-core-dialog-control";
 import type WoltlabCoreGoogleMapsElement from "WoltLabSuite/Core/Component/GoogleMaps/woltlab-core-google-maps";
+import type WoltlabCoreMenuElement from "WoltLabSuite/Core/Element/woltlab-core-menu";
+import type WoltlabCoreMenuGroupElement from "WoltLabSuite/Core/Element/woltlab-core-menu-group";
+import type WoltlabCoreMenuItemElement from "WoltLabSuite/Core/Element/woltlab-core-menu-item";
+import type WoltlabCoreMenuSeparatorElement from "WoltLabSuite/Core/Element/woltlab-core-menu-separator";
 
 type Codepoint = string;
 type HasRegularVariant = boolean;
@@ -122,6 +126,10 @@ declare global {
     "woltlab-core-dialog-control": WoltlabCoreDialogControlElement;
     "woltlab-core-date-time": WoltlabCoreDateTime;
     "woltlab-core-loading-indicator": WoltlabCoreLoadingIndicatorElement;
+    "woltlab-core-menu": WoltlabCoreMenuElement;
+    "woltlab-core-menu-group": WoltlabCoreMenuGroupElement;
+    "woltlab-core-menu-item": WoltlabCoreMenuItemElement;
+    "woltlab-core-menu-separator": WoltlabCoreMenuSeparatorElement;
     "woltlab-core-pagination": WoltlabCorePaginationElement;
     "woltlab-core-google-maps": WoltlabCoreGoogleMapsElement;
     "woltlab-core-reaction-summary": WoltlabCoreReactionSummaryElement;
index f2d0f35cb5884666501ea37be0f067dfd5024d09..8ac9e1b4500fdb34da68a264f1cd4012717c4622 100644 (file)
@@ -3,9 +3,18 @@ define(["require", "exports"], function (require, exports) {
     Object.defineProperty(exports, "__esModule", { value: true });
     exports.WoltlabCoreMenuElement = void 0;
     class WoltlabCoreMenuElement extends HTMLElement {
+        #index = -1;
+        constructor() {
+            super();
+            this.addEventListener("keydown", (event) => {
+                this.#keydown(event);
+            });
+        }
         connectedCallback() {
             this.setAttribute("role", "menu");
             this.label = this.getAttribute("label");
+            this.#index = 0;
+            this.#focusCurrentItem();
         }
         get label() {
             return this.getAttribute("label");
@@ -14,6 +23,65 @@ define(["require", "exports"], function (require, exports) {
             this.setAttribute("label", label);
             this.setAttribute("aria-label", label);
         }
+        #keydown(event) {
+            const { code, key } = event;
+            // Ignore any keystrokes that are most likely keyboard shortcuts.
+            if (event.altKey !== false || event.ctrlKey !== false || event.metaKey !== false) {
+                return;
+            }
+            if (code === "ArrowDown") {
+                this.#index++;
+                this.#focusCurrentItem();
+                event.preventDefault();
+                return;
+            }
+            if (code === "ArrowUp") {
+                this.#index--;
+                this.#focusCurrentItem();
+                event.preventDefault();
+                return;
+            }
+            if (code === "End") {
+                this.#index = this.#getItems().length - 1;
+                this.#focusCurrentItem();
+                event.preventDefault();
+                return;
+            }
+            if (code === "Home") {
+                this.#index = 0;
+                this.#focusCurrentItem();
+                event.preventDefault();
+                return;
+            }
+            if (key.length !== 1) {
+                return;
+            }
+            const value = event.key.toLowerCase();
+            const newIndex = this.#getItems().findIndex((item) => {
+                return item.textContent.trim().toLowerCase().startsWith(value);
+            });
+            if (newIndex !== -1) {
+                this.#index = newIndex;
+                this.#focusCurrentItem();
+                event.preventDefault();
+            }
+        }
+        #focusCurrentItem() {
+            const items = this.#getItems();
+            if (items.length === 0) {
+                throw new Error("There are no focusable items");
+            }
+            if (this.#index < 0) {
+                this.#index = items.length - 1;
+            }
+            else if (this.#index >= items.length) {
+                this.#index = 0;
+            }
+            items[this.#index].focus();
+        }
+        #getItems() {
+            return Array.from(this.querySelectorAll("woltlab-core-menu-item:not([disabled])"));
+        }
     }
     exports.WoltlabCoreMenuElement = WoltlabCoreMenuElement;
     exports.default = WoltlabCoreMenuElement;