Keyboard support for reactions
authorAlexander Ebert <ebert@woltlab.com>
Mon, 22 Feb 2021 09:55:22 +0000 (10:55 +0100)
committerAlexander Ebert <ebert@woltlab.com>
Mon, 22 Feb 2021 09:55:22 +0000 (10:55 +0100)
Fixes #3703

ts/WoltLabSuite/Core/Ui/Reaction/Handler.ts
wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Reaction/Handler.js

index 01ff388bba8c885e3d52b90fcc9b6bcb2762a98c..e4e54a4b260fdd2853ced31791979f53afdd882a 100644 (file)
@@ -54,6 +54,8 @@ interface AjaxResponse {
 const availableReactions = Object.values(window.REACTION_TYPES);
 
 class UiReactionHandler {
+  protected activeButton?: HTMLElement | undefined = undefined;
+  protected readonly callbackFocus: (event: Event) => void;
   readonly countButtons: CountButtons;
   protected readonly _cache = new Map<string, unknown>();
   protected readonly _containers = new Map<string, ElementData>();
@@ -63,6 +65,7 @@ class UiReactionHandler {
   protected _popoverCurrentObjectId = 0;
   protected _popover: HTMLElement | null;
   protected _popoverContent: HTMLElement | null;
+  protected wasInsideReactions: boolean;
 
   /**
    * Initializes the reaction handler.
@@ -101,6 +104,8 @@ class UiReactionHandler {
 
     DomChangeListener.add(`WoltLabSuite/Core/Ui/Reaction/Handler-${objectType}`, () => this.initReactButtons());
     UiCloseOverlay.add("WoltLabSuite/Core/Ui/Reaction/Handler", () => this._closePopover());
+
+    this.callbackFocus = (event: Event) => this.maintainFocus(event);
   }
 
   /**
@@ -161,9 +166,22 @@ class UiReactionHandler {
       textSpan.textContent = reaction.title;
     }
 
+    elementData.reactButton.setAttribute("role", "button");
+    if (availableReactions.length > 1) {
+      elementData.reactButton.setAttribute("aria-haspopup", "true");
+      elementData.reactButton.setAttribute("aria-expanded", "false");
+    }
+
     elementData.reactButton.addEventListener("click", (ev) => {
       this._toggleReactPopover(elementData.objectId, elementData.reactButton!, ev);
     });
+    elementData.reactButton.addEventListener("keydown", (event) => {
+      if (event.key === "Enter") {
+        event.preventDefault();
+
+        this._toggleReactPopover(elementData.objectId, elementData.reactButton!, null);
+      }
+    });
   }
 
   protected _updateReactButton(objectID: number, reactionTypeID: number): void {
@@ -194,7 +212,10 @@ class UiReactionHandler {
 
     //  Clear the old active state.
     const popover = this._getPopover();
-    popover.querySelectorAll(".reactionTypeButton.active").forEach((el) => el.classList.remove("active"));
+    popover.querySelectorAll(".reactionTypeButton.active").forEach((element: HTMLElement) => {
+      element.classList.remove("active");
+      element.removeAttribute("aria-selected");
+    });
 
     const scrollableContainer = popover.querySelector(".reactionPopoverContent") as HTMLElement;
     if (reactionTypeID) {
@@ -202,6 +223,7 @@ class UiReactionHandler {
         `.reactionTypeButton[data-reaction-type-id="${reactionTypeID!}"]`,
       ) as HTMLElement;
       reactionTypeButton.classList.add("active");
+      reactionTypeButton.setAttribute("aria-selected", "true");
 
       if (~~reactionTypeButton.dataset.isAssignable! === 0) {
         DomUtil.show(reactionTypeButton);
@@ -239,7 +261,7 @@ class UiReactionHandler {
   /**
    * Toggle the visibility of the react popover.
    */
-  protected _toggleReactPopover(objectId: number, element: HTMLElement, event: MouseEvent): void {
+  protected _toggleReactPopover(objectId: number, element: HTMLElement, event: MouseEvent | null): void {
     if (event !== null) {
       event.preventDefault();
       event.stopPropagation();
@@ -298,6 +320,12 @@ class UiReactionHandler {
 
     popover.classList.remove("forceHide");
     popover.classList.add("active");
+
+    this.activeButton = element;
+    if (availableReactions.length > 1) {
+      this.activeButton.setAttribute("aria-expanded", "true");
+      document.body.addEventListener("focus", this.callbackFocus, { capture: true });
+    }
   }
 
   /**
@@ -316,6 +344,8 @@ class UiReactionHandler {
 
       this._getSortedReactionTypes().forEach((reactionType) => {
         const reactionTypeItem = document.createElement("li");
+        reactionTypeItem.tabIndex = 0;
+        reactionTypeItem.setAttribute("role", "button");
         reactionTypeItem.className = "reactionTypeButton jsTooltip";
         reactionTypeItem.dataset.reactionTypeId = reactionType.reactionTypeID.toString();
         reactionTypeItem.dataset.title = reactionType.title;
@@ -332,6 +362,7 @@ class UiReactionHandler {
         reactionTypeItem.appendChild(reactionTypeItemSpan);
 
         reactionTypeItem.addEventListener("click", () => this._react(reactionType.reactionTypeID));
+        reactionTypeItem.addEventListener("keydown", (ev) => this.keydown(ev));
 
         if (!reactionType.isAssignable) {
           DomUtil.hide(reactionTypeItem);
@@ -358,6 +389,25 @@ class UiReactionHandler {
     return this._popover;
   }
 
+  protected keydown(event: KeyboardEvent): void {
+    if (event.key === "Enter" || event.key === " " || event.key === "Escape") {
+      event.preventDefault();
+
+      const activeButton = this.activeButton!;
+
+      if (event.key === "Escape") {
+        this._closePopover();
+      } else {
+        const reactionTypeItem = event.currentTarget as HTMLElement;
+        const reactionTypeId = ~~reactionTypeItem.dataset.reactionTypeId!;
+
+        this._react(reactionTypeId);
+      }
+
+      activeButton.focus();
+    }
+  }
+
   protected _rebuildOverflowIndicator(): void {
     const popoverContent = this._popoverContent!;
     const hasTopOverflow = popoverContent.scrollTop > 0;
@@ -400,6 +450,12 @@ class UiReactionHandler {
         });
       }
 
+      if (availableReactions.length > 1) {
+        this.activeButton!.setAttribute("aria-expanded", "false");
+        document.body.removeEventListener("focus", this.callbackFocus, { capture: true });
+      }
+
+      this.activeButton = undefined;
       this._popoverCurrentObjectId = 0;
     }
   }
@@ -439,6 +495,27 @@ class UiReactionHandler {
       },
     };
   }
+
+  protected maintainFocus(event: Event): void {
+    // Ignore a focus shift that was not the result of a keyboard interaction.
+    if (document.activeElement && !document.activeElement.classList.contains("focus-visible")) {
+      return;
+    }
+
+    const popover = this._getPopover();
+
+    if (!popover.contains(event.target as Element)) {
+      if (this.wasInsideReactions) {
+        this.activeButton!.focus();
+        this.wasInsideReactions = false;
+      } else {
+        const firstReaction = popover.querySelector(".reactionTypeButton") as HTMLElement;
+        firstReaction.focus();
+      }
+    } else {
+      this.wasInsideReactions = true;
+    }
+  }
 }
 
 Core.enableLegacyInheritance(UiReactionHandler);
index 830cefe989c102601990b0ad348f04fcfd676aa3..f7758c8181d8a06bc14babfd843f5344ecc444f7 100644 (file)
@@ -23,6 +23,7 @@ define(["require", "exports", "tslib", "../../Ajax", "../../Core", "../../Dom/Ch
          * Initializes the reaction handler.
          */
         constructor(objectType, opts) {
+            this.activeButton = undefined;
             this._cache = new Map();
             this._containers = new Map();
             this._objects = new Map();
@@ -48,6 +49,7 @@ define(["require", "exports", "tslib", "../../Ajax", "../../Core", "../../Dom/Ch
             this.countButtons = new CountButtons_1.default(this._objectType, this._options);
             Listener_1.default.add(`WoltLabSuite/Core/Ui/Reaction/Handler-${objectType}`, () => this.initReactButtons());
             CloseOverlay_1.default.add("WoltLabSuite/Core/Ui/Reaction/Handler", () => this._closePopover());
+            this.callbackFocus = (event) => this.maintainFocus(event);
         }
         /**
          * Initializes all applicable react buttons with the given selector.
@@ -96,9 +98,20 @@ define(["require", "exports", "tslib", "../../Ajax", "../../Core", "../../Dom/Ch
                 const textSpan = elementData.reactButton.querySelector(".invisible");
                 textSpan.textContent = reaction.title;
             }
+            elementData.reactButton.setAttribute("role", "button");
+            if (availableReactions.length > 1) {
+                elementData.reactButton.setAttribute("aria-haspopup", "true");
+                elementData.reactButton.setAttribute("aria-expanded", "false");
+            }
             elementData.reactButton.addEventListener("click", (ev) => {
                 this._toggleReactPopover(elementData.objectId, elementData.reactButton, ev);
             });
+            elementData.reactButton.addEventListener("keydown", (event) => {
+                if (event.key === "Enter") {
+                    event.preventDefault();
+                    this._toggleReactPopover(elementData.objectId, elementData.reactButton, null);
+                }
+            });
         }
         _updateReactButton(objectID, reactionTypeID) {
             this._objects.get(objectID).forEach((elementData) => {
@@ -126,11 +139,15 @@ define(["require", "exports", "tslib", "../../Ajax", "../../Core", "../../Dom/Ch
             }
             //  Clear the old active state.
             const popover = this._getPopover();
-            popover.querySelectorAll(".reactionTypeButton.active").forEach((el) => el.classList.remove("active"));
+            popover.querySelectorAll(".reactionTypeButton.active").forEach((element) => {
+                element.classList.remove("active");
+                element.removeAttribute("aria-selected");
+            });
             const scrollableContainer = popover.querySelector(".reactionPopoverContent");
             if (reactionTypeID) {
                 const reactionTypeButton = popover.querySelector(`.reactionTypeButton[data-reaction-type-id="${reactionTypeID}"]`);
                 reactionTypeButton.classList.add("active");
+                reactionTypeButton.setAttribute("aria-selected", "true");
                 if (~~reactionTypeButton.dataset.isAssignable === 0) {
                     Util_1.default.show(reactionTypeButton);
                 }
@@ -218,6 +235,11 @@ define(["require", "exports", "tslib", "../../Ajax", "../../Core", "../../Dom/Ch
             this._rebuildOverflowIndicator();
             popover.classList.remove("forceHide");
             popover.classList.add("active");
+            this.activeButton = element;
+            if (availableReactions.length > 1) {
+                this.activeButton.setAttribute("aria-expanded", "true");
+                document.body.addEventListener("focus", this.callbackFocus, { capture: true });
+            }
         }
         /**
          * Returns the react popover element.
@@ -232,6 +254,8 @@ define(["require", "exports", "tslib", "../../Ajax", "../../Core", "../../Dom/Ch
                 popoverContentHTML.className = "reactionTypeButtonList";
                 this._getSortedReactionTypes().forEach((reactionType) => {
                     const reactionTypeItem = document.createElement("li");
+                    reactionTypeItem.tabIndex = 0;
+                    reactionTypeItem.setAttribute("role", "button");
                     reactionTypeItem.className = "reactionTypeButton jsTooltip";
                     reactionTypeItem.dataset.reactionTypeId = reactionType.reactionTypeID.toString();
                     reactionTypeItem.dataset.title = reactionType.title;
@@ -243,6 +267,7 @@ define(["require", "exports", "tslib", "../../Ajax", "../../Core", "../../Dom/Ch
                     reactionTypeItem.innerHTML = reactionType.renderedIcon;
                     reactionTypeItem.appendChild(reactionTypeItemSpan);
                     reactionTypeItem.addEventListener("click", () => this._react(reactionType.reactionTypeID));
+                    reactionTypeItem.addEventListener("keydown", (ev) => this.keydown(ev));
                     if (!reactionType.isAssignable) {
                         Util_1.default.hide(reactionTypeItem);
                     }
@@ -260,6 +285,21 @@ define(["require", "exports", "tslib", "../../Ajax", "../../Core", "../../Dom/Ch
             }
             return this._popover;
         }
+        keydown(event) {
+            if (event.key === "Enter" || event.key === " " || event.key === "Escape") {
+                event.preventDefault();
+                const activeButton = this.activeButton;
+                if (event.key === "Escape") {
+                    this._closePopover();
+                }
+                else {
+                    const reactionTypeItem = event.currentTarget;
+                    const reactionTypeId = ~~reactionTypeItem.dataset.reactionTypeId;
+                    this._react(reactionTypeId);
+                }
+                activeButton.focus();
+            }
+        }
         _rebuildOverflowIndicator() {
             const popoverContent = this._popoverContent;
             const hasTopOverflow = popoverContent.scrollTop > 0;
@@ -298,6 +338,11 @@ define(["require", "exports", "tslib", "../../Ajax", "../../Core", "../../Dom/Ch
                         elementData.reactButton.closest("nav").style.cssText = "";
                     });
                 }
+                if (availableReactions.length > 1) {
+                    this.activeButton.setAttribute("aria-expanded", "false");
+                    document.body.removeEventListener("focus", this.callbackFocus, { capture: true });
+                }
+                this.activeButton = undefined;
                 this._popoverCurrentObjectId = 0;
             }
         }
@@ -330,6 +375,26 @@ define(["require", "exports", "tslib", "../../Ajax", "../../Core", "../../Dom/Ch
                 },
             };
         }
+        maintainFocus(event) {
+            // Ignore a focus shift that was not the result of a keyboard interaction.
+            if (document.activeElement && !document.activeElement.classList.contains("focus-visible")) {
+                return;
+            }
+            const popover = this._getPopover();
+            if (!popover.contains(event.target)) {
+                if (this.wasInsideReactions) {
+                    this.activeButton.focus();
+                    this.wasInsideReactions = false;
+                }
+                else {
+                    const firstReaction = popover.querySelector(".reactionTypeButton");
+                    firstReaction.focus();
+                }
+            }
+            else {
+                this.wasInsideReactions = true;
+            }
+        }
     }
     Core.enableLegacyInheritance(UiReactionHandler);
     return UiReactionHandler;