From de10c39fa43a84d18aa15509eb6e17888808b686 Mon Sep 17 00:00:00 2001 From: Roman Rizzi Date: Tue, 2 Mar 2021 12:22:32 -0300 Subject: [PATCH] A11Y: Switch tabs using the keyboard (#12241) * A11Y: Switch tabs using the keyboard According to the WAI-ARIA Authoring Practices, tabs should be navigable using the left/right arrow keys. Additionally, the screen reader couldn't correctly announce that a tab was selected when clicking the tab icon. To fix this, we made the SVG icon non-clickable and set the "aria-hidden" attribute to true. * Handle navigation events using appEvents --- .../discourse/app/components/site-header.js | 25 +++++++++++ .../discourse/app/widgets/button.js | 6 ++- .../app/widgets/quick-access-panel.js | 6 ++- .../discourse/app/widgets/user-menu.js | 44 +++++++++++++++---- .../stylesheets/common/base/menu-panel.scss | 4 ++ 5 files changed, 74 insertions(+), 11 deletions(-) diff --git a/app/assets/javascripts/discourse/app/components/site-header.js b/app/assets/javascripts/discourse/app/components/site-header.js index d66f49fca0c..faca98fbfbd 100644 --- a/app/assets/javascripts/discourse/app/components/site-header.js +++ b/app/assets/javascripts/discourse/app/components/site-header.js @@ -6,6 +6,7 @@ import PanEvents, { import { cancel, later, schedule } from "@ember/runloop"; import Docking from "discourse/mixins/docking"; import MountWidget from "discourse/components/mount-widget"; +import Mousetrap from "mousetrap"; import RerenderOnDoNotDisturbChange from "discourse/mixins/rerender-on-do-not-disturb-change"; import { observes } from "discourse-common/utils/decorators"; import { topicTitleDecorators } from "discourse/components/topic-title"; @@ -25,6 +26,7 @@ const SiteHeaderComponent = MountWidget.extend( _scheduledMovingAnimation: null, _scheduledRemoveAnimate: null, _topic: null, + _mousetrap: null, @observes( "currentUser.unread_notifications", @@ -209,6 +211,7 @@ const SiteHeaderComponent = MountWidget.extend( this.dispatch("notifications:changed", "user-notifications"); this.dispatch("header:keyboard-trigger", "header"); this.dispatch("search-autocomplete:after-complete", "search-term"); + this.dispatch("user-menu:navigation", "user-menu"); this.appEvents.on("dom:clean", this, "_cleanDom"); @@ -236,6 +239,26 @@ const SiteHeaderComponent = MountWidget.extend( once: true, }); } + + const header = document.querySelector("header.d-header"); + const mousetrap = new Mousetrap(header); + mousetrap.bind(["right", "left"], (e) => { + const activeTab = document.querySelector(".glyphs .menu-link.active"); + + if (activeTab) { + let focusedTab = document.activeElement; + if (!focusedTab.dataset.tabNumber) { + focusedTab = activeTab; + } + + this.appEvents.trigger("user-menu:navigation", { + key: e.key, + tabNumber: Number(focusedTab.dataset.tabNumber), + }); + } + }); + + this.set("_mousetrap", mousetrap); }, _cleanDom() { @@ -257,6 +280,8 @@ const SiteHeaderComponent = MountWidget.extend( cancel(this._scheduledRemoveAnimate); window.cancelAnimationFrame(this._scheduledMovingAnimation); + this._mousetrap.unbind(["right", "left"]); + document.removeEventListener("click", this._dismissFirstNotification); }, diff --git a/app/assets/javascripts/discourse/app/widgets/button.js b/app/assets/javascripts/discourse/app/widgets/button.js index 01df29e6cf7..6cdeca2d7bd 100644 --- a/app/assets/javascripts/discourse/app/widgets/button.js +++ b/app/assets/javascripts/discourse/app/widgets/button.js @@ -28,6 +28,10 @@ export const ButtonClass = { return className; }, + buildId(attrs) { + return attrs.id; + }, + buildAttributes() { const attrs = this.attrs; const attributes = {}; @@ -70,7 +74,7 @@ export const ButtonClass = { const icon = iconNode(attrs.icon, { class: attrs.iconClass }); if (attrs["aria-label"]) { icon.properties.attributes["role"] = "img"; - icon.properties.attributes["aria-hidden"] = false; + icon.properties.attributes["aria-hidden"] = true; } return icon; }, diff --git a/app/assets/javascripts/discourse/app/widgets/quick-access-panel.js b/app/assets/javascripts/discourse/app/widgets/quick-access-panel.js index c13bd2705b5..8e2c0c83303 100644 --- a/app/assets/javascripts/discourse/app/widgets/quick-access-panel.js +++ b/app/assets/javascripts/discourse/app/widgets/quick-access-panel.js @@ -40,9 +40,13 @@ export default createWidget("quick-access-panel", { return Promise.resolve([]); }, + buildId() { + return this.key; + }, + buildAttributes() { const attributes = this.attrs; - attributes["aria-labelledby"] = this.key; + attributes["aria-labelledby"] = attributes.currentQuickAccess; attributes["tabindex"] = "0"; attributes["role"] = "tabpanel"; diff --git a/app/assets/javascripts/discourse/app/widgets/user-menu.js b/app/assets/javascripts/discourse/app/widgets/user-menu.js index 5ad3e5309fa..f062412a8de 100644 --- a/app/assets/javascripts/discourse/app/widgets/user-menu.js +++ b/app/assets/javascripts/discourse/app/widgets/user-menu.js @@ -1,6 +1,6 @@ +import { later } from "@ember/runloop"; import { createWidget } from "discourse/widgets/widget"; import { h } from "virtual-dom"; -import { later } from "@ember/runloop"; const UserMenuAction = { QUICK_ACCESS: "quickAccess", @@ -59,7 +59,8 @@ createWidget("user-menu-links", { profileGlyph() { return { title: Titles["profile"], - className: "user-preferences-link", + className: "user-preferences-link menu-link", + id: QuickAccess.PROFILE, icon: "user", action: UserMenuAction.QUICK_ACCESS, actionParam: QuickAccess.PROFILE, @@ -72,7 +73,8 @@ createWidget("user-menu-links", { notificationsGlyph() { return { title: Titles["notifications"], - className: "user-notifications-link", + className: "user-notifications-link menu-link", + id: QuickAccess.NOTIFICATIONS, icon: "bell", action: UserMenuAction.QUICK_ACCESS, actionParam: QuickAccess.NOTIFICATIONS, @@ -87,7 +89,8 @@ createWidget("user-menu-links", { title: Titles["bookmarks"], action: UserMenuAction.QUICK_ACCESS, actionParam: QuickAccess.BOOKMARKS, - className: "user-bookmarks-link", + className: "user-bookmarks-link menu-link", + id: QuickAccess.BOOKMARKS, icon: "bookmark", data: { url: `${this.attrs.path}/activity/bookmarks` }, "aria-label": "user.bookmarks", @@ -101,7 +104,8 @@ createWidget("user-menu-links", { title: Titles["messages"], action: UserMenuAction.QUICK_ACCESS, actionParam: QuickAccess.MESSAGES, - className: "user-pms-link", + className: "user-pms-link menu-link", + id: QuickAccess.MESSAGES, icon: "envelope", data: { url: `${this.attrs.path}/messages` }, role: "tab", @@ -116,10 +120,12 @@ createWidget("user-menu-links", { return this.attach("link", link); }, - glyphHtml(glyph) { + glyphHtml(glyph, idx) { if (this.isActive(glyph)) { glyph = this.markAsActive(glyph); } + glyph.data["tab-number"] = `${idx}`; + return this.attach("flat-button", glyph); }, @@ -153,7 +159,7 @@ createWidget("user-menu-links", { h( "div.glyphs", { attributes: { "aria-label": "Menu links", role: "tablist" } }, - glyphs.map((l) => this.glyphHtml(l)) + glyphs.map((l, index) => this.glyphHtml(l, index)) ), ]); }, @@ -194,6 +200,25 @@ export default createWidget("user-menu", { showLogoutButton: true, }, + userMenuNavigation(nav) { + const maxTabNumber = document.querySelectorAll(".glyphs button").length - 1; + const isLeft = nav.key === "ArrowLeft"; + + let nextTab = isLeft ? nav.tabNumber - 1 : nav.tabNumber + 1; + + if (isLeft && nextTab < 0) { + nextTab = maxTabNumber; + } + + if (!isLeft && nextTab > maxTabNumber) { + nextTab = 0; + } + + document + .querySelector(`.menu-link[role='tab'][data-tab-number='${nextTab}']`) + .focus(); + }, + defaultState() { return { currentQuickAccess: QuickAccess.NOTIFICATIONS, @@ -212,7 +237,7 @@ export default createWidget("user-menu", { path, currentQuickAccess, }), - this.quickAccessPanel(path, titleKey), + this.quickAccessPanel(path, titleKey, currentQuickAccess), ]; return result; @@ -269,13 +294,14 @@ export default createWidget("user-menu", { } }, - quickAccessPanel(path, titleKey) { + quickAccessPanel(path, titleKey, currentQuickAccess) { const { showLogoutButton } = this.settings; // This deliberately does NOT fallback to a default quick access panel. return this.attach(`quick-access-${this.state.currentQuickAccess}`, { path, showLogoutButton, titleKey, + currentQuickAccess, }); }, }); diff --git a/app/assets/stylesheets/common/base/menu-panel.scss b/app/assets/stylesheets/common/base/menu-panel.scss index 3e808b29467..bf0c6291149 100644 --- a/app/assets/stylesheets/common/base/menu-panel.scss +++ b/app/assets/stylesheets/common/base/menu-panel.scss @@ -416,6 +416,10 @@ div.menu-links-header { flex: 1 1 auto; padding: 0.65em 0.25em 0.75em; justify-content: center; + + svg { + pointer-events: none; + } } }