Bug 1525178 - Part 1: Extract common tab focus from named-deck components r=Gijs,Jamie

Differential Revision: https://phabricator.services.mozilla.com/D60422

--HG--
extra : moz-landing-system : lando
This commit is contained in:
Mark Striemer 2020-03-30 19:44:49 +00:00
Родитель 0d71e9c342
Коммит 541c10fc2d
10 изменённых файлов: 289 добавлений и 114 удалений

Просмотреть файл

@ -512,10 +512,51 @@ addon-permissions-list > .addon-detail-row:first-of-type {
border-top: none;
}
.deck-tab-group {
.tab-group {
display: block;
margin-top: 8px;
/* Pull the buttons flush with the side of the card */
margin-inline: calc(var(--card-padding) * -1);
border-bottom: 1px solid var(--in-content-box-border-color);
border-top: 1px solid var(--in-content-box-border-color);
font-size: 0;
line-height: 0;
}
button.tab-button {
-moz-appearance: none;
border-inline: none;
border-block: 2px solid transparent;
border-radius: 0;
background: transparent;
font-size: 14px;
line-height: 20px;
margin: 0;
padding: 4px 16px;
color: var(--in-content-text-color);
}
button.tab-button:hover {
background-color: var(--in-content-button-background);
border-top-color: var(--in-content-box-border-color);
}
button.tab-button:hover:active {
background-color: var(--in-content-button-background-hover);
}
button.tab-button[selected] {
border-top-color: var(--in-content-border-highlight);
color: var(--in-content-category-text-selected) !important;
}
button.tab-button:focus {
border-top-color: transparent;
}
button.tab-button:-moz-focusring {
outline-offset: -2px;
-moz-outline-radius: 0;
}
panel-list {

Просмотреть файл

@ -162,14 +162,12 @@
</template>
<template name="addon-details">
<div class="deck-tab-group">
<named-deck-button-group>
<named-deck-button deck="details-deck" name="details" data-l10n-id="details-addon-button"></named-deck-button>
<named-deck-button deck="details-deck" name="preferences" data-l10n-id="preferences-addon-button"></named-deck-button>
<named-deck-button deck="details-deck" name="permissions" data-l10n-id="permissions-addon-button"></named-deck-button>
<named-deck-button deck="details-deck" name="release-notes" data-l10n-id="release-notes-addon-button"></named-deck-button>
</named-deck-button-group>
</div>
<button-group class="tab-group">
<button is="named-deck-button" deck="details-deck" name="details" data-l10n-id="details-addon-button" class="tab-button"></button>
<button is="named-deck-button" deck="details-deck" name="preferences" data-l10n-id="preferences-addon-button" class="tab-button"></button>
<button is="named-deck-button" deck="details-deck" name="permissions" data-l10n-id="permissions-addon-button" class="tab-button"></button>
<button is="named-deck-button" deck="details-deck" name="release-notes" data-l10n-id="release-notes-addon-button" class="tab-button"></button>
</button-group>
<named-deck id="details-deck">
<section name="details">
<div class="addon-detail-description"></div>

Просмотреть файл

@ -2192,7 +2192,7 @@ class AddonDetails extends HTMLElement {
}
// Hide the tab group if "details" is the only visible button.
let tabGroupButtons = this.tabGroup.querySelectorAll("named-deck-button");
let tabGroupButtons = this.tabGroup.querySelectorAll(".tab-button");
this.tabGroup.hidden = Array.from(tabGroupButtons).every(button => {
return button.name == "details" || button.hidden;
});
@ -2222,7 +2222,7 @@ class AddonDetails extends HTMLElement {
this.appendChild(importTemplate("addon-details"));
this.deck = this.querySelector("named-deck");
this.tabGroup = this.querySelector(".deck-tab-group");
this.tabGroup = this.querySelector(".tab-group");
// Set the add-on for the permissions section.
this.permissionsList = this.querySelector("addon-permissions-list");

Просмотреть файл

@ -11,16 +11,20 @@
* will reflect the deck's state. When the button is clicked, it will set the
* view in the <named-deck> to the button's "name" attribute.
*
* The "tab" role will be added unless a different role is provided. Wrapping
* a set of these buttons in a <button-group> element will add the key handling
* for a tablist.
*
* NOTE: This does not observe changes to the "deck" or "name" attributes, so
* changing them likely won't work properly.
*
* <named-deck-button deck="pet-deck" name="dogs">Dogs</named-deck-button>
* <button is="named-deck-button" deck="pet-deck" name="dogs">Dogs</button>
* <named-deck id="pet-deck">
* <p name="cats">I like cats.</p>
* <p name="dogs">I like dogs.</p>
* </named-deck>
*
* let btn = document.querySelector("named-deck-button");
* let btn = document.querySelector('button[name="dogs"]');
* let deck = document.querySelector("named-deck");
* deck.selectedViewName == "cats";
* btn.selected == false; // Selected was pulled from the related deck.
@ -28,61 +32,26 @@
* deck.selectedViewName == "dogs";
* btn.selected == true; // Selected updated when view changed.
*/
class NamedDeckButton extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
// Include styles inline to avoid a FOUC.
let style = document.createElement("style");
style.textContent = `
button {
-moz-appearance: none;
border: none;
border-top: 2px solid transparent;
border-bottom: 2px solid transparent;
background: var(--in-content-box-background);
font-size: 14px;
line-height: 20px;
padding: 4px 16px;
color: var(--in-content-text-color);
}
button:hover {
background-color: var(--in-content-box-background-hover);
border-top-color: var(--in-content-box-border-color);
}
button:hover:active {
background-color: var(--in-content-box-background-active);
}
:host([selected]) button {
border-top-color: var(--in-content-border-highlight);
color: var(--in-content-category-text-selected);
}
`;
this.shadowRoot.appendChild(style);
this.button = document.createElement("button");
this.button.setAttribute("role", "tab");
this.button.appendChild(document.createElement("slot"));
this.shadowRoot.appendChild(this.button);
this.addEventListener("click", this);
}
class NamedDeckButton extends HTMLButtonElement {
connectedCallback() {
this.id = `${this.deckId}-button-${this.name}`;
if (!this.hasAttribute("role")) {
this.setAttribute("role", "tab");
}
this.setSelectedFromDeck();
this.addEventListener("click", this);
document.addEventListener("view-changed", this, { capture: true });
}
disconnectedCallback() {
this.removeEventListener("click", this);
document.removeEventListener("view-changed", this, { capture: true });
}
focus() {
this.button.focus();
attributeChangedCallback(name, oldVal, newVal) {
if (name == "selected") {
this.selected = newVal;
}
}
get deckId() {
@ -117,80 +86,185 @@ class NamedDeckButton extends HTMLElement {
}
set selected(val) {
this.toggleAttribute("selected", !!val);
this.button.setAttribute("aria-selected", !!val);
this.button.setAttribute("tabindex", val ? "0" : "-1");
if (this.selected != val) {
this.toggleAttribute("selected", val);
}
this.setAttribute("aria-selected", !!val);
}
setSelectedFromDeck() {
let { deck } = this;
this.selected = deck && deck.selectedViewName == this.name;
if (this.selected) {
this.dispatchEvent(
new CustomEvent("button-group:selected", { bubbles: true })
);
}
}
}
customElements.define("named-deck-button", NamedDeckButton);
customElements.define("named-deck-button", NamedDeckButton, {
extends: "button",
});
class NamedDeckButtonGroup extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
class ButtonGroup extends HTMLElement {
static get observedAttributes() {
return ["orientation"];
}
let style = document.createElement("style");
style.textContent = `
div {
border-bottom: 1px solid var(--in-content-box-border-color);
border-top: 1px solid var(--in-content-box-border-color);
font-size: 0;
line-height: 0;
}
`;
this.shadowRoot.appendChild(style);
connectedCallback() {
this.setAttribute("role", "tablist");
let container = document.createElement("div");
container.setAttribute("role", "tablist");
container.appendChild(document.createElement("slot"));
this.shadowRoot.appendChild(container);
if (!this.observer) {
this.observer = new MutationObserver(changes => {
for (let change of changes) {
this.setChildAttributes(change.addedNodes);
for (let node of change.removedNodes) {
if (this.activeChild == node) {
// Ensure there's still an active child.
this.activeChild = this.firstElementChild;
}
}
}
});
}
this.observer.observe(this, { childList: true });
// Set the role and tabindex for the current children.
this.setChildAttributes(this.children);
// Try assigning the active child again, this will run through the checks
// to ensure it's still valid.
this.activeChild = this._activeChild;
this.addEventListener("button-group:selected", this);
this.addEventListener("keydown", this);
}
handleEvent(e) {
if (
e.type === "keydown" &&
e.target.localName === "named-deck-button" &&
["ArrowLeft", "ArrowRight"].includes(e.key)
) {
let previousDirectionKey =
document.dir === "rtl" ? "ArrowRight" : "ArrowLeft";
this.walker.currentNode = e.target;
let nextItem =
e.key === previousDirectionKey
? this.walker.previousNode()
: this.walker.nextNode();
if (nextItem) {
nextItem.focus();
disconnectedCallback() {
this.observer.disconnect();
this.removeEventListener("button-group:selected", this);
this.removeEventListener("keydown", this);
}
attributeChangedCallback(name, oldVal, newVal) {
if (name == "orientation") {
if (this.isVertical) {
this.setAttribute("aria-orientation", this.orientation);
} else {
this.removeAttribute("aria-orientation");
}
}
}
setChildAttributes(nodes) {
for (let node of nodes) {
if (node.nodeType == Node.ELEMENT_NODE && node != this.activeChild) {
node.setAttribute("tabindex", "-1");
}
}
}
// The activeChild is the child that can be focused with tab.
get activeChild() {
return this._activeChild;
}
set activeChild(node) {
let prevActiveChild = this._activeChild;
let newActiveChild;
if (node && this.contains(node)) {
newActiveChild = node;
} else {
newActiveChild = this.firstElementChild;
}
this._activeChild = newActiveChild;
if (newActiveChild) {
newActiveChild.setAttribute("tabindex", "0");
}
if (prevActiveChild && prevActiveChild != newActiveChild) {
prevActiveChild.setAttribute("tabindex", "-1");
}
}
get isVertical() {
return this.orientation == "vertical";
}
get orientation() {
return this.getAttribute("orientation") == "vertical"
? "vertical"
: "horizontal";
}
set orientation(val) {
if (val == "vertical") {
this.setAttribute("orientation", val);
} else {
this.removeAttribute("orientation");
}
}
_navigationKeys() {
if (this.isVertical) {
return {
previousKey: "ArrowUp",
nextKey: "ArrowDown",
};
}
if (document.dir == "rtl") {
return {
previousKey: "ArrowRight",
nextKey: "ArrowLeft",
};
}
return {
previousKey: "ArrowLeft",
nextKey: "ArrowRight",
};
}
handleEvent(e) {
let { previousKey, nextKey } = this._navigationKeys();
if (e.type == "keydown" && (e.key == previousKey || e.key == nextKey)) {
e.preventDefault();
let oldFocus = this.activeChild;
this.walker.currentNode = oldFocus;
let newFocus;
if (e.key == previousKey) {
newFocus = this.walker.previousNode();
} else {
newFocus = this.walker.nextNode();
}
if (newFocus) {
this.activeChild = newFocus;
}
} else if (e.type == "button-group:selected") {
this.activeChild = e.target;
}
}
get walker() {
if (!this._walker) {
this._walker = document.createTreeWalker(this, NodeFilter.SHOW_ELEMENT, {
acceptNode: node => {
if (
node.hidden ||
node.disabled ||
node.localName !== "named-deck-button"
) {
if (node.hidden || node.disabled) {
return NodeFilter.FILTER_REJECT;
}
return NodeFilter.FILTER_ACCEPT;
node.focus();
return document.activeElement == node
? NodeFilter.FILTER_ACCEPT
: NodeFilter.FILTER_REJECT;
},
});
}
return this._walker;
}
}
customElements.define("named-deck-button-group", NamedDeckButtonGroup);
customElements.define("button-group", ButtonGroup);
/**
* A deck that is indexed by the "name" attribute of its children. The

Просмотреть файл

@ -87,7 +87,7 @@ function checkOptions(doc, options, expectedOptions) {
function assertDeckHeadingHidden(group) {
ok(group.hidden, "The tab group is hidden");
let buttons = group.querySelectorAll("named-deck-button");
let buttons = group.querySelectorAll(".tab-button");
for (let button of buttons) {
ok(button.offsetHeight == 0, `The ${button.name} is hidden`);
}
@ -95,7 +95,7 @@ function assertDeckHeadingHidden(group) {
function assertDeckHeadingButtons(group, visibleButtons) {
ok(!group.hidden, "The tab group is shown");
let buttons = group.querySelectorAll("named-deck-button");
let buttons = group.querySelectorAll(".tab-button");
ok(
buttons.length >= visibleButtons.length,
`There should be at least ${visibleButtons.length} buttons`
@ -951,6 +951,7 @@ add_task(async function testExternalUninstall() {
// Verify the list view was loaded and the card is gone.
let list = doc.querySelector("addon-list");
ok(list, "Moved to a list page");
is(list.type, "extension", "We're on the extension list page");
card = list.querySelector(`addon-card[addon-id="${id}"]`);
ok(!card, "The card has been removed");

Просмотреть файл

@ -7,7 +7,7 @@
const DEFAULT_SECTION_NAMES = ["one", "two", "three"];
function makeButton({ doc, name, deckId }) {
let button = doc.createElement("named-deck-button");
let button = doc.createElement("button", { is: "named-deck-button" });
button.setAttribute("name", name);
button.deckId = deckId;
button.textContent = name.toUpperCase();
@ -145,7 +145,7 @@ async function setup({ doc, beAsync, first }) {
for (let name of DEFAULT_SECTION_NAMES) {
deck.appendChild(makeSection({ doc, name }));
}
const buttons = doc.createElement("div");
const buttons = doc.createElement("button-group");
for (let name of DEFAULT_SECTION_NAMES) {
buttons.appendChild(makeButton({ doc, name, deckId }));
}
@ -187,3 +187,64 @@ add_task(async function testNamedDeckAndButtons() {
await closeView(win);
});
add_task(async function testFocusAndClickMixing() {
const win = await loadInitialView("extension");
const doc = win.document;
const waitForAnimationFrame = () =>
new Promise(r => requestAnimationFrame(r));
const tab = (e = {}) => {
EventUtils.synthesizeKey("VK_TAB", e, win);
return waitForAnimationFrame();
};
const firstButton = doc.createElement("button");
doc.body.append(firstButton);
const { deck, buttons: buttonGroup } = await setup({
doc,
beAsync: false,
first: "buttons",
});
const buttons = buttonGroup.children;
firstButton.focus();
const secondButton = doc.createElement("button");
doc.body.append(secondButton);
await tab();
is(doc.activeElement, buttons[0], "first deck button is focused");
is(deck.selectedViewName, "one", "first view is shown");
await tab();
is(doc.activeElement, secondButton, "focus moves out of group");
await tab({ shiftKey: true });
is(doc.activeElement, buttons[0], "focus moves back to first button");
// Click on another tab button, this should make it the focusable button.
EventUtils.synthesizeMouseAtCenter(buttons[1], {}, win);
await waitForAnimationFrame();
is(deck.selectedViewName, "two", "second view is shown");
if (doc.activeElement != buttons[1]) {
// On Mac the button isn't focused on click, but it is on Windows/Linux.
await tab();
}
is(doc.activeElement, buttons[1], "second deck button is focusable");
await tab();
is(doc.activeElement, secondButton, "focus moved to second plain button");
await tab({ shiftKey: true });
is(doc.activeElement, buttons[1], "second deck button is focusable");
await tab({ shiftKey: true });
is(
doc.activeElement,
firstButton,
"next shift-tab moves out of button group"
);
await closeView(win);
});

Просмотреть файл

@ -208,7 +208,7 @@ add_task(async function testCardRerender() {
card = doc.querySelector("addon-card");
let browserAdded = waitOptionsBrowserInserted();
card.querySelector('named-deck-button[name="preferences"]').click();
card.querySelector('.tab-button[name="preferences"]').click();
await browserAdded;
is(
@ -234,7 +234,7 @@ add_task(async function testCardRerender() {
// Load the permissions tab again.
browserAdded = waitOptionsBrowserInserted();
card.querySelector('named-deck-button[name="preferences"]').click();
card.querySelector('.tab-button[name="preferences"]').click();
await browserAdded;
// Switching to preferences will create a new browser element.
@ -397,7 +397,7 @@ add_task(async function testUpgradeTemporary() {
card = doc.querySelector("addon-card");
let browserAdded = waitOptionsBrowserInserted();
card.querySelector('named-deck-button[name="preferences"]').click();
card.querySelector('.tab-button[name="preferences"]').click();
await browserAdded;
await firstExtension.awaitMessage("options-loaded");
@ -459,7 +459,7 @@ add_task(async function testReloadExtension() {
is(deck.selectedViewName, "details", "Details load first");
let browserAdded = waitOptionsBrowserInserted();
card.querySelector('named-deck-button[name="preferences"]').click();
card.querySelector('.tab-button[name="preferences"]').click();
await browserAdded;
is(deck.selectedViewName, "preferences", "Preferences are shown");

Просмотреть файл

@ -193,7 +193,7 @@ add_task(async function test_scroll_restoration() {
// Switch from the default details tab to the permissions tab.
// (this does not change the history).
win.document.querySelector("named-deck-button[name='permissions']").click();
win.document.querySelector(".tab-button[name='permissions']").click();
// Switch back from the details view to the extension list.

Просмотреть файл

@ -399,7 +399,7 @@ add_task(async function testReleaseNotesLoad() {
info("Switch away and back to release notes");
// Load details view.
let detailsBtn = tabGroup.querySelector('named-deck-button[name="details"]');
let detailsBtn = tabGroup.querySelector('.tab-button[name="details"]');
let viewChanged = BrowserTestUtils.waitForEvent(deck, "view-changed");
detailsBtn.click();
await viewChanged;
@ -506,7 +506,7 @@ add_task(async function testReleaseNotesError() {
info("Switch away and back to release notes");
// Load details view.
let detailsBtn = tabGroup.querySelector('named-deck-button[name="details"]');
let detailsBtn = tabGroup.querySelector('.tab-button[name="details"]');
let viewChanged = BrowserTestUtils.waitForEvent(deck, "view-changed");
detailsBtn.click();
await viewChanged;

Просмотреть файл

@ -45,7 +45,7 @@ function getPreferencesButtonAtDetailsView() {
function isInlineOptionsVisible() {
// The following button is used to open the inline options browser.
return !getHtmlElem("named-deck-button[name='preferences']").hidden;
return !getHtmlElem(".tab-button[name='preferences']").hidden;
}
function getPrivateBrowsingValue() {