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>();
protected _popoverCurrentObjectId = 0;
protected _popover: HTMLElement | null;
protected _popoverContent: HTMLElement | null;
+ protected wasInsideReactions: boolean;
/**
* Initializes the reaction handler.
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);
}
/**
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 {
// 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) {
`.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);
/**
* 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();
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 });
+ }
}
/**
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;
reactionTypeItem.appendChild(reactionTypeItemSpan);
reactionTypeItem.addEventListener("click", () => this._react(reactionType.reactionTypeID));
+ reactionTypeItem.addEventListener("keydown", (ev) => this.keydown(ev));
if (!reactionType.isAssignable) {
DomUtil.hide(reactionTypeItem);
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;
});
}
+ if (availableReactions.length > 1) {
+ this.activeButton!.setAttribute("aria-expanded", "false");
+ document.body.removeEventListener("focus", this.callbackFocus, { capture: true });
+ }
+
+ this.activeButton = undefined;
this._popoverCurrentObjectId = 0;
}
}
},
};
}
+
+ 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);
* Initializes the reaction handler.
*/
constructor(objectType, opts) {
+ this.activeButton = undefined;
this._cache = new Map();
this._containers = new Map();
this._objects = new Map();
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.
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) => {
}
// 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);
}
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.
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;
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);
}
}
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;
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;
}
}
},
};
}
+ 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;