A11Y: Structure user menu as tabs. (#11789)

* A11Y: Structure user menu as tabs.

Although the user menu content has the appearance of tabs and relies on the functionality of tabs to make sense in terms of content and focus order, it is not marked up correctly as tabs and tab panels. See [WAI-ARIA Authoring Practices 1.1](https://www.w3.org/TR/wai-aria-practices-1.1/#tabpanel) and the [example](https://www.w3.org/TR/wai-aria-practices-1.1/examples/tabs/tabs-2/tabs.html) for details.

* Make plugin api backwards compatible
This commit is contained in:
Roman Rizzi 2021-01-22 19:05:14 -03:00 committed by GitHub
parent 73cb083b7b
commit 6d30e01d1c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 132 additions and 67 deletions

View File

@ -737,10 +737,10 @@ class PluginApi {
* example:
*
* api.addUserMenuGlyph({
* label: 'awesome.label',
* title: 'awesome.label',
* className: 'my-class',
* icon: 'my-icon',
* href: `/some/path`
* data: { url: `/some/path` },
* });
*
*/

View File

@ -38,6 +38,17 @@ export const ButtonClass = {
attributes.title = title;
}
if (attrs.role) {
attributes["role"] = attrs.role;
}
if (attrs.tabAttrs) {
const tab = attrs.tabAttrs;
attributes["aria-selected"] = tab["aria-selected"];
attributes["tabindex"] = tab["tabindex"];
attributes["aria-controls"] = tab["aria-controls"];
}
if (attrs.disabled) {
attributes.disabled = "true";
}
@ -51,11 +62,20 @@ export const ButtonClass = {
return attributes;
},
_buildIcon(attrs) {
const icon = iconNode(attrs.icon, { class: attrs.iconClass });
if (attrs["aria-label"]) {
icon.properties.attributes["role"] = "img";
icon.properties.attributes["aria-hidden"] = false;
}
return icon;
},
html(attrs) {
const contents = [];
const left = !attrs.iconRight;
if (attrs.icon && left) {
contents.push(iconNode(attrs.icon, { class: attrs.iconClass }));
contents.push(this._buildIcon(attrs));
}
if (attrs.label) {
contents.push(
@ -75,7 +95,7 @@ export const ButtonClass = {
contents.push(attrs.contents);
}
if (attrs.icon && !left) {
contents.push(iconNode(attrs.icon, { class: attrs.iconClass }));
contents.push(this._buildIcon(attrs));
}
return contents;

View File

@ -40,6 +40,15 @@ export default createWidget("quick-access-panel", {
return Promise.resolve([]);
},
buildAttributes() {
const attributes = this.attrs;
attributes["aria-labelledby"] = this.key;
attributes["tabindex"] = "0";
attributes["role"] = "tabpanel";
return attributes;
},
newItemsLoaded() {},
itemHtml(item) {}, // eslint-disable-line no-unused-vars

View File

@ -23,51 +23,82 @@ export function addUserMenuGlyph(glyph) {
createWidget("user-menu-links", {
tagName: "div.menu-links-header",
_tabAttrs(quickAccessType) {
return {
"aria-controls": `quick-access-${quickAccessType}`,
"aria-selected": "false",
tabindex: "-1",
};
},
// TODO: Remove when 2.7 gets released.
_structureAsTab(extraGlyph) {
const glyph = extraGlyph;
// Assume glyph is a button if it has a data-url field.
if (!glyph.data || !glyph.data.url) {
glyph.title = glyph.label;
glyph.data = { url: glyph.href };
glyph.label = null;
glyph.href = null;
}
glyph.role = "tab";
glyph.tabAttrs = this._tabAttrs(glyph.actionParam);
return glyph;
},
profileGlyph() {
return {
label: "user.preferences",
title: "user.preferences",
className: "user-preferences-link",
icon: "user",
href: `${this.attrs.path}/summary`,
action: UserMenuAction.QUICK_ACCESS,
actionParam: QuickAccess.PROFILE,
"aria-label": "user.preferences",
data: { url: `${this.attrs.path}/summary` },
role: "tab",
tabAttrs: this._tabAttrs(QuickAccess.PROFILE),
};
},
notificationsGlyph() {
return {
label: "user.notifications",
title: "user.notifications",
className: "user-notifications-link",
icon: "bell",
href: `${this.attrs.path}/notifications`,
action: UserMenuAction.QUICK_ACCESS,
actionParam: QuickAccess.NOTIFICATIONS,
"aria-label": "user.notifications",
data: { url: `${this.attrs.path}/notifications` },
role: "tab",
tabAttrs: this._tabAttrs(QuickAccess.NOTIFICATIONS),
};
},
bookmarksGlyph() {
return {
title: "user.bookmarks",
action: UserMenuAction.QUICK_ACCESS,
actionParam: QuickAccess.BOOKMARKS,
label: "user.bookmarks",
className: "user-bookmarks-link",
icon: "bookmark",
href: `${this.attrs.path}/activity/bookmarks`,
data: { url: `${this.attrs.path}/activity/bookmarks` },
"aria-label": "user.bookmarks",
role: "tab",
tabAttrs: this._tabAttrs(QuickAccess.BOOKMARKS),
};
},
messagesGlyph() {
return {
title: "user.private_messages",
action: UserMenuAction.QUICK_ACCESS,
actionParam: QuickAccess.MESSAGES,
label: "user.private_messages",
className: "user-pms-link",
icon: "envelope",
href: `${this.attrs.path}/messages`,
"aria-label": "user.private_messages",
data: { url: `${this.attrs.path}/messages` },
role: "tab",
tabAttrs: this._tabAttrs(QuickAccess.MESSAGES),
};
},
@ -82,7 +113,7 @@ createWidget("user-menu-links", {
if (this.isActive(glyph)) {
glyph = this.markAsActive(glyph);
}
return this.attach("link", $.extend(glyph, { hideLabel: true }));
return this.attach("flat-button", glyph);
},
html() {
@ -94,7 +125,8 @@ createWidget("user-menu-links", {
g = g(this);
}
if (g) {
glyphs.push(g);
const structuredGlyph = this._structureAsTab(g);
glyphs.push(structuredGlyph);
}
});
}
@ -108,9 +140,10 @@ createWidget("user-menu-links", {
glyphs.push(this.profileGlyph());
return h("ul.menu-links-row", [
return h("div.menu-links-row", [
h(
"li.glyphs",
"div.glyphs",
{ attributes: { "aria-label": "Menu links", role: "tablist" } },
glyphs.map((l) => this.glyphHtml(l))
),
]);
@ -121,6 +154,7 @@ createWidget("user-menu-links", {
// the full page.
definition.action = null;
definition.actionParam = null;
definition.url = definition.data.url;
if (definition.className) {
definition.className += " active";
@ -128,6 +162,9 @@ createWidget("user-menu-links", {
definition.className = "active";
}
definition.tabAttrs["tabindex"] = "0";
definition.tabAttrs["aria-selected"] = "true";
return definition;
},

View File

@ -79,7 +79,9 @@ discourseModule("Integration | Component | Widget | user-menu", function (
const routeToStub = sinon.stub(DiscourseURL, "routeTo");
await click(".user-notifications-link");
assert.ok(
routeToStub.calledWith(queryAll(".user-notifications-link")[0].href),
routeToStub.calledWith(
queryAll(".user-notifications-link").data("url")
),
"a second click should redirect to the full notifications page"
);
},
@ -120,7 +122,7 @@ discourseModule("Integration | Component | Widget | user-menu", function (
},
async test(assert) {
const userPmsLink = queryAll(".user-pms-link")[0];
const userPmsLink = queryAll(".user-pms-link").data("url");
assert.ok(userPmsLink);
await click(".user-pms-link");
@ -143,7 +145,7 @@ discourseModule("Integration | Component | Widget | user-menu", function (
const routeToStub = sinon.stub(DiscourseURL, "routeTo");
await click(".user-pms-link");
assert.ok(
routeToStub.calledWith(userPmsLink.href),
routeToStub.calledWith(userPmsLink),
"a second click should redirect to the full private messages page"
);
},
@ -171,7 +173,7 @@ discourseModule("Integration | Component | Widget | user-menu", function (
const routeToStub = sinon.stub(DiscourseURL, "routeTo");
await click(".user-bookmarks-link");
assert.ok(
routeToStub.calledWith(queryAll(".user-bookmarks-link")[0].href),
routeToStub.calledWith(queryAll(".user-bookmarks-link").data("url")),
"a second click should redirect to the full bookmarks page"
);
},

View File

@ -380,76 +380,73 @@ div.menu-links-header {
display: flex;
width: 100%;
z-index: 2;
justify-content: space-between;
li {
.glyphs {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
flex-wrap: nowrap;
width: 100%;
justify-content: space-between;
padding: 0;
&.glyphs {
flex-wrap: nowrap;
width: 100%;
justify-content: space-between;
padding: 0;
a {
display: flex;
flex: 1 1 auto;
padding: 0.65em 0.25em 0.75em;
justify-content: center;
}
}
a,
button {
// This is to make sure active and inactive tab icons have the same
// size. `box-sizing` does not work and I have no idea why.
border: 1px solid transparent;
&:not(.active):hover {
border-bottom: 0;
margin-top: -1px;
}
display: flex;
flex: 1 1 auto;
padding: 0.65em 0.25em 0.75em;
justify-content: center;
}
}
button {
// This is to make sure active and inactive tab icons have the same
// size. `box-sizing` does not work and I have no idea why.
border: 1px solid transparent;
&:not(.active):hover {
border-bottom: 0;
margin-top: -1px;
}
}
button.active {
border: 1px solid var(--primary-low);
border-bottom: 1px solid var(--secondary);
position: relative;
.d-icon {
color: var(--primary-high);
}
a.active {
border: 1px solid var(--primary-low);
border-bottom: 1px solid var(--secondary);
position: relative;
.d-icon {
color: var(--primary-high);
}
&:focus,
&:hover {
background-color: inherit;
}
&:focus,
&:hover {
background-color: inherit;
}
}
}
a:hover,
a:focus {
button:hover,
button:focus {
background-color: var(--highlight-medium);
outline: none;
}
a {
button {
padding: 0.3em 0.5em;
}
li {
.glyphs {
display: table-cell;
width: auto;
text-align: center;
}
li:first-child {
.glyphs:first-child {
text-align: left;
}
li:last-child {
.glyphs:last-child {
text-align: right;
}
.fa,
a {
button {
color: var(--primary-med-or-secondary-med);
}
}