From b252eb2226bf1d374a0055134e21fb56db83acaf Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Mon, 22 Feb 2021 10:55:22 +0100 Subject: [PATCH] Keyboard support for reactions Fixes #3703 --- ts/WoltLabSuite/Core/Ui/Reaction/Handler.ts | 81 ++++++++++++++++++- .../WoltLabSuite/Core/Ui/Reaction/Handler.js | 67 ++++++++++++++- 2 files changed, 145 insertions(+), 3 deletions(-) diff --git a/ts/WoltLabSuite/Core/Ui/Reaction/Handler.ts b/ts/WoltLabSuite/Core/Ui/Reaction/Handler.ts index 01ff388bba..e4e54a4b26 100644 --- a/ts/WoltLabSuite/Core/Ui/Reaction/Handler.ts +++ b/ts/WoltLabSuite/Core/Ui/Reaction/Handler.ts @@ -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(); protected readonly _containers = new Map(); @@ -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); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Reaction/Handler.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Reaction/Handler.js index 830cefe989..f7758c8181 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Reaction/Handler.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Reaction/Handler.js @@ -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; -- 2.20.1