Trap the focus inside the menu content
authorAlexander Ebert <ebert@woltlab.com>
Fri, 3 Dec 2021 14:17:07 +0000 (15:17 +0100)
committerAlexander Ebert <ebert@woltlab.com>
Fri, 3 Dec 2021 14:17:07 +0000 (15:17 +0100)
ts/WoltLabSuite/Core/Ui/User/Menu/DropDown.ts
ts/WoltLabSuite/Core/Ui/User/Menu/View.ts
wcfsetup/install/files/js/WoltLabSuite/Core/Ui/User/Menu/DropDown.js
wcfsetup/install/files/js/WoltLabSuite/Core/Ui/User/Menu/View.js
wcfsetup/install/files/js/require.config.js

index a3b1725ed67c56f86c39dbbed812772caa4f19eb..45ab958d2716acf8d3603b545c47ce6640afc9a6 100644 (file)
@@ -66,7 +66,10 @@ function close(provider: UserMenuProvider): void {
 function getView(provider: UserMenuProvider): UserMenuView {
   if (!views.has(provider)) {
     const view = provider.getView();
-    getContainer().append(view.getElement());
+    const element = view.getElement();
+    getContainer().append(element);
+
+    element.addEventListener("shouldClose", () => close(provider));
 
     views.set(provider, view);
   }
index 162f8940b2b0a6266e20bdd074f80f4deaaa53c4..d5a00270857b3b0c8c2139a4f2fa4bc7c7a94f16 100644 (file)
@@ -3,9 +3,11 @@ import { getTimeElement } from "../../../Date/Util";
 import { escapeHTML } from "../../../StringUtil";
 import * as DomChangeListener from "../../../Dom/Change/Listener";
 import * as Language from "../../../Language";
+import { createFocusTrap, FocusTrap } from "focus-trap";
 
 export class UserMenuView {
   private readonly element: HTMLElement;
+  private readonly focusTrap: FocusTrap;
   private readonly markAllAsReadButton: HTMLElement;
   private readonly provider: UserMenuProvider;
 
@@ -21,6 +23,17 @@ export class UserMenuView {
       name: "markAllAsRead",
       title: Language.get("wcf.user.panel.markAllAsRead"),
     });
+
+    this.focusTrap = createFocusTrap(this.element, {
+      allowOutsideClick: true,
+      escapeDeactivates: (): boolean => {
+        // Intercept the "Escape" key and close the dialog through other means.
+        this.element.dispatchEvent(new Event("shouldClose"));
+
+        return false;
+      },
+      fallbackFocus: this.element,
+    });
   }
 
   getElement(): HTMLElement {
@@ -28,20 +41,24 @@ export class UserMenuView {
   }
 
   async open(): Promise<void> {
-    if (this.provider.isStale()) {
+    const isStale = this.provider.isStale();
+    if (isStale) {
       this.reset();
+    }
 
-      this.element.hidden = false;
+    this.element.hidden = false;
+    this.focusTrap.activate();
 
+    if (isStale) {
       const data = await this.provider.getData();
 
       this.setContent(data);
-    } else {
-      this.element.hidden = false;
     }
   }
 
   close(): void {
+    this.focusTrap.deactivate();
+
     this.element.hidden = true;
   }
 
@@ -124,6 +141,7 @@ export class UserMenuView {
     this.element.hidden = true;
     this.element.classList.add("userMenu");
     this.element.dataset.origin = this.provider.getPanelButton().id;
+    this.element.tabIndex = -1;
     this.element.innerHTML = `
       <div class="userMenuHeader">
         <div class="userMenuTitle">${this.provider.getTitle()}</div>
index b3e82998b81c9e9592b3c122938df50b47630e1a..3c93b45d2caa30476728f9e971b2b8a02e81c5e0 100644 (file)
@@ -54,7 +54,9 @@ define(["require", "exports", "tslib", "../../Alignment", "../../CloseOverlay"],
     function getView(provider) {
         if (!views.has(provider)) {
             const view = provider.getView();
-            getContainer().append(view.getElement());
+            const element = view.getElement();
+            getContainer().append(element);
+            element.addEventListener("shouldClose", () => close(provider));
             views.set(provider, view);
         }
         return views.get(provider);
index 73e17c26ba49d70dcbe72a78bd1fac9ddb7b09b9..8bb1738f151533d905a1937c48db0a24581fd272 100644 (file)
@@ -1,4 +1,4 @@
-define(["require", "exports", "tslib", "../../../Date/Util", "../../../StringUtil", "../../../Dom/Change/Listener", "../../../Language"], function (require, exports, tslib_1, Util_1, StringUtil_1, DomChangeListener, Language) {
+define(["require", "exports", "tslib", "../../../Date/Util", "../../../StringUtil", "../../../Dom/Change/Listener", "../../../Language", "focus-trap"], function (require, exports, tslib_1, Util_1, StringUtil_1, DomChangeListener, Language, focus_trap_1) {
     "use strict";
     Object.defineProperty(exports, "__esModule", { value: true });
     exports.UserMenuView = void 0;
@@ -15,22 +15,33 @@ define(["require", "exports", "tslib", "../../../Date/Util", "../../../StringUti
                 name: "markAllAsRead",
                 title: Language.get("wcf.user.panel.markAllAsRead"),
             });
+            this.focusTrap = (0, focus_trap_1.createFocusTrap)(this.element, {
+                allowOutsideClick: true,
+                escapeDeactivates: () => {
+                    // Intercept the "Escape" key and close the dialog through other means.
+                    this.element.dispatchEvent(new Event("shouldClose"));
+                    return false;
+                },
+                fallbackFocus: this.element,
+            });
         }
         getElement() {
             return this.element;
         }
         async open() {
-            if (this.provider.isStale()) {
+            const isStale = this.provider.isStale();
+            if (isStale) {
                 this.reset();
-                this.element.hidden = false;
+            }
+            this.element.hidden = false;
+            this.focusTrap.activate();
+            if (isStale) {
                 const data = await this.provider.getData();
                 this.setContent(data);
             }
-            else {
-                this.element.hidden = false;
-            }
         }
         close() {
+            this.focusTrap.deactivate();
             this.element.hidden = true;
         }
         getItems() {
@@ -95,6 +106,7 @@ define(["require", "exports", "tslib", "../../../Date/Util", "../../../StringUti
             this.element.hidden = true;
             this.element.classList.add("userMenu");
             this.element.dataset.origin = this.provider.getPanelButton().id;
+            this.element.tabIndex = -1;
             this.element.innerHTML = `
       <div class="userMenuHeader">
         <div class="userMenuTitle">${this.provider.getTitle()}</div>
index 9ce793a4d19bd6936cb8de9ec01ad01635cd018e..6828791a17a42215cdce9aa3e6377f51d20a0897 100644 (file)
@@ -18,7 +18,6 @@ requirejs.config({
                main: "lib/codemirror"
        }],
        shim: {
-               'focus-trap': { deps: ['tabbable'], exports: 'focus-trap' },
                'perfect-scrollbar': { exports: 'PerfectScrollbar' },
                'qr-creator': { exports: 'QrCreator' },
        },