Use `focus-trap` to maintain the focus in dialogs
authorAlexander Ebert <ebert@woltlab.com>
Thu, 9 Dec 2021 17:41:33 +0000 (18:41 +0100)
committerAlexander Ebert <ebert@woltlab.com>
Fri, 10 Dec 2021 12:28:25 +0000 (13:28 +0100)
ts/WoltLabSuite/Core/Ui/Dialog.ts
ts/WoltLabSuite/Core/Ui/Dialog/Data.ts
wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Dialog.js
wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Dialog/Data.js

index 67d26e953f6a071e08027bebe3975b9db0a96ba6..f7d661aac645f88d899389aa6fab19631b68105e 100644 (file)
@@ -25,9 +25,9 @@ import * as Environment from "../Environment";
 import * as EventHandler from "../Event/Handler";
 import { AjaxCallbackSetup } from "../Ajax/Data";
 import CloseOverlay from "./CloseOverlay";
+import { createFocusTrap } from "focus-trap";
 
 let _activeDialog: string | null = null;
-let _callbackFocus: (event: FocusEvent) => void;
 let _container: HTMLElement;
 const _dialogs = new Map<ElementId, DialogData>();
 let _dialogFullHeight = false;
@@ -39,20 +39,6 @@ const _validCallbacks = ["onBeforeClose", "onClose", "onShow"];
 // list of supported `input[type]` values for dialog submit
 const _validInputTypes = ["number", "password", "search", "tel", "text", "url"];
 
-const _focusableElements = [
-  'a[href]:not([tabindex^="-"]):not([inert])',
-  'area[href]:not([tabindex^="-"]):not([inert])',
-  "input:not([disabled]):not([inert])",
-  "select:not([disabled]):not([inert])",
-  "textarea:not([disabled]):not([inert])",
-  "button:not([disabled]):not([inert])",
-  'iframe:not([tabindex^="-"]):not([inert])',
-  'audio:not([tabindex^="-"]):not([inert])',
-  'video:not([tabindex^="-"]):not([inert])',
-  '[contenteditable]:not([tabindex^="-"]):not([inert])',
-  '[tabindex]:not([tabindex^="-"]):not([inert])',
-];
-
 /**
  * @exports  WoltLabSuite/Core/Ui/Dialog
  */
@@ -482,12 +468,23 @@ const UiDialog = {
       DomUtil.show(content);
     }
 
+    const focusTrap = createFocusTrap(dialog, {
+      allowOutsideClick: true,
+      escapeDeactivates(): boolean {
+        UiDialog.close(id);
+
+        return false;
+      },
+      fallbackFocus: dialog,
+    });
+
     _dialogs.set(id, {
       backdropCloseOnClick: options.backdropCloseOnClick,
       closable: options.closable,
-      content: content,
-      dialog: dialog,
-      header: header,
+      content,
+      dialog,
+      focusTrap,
+      header,
       onBeforeClose: options.onBeforeClose!,
       onClose: options.onClose!,
       onShow: options.onShow!,
@@ -521,11 +518,6 @@ const UiDialog = {
     if (Core.stringToBool(data.dialog.getAttribute("aria-hidden"))) {
       CloseOverlay.execute();
 
-      if (_callbackFocus === null) {
-        _callbackFocus = this._maintainFocus.bind(this);
-        document.body.addEventListener("focus", _callbackFocus, { capture: true });
-      }
-
       if (data.closable && Core.stringToBool(_container.getAttribute("aria-hidden"))) {
         window.addEventListener("keyup", _keyupListener);
       }
@@ -542,7 +534,6 @@ const UiDialog = {
       // Set the focus to the first focusable child of the dialog element.
       const closeButton = data.header.querySelector(".dialogCloseButton");
       if (closeButton) closeButton.setAttribute("inert", "true");
-      this._setFocusToFirstItem(data.dialog, false);
       if (closeButton) closeButton.removeAttribute("inert");
 
       if (typeof data.onShow === "function") {
@@ -560,63 +551,8 @@ const UiDialog = {
     this.rebuild(id);
 
     DomChangeListener.trigger();
-  },
-
-  _maintainFocus(event: FocusEvent): void {
-    if (_activeDialog) {
-      const data = _dialogs.get(_activeDialog) as DialogData;
-      const target = event.target as HTMLElement;
-      if (
-        !data.dialog.contains(target) &&
-        !target.closest(".dropdownMenuContainer") &&
-        !target.closest(".datePicker")
-      ) {
-        this._setFocusToFirstItem(data.dialog, true);
-      }
-    }
-  },
 
-  _setFocusToFirstItem(dialog: HTMLElement, maintain: boolean): void {
-    let focusElement = this._getFirstFocusableChild(dialog);
-    if (focusElement !== null) {
-      if (maintain) {
-        if (focusElement.id === "username" || (focusElement as HTMLInputElement).name === "username") {
-          if (Environment.browser() === "safari" && Environment.platform() === "ios") {
-            // iOS Safari's username/password autofill breaks if the input field is focused
-            focusElement = null;
-          }
-        }
-      }
-
-      if (focusElement) {
-        // Setting the focus to a select element in iOS is pretty strange, because
-        // it focuses it, but also displays the keyboard for a fraction of a second,
-        // causing it to pop out from below and immediately vanish.
-        //
-        // iOS will only show the keyboard if an input element is focused *and* the
-        // focus is an immediate result of a user interaction. This method must be
-        // assumed to be called from within a click event, but we want to set the
-        // focus without triggering the keyboard.
-        //
-        // We can break the condition by wrapping it in a setTimeout() call,
-        // effectively tricking iOS into focusing the element without showing the
-        // keyboard.
-        setTimeout(() => {
-          focusElement!.focus();
-        }, 1);
-      }
-    }
-  },
-
-  _getFirstFocusableChild(element: HTMLElement): HTMLElement | null {
-    const nodeList = element.querySelectorAll<HTMLElement>(_focusableElements.join(","));
-    for (let i = 0, length = nodeList.length; i < length; i++) {
-      if (nodeList[i].offsetWidth && nodeList[i].offsetHeight && nodeList[i].getClientRects().length) {
-        return nodeList[i];
-      }
-    }
-
-    return null;
+    data.focusTrap.activate();
   },
 
   /**
@@ -819,6 +755,8 @@ const UiDialog = {
       data.onClose(id);
     }
 
+    data.focusTrap.deactivate();
+
     // get next active dialog
     _activeDialog = null;
     for (let i = 0; i < _container.childElementCount; i++) {
index 02eb4032792d37f4f6742da15030e2ade70c8720..8e528ee74fa402579f6bc85322476009b444c8ea 100644 (file)
@@ -1,4 +1,15 @@
+/**
+ * Interfaces and data types for dialogs.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2021 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Ui/Dialog/Data
+ * @woltlabExcludeBundle all
+ */
+
 import { RequestPayload, ResponseData } from "../../Ajax/Data";
+import { FocusTrap } from "focus-trap";
 
 export type DialogHtml = DocumentFragment | string | null;
 
@@ -48,6 +59,7 @@ export interface DialogData {
   closable: boolean;
   content: HTMLElement;
   dialog: HTMLElement;
+  focusTrap: FocusTrap;
   header: HTMLElement;
 
   onBeforeClose: CallbackOnBeforeClose;
index 53cc64f689f25a10f887191c7acaa6eff34bf850..854eb551cfdde0cf142fa691e858bd9f025eb9e2 100644 (file)
@@ -7,7 +7,7 @@
  * @module  Ui/Dialog (alias)
  * @module  WoltLabSuite/Core/Ui/Dialog
  */
-define(["require", "exports", "tslib", "../Core", "../Dom/Change/Listener", "./Screen", "../Dom/Util", "../Language", "../Environment", "../Event/Handler", "./CloseOverlay"], function (require, exports, tslib_1, Core, Listener_1, UiScreen, Util_1, Language, Environment, EventHandler, CloseOverlay_1) {
+define(["require", "exports", "tslib", "../Core", "../Dom/Change/Listener", "./Screen", "../Dom/Util", "../Language", "../Environment", "../Event/Handler", "./CloseOverlay", "focus-trap"], function (require, exports, tslib_1, Core, Listener_1, UiScreen, Util_1, Language, Environment, EventHandler, CloseOverlay_1, focus_trap_1) {
     "use strict";
     Core = (0, tslib_1.__importStar)(Core);
     Listener_1 = (0, tslib_1.__importDefault)(Listener_1);
@@ -18,7 +18,6 @@ define(["require", "exports", "tslib", "../Core", "../Dom/Change/Listener", "./S
     EventHandler = (0, tslib_1.__importStar)(EventHandler);
     CloseOverlay_1 = (0, tslib_1.__importDefault)(CloseOverlay_1);
     let _activeDialog = null;
-    let _callbackFocus;
     let _container;
     const _dialogs = new Map();
     let _dialogFullHeight = false;
@@ -28,19 +27,6 @@ define(["require", "exports", "tslib", "../Core", "../Dom/Change/Listener", "./S
     const _validCallbacks = ["onBeforeClose", "onClose", "onShow"];
     // list of supported `input[type]` values for dialog submit
     const _validInputTypes = ["number", "password", "search", "tel", "text", "url"];
-    const _focusableElements = [
-        'a[href]:not([tabindex^="-"]):not([inert])',
-        'area[href]:not([tabindex^="-"]):not([inert])',
-        "input:not([disabled]):not([inert])",
-        "select:not([disabled]):not([inert])",
-        "textarea:not([disabled]):not([inert])",
-        "button:not([disabled]):not([inert])",
-        'iframe:not([tabindex^="-"]):not([inert])',
-        'audio:not([tabindex^="-"]):not([inert])',
-        'video:not([tabindex^="-"]):not([inert])',
-        '[contenteditable]:not([tabindex^="-"]):not([inert])',
-        '[tabindex]:not([tabindex^="-"]):not([inert])',
-    ];
     /**
      * @exports  WoltLabSuite/Core/Ui/Dialog
      */
@@ -404,12 +390,21 @@ define(["require", "exports", "tslib", "../Core", "../Dom/Change/Listener", "./S
             if (content.style.getPropertyValue("display") === "none") {
                 Util_1.default.show(content);
             }
+            const focusTrap = (0, focus_trap_1.createFocusTrap)(dialog, {
+                allowOutsideClick: true,
+                escapeDeactivates() {
+                    UiDialog.close(id);
+                    return false;
+                },
+                fallbackFocus: dialog,
+            });
             _dialogs.set(id, {
                 backdropCloseOnClick: options.backdropCloseOnClick,
                 closable: options.closable,
-                content: content,
-                dialog: dialog,
-                header: header,
+                content,
+                dialog,
+                focusTrap,
+                header,
                 onBeforeClose: options.onBeforeClose,
                 onClose: options.onClose,
                 onShow: options.onShow,
@@ -435,10 +430,6 @@ define(["require", "exports", "tslib", "../Core", "../Dom/Change/Listener", "./S
             }
             if (Core.stringToBool(data.dialog.getAttribute("aria-hidden"))) {
                 CloseOverlay_1.default.execute();
-                if (_callbackFocus === null) {
-                    _callbackFocus = this._maintainFocus.bind(this);
-                    document.body.addEventListener("focus", _callbackFocus, { capture: true });
-                }
                 if (data.closable && Core.stringToBool(_container.getAttribute("aria-hidden"))) {
                     window.addEventListener("keyup", _keyupListener);
                 }
@@ -453,7 +444,6 @@ define(["require", "exports", "tslib", "../Core", "../Dom/Change/Listener", "./S
                 const closeButton = data.header.querySelector(".dialogCloseButton");
                 if (closeButton)
                     closeButton.setAttribute("inert", "true");
-                this._setFocusToFirstItem(data.dialog, false);
                 if (closeButton)
                     closeButton.removeAttribute("inert");
                 if (typeof data.onShow === "function") {
@@ -468,56 +458,7 @@ define(["require", "exports", "tslib", "../Core", "../Dom/Change/Listener", "./S
             }
             this.rebuild(id);
             Listener_1.default.trigger();
-        },
-        _maintainFocus(event) {
-            if (_activeDialog) {
-                const data = _dialogs.get(_activeDialog);
-                const target = event.target;
-                if (!data.dialog.contains(target) &&
-                    !target.closest(".dropdownMenuContainer") &&
-                    !target.closest(".datePicker")) {
-                    this._setFocusToFirstItem(data.dialog, true);
-                }
-            }
-        },
-        _setFocusToFirstItem(dialog, maintain) {
-            let focusElement = this._getFirstFocusableChild(dialog);
-            if (focusElement !== null) {
-                if (maintain) {
-                    if (focusElement.id === "username" || focusElement.name === "username") {
-                        if (Environment.browser() === "safari" && Environment.platform() === "ios") {
-                            // iOS Safari's username/password autofill breaks if the input field is focused
-                            focusElement = null;
-                        }
-                    }
-                }
-                if (focusElement) {
-                    // Setting the focus to a select element in iOS is pretty strange, because
-                    // it focuses it, but also displays the keyboard for a fraction of a second,
-                    // causing it to pop out from below and immediately vanish.
-                    //
-                    // iOS will only show the keyboard if an input element is focused *and* the
-                    // focus is an immediate result of a user interaction. This method must be
-                    // assumed to be called from within a click event, but we want to set the
-                    // focus without triggering the keyboard.
-                    //
-                    // We can break the condition by wrapping it in a setTimeout() call,
-                    // effectively tricking iOS into focusing the element without showing the
-                    // keyboard.
-                    setTimeout(() => {
-                        focusElement.focus();
-                    }, 1);
-                }
-            }
-        },
-        _getFirstFocusableChild(element) {
-            const nodeList = element.querySelectorAll(_focusableElements.join(","));
-            for (let i = 0, length = nodeList.length; i < length; i++) {
-                if (nodeList[i].offsetWidth && nodeList[i].offsetHeight && nodeList[i].getClientRects().length) {
-                    return nodeList[i];
-                }
-            }
-            return null;
+            data.focusTrap.activate();
         },
         /**
          * Rebuilds dialog identified by given id.
@@ -680,6 +621,7 @@ define(["require", "exports", "tslib", "../Core", "../Dom/Change/Listener", "./S
             if (typeof data.onClose === "function") {
                 data.onClose(id);
             }
+            data.focusTrap.deactivate();
             // get next active dialog
             _activeDialog = null;
             for (let i = 0; i < _container.childElementCount; i++) {
index 2ae92b6a8b5db3c75f02775e473eca4300b025f8..8791bcbc0269ebb2e7ef527956ae8a137d772abe 100644 (file)
@@ -1,3 +1,12 @@
+/**
+ * Interfaces and data types for dialogs.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2021 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Ui/Dialog/Data
+ * @woltlabExcludeBundle all
+ */
 define(["require", "exports"], function (require, exports) {
     "use strict";
     Object.defineProperty(exports, "__esModule", { value: true });