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
This commit is contained in:
parent
6217b0b53b
commit
de10c39fa4
|
@ -6,6 +6,7 @@ import PanEvents, {
|
||||||
import { cancel, later, schedule } from "@ember/runloop";
|
import { cancel, later, schedule } from "@ember/runloop";
|
||||||
import Docking from "discourse/mixins/docking";
|
import Docking from "discourse/mixins/docking";
|
||||||
import MountWidget from "discourse/components/mount-widget";
|
import MountWidget from "discourse/components/mount-widget";
|
||||||
|
import Mousetrap from "mousetrap";
|
||||||
import RerenderOnDoNotDisturbChange from "discourse/mixins/rerender-on-do-not-disturb-change";
|
import RerenderOnDoNotDisturbChange from "discourse/mixins/rerender-on-do-not-disturb-change";
|
||||||
import { observes } from "discourse-common/utils/decorators";
|
import { observes } from "discourse-common/utils/decorators";
|
||||||
import { topicTitleDecorators } from "discourse/components/topic-title";
|
import { topicTitleDecorators } from "discourse/components/topic-title";
|
||||||
|
@ -25,6 +26,7 @@ const SiteHeaderComponent = MountWidget.extend(
|
||||||
_scheduledMovingAnimation: null,
|
_scheduledMovingAnimation: null,
|
||||||
_scheduledRemoveAnimate: null,
|
_scheduledRemoveAnimate: null,
|
||||||
_topic: null,
|
_topic: null,
|
||||||
|
_mousetrap: null,
|
||||||
|
|
||||||
@observes(
|
@observes(
|
||||||
"currentUser.unread_notifications",
|
"currentUser.unread_notifications",
|
||||||
|
@ -209,6 +211,7 @@ const SiteHeaderComponent = MountWidget.extend(
|
||||||
this.dispatch("notifications:changed", "user-notifications");
|
this.dispatch("notifications:changed", "user-notifications");
|
||||||
this.dispatch("header:keyboard-trigger", "header");
|
this.dispatch("header:keyboard-trigger", "header");
|
||||||
this.dispatch("search-autocomplete:after-complete", "search-term");
|
this.dispatch("search-autocomplete:after-complete", "search-term");
|
||||||
|
this.dispatch("user-menu:navigation", "user-menu");
|
||||||
|
|
||||||
this.appEvents.on("dom:clean", this, "_cleanDom");
|
this.appEvents.on("dom:clean", this, "_cleanDom");
|
||||||
|
|
||||||
|
@ -236,6 +239,26 @@ const SiteHeaderComponent = MountWidget.extend(
|
||||||
once: true,
|
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() {
|
_cleanDom() {
|
||||||
|
@ -257,6 +280,8 @@ const SiteHeaderComponent = MountWidget.extend(
|
||||||
cancel(this._scheduledRemoveAnimate);
|
cancel(this._scheduledRemoveAnimate);
|
||||||
window.cancelAnimationFrame(this._scheduledMovingAnimation);
|
window.cancelAnimationFrame(this._scheduledMovingAnimation);
|
||||||
|
|
||||||
|
this._mousetrap.unbind(["right", "left"]);
|
||||||
|
|
||||||
document.removeEventListener("click", this._dismissFirstNotification);
|
document.removeEventListener("click", this._dismissFirstNotification);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
@ -28,6 +28,10 @@ export const ButtonClass = {
|
||||||
return className;
|
return className;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
buildId(attrs) {
|
||||||
|
return attrs.id;
|
||||||
|
},
|
||||||
|
|
||||||
buildAttributes() {
|
buildAttributes() {
|
||||||
const attrs = this.attrs;
|
const attrs = this.attrs;
|
||||||
const attributes = {};
|
const attributes = {};
|
||||||
|
@ -70,7 +74,7 @@ export const ButtonClass = {
|
||||||
const icon = iconNode(attrs.icon, { class: attrs.iconClass });
|
const icon = iconNode(attrs.icon, { class: attrs.iconClass });
|
||||||
if (attrs["aria-label"]) {
|
if (attrs["aria-label"]) {
|
||||||
icon.properties.attributes["role"] = "img";
|
icon.properties.attributes["role"] = "img";
|
||||||
icon.properties.attributes["aria-hidden"] = false;
|
icon.properties.attributes["aria-hidden"] = true;
|
||||||
}
|
}
|
||||||
return icon;
|
return icon;
|
||||||
},
|
},
|
||||||
|
|
|
@ -40,9 +40,13 @@ export default createWidget("quick-access-panel", {
|
||||||
return Promise.resolve([]);
|
return Promise.resolve([]);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
buildId() {
|
||||||
|
return this.key;
|
||||||
|
},
|
||||||
|
|
||||||
buildAttributes() {
|
buildAttributes() {
|
||||||
const attributes = this.attrs;
|
const attributes = this.attrs;
|
||||||
attributes["aria-labelledby"] = this.key;
|
attributes["aria-labelledby"] = attributes.currentQuickAccess;
|
||||||
attributes["tabindex"] = "0";
|
attributes["tabindex"] = "0";
|
||||||
attributes["role"] = "tabpanel";
|
attributes["role"] = "tabpanel";
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
|
import { later } from "@ember/runloop";
|
||||||
import { createWidget } from "discourse/widgets/widget";
|
import { createWidget } from "discourse/widgets/widget";
|
||||||
import { h } from "virtual-dom";
|
import { h } from "virtual-dom";
|
||||||
import { later } from "@ember/runloop";
|
|
||||||
|
|
||||||
const UserMenuAction = {
|
const UserMenuAction = {
|
||||||
QUICK_ACCESS: "quickAccess",
|
QUICK_ACCESS: "quickAccess",
|
||||||
|
@ -59,7 +59,8 @@ createWidget("user-menu-links", {
|
||||||
profileGlyph() {
|
profileGlyph() {
|
||||||
return {
|
return {
|
||||||
title: Titles["profile"],
|
title: Titles["profile"],
|
||||||
className: "user-preferences-link",
|
className: "user-preferences-link menu-link",
|
||||||
|
id: QuickAccess.PROFILE,
|
||||||
icon: "user",
|
icon: "user",
|
||||||
action: UserMenuAction.QUICK_ACCESS,
|
action: UserMenuAction.QUICK_ACCESS,
|
||||||
actionParam: QuickAccess.PROFILE,
|
actionParam: QuickAccess.PROFILE,
|
||||||
|
@ -72,7 +73,8 @@ createWidget("user-menu-links", {
|
||||||
notificationsGlyph() {
|
notificationsGlyph() {
|
||||||
return {
|
return {
|
||||||
title: Titles["notifications"],
|
title: Titles["notifications"],
|
||||||
className: "user-notifications-link",
|
className: "user-notifications-link menu-link",
|
||||||
|
id: QuickAccess.NOTIFICATIONS,
|
||||||
icon: "bell",
|
icon: "bell",
|
||||||
action: UserMenuAction.QUICK_ACCESS,
|
action: UserMenuAction.QUICK_ACCESS,
|
||||||
actionParam: QuickAccess.NOTIFICATIONS,
|
actionParam: QuickAccess.NOTIFICATIONS,
|
||||||
|
@ -87,7 +89,8 @@ createWidget("user-menu-links", {
|
||||||
title: Titles["bookmarks"],
|
title: Titles["bookmarks"],
|
||||||
action: UserMenuAction.QUICK_ACCESS,
|
action: UserMenuAction.QUICK_ACCESS,
|
||||||
actionParam: QuickAccess.BOOKMARKS,
|
actionParam: QuickAccess.BOOKMARKS,
|
||||||
className: "user-bookmarks-link",
|
className: "user-bookmarks-link menu-link",
|
||||||
|
id: QuickAccess.BOOKMARKS,
|
||||||
icon: "bookmark",
|
icon: "bookmark",
|
||||||
data: { url: `${this.attrs.path}/activity/bookmarks` },
|
data: { url: `${this.attrs.path}/activity/bookmarks` },
|
||||||
"aria-label": "user.bookmarks",
|
"aria-label": "user.bookmarks",
|
||||||
|
@ -101,7 +104,8 @@ createWidget("user-menu-links", {
|
||||||
title: Titles["messages"],
|
title: Titles["messages"],
|
||||||
action: UserMenuAction.QUICK_ACCESS,
|
action: UserMenuAction.QUICK_ACCESS,
|
||||||
actionParam: QuickAccess.MESSAGES,
|
actionParam: QuickAccess.MESSAGES,
|
||||||
className: "user-pms-link",
|
className: "user-pms-link menu-link",
|
||||||
|
id: QuickAccess.MESSAGES,
|
||||||
icon: "envelope",
|
icon: "envelope",
|
||||||
data: { url: `${this.attrs.path}/messages` },
|
data: { url: `${this.attrs.path}/messages` },
|
||||||
role: "tab",
|
role: "tab",
|
||||||
|
@ -116,10 +120,12 @@ createWidget("user-menu-links", {
|
||||||
return this.attach("link", link);
|
return this.attach("link", link);
|
||||||
},
|
},
|
||||||
|
|
||||||
glyphHtml(glyph) {
|
glyphHtml(glyph, idx) {
|
||||||
if (this.isActive(glyph)) {
|
if (this.isActive(glyph)) {
|
||||||
glyph = this.markAsActive(glyph);
|
glyph = this.markAsActive(glyph);
|
||||||
}
|
}
|
||||||
|
glyph.data["tab-number"] = `${idx}`;
|
||||||
|
|
||||||
return this.attach("flat-button", glyph);
|
return this.attach("flat-button", glyph);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -153,7 +159,7 @@ createWidget("user-menu-links", {
|
||||||
h(
|
h(
|
||||||
"div.glyphs",
|
"div.glyphs",
|
||||||
{ attributes: { "aria-label": "Menu links", role: "tablist" } },
|
{ 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,
|
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() {
|
defaultState() {
|
||||||
return {
|
return {
|
||||||
currentQuickAccess: QuickAccess.NOTIFICATIONS,
|
currentQuickAccess: QuickAccess.NOTIFICATIONS,
|
||||||
|
@ -212,7 +237,7 @@ export default createWidget("user-menu", {
|
||||||
path,
|
path,
|
||||||
currentQuickAccess,
|
currentQuickAccess,
|
||||||
}),
|
}),
|
||||||
this.quickAccessPanel(path, titleKey),
|
this.quickAccessPanel(path, titleKey, currentQuickAccess),
|
||||||
];
|
];
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
@ -269,13 +294,14 @@ export default createWidget("user-menu", {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
quickAccessPanel(path, titleKey) {
|
quickAccessPanel(path, titleKey, currentQuickAccess) {
|
||||||
const { showLogoutButton } = this.settings;
|
const { showLogoutButton } = this.settings;
|
||||||
// This deliberately does NOT fallback to a default quick access panel.
|
// This deliberately does NOT fallback to a default quick access panel.
|
||||||
return this.attach(`quick-access-${this.state.currentQuickAccess}`, {
|
return this.attach(`quick-access-${this.state.currentQuickAccess}`, {
|
||||||
path,
|
path,
|
||||||
showLogoutButton,
|
showLogoutButton,
|
||||||
titleKey,
|
titleKey,
|
||||||
|
currentQuickAccess,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -416,6 +416,10 @@ div.menu-links-header {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
padding: 0.65em 0.25em 0.75em;
|
padding: 0.65em 0.25em 0.75em;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue