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:
parent
73cb083b7b
commit
6d30e01d1c
|
@ -737,10 +737,10 @@ class PluginApi {
|
||||||
* example:
|
* example:
|
||||||
*
|
*
|
||||||
* api.addUserMenuGlyph({
|
* api.addUserMenuGlyph({
|
||||||
* label: 'awesome.label',
|
* title: 'awesome.label',
|
||||||
* className: 'my-class',
|
* className: 'my-class',
|
||||||
* icon: 'my-icon',
|
* icon: 'my-icon',
|
||||||
* href: `/some/path`
|
* data: { url: `/some/path` },
|
||||||
* });
|
* });
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -38,6 +38,17 @@ export const ButtonClass = {
|
||||||
attributes.title = title;
|
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) {
|
if (attrs.disabled) {
|
||||||
attributes.disabled = "true";
|
attributes.disabled = "true";
|
||||||
}
|
}
|
||||||
|
@ -51,11 +62,20 @@ export const ButtonClass = {
|
||||||
return attributes;
|
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) {
|
html(attrs) {
|
||||||
const contents = [];
|
const contents = [];
|
||||||
const left = !attrs.iconRight;
|
const left = !attrs.iconRight;
|
||||||
if (attrs.icon && left) {
|
if (attrs.icon && left) {
|
||||||
contents.push(iconNode(attrs.icon, { class: attrs.iconClass }));
|
contents.push(this._buildIcon(attrs));
|
||||||
}
|
}
|
||||||
if (attrs.label) {
|
if (attrs.label) {
|
||||||
contents.push(
|
contents.push(
|
||||||
|
@ -75,7 +95,7 @@ export const ButtonClass = {
|
||||||
contents.push(attrs.contents);
|
contents.push(attrs.contents);
|
||||||
}
|
}
|
||||||
if (attrs.icon && !left) {
|
if (attrs.icon && !left) {
|
||||||
contents.push(iconNode(attrs.icon, { class: attrs.iconClass }));
|
contents.push(this._buildIcon(attrs));
|
||||||
}
|
}
|
||||||
|
|
||||||
return contents;
|
return contents;
|
||||||
|
|
|
@ -40,6 +40,15 @@ export default createWidget("quick-access-panel", {
|
||||||
return Promise.resolve([]);
|
return Promise.resolve([]);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
buildAttributes() {
|
||||||
|
const attributes = this.attrs;
|
||||||
|
attributes["aria-labelledby"] = this.key;
|
||||||
|
attributes["tabindex"] = "0";
|
||||||
|
attributes["role"] = "tabpanel";
|
||||||
|
|
||||||
|
return attributes;
|
||||||
|
},
|
||||||
|
|
||||||
newItemsLoaded() {},
|
newItemsLoaded() {},
|
||||||
|
|
||||||
itemHtml(item) {}, // eslint-disable-line no-unused-vars
|
itemHtml(item) {}, // eslint-disable-line no-unused-vars
|
||||||
|
|
|
@ -23,51 +23,82 @@ export function addUserMenuGlyph(glyph) {
|
||||||
createWidget("user-menu-links", {
|
createWidget("user-menu-links", {
|
||||||
tagName: "div.menu-links-header",
|
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() {
|
profileGlyph() {
|
||||||
return {
|
return {
|
||||||
label: "user.preferences",
|
title: "user.preferences",
|
||||||
className: "user-preferences-link",
|
className: "user-preferences-link",
|
||||||
icon: "user",
|
icon: "user",
|
||||||
href: `${this.attrs.path}/summary`,
|
|
||||||
action: UserMenuAction.QUICK_ACCESS,
|
action: UserMenuAction.QUICK_ACCESS,
|
||||||
actionParam: QuickAccess.PROFILE,
|
actionParam: QuickAccess.PROFILE,
|
||||||
"aria-label": "user.preferences",
|
data: { url: `${this.attrs.path}/summary` },
|
||||||
|
role: "tab",
|
||||||
|
tabAttrs: this._tabAttrs(QuickAccess.PROFILE),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
notificationsGlyph() {
|
notificationsGlyph() {
|
||||||
return {
|
return {
|
||||||
label: "user.notifications",
|
title: "user.notifications",
|
||||||
className: "user-notifications-link",
|
className: "user-notifications-link",
|
||||||
icon: "bell",
|
icon: "bell",
|
||||||
href: `${this.attrs.path}/notifications`,
|
|
||||||
action: UserMenuAction.QUICK_ACCESS,
|
action: UserMenuAction.QUICK_ACCESS,
|
||||||
actionParam: QuickAccess.NOTIFICATIONS,
|
actionParam: QuickAccess.NOTIFICATIONS,
|
||||||
"aria-label": "user.notifications",
|
data: { url: `${this.attrs.path}/notifications` },
|
||||||
|
role: "tab",
|
||||||
|
tabAttrs: this._tabAttrs(QuickAccess.NOTIFICATIONS),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
bookmarksGlyph() {
|
bookmarksGlyph() {
|
||||||
return {
|
return {
|
||||||
|
title: "user.bookmarks",
|
||||||
action: UserMenuAction.QUICK_ACCESS,
|
action: UserMenuAction.QUICK_ACCESS,
|
||||||
actionParam: QuickAccess.BOOKMARKS,
|
actionParam: QuickAccess.BOOKMARKS,
|
||||||
label: "user.bookmarks",
|
|
||||||
className: "user-bookmarks-link",
|
className: "user-bookmarks-link",
|
||||||
icon: "bookmark",
|
icon: "bookmark",
|
||||||
href: `${this.attrs.path}/activity/bookmarks`,
|
data: { url: `${this.attrs.path}/activity/bookmarks` },
|
||||||
"aria-label": "user.bookmarks",
|
"aria-label": "user.bookmarks",
|
||||||
|
role: "tab",
|
||||||
|
tabAttrs: this._tabAttrs(QuickAccess.BOOKMARKS),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
messagesGlyph() {
|
messagesGlyph() {
|
||||||
return {
|
return {
|
||||||
|
title: "user.private_messages",
|
||||||
action: UserMenuAction.QUICK_ACCESS,
|
action: UserMenuAction.QUICK_ACCESS,
|
||||||
actionParam: QuickAccess.MESSAGES,
|
actionParam: QuickAccess.MESSAGES,
|
||||||
label: "user.private_messages",
|
|
||||||
className: "user-pms-link",
|
className: "user-pms-link",
|
||||||
icon: "envelope",
|
icon: "envelope",
|
||||||
href: `${this.attrs.path}/messages`,
|
data: { url: `${this.attrs.path}/messages` },
|
||||||
"aria-label": "user.private_messages",
|
role: "tab",
|
||||||
|
tabAttrs: this._tabAttrs(QuickAccess.MESSAGES),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -82,7 +113,7 @@ createWidget("user-menu-links", {
|
||||||
if (this.isActive(glyph)) {
|
if (this.isActive(glyph)) {
|
||||||
glyph = this.markAsActive(glyph);
|
glyph = this.markAsActive(glyph);
|
||||||
}
|
}
|
||||||
return this.attach("link", $.extend(glyph, { hideLabel: true }));
|
return this.attach("flat-button", glyph);
|
||||||
},
|
},
|
||||||
|
|
||||||
html() {
|
html() {
|
||||||
|
@ -94,7 +125,8 @@ createWidget("user-menu-links", {
|
||||||
g = g(this);
|
g = g(this);
|
||||||
}
|
}
|
||||||
if (g) {
|
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());
|
glyphs.push(this.profileGlyph());
|
||||||
|
|
||||||
return h("ul.menu-links-row", [
|
return h("div.menu-links-row", [
|
||||||
h(
|
h(
|
||||||
"li.glyphs",
|
"div.glyphs",
|
||||||
|
{ attributes: { "aria-label": "Menu links", role: "tablist" } },
|
||||||
glyphs.map((l) => this.glyphHtml(l))
|
glyphs.map((l) => this.glyphHtml(l))
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
@ -121,6 +154,7 @@ createWidget("user-menu-links", {
|
||||||
// the full page.
|
// the full page.
|
||||||
definition.action = null;
|
definition.action = null;
|
||||||
definition.actionParam = null;
|
definition.actionParam = null;
|
||||||
|
definition.url = definition.data.url;
|
||||||
|
|
||||||
if (definition.className) {
|
if (definition.className) {
|
||||||
definition.className += " active";
|
definition.className += " active";
|
||||||
|
@ -128,6 +162,9 @@ createWidget("user-menu-links", {
|
||||||
definition.className = "active";
|
definition.className = "active";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
definition.tabAttrs["tabindex"] = "0";
|
||||||
|
definition.tabAttrs["aria-selected"] = "true";
|
||||||
|
|
||||||
return definition;
|
return definition;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
@ -79,7 +79,9 @@ discourseModule("Integration | Component | Widget | user-menu", function (
|
||||||
const routeToStub = sinon.stub(DiscourseURL, "routeTo");
|
const routeToStub = sinon.stub(DiscourseURL, "routeTo");
|
||||||
await click(".user-notifications-link");
|
await click(".user-notifications-link");
|
||||||
assert.ok(
|
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"
|
"a second click should redirect to the full notifications page"
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
@ -120,7 +122,7 @@ discourseModule("Integration | Component | Widget | user-menu", function (
|
||||||
},
|
},
|
||||||
|
|
||||||
async test(assert) {
|
async test(assert) {
|
||||||
const userPmsLink = queryAll(".user-pms-link")[0];
|
const userPmsLink = queryAll(".user-pms-link").data("url");
|
||||||
assert.ok(userPmsLink);
|
assert.ok(userPmsLink);
|
||||||
await click(".user-pms-link");
|
await click(".user-pms-link");
|
||||||
|
|
||||||
|
@ -143,7 +145,7 @@ discourseModule("Integration | Component | Widget | user-menu", function (
|
||||||
const routeToStub = sinon.stub(DiscourseURL, "routeTo");
|
const routeToStub = sinon.stub(DiscourseURL, "routeTo");
|
||||||
await click(".user-pms-link");
|
await click(".user-pms-link");
|
||||||
assert.ok(
|
assert.ok(
|
||||||
routeToStub.calledWith(userPmsLink.href),
|
routeToStub.calledWith(userPmsLink),
|
||||||
"a second click should redirect to the full private messages page"
|
"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");
|
const routeToStub = sinon.stub(DiscourseURL, "routeTo");
|
||||||
await click(".user-bookmarks-link");
|
await click(".user-bookmarks-link");
|
||||||
assert.ok(
|
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"
|
"a second click should redirect to the full bookmarks page"
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
@ -380,76 +380,73 @@ div.menu-links-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
li {
|
.glyphs {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
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 {
|
button {
|
||||||
// This is to make sure active and inactive tab icons have the same
|
display: flex;
|
||||||
// size. `box-sizing` does not work and I have no idea why.
|
flex: 1 1 auto;
|
||||||
border: 1px solid transparent;
|
padding: 0.65em 0.25em 0.75em;
|
||||||
&:not(.active):hover {
|
justify-content: center;
|
||||||
border-bottom: 0;
|
}
|
||||||
margin-top: -1px;
|
}
|
||||||
}
|
|
||||||
|
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 {
|
&:focus,
|
||||||
border: 1px solid var(--primary-low);
|
&:hover {
|
||||||
border-bottom: 1px solid var(--secondary);
|
background-color: inherit;
|
||||||
position: relative;
|
|
||||||
|
|
||||||
.d-icon {
|
|
||||||
color: var(--primary-high);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:focus,
|
|
||||||
&:hover {
|
|
||||||
background-color: inherit;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
a:hover,
|
|
||||||
a:focus {
|
button:hover,
|
||||||
|
button:focus {
|
||||||
background-color: var(--highlight-medium);
|
background-color: var(--highlight-medium);
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
a {
|
button {
|
||||||
padding: 0.3em 0.5em;
|
padding: 0.3em 0.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
li {
|
.glyphs {
|
||||||
display: table-cell;
|
display: table-cell;
|
||||||
width: auto;
|
width: auto;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
li:first-child {
|
.glyphs:first-child {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
li:last-child {
|
.glyphs:last-child {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
.fa,
|
.fa,
|
||||||
a {
|
button {
|
||||||
color: var(--primary-med-or-secondary-med);
|
color: var(--primary-med-or-secondary-med);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue